22c3b54746
The eval (#1130) proved the frozen-embedding + trained-head spine; this lands its production form (the first of three slices that make heads the suggestion source, replacing Camie + centroid). - tag_head: one logistic-regression head per general/character concept with enough labelled positives. Weights (pgvector), honest CV-derived suggest threshold + earned-auto-apply point, and per-concept quality metrics. - head_training_run: persisted batch lifecycle (mirrors tag_eval_run) so the admin card shows live + historical status across navigation. - services/ml/heads.py: TRAIN (sync, ml worker, reuses tag_eval's proven data loaders + metric math so production heads match measured eval numbers) and SCORE (async, API worker — numpy via pgvector, no scikit-learn): score one image's embedding against all heads → the rail's suggestions, cached on (count, max trained_at) so a retrain invalidates without per-request loads. - tasks.ml.train_heads (ml queue, commits per head so a kill leaves progress) + recover_stalled_head_training_runs sweep + retention(20) + 5-min beat (rule 89). - api/heads.py: POST /api/heads/train (one run at a time, 409 guard) + GET /api/heads (count, graduated, last-trained, running, per-concept table, recent runs). - ml_settings: head_min_positives + head_auto_apply_precision, tunable via /api/ml/settings. Scoring isn't wired into the rail yet (slice C) and the admin UI is slice B — this slice makes training + scoring exist and CI-verifiable. 'precision' column stored as precision_cv (SQL reserved word). Migration 0058. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
45 lines
1.9 KiB
Python
45 lines
1.9 KiB
Python
"""HeadTrainingRun — persisted lifecycle of a head-training batch (#114).
|
|
|
|
Mirrors TagEvalRun so the run SURVIVES navigation and the admin card can show
|
|
live + historical status instead of holding it in transient frontend state.
|
|
Training is idempotent (it upserts tag_head rows), so a SIGKILL'd run is harmless
|
|
— a maintenance recovery sweep flips a stalled `running` row to `error`, and the
|
|
next run re-trains. State machine: running → ready / error.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import DateTime, Integer, String, Text, func
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class HeadTrainingRun(Base):
|
|
__tablename__ = "head_training_run"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
# Training parameters: {min_positives, neg_ratio, precision_target, ...}.
|
|
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, default="running", index=True
|
|
)
|
|
# running | ready | error
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
finished_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# How many concepts got a (re)trained head vs were skipped (too few labels).
|
|
n_trained: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
n_skipped: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Last time the task made progress — the recovery sweep tells a live run from
|
|
# a SIGKILL'd one by this (mirrors TagEvalRun).
|
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|