- 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
263 lines
11 KiB
Python
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]
|