Files
ss-tools/backend/src/plugins/translate/metrics.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

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]