Files
ss-tools/backend/src/plugins/translate/events.py
busya 67ba04d4ff feat: implement LLM table translation service with searchable datasource dropdown, i18n, sidebar, and RBAC
- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics
- Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections
- Add ORM models (12 tables) and Pydantic schemas (15 DTOs)
- Register translate router in app.py with RBAC permission guards
- Add frontend pages: job list, job config, dictionary list/editor, history
- Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard
- Add searchable datasource dropdown with Superset API integration
- Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces
- Add Translation sidebar category with Jobs/Dictionaries/History sub-items
- Hide health monitor error toast via suppressToast API option
- Add 69 backend tests and 44 frontend test files
- Fix: SupersetClient env resolution (string -> Environment object)
- Fix: Dataset detail API returns proper database dict
- Fix: Database dialect extraction fallback when metadata incomplete
2026-05-09 19:34:25 +03:00

263 lines
11 KiB
Python

# [DEF:TranslationEventLog:Module]
# @COMPLEXITY: 5
# @SEMANTICS: translate, events, audit, logging
# @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationEvent]
# @RELATION: DEPENDS_ON -> [MetricSnapshot]
# @PRE: Database session is open and valid.
# @POST: Events are persisted immutably; terminal events enforce exactly-one invariant per run.
# @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events with MetricSnapshot before deletion.
# @DATA_CONTRACT: Input[run_id:Optional[str], job_id:str, event_type:str, payload:dict] -> Output[TranslationEvent]
# @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id.
# @RATIONALE: Immutable event log with nullable run_id allows job-level events (no run context) and run-level events.
# @REJECTED: stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
# @REJECTED: Separate event table per entity — single TranslationEvent table with nullable run_id is simpler for audit.
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from datetime import datetime, timezone, timedelta
import uuid
from ...core.logger import logger, belief_scope
from ...models.translate import TranslationEvent, MetricSnapshot
# Terminal events per run — exactly one of these must exist for a completed run.
TERMINAL_EVENT_TYPES = {"RUN_COMPLETED", "RUN_FAILED", "RUN_CANCELLED"}
# All valid event types for validation.
VALID_EVENT_TYPES = TERMINAL_EVENT_TYPES | {
"JOB_CREATED", "JOB_UPDATED", "JOB_DELETED",
"RUN_STARTED", "RUN_RETRYING", "RUN_RETRY_INSERT",
"BATCH_STARTED", "BATCH_COMPLETED", "BATCH_FAILED", "BATCH_RETRYING",
"TRANSLATION_PHASE_STARTED", "TRANSLATION_PHASE_COMPLETED",
"INSERT_PHASE_STARTED", "INSERT_PHASE_COMPLETED",
"PREVIEW_CREATED", "PREVIEW_ACCEPTED", "PREVIEW_DISCARDED",
"SCHEDULE_CREATED", "SCHEDULE_UPDATED", "SCHEDULE_DELETED",
"METRICS_SNAPSHOT_CREATED",
}
DEFAULT_RETENTION_DAYS = 90
# [DEF:TranslationEventLog:Class]
# @COMPLEXITY: 5
# @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement.
# @PRE: Database session is available.
# @POST: Events are written immutably; terminal events enforced per run.
# @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events.
# @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id.
class TranslationEventLog:
def __init__(self, db: Session):
self.db = db
# [DEF:log_event:Function]
# @COMPLEXITY: 4
# @PURPOSE: Write an immutable event. Enforces terminal event invariant for non-null run_id.
# @PRE: event_type must be a known type. If run_id is not None, enforce terminal invariant.
# @POST: TranslationEvent row is created.
# @SIDE_EFFECT: DB write.
def log_event(
self,
job_id: str,
event_type: str,
payload: Optional[Dict[str, Any]] = None,
run_id: Optional[str] = None,
created_by: Optional[str] = None,
) -> TranslationEvent:
with belief_scope("TranslationEventLog.log_event"):
if event_type not in VALID_EVENT_TYPES:
raise ValueError(
f"Invalid event_type '{event_type}'. "
f"Must be one of: {', '.join(sorted(VALID_EVENT_TYPES))}"
)
# Enforce terminal event invariant for non-null run_id
if run_id is not None and event_type in TERMINAL_EVENT_TYPES:
existing_terminal = (
self.db.query(TranslationEvent.id)
.filter(
TranslationEvent.run_id == run_id,
TranslationEvent.event_type.in_(TERMINAL_EVENT_TYPES),
)
.first()
)
if existing_terminal:
raise ValueError(
f"Run '{run_id}' already has a terminal event "
f"({existing_terminal[0]}). Cannot add another terminal event."
)
# Enforce run_started invariant — must exist before other run events
if run_id is not None and event_type not in {"RUN_STARTED"} | TERMINAL_EVENT_TYPES:
has_started = (
self.db.query(TranslationEvent.id)
.filter(
TranslationEvent.run_id == run_id,
TranslationEvent.event_type == "RUN_STARTED",
)
.first()
)
if not has_started:
raise ValueError(
f"Cannot log '{event_type}' for run '{run_id}': "
f"RUN_STARTED event must precede other run events."
)
event = TranslationEvent(
id=str(uuid.uuid4()),
job_id=job_id,
run_id=run_id,
event_type=event_type,
event_data=payload or {},
created_by=created_by,
created_at=datetime.now(timezone.utc),
)
self.db.add(event)
self.db.flush()
logger.reason(f"Event logged: {event_type}", {
"event_id": event.id,
"job_id": job_id,
"run_id": run_id,
"event_type": event_type,
})
return event
# [/DEF:log_event:Function]
# [DEF:query_events:Function]
# @PURPOSE: Query events with optional filters.
# @PRE: None.
# @POST: Returns list of TranslationEvent dicts matching filters.
def query_events(
self,
job_id: Optional[str] = None,
run_id: Optional[str] = None,
event_type: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> List[Dict[str, Any]]:
with belief_scope("TranslationEventLog.query_events"):
query = self.db.query(TranslationEvent)
if job_id:
query = query.filter(TranslationEvent.job_id == job_id)
if run_id:
query = query.filter(TranslationEvent.run_id == run_id)
if event_type:
query = query.filter(TranslationEvent.event_type == event_type)
events = (
query.order_by(TranslationEvent.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [
{
"id": e.id,
"job_id": e.job_id,
"run_id": e.run_id,
"event_type": e.event_type,
"event_data": e.event_data or {},
"created_by": e.created_by,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in events
]
# [/DEF:query_events:Function]
# [DEF:prune_expired:Function]
# @PURPOSE: Delete events older than retention_days. Persists MetricSnapshot before pruning.
# @PRE: None.
# @POST: Expired events are deleted; MetricSnapshot is created before deletion.
# @SIDE_EFFECT: Creates MetricSnapshot row; deletes TranslationEvent rows.
def prune_expired(
self,
retention_days: int = DEFAULT_RETENTION_DAYS,
batch_size: int = 1000,
) -> Dict[str, Any]:
with belief_scope("TranslationEventLog.prune_expired"):
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
logger.reason("Pruning expired events", {
"cutoff": cutoff.isoformat(),
"retention_days": retention_days,
})
# Find events to prune
expired_query = self.db.query(TranslationEvent).filter(
TranslationEvent.created_at < cutoff
)
total_expired = expired_query.count()
if total_expired == 0:
logger.reflect("No expired events to prune", {})
return {"pruned": 0, "snapshot_id": None}
# Create MetricSnapshot before pruning
snapshot = MetricSnapshot(
id=str(uuid.uuid4()),
job_id="_prune_aggregate_",
key_hash=f"prune_{cutoff.timestamp():.0f}",
covers_events_before=cutoff,
total_records=total_expired,
snapshot_date=datetime.now(timezone.utc),
)
self.db.add(snapshot)
self.db.flush()
snapshot_id = snapshot.id
# Delete in batches
pruned = 0
while True:
batch_ids = (
self.db.query(TranslationEvent.id)
.filter(TranslationEvent.created_at < cutoff)
.limit(batch_size)
.all()
)
if not batch_ids:
break
ids = [row[0] for row in batch_ids]
self.db.query(TranslationEvent).filter(
TranslationEvent.id.in_(ids)
).delete(synchronize_session=False)
self.db.flush()
pruned += len(ids)
logger.reason("Pruned batch", {"batch_size": len(ids), "total_pruned": pruned})
self.db.commit()
logger.reflect("Pruning complete", {
"pruned": pruned,
"snapshot_id": snapshot_id,
})
return {"pruned": pruned, "snapshot_id": snapshot_id}
# [/DEF:prune_expired:Function]
# [DEF:get_run_event_summary:Function]
# @PURPOSE: Get a summary of events for a run, including invariant check.
# @PRE: run_id is not None.
# @POST: Returns dict with event list and invariant validity.
def get_run_event_summary(self, run_id: str) -> Dict[str, Any]:
with belief_scope("TranslationEventLog.get_run_event_summary"):
events = self.query_events(run_id=run_id)
has_started = any(e["event_type"] == "RUN_STARTED" for e in events)
terminal_events = [e for e in events if e["event_type"] in TERMINAL_EVENT_TYPES]
return {
"run_id": run_id,
"event_count": len(events),
"has_run_started": has_started,
"terminal_event_count": len(terminal_events),
"terminal_events": terminal_events,
"invariant_valid": has_started and len(terminal_events) <= 1,
"events": events,
}
# [/DEF:get_run_event_summary:Function]
# [/DEF:TranslationEventLog:Class]
# [/DEF:TranslationEventLog:Module]