Warnings fixed: - datetime.utcnow() → datetime.now(UTC) across 48+ files (src/ + tests/) - datetime.utcnow (callback ref) → lambda: datetime.now(UTC) in model fields (18 files) - Pydantic class Config → model_config = ConfigDict(...) (16 files) - Pydantic .dict() → .model_dump() (8 files) - ConfigDict(allow_population_by_field_name=True) → validate_by_name=True - SQLAlchemy declarative_base() import path updated - FastAPI on_event → lifespan context manager (app.py) - Import sorting (ruff I001) auto-fixed across all files - Fixed broken re-export chains that ruff F401 cleanup broke: _validate_bcp47: service.py now imports from dictionary_validation directly job_to_response: _job_routes.py and test imports from service_utils directly fetch_datasource_metadata: restored re-export in service.py - Added missing TranslateJobService import in _job_routes.py (was deleted by F401) - Added ConfigDict(protected_namespaces=()) for DashboardDatasetItem schema field - pytest.ini: replaced deprecated importmode with asyncio_mode All 440 tests pass with zero deprecation warnings.
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
# #region TranslationMetrics [C:3] [TYPE Module] [SEMANTICS sqlalchemy, translate, metrics, job, statistics]
|
|
# @BRIEF Aggregate translation metrics from live TranslationEvent + MetricSnapshot for per-job reporting.
|
|
# @LAYER Domain
|
|
# @RELATION DEPENDS_ON -> [TranslationSchedule]
|
|
# @RELATION DEPENDS_ON -> [TranslationSchedule]
|
|
# @RELATION DEPENDS_ON -> [TranslationSchedule]
|
|
# @RELATION DEPENDS_ON -> [TranslationSchedule]
|
|
# @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.
|
|
# @COMPLEXITY 4
|
|
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...core.logger import belief_scope
|
|
from ...models.translate import (
|
|
MetricSnapshot,
|
|
TranslationRun,
|
|
TranslationRunLanguageStats,
|
|
TranslationSchedule,
|
|
)
|
|
|
|
|
|
# #region TranslationMetrics [C:3] [TYPE Class]
|
|
# @BRIEF Aggregate translation metrics from live events and MetricSnapshot.
|
|
class TranslationMetrics:
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
# region get_job_metrics [TYPE 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
|
|
|
|
# Per-language metrics from TranslationRunLanguageStats (live runs)
|
|
per_language: dict[str, dict[str, int | float]] = {}
|
|
lang_stats_rows = (
|
|
self.db.query(TranslationRunLanguageStats)
|
|
.join(TranslationRun, TranslationRunLanguageStats.run_id == TranslationRun.id)
|
|
.filter(TranslationRun.job_id == job_id)
|
|
.all()
|
|
)
|
|
for ls in lang_stats_rows:
|
|
lang = ls.language_code
|
|
if lang not in per_language:
|
|
per_language[lang] = {"tokens": 0, "cost": 0.0, "runs": 0, "translated_rows": 0}
|
|
per_language[lang]["tokens"] += ls.token_count or 0
|
|
per_language[lang]["cost"] += ls.estimated_cost or 0.0
|
|
per_language[lang]["runs"] += 1
|
|
per_language[lang]["translated_rows"] += ls.translated_rows or 0
|
|
|
|
# Merge in per-language data from MetricSnapshot (pruned period)
|
|
latest_snapshot = (
|
|
self.db.query(MetricSnapshot)
|
|
.filter(MetricSnapshot.job_id == job_id)
|
|
.order_by(MetricSnapshot.snapshot_date.desc())
|
|
.first()
|
|
)
|
|
if latest_snapshot and latest_snapshot.per_language_metrics:
|
|
for lang, snap_data in latest_snapshot.per_language_metrics.items():
|
|
if lang not in per_language:
|
|
per_language[lang] = {"tokens": 0, "cost": 0.0, "runs": 0, "translated_rows": 0}
|
|
per_language[lang]["tokens"] += snap_data.get("cumulative_tokens", 0)
|
|
per_language[lang]["cost"] += snap_data.get("cumulative_cost", 0.0)
|
|
per_language[lang]["runs"] += snap_data.get("runs", 0)
|
|
|
|
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,
|
|
"per_language_metrics": per_language,
|
|
}
|
|
# endregion get_job_metrics
|
|
|
|
# region get_all_metrics [TYPE 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]]
|
|
# endregion get_all_metrics
|
|
|
|
|
|
# #endregion TranslationMetrics
|
|
# #endregion TranslationMetrics
|