Skip to content

training: versioned TrainedModels per template - #125

Merged
alex-struk merged 12 commits into
developfrom
feature/template-model-versioning
May 14, 2026
Merged

training: versioned TrainedModels per template#125
alex-struk merged 12 commits into
developfrom
feature/template-model-versioning

Conversation

@alex-struk

Copy link
Copy Markdown
Collaborator

Summary

Each retrain previously overwrote the prior TrainedModel and Azure model. Benchmarks pinned ctx.modelId.defaultValue: "<model_id>" could no longer tell what they had measured against, and there was no way to look back at "what was v2 trained on?".

This branch makes TrainedModel versioned per template, with a manual-management UI:

  • v1 keeps the bare TemplateModel.model_id for backwards compatibility — existing benchmarks/documents that reference it keep resolving.
  • Retrains create v2, v3, … with Azure model ids of <base>-v<n>. The new version becomes active automatically; the prior one is demoted but kept.
  • A dataset snapshot (labeled docs + labels at training time) is captured on each successful training and viewable per version.
  • New "Versions" tab on the template-model detail page exposes per-version actions: view training data, set active, delete. Deletion tombstones the row, removes the Azure artifact, and is blocked when the version is currently active or referenced by a benchmark definition.
  • "Copy model ID" on the template-models list and the detail page header now hands out the active version's model id (e.g., km-invoice-v3) instead of the bare stub. Updates without a manual refresh after retrain / activate / delete.

Schema

Two migrations under apps/shared/prisma/migrations/:

  1. 20260501225325_add_trained_model_versioning — adds version, is_active, deleted_at, dataset_snapshot on trained_models; replaces the unique on template_model_id with (template_model_id, version) plus a (template_model_id, is_active) index. Existing rows are backfilled to version = 1, is_active = true.
  2. 20260501225925_training_job_target_version — adds target_model_id and target_version on training_jobs so the poller knows the versioned Azure name without re-deriving it.

No data destruction; existing single-version templates upgrade transparently.

Test plan

  • npm run db:migrate from apps/backend-services applies cleanly (already applied locally).
  • First training of a brand-new template produces v1 with the bare model id (e.g., km-invoice); existing benchmarks/documents continue resolving.
  • Retraining a template produces v2 (km-invoice-v2), the prior version is demoted, the new one becomes active, and the Versions tab shows both.
  • After training completes, the Versions tab updates without a manual refresh; the list page card and detail page header show the new active model id.
  • "View training data" on a version opens an Accordion drawer with the labeled-docs list collapsed by default; expanding shows labels as-of-training.
  • Deleting an inactive version tombstones it (Status badge: "Deleted"), and the Azure model goes away. Active versions can't be deleted.
  • Trying to delete a version still referenced by a benchmark definition fails with a notification stating the count.
  • OCR model picker (/api/template-models/...trained-model-ids) excludes tombstoned versions.

Notes

  • The Versions tab does not yet support a "Start from vN" workflow (re-hydrating an old version's labeled-doc set into the live editor for tweaking). Easy to add later if useful.
  • Benchmark definitions still pin a model id string. Long-term we may want to normalize that to a FK on a versioned ModelVersion row, but that's out of scope here and would be a separate migration.

🤖 Generated with Claude Code

strukalex and others added 4 commits May 1, 2026 16:08
Each retrain now produces a new TrainedModel version instead of overwriting
the previous artifact. v1 keeps the bare TemplateModel.model_id for
backwards compatibility; v2+ append "-v<n>" so existing benchmarks and
documents that reference older Azure model names keep resolving.

Schema changes:
- TrainedModel: add version, is_active, deleted_at, dataset_snapshot.
  Drop unique(template_model_id); add unique(template_model_id, version)
  + index(template_model_id, is_active). Existing rows backfilled to
  version=1, is_active=true.
- TrainingJob: add target_model_id and target_version so the poller and
  consumers don't have to re-derive the version at completion time.

Service changes:
- TrainingService.startTraining: never delete tracked TrainedModel rows
  on retrain; mint a versioned Azure model id; persist target on the job.
- TrainingPollerService: capture a labeled-document snapshot at success,
  demote any prior active version, create the new version with is_active
  + dataset_snapshot.
- TrainingService gains listTrainedVersions, getTrainedVersionSnapshot,
  setActiveTrainedVersion, deleteTrainedVersion. Deletion blocks on the
  active version and on any benchmark definition that pins the model id;
  successful deletes tombstone the row (deleted_at) and remove the Azure
  artifact best-effort.
- TrainingDbService gains getNextVersionNumber, demoteActiveTrainedModels,
  setActiveTrainedModel, tombstoneTrainedModel, buildTrainedModelSnapshot,
  and filters tombstoned rows out of findAllTrainedModelIds (OCR picker).

Controller exposes new routes under /api/template-models/:modelId/training/
versions[/:versionId][/snapshot|activate].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a fifth tab to ModelDetailPage that lists every trained version of a
template (including tombstoned), shows the active version, and exposes the
manual-management actions matching the new backend:

- View training data: opens a drawer with the labeled documents and their
  labels as captured at training time.
- Set active: promote a non-deleted version to be the one resolved at OCR /
  benchmark time.
- Delete: tombstones the version and deletes the Azure model. Disabled when
  the version is active or referenced by a benchmark definition (the
  backend enforces and surfaces a message we render in a notification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Versions tab auto-refreshes while training is in progress, and forces a
  final refetch when the active job transitions to idle so the new version
  appears without a manual page reload.
- Header "Copy model ID" now copies the active trained version's model id
  (e.g. km-invoice-v3) and shows a small "v3" indicator next to the code.
  Falls back to the bare template id when no active version exists.
- TrainingPanel relabels "Azure Model ID" to "Template Model ID" and adds
  a hint that retrains use a versioned suffix; removes the redundant copy
  button (use the header).
- Snapshot drawer is now an Accordion of files; labels are collapsed by
  default. Each row shows filename + label-count badge so the file list
  is scannable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /template-models cards still showed the bare TemplateModel.model_id even
after retrains had moved the active Azure model name to a versioned id.

Backend: include the active TrainedModel slice (one row per template) on
findAllTemplateModels and findTemplateModel, and reshape it onto a single
active_trained_model field on TemplateModelData. Avoids N+1 lookups from
the list page.

Frontend:
- ModelCard now renders + copies active_trained_model.model_id when present,
  with a small "v3" indicator.
- ModelDetailPage drops its extra useTrainedVersions call and reads the
  active version from the template-model query directly.
- TrainedVersionsPanel + useTrainedVersions invalidate the template-model
  queries on activate/delete and on training-finished transitions, so the
  list and detail headers update without a page refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alex-struk
alex-struk marked this pull request as ready for review May 7, 2026 23:08

@dbarkowsky dbarkowsky left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing breaking, but some comments worth capturing in another ticket if not addressed before merge.

Comment thread apps/backend-services/src/training/training-poller.service.ts Outdated
Comment thread apps/backend-services/src/training/training.service.ts Outdated
Comment thread apps/backend-services/src/training/training-db.service.ts
Comment thread apps/backend-services/src/training/training.service.ts
Comment thread apps/frontend/src/features/annotation/template-models/hooks/useTrainedVersions.ts Outdated
strukalex and others added 5 commits May 13, 2026 15:22
BenchmarkModule never imports TrainingModule, so neither the module-level
forwardRef wrapper nor the constructor @Inject(forwardRef(...)) is breaking
any cycle. Replace with the plain references.

Addresses PR #125 review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apiService swallows axios errors into { success: false, message, data }.
activateMutation was returning response.data unconditionally, so backend
rejections (e.g. ConflictException when activating a deleted version)
were silently treated as success and the user saw no error.

Routes both activate and delete responses through a small unwrap helper
that throws on !success, removing the inconsistency between them.

Addresses PR #125 review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getTrainedVersionSnapshot, setActiveTrainedVersion, and deleteTrainedVersion
were loading every version for the template and filtering by id in JS. That
incidentally enforced the templateModelId/trainedModelId scoping; switching
to a naive findUnique({ id }) would have silently dropped that check.

Add findTrainedModelForTemplate(templateModelId, trainedModelId, opts) to
TrainingDbService and use it in all three callers — single-row query, with
the scope check now explicit in the where clause.

Addresses PR #125 review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a successful Azure training run, the poller demoted the prior active
TrainedModel and then created the new one in two separate writes. A crash
between them would leave the template with zero active versions, and
downstream consumers (findActiveTrainedModel, the OCR picker, the active
trained-model on the template detail page) would silently see nothing.

Add TrainingDbService.replaceActiveTrainedModel(templateModelId, data) that
runs both writes inside prisma.$transaction, and switch the poller to it.

Addresses PR #125 review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getNextVersionNumber + the eventual createTrainedModel were not atomic, so
two concurrent training starts on the same template would compute the same
next version, both upload to Azure under the same versioned model id, and
the loser's poller would later hit the @@unique(template_model_id, version)
constraint. By then the Azure model would already exist and the job row
would be left in a partial state.

Rather than serialize version assignment (which still permits the double
Azure run), refuse the second start outright: findInFlightJobForTemplate
returns any non-terminal job for the template, and startTraining throws
ConflictException if one exists. The user-visible behaviour now matches
the obvious intent — wait for or cancel the current training.

Addresses PR #125 review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alex-struk

alex-struk commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review @dbarkowsky — addressed all five in separate commits on this branch:

# Comment Commit
1 Wrap demote + create in a transaction 243592c — new TrainingDbService.replaceActiveTrainedModel(templateModelId, data) runs both writes inside prisma.$transaction
2 Fetch-by-id instead of fetch-all-then-.find f53b6f7 — new findTrainedModelForTemplate(templateModelId, trainedModelId, opts); used in getTrainedVersionSnapshot, setActiveTrainedVersion, deleteTrainedVersion. Kept the implicit templateModelId-scope check explicit in the where
3 Concurrent retrain race on getNextVersionNumber 4a49913 — went with an in-flight job guard in startTraining instead of SELECT FOR UPDATE. Serializing version assignment still allows two parallel Azure runs (wasted cost, plus an orphaned Azure model when the loser's poller hits @@unique). Refusing the second start matches obvious user intent: wait for or cancel the running job
4 Move benchmark check to TrainingDbService to drop the forwardRef 5cd7c9a — just dropped both forwardRef wrappersreverted in f399153. I was wrong: there is a real cycle, routed through OcrModule (TrainingModule → BenchmarkModule → OcrModule → TrainingModule, because OcrModule imports TrainingModule for trained-model lookup). My earlier grep only checked src/benchmark/ for direct training references and missed the transitive path. Restored both forwardRefs and added a comment in training.module.ts documenting the cycle so this isn't tripped over again. Moving the check into TrainingDbService (your original suggestion) would have made the cycle worse, not better — so leaving the architecture as-is is the right call here
5 activateMutation missing the response.success check 70d46e4 — was a silent-failure bug, not just style. apiService swallows axios errors into { success: false, message }, so activateMutation was treating backend rejections as success. Both mutations now share a small unwrap() helper

Backend suite: 2075 passing (+8 new tests covering replaceActiveTrainedModel, findTrainedModelForTemplate, findInFlightJobForTemplate, and the guard in startTraining). Nest boot verified after the f399153 revert.

strukalex and others added 2 commits May 13, 2026 15:54
5cd7c9a dropped the forwardRef on TrainingModule's BenchmarkModule import
on the premise that no cycle existed. That premise was wrong — the cycle
runs through OcrModule:

  TrainingModule → BenchmarkModule → OcrModule → TrainingModule

OcrModule imports TrainingModule because the OCR controller depends on
TrainingService for trained-model lookup. Without forwardRef, BenchmarkModule
is referenced at file-eval time before its export has initialized, and Nest
crashes at boot with "Cannot access 'BenchmarkModule' before initialization".

Restores both the module-level forwardRef and the constructor-level
@Inject(forwardRef(() => BenchmarkDefinitionDbService)), and adds a comment
documenting the cycle so a future reader doesn't make the same mistake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…DbService

Points future readers at the module-level comment in training.module.ts
that explains the TrainingModule → BenchmarkModule → OcrModule cycle, so
the @Inject(forwardRef(...)) isn't mistaken for cargo-cult.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alex-struk
alex-struk merged commit be9e689 into develop May 14, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants