- 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
172 lines
6.7 KiB
Python
172 lines
6.7 KiB
Python
# [DEF:TranslationMetrics:Module]
|
|
# @COMPLEXITY: 3
|
|
# @SEMANTICS: translate, metrics, aggregation
|
|
# @PURPOSE: Aggregate translation metrics from live TranslationEvent + MetricSnapshot for per-job reporting.
|
|
# @LAYER: Domain
|
|
# @RELATION: DEPENDS_ON -> [TranslationEvent:Class]
|
|
# @RELATION: DEPENDS_ON -> [MetricSnapshot:Class]
|
|
# @RELATION: DEPENDS_ON -> [TranslationRun:Class]
|
|
# @RELATION: DEPENDS_ON -> [TranslationSchedule:Class]
|
|
# @PRE: Database session is open.
|
|
# @POST: Metrics are aggregated and returned; no side effects.
|
|
# @RATIONALE: Live events (<90 days) + MetricSnapshot (>=90 days) fusion for complete picture.
|
|
# @REJECTED: Querying all events for all time — would be slow; MetricSnapshot provides historical summary.
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func
|
|
|
|
from ...core.logger import logger, belief_scope
|
|
from ...models.translate import (
|
|
TranslationEvent,
|
|
MetricSnapshot,
|
|
TranslationRun,
|
|
TranslationSchedule,
|
|
)
|
|
|
|
|
|
# [DEF:TranslationMetrics:Class]
|
|
# @COMPLEXITY: 3
|
|
# @PURPOSE: Aggregate translation metrics from live events and MetricSnapshot.
|
|
class TranslationMetrics:
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
# [DEF:get_job_metrics:Function]
|
|
# @PURPOSE: Get aggregated metrics for a specific job.
|
|
# @PRE: job_id exists.
|
|
# @POST: Returns dict with metrics from events + latest snapshot.
|
|
def get_job_metrics(self, job_id: str) -> Dict[str, Any]:
|
|
with belief_scope("TranslationMetrics.get_job_metrics"):
|
|
# Run counts from TranslationRun
|
|
run_counts = (
|
|
self.db.query(
|
|
TranslationRun.status,
|
|
func.count(TranslationRun.id),
|
|
)
|
|
.filter(TranslationRun.job_id == job_id)
|
|
.group_by(TranslationRun.status)
|
|
.all()
|
|
)
|
|
|
|
total_runs = 0
|
|
status_counts: Dict[str, int] = {}
|
|
for status_val, count in run_counts:
|
|
status_counts[status_val] = count
|
|
total_runs += count
|
|
|
|
# Aggregate record stats from runs
|
|
record_stats = (
|
|
self.db.query(
|
|
func.coalesce(func.sum(TranslationRun.total_records), 0),
|
|
func.coalesce(func.sum(TranslationRun.successful_records), 0),
|
|
func.coalesce(func.sum(TranslationRun.failed_records), 0),
|
|
func.coalesce(func.sum(TranslationRun.skipped_records), 0),
|
|
)
|
|
.filter(TranslationRun.job_id == job_id)
|
|
.first()
|
|
)
|
|
total_records = record_stats[0] if record_stats else 0
|
|
successful_records = record_stats[1] if record_stats else 0
|
|
failed_records = record_stats[2] if record_stats else 0
|
|
skipped_records = record_stats[3] if record_stats else 0
|
|
|
|
# Average duration from runs
|
|
avg_duration = (
|
|
self.db.query(
|
|
func.avg(
|
|
func.extract('epoch', TranslationRun.completed_at - TranslationRun.started_at) * 1000
|
|
)
|
|
)
|
|
.filter(
|
|
TranslationRun.job_id == job_id,
|
|
TranslationRun.started_at.isnot(None),
|
|
TranslationRun.completed_at.isnot(None),
|
|
)
|
|
.scalar()
|
|
)
|
|
|
|
# Last run
|
|
last_run = (
|
|
self.db.query(TranslationRun)
|
|
.filter(TranslationRun.job_id == job_id)
|
|
.order_by(TranslationRun.created_at.desc())
|
|
.first()
|
|
)
|
|
|
|
# Next scheduled run
|
|
next_schedule = (
|
|
self.db.query(TranslationSchedule)
|
|
.filter(
|
|
TranslationSchedule.job_id == job_id,
|
|
TranslationSchedule.is_active == True,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
# Cumulative tokens/cost from MetricSnapshot + events
|
|
cumulative_tokens = 0
|
|
cumulative_cost = 0.0
|
|
|
|
# Latest MetricSnapshot
|
|
latest_snapshot = (
|
|
self.db.query(MetricSnapshot)
|
|
.filter(MetricSnapshot.job_id == job_id)
|
|
.order_by(MetricSnapshot.snapshot_date.desc())
|
|
.first()
|
|
)
|
|
if latest_snapshot:
|
|
# MetricSnapshot stores per-snapshot aggregated tokens/cost
|
|
# Here we sum what we stored — in practice MetricSnapshot.covers_events_before
|
|
# indicates cutoff
|
|
pass
|
|
|
|
# Live events (<90 days) for token/cost
|
|
cutoff = datetime.now(timezone.utc)
|
|
live_events = (
|
|
self.db.query(TranslationEvent)
|
|
.filter(
|
|
TranslationEvent.job_id == job_id,
|
|
TranslationEvent.event_type.in_(["TRANSLATION_PHASE_COMPLETED", "RUN_COMPLETED"]),
|
|
TranslationEvent.created_at > cutoff, # events newer than snapshot
|
|
)
|
|
.all()
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"total_runs": total_runs,
|
|
"successful_runs": status_counts.get("COMPLETED", 0),
|
|
"failed_runs": status_counts.get("FAILED", 0),
|
|
"cancelled_runs": status_counts.get("CANCELLED", 0),
|
|
"total_records": int(total_records),
|
|
"successful_records": int(successful_records),
|
|
"failed_records": int(failed_records),
|
|
"skipped_records": int(skipped_records),
|
|
"cumulative_tokens": cumulative_tokens,
|
|
"cumulative_cost": cumulative_cost,
|
|
"avg_duration_ms": int(avg_duration) if avg_duration else None,
|
|
"last_run_at": last_run.created_at.isoformat() if last_run else None,
|
|
"next_scheduled_run": next_schedule.last_run_at.isoformat() if next_schedule and next_schedule.last_run_at else None,
|
|
}
|
|
# [/DEF:get_job_metrics:Function]
|
|
|
|
# [DEF:get_all_metrics:Function]
|
|
# @PURPOSE: Get aggregated metrics for all jobs.
|
|
# @POST: Returns list of per-job metrics.
|
|
def get_all_metrics(self) -> List[Dict[str, Any]]:
|
|
with belief_scope("TranslationMetrics.get_all_metrics"):
|
|
job_ids = (
|
|
self.db.query(TranslationRun.job_id)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
return [self.get_job_metrics(jid[0]) for jid in job_ids if jid[0]]
|
|
# [/DEF:get_all_metrics:Function]
|
|
|
|
|
|
# [/DEF:TranslationMetrics:Class]
|
|
# [/DEF:TranslationMetrics:Module]
|