@marinoscar/kvox-eval 0.0.0-bootstrap → 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/ask/index.d.ts +6 -0
  5. package/dist/ask/index.js +28 -0
  6. package/dist/ask/question-schema.d.ts +121 -0
  7. package/dist/ask/question-schema.js +167 -0
  8. package/dist/ask/report.d.ts +88 -0
  9. package/dist/ask/report.js +170 -0
  10. package/dist/ask/scorer.d.ts +104 -0
  11. package/dist/ask/scorer.js +164 -0
  12. package/dist/ask-run/ask-command.d.ts +62 -0
  13. package/dist/ask-run/ask-command.js +254 -0
  14. package/dist/ask-run/host/app.d.ts +65 -0
  15. package/dist/ask-run/host/app.js +146 -0
  16. package/dist/ask-run/host/db.d.ts +38 -0
  17. package/dist/ask-run/host/db.js +126 -0
  18. package/dist/ask-run/host/ports.d.ts +35 -0
  19. package/dist/ask-run/host/ports.js +113 -0
  20. package/dist/ask-run/index.d.ts +13 -0
  21. package/dist/ask-run/index.js +35 -0
  22. package/dist/ask-run/replay.d.ts +128 -0
  23. package/dist/ask-run/replay.js +212 -0
  24. package/dist/ask-run/run-ask-eval.d.ts +125 -0
  25. package/dist/ask-run/run-ask-eval.js +193 -0
  26. package/dist/ask-run/seed-golden-graph.d.ts +64 -0
  27. package/dist/ask-run/seed-golden-graph.js +495 -0
  28. package/dist/cli/index.d.ts +3 -0
  29. package/dist/cli/index.js +12 -0
  30. package/dist/cli/kg-command.d.ts +66 -0
  31. package/dist/cli/kg-command.js +238 -0
  32. package/dist/cli/main.d.ts +18 -0
  33. package/dist/cli/main.js +69 -0
  34. package/dist/fixtures/domains.d.ts +16 -0
  35. package/dist/fixtures/domains.js +33 -0
  36. package/dist/fixtures/fixture-schema.d.ts +323 -0
  37. package/dist/fixtures/fixture-schema.js +199 -0
  38. package/dist/fixtures/index.d.ts +16 -0
  39. package/dist/fixtures/index.js +40 -0
  40. package/dist/fixtures/load.d.ts +73 -0
  41. package/dist/fixtures/load.js +137 -0
  42. package/dist/fixtures/questions.d.ts +15 -0
  43. package/dist/fixtures/questions.js +26 -0
  44. package/dist/index.d.ts +20 -0
  45. package/dist/index.js +36 -0
  46. package/dist/kg/gold-runner.d.ts +13 -0
  47. package/dist/kg/gold-runner.js +57 -0
  48. package/dist/kg/index.d.ts +17 -0
  49. package/dist/kg/index.js +65 -0
  50. package/dist/kg/internals.d.ts +7 -0
  51. package/dist/kg/internals.js +17 -0
  52. package/dist/kg/prediction-schema.d.ts +245 -0
  53. package/dist/kg/prediction-schema.js +102 -0
  54. package/dist/kg/predictions.d.ts +11 -0
  55. package/dist/kg/predictions.js +49 -0
  56. package/dist/kg/report.d.ts +84 -0
  57. package/dist/kg/report.js +125 -0
  58. package/dist/kg/run-kg-eval.d.ts +77 -0
  59. package/dist/kg/run-kg-eval.js +137 -0
  60. package/dist/kg/runner.d.ts +72 -0
  61. package/dist/kg/runner.js +51 -0
  62. package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
  63. package/dist/kg/runners/extract-resolve-runner.js +199 -0
  64. package/dist/kg/runners/extract-runner.d.ts +65 -0
  65. package/dist/kg/runners/extract-runner.js +243 -0
  66. package/dist/kg/scorer.d.ts +226 -0
  67. package/dist/kg/scorer.js +463 -0
  68. package/dist/kg/targets.d.ts +17 -0
  69. package/dist/kg/targets.js +16 -0
  70. package/dist/kg/text.d.ts +45 -0
  71. package/dist/kg/text.js +100 -0
  72. package/dist/live/adapters.d.ts +16 -0
  73. package/dist/live/adapters.js +58 -0
  74. package/dist/live/env.d.ts +39 -0
  75. package/dist/live/env.js +56 -0
  76. package/dist/live/index.d.ts +6 -0
  77. package/dist/live/index.js +16 -0
  78. package/dist/live/live-ai.d.ts +104 -0
  79. package/dist/live/live-ai.js +157 -0
  80. package/dist/parity/constants.d.ts +79 -0
  81. package/dist/parity/constants.js +104 -0
  82. package/dist/parity/index.d.ts +20 -0
  83. package/dist/parity/index.js +38 -0
  84. package/dist/parity/internals.d.ts +8 -0
  85. package/dist/parity/internals.js +16 -0
  86. package/dist/parity/scenario.d.ts +45 -0
  87. package/dist/parity/scenario.js +245 -0
  88. package/dist/parity/script.d.ts +44 -0
  89. package/dist/parity/script.js +136 -0
  90. package/dist/parity/snapshot.d.ts +43 -0
  91. package/dist/parity/snapshot.js +71 -0
  92. package/dist/real-data/index.d.ts +2 -0
  93. package/dist/real-data/index.js +12 -0
  94. package/dist/real-data/real-data.d.ts +76 -0
  95. package/dist/real-data/real-data.js +156 -0
  96. package/fixtures/kg-golden/README.md +136 -0
  97. package/fixtures/kg-golden/ask-questions.json +458 -0
  98. package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
  99. package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
  100. package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
  101. package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
  102. package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
  103. package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
  104. package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
  105. package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
  106. package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
  107. package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
  108. package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
  109. package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
  110. package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
  111. package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
  112. package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
  113. package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
  114. package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
  115. package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
  116. package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
  117. package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
  118. package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
  119. package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
  120. package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
  121. package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
  122. package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
  123. package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
  124. package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
  125. package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
  126. package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
  127. package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
  128. package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
  129. package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
  130. package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
  131. package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
  132. package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
  133. package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
  134. package/host/generated/client.d.ts +1 -0
  135. package/host/generated/client.js +5 -0
  136. package/host/generated/default.d.ts +1 -0
  137. package/host/generated/default.js +5 -0
  138. package/host/generated/edge.d.ts +1 -0
  139. package/host/generated/edge.js +1658 -0
  140. package/host/generated/index-browser.js +1684 -0
  141. package/host/generated/index.d.ts +192335 -0
  142. package/host/generated/index.js +1658 -0
  143. package/host/generated/package.json +139 -0
  144. package/host/generated/query_compiler_fast_bg.js +2 -0
  145. package/host/generated/query_compiler_fast_bg.wasm +0 -0
  146. package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
  147. package/host/generated/runtime/client.d.ts +3433 -0
  148. package/host/generated/runtime/client.js +110 -0
  149. package/host/generated/runtime/index-browser.d.ts +90 -0
  150. package/host/generated/runtime/index-browser.js +6 -0
  151. package/host/generated/runtime/wasm-compiler-edge.js +100 -0
  152. package/host/generated/schema.prisma +6312 -0
  153. package/host/generated/wasm-edge-light-loader.mjs +5 -0
  154. package/host/generated/wasm-worker-loader.mjs +5 -0
  155. package/host/prisma/fragments/kvox.ask.prisma +126 -0
  156. package/host/prisma/fragments/kvox.graph.prisma +1073 -0
  157. package/host/prisma/fragments/kvox.notes.prisma +787 -0
  158. package/host/prisma/fragments/kvox.search.prisma +211 -0
  159. package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
  160. package/host/prisma/kvox.lock +101 -0
  161. package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
  162. package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
  163. package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
  164. package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
  165. package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
  166. package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
  167. package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
  168. package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
  169. package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
  170. package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
  171. package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
  172. package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
  173. package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
  174. package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
  175. package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
  176. package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
  177. package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
  178. package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
  179. package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
  180. package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
  181. package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
  182. package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
  183. package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
  184. package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
  185. package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
  186. package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
  187. package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
  188. package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
  189. package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
  190. package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
  191. package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
  192. package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
  193. package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
  194. package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
  195. package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
  196. package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
  197. package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
  198. package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
  199. package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
  200. package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
  201. package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
  202. package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
  203. package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
  204. package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
  205. package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
  206. package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
  207. package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
  208. package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
  209. package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
  210. package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
  211. package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
  212. package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
  213. package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
  214. package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
  215. package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
  216. package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
  217. package/host/prisma/platform.lock +319 -0
  218. package/host/prisma/schema/app.kvox.ask.prisma +110 -0
  219. package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
  220. package/host/prisma/schema/app.kvox.notes.prisma +746 -0
  221. package/host/prisma/schema/app.kvox.search.prisma +208 -0
  222. package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
  223. package/host/prisma/schema/platform.ai.prisma +419 -0
  224. package/host/prisma/schema/platform.android-app.prisma +46 -0
  225. package/host/prisma/schema/platform.base.prisma +15 -0
  226. package/host/prisma/schema/platform.credentials.prisma +156 -0
  227. package/host/prisma/schema/platform.db-backup.prisma +359 -0
  228. package/host/prisma/schema/platform.identity.prisma +638 -0
  229. package/host/prisma/schema/platform.jobs.prisma +824 -0
  230. package/host/prisma/schema/platform.notifications.prisma +641 -0
  231. package/host/prisma/schema/platform.settings.prisma +53 -0
  232. package/host/prisma/schema/platform.sharing.prisma +208 -0
  233. package/host/prisma/schema/platform.storage.prisma +118 -0
  234. package/package.json +112 -1
  235. package/parity/requests.ask.json +2681 -0
  236. package/parity/requests.m01.json +1554 -0
  237. package/parity/requests.m02.json +1554 -0
  238. package/parity/requests.m03.json +1554 -0
  239. package/parity/requests.m04.json +1554 -0
  240. package/parity/requests.m05.json +1554 -0
  241. package/parity/requests.m06.json +1554 -0
  242. package/parity/requests.m07.json +1554 -0
  243. package/parity/requests.m08.json +1554 -0
  244. package/parity/requests.m09.json +1554 -0
  245. package/parity/requests.m10.json +1554 -0
@@ -0,0 +1,726 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: app:kvox.transcripts.prisma
3
+
4
+ // GENERATED by kvox-db sync — do not edit
5
+ // source: @marinoscar/kvox-db:schema/kvox.transcripts.prisma
6
+
7
+ // =============================================================================
8
+ // Transcripts (issue #24, epic #19) — docs/specs/transcription.md §3
9
+ // =============================================================================
10
+ //
11
+ // Six tables, all keyed to one `transcripts` row per uploaded recording. This
12
+ // issue adds only the schema — the upload/pipeline/editing/sharing/export
13
+ // logic that reads and writes it all arrives in issues #25–#29. What is here
14
+ // is deliberately complete on its own, mirroring the `jobs`/`worker_nodes`
15
+ // precedent of "the schema ships whole, ahead of the code that will use it,
16
+ // so nothing downstream needs a follow-up migration to these tables
17
+ // themselves." Every column, enum member and index below is named for and
18
+ // justified by docs/specs/transcription.md §1, §3, §4, §5, §6, §8 and §10 —
19
+ // read those sections for the full reasoning; this comment restates only
20
+ // what has to live beside the code to stay correct.
21
+ //
22
+ // -----------------------------------------------------------------------------
23
+ // Three status enums, not one — restated from spec §1.4
24
+ // -----------------------------------------------------------------------------
25
+ // `transcripts.status` (`TranscriptStatus`) is what every list view and every
26
+ // permission check reads. `transcriptionStatus` and `playbackStatus` are
27
+ // detail fields for the pipeline stepper (issue #30): the transcode and the
28
+ // transcription round trip run CONCURRENTLY, so a single top-level enum would
29
+ // need one member per combination of the two sub-pipelines' states and would
30
+ // still need a second field to say which failure belongs to which
31
+ // sub-pipeline. This is the same design `worker_nodes` already uses for its
32
+ // own health (`status` for stated intent, `lastHeartbeatAt` for computed
33
+ // liveness): small, independently updatable pieces of state, not one field
34
+ // pretending to be several.
35
+ //
36
+ // -----------------------------------------------------------------------------
37
+ // owner_id CASCADES; source_object_id (and every other storage_objects FK
38
+ // below) RESTRICTS — the same column shape, the opposite `onDelete`, because
39
+ // they answer opposite ownership questions
40
+ // -----------------------------------------------------------------------------
41
+ // `owner_id` points UP the ownership chain: a `transcripts` row's only reason
42
+ // to exist is that exactly one user owns it. This is the same "belongs
43
+ // entirely to this user" shape `user_settings`, `refresh_tokens`,
44
+ // `personal_access_tokens` and `device_codes` already use Cascade for — NOT
45
+ // the audit/history shape `AuditEvent.actorUserId`, `Credential
46
+ // .updatedByUserId` or `DatabaseBackupRun.createdById` use SetNull for. The
47
+ // difference is what the row IS: an audit row is a record of an act someone
48
+ // performed against shared infrastructure and must survive the actor's
49
+ // deletion to keep meaning; a transcript is not a record OF anything, it IS
50
+ // the private content itself, and this application's own access model (§6.1)
51
+ // has no path to it once its owner is gone — there is deliberately no
52
+ // `transcripts:read_any` (§6.2), so an owner-less transcript would be a row
53
+ // nobody, including an admin, could ever read, share, edit or delete again.
54
+ // Cascading it away when the account goes is the only outcome consistent
55
+ // with "no read-any, ever."
56
+ //
57
+ // `source_object_id` / `playback_object_id` / `raw_result_object_id` (below)
58
+ // and `transcript_versions.snapshot_object_id` / `transcript_exports
59
+ // .object_id` all point SIDEWAYS, across a module boundary, into a DIFFERENT
60
+ // feature's table (`storage_objects`, issue #21) that this feature does not
61
+ // own. Restrict here protects the referenced object FROM this table's own
62
+ // module boundary being crossed by someone else: a `storage_objects` row a
63
+ // transcript still needs may never be deleted out from under it by the
64
+ // generic storage endpoints or an unrelated cleanup sweep (the exact
65
+ // `managed_by` discipline spec §9.3 already gives every managed object). The
66
+ // only path that may ever remove one of these objects is `transcript.purge`
67
+ // (spec §1.5.7, §10) deleting the referencing row FIRST — which a Restrict
68
+ // FK permits freely, because Restrict blocks deleting the REFERENCED
69
+ // `storage_objects` row while a reference exists, not deleting the
70
+ // REFERENCING transcript row itself.
71
+ //
72
+ // ⚠ ONE CONSEQUENCE OF THAT PAIRING, STATED HONESTLY RATHER THAN PAPERED
73
+ // OVER: `owner_id`'s Cascade fires at the Postgres level on a raw user
74
+ // deletion and does not, and cannot, invoke `transcript.purge`'s storage and
75
+ // provider cleanup — a cascading DELETE only removes SQL rows, and Restrict
76
+ // on the sideways FKs means Postgres will not even attempt to touch the
77
+ // `storage_objects` rows those columns pointed at (a Restrict's ON DELETE
78
+ // action fires only when deleting the REFERENCED row, never when the
79
+ // referencing row disappears by any means, cascade included). A user
80
+ // deleted by a path other than the transcript's own `DELETE
81
+ // /api/transcripts/:id` → `transcript.purge` route therefore leaves that
82
+ // transcript's audio, rendition, raw result and export files as orphaned
83
+ // `storage_objects` rows — reclaimable, but not reclaimed by this cascade.
84
+ // This mirrors the same asymmetry already accepted for `WorkerNode
85
+ // .createdBy`'s Cascade (deleting the SQL row is not the same act as
86
+ // deleting the credential's real-world effects) and is exactly why spec §10
87
+ // frames `transcript.purge` as the only complete-deletion path — a future
88
+ // issue that adds account deletion as a first-class flow should route it
89
+ // through that job rather than a bare `prisma.user.delete()`. This is a
90
+ // decision made explicitly for issue #24, not an oversight; #25–#29 are the
91
+ // right place to close it if account deletion becomes a real user-facing
92
+ // flow.
93
+ //
94
+ // -----------------------------------------------------------------------------
95
+ // speaker_id on transcript_segments — also Restrict, for a same-feature
96
+ // reason rather than a cross-module one
97
+ // -----------------------------------------------------------------------------
98
+ // Unlike the storage FKs above, this Restrict is WITHIN the transcript
99
+ // feature: a speaker may not be deleted while any segment still names it.
100
+ // `speaker.merge` (spec §4.1) is the only op that ever deletes a speaker, and
101
+ // it re-points every one of that speaker's segments to the merge target
102
+ // FIRST, in the same transaction, before deleting the source speaker rows —
103
+ // exactly the shape Restrict requires and enforces. A hypothetical direct
104
+ // "delete this speaker" operation with segments still attached is therefore
105
+ // unrepresentable at the database level, not merely disallowed by service
106
+ // code that could have a bug.
107
+ //
108
+ // -----------------------------------------------------------------------------
109
+ // Hand-written, Prisma-inexpressible constraints — INTENTIONAL SCHEMA DRIFT,
110
+ // the same pattern `jobs_active_dedup_uniq_idx` / `database_backup_runs
111
+ // _active_uniq_idx` already establish
112
+ // -----------------------------------------------------------------------------
113
+ // `transcript_speakers_transcript_id_label_key`, declared only in this
114
+ // migration's `migration.sql`, not as a `@@unique` here: a provider-supplied
115
+ // `label` (e.g. `"A"`) must be unique per transcript, but a user-created
116
+ // speaker (`speaker.create`, spec §4.1) legitimately has none, and Prisma's
117
+ // `@@index`/`@@unique` DSL has no syntax for the partial index's `WHERE
118
+ // "label" IS NOT NULL` clause that scopes the constraint to labelled rows.
119
+ // See that migration file's own header for the full accounting. Every OTHER
120
+ // uniqueness constraint below (`transcript_versions` on `(transcriptId,
121
+ // version)` and `(transcriptId, clientBatchId)`) is fully expressible as a
122
+ // plain `@@unique` and is declared that way — Postgres's standard "NULLS DISTINCT" behaviour already
123
+ // gives `clientBatchId` exactly the semantics spec §5 wants (many versions
124
+ // with a null `clientBatchId`, at most one per transcript for any given
125
+ // non-null value) with no partial index required.
126
+ // =============================================================================
127
+
128
+ enum TranscriptStatus {
129
+ uploading
130
+ processing
131
+ ready
132
+ failed
133
+ deleting
134
+ }
135
+
136
+ enum TranscriptionStatus {
137
+ waiting_input
138
+ queued
139
+ submitting
140
+ submitted
141
+ processing
142
+ completed
143
+ failed
144
+ cancelled
145
+ }
146
+
147
+ enum PlaybackStatus {
148
+ pending
149
+ processing
150
+ ready
151
+ failed
152
+ not_needed
153
+ }
154
+
155
+ model Transcript {
156
+ id String @id @default(uuid()) @db.Uuid
157
+
158
+ ownerId String @map("owner_id") @db.Uuid
159
+ // The organization this transcript belongs to (issue #485): the scope the
160
+ // platform sharing slice decides access in (`grants.org_id` must equal it).
161
+ // Single-organization deployment, so every row carries the default
162
+ // organization. `transcripts` is deliberately NOT under row-level security:
163
+ // `AccessPolicy` and the conformance suite need only this column (spike in
164
+ // the PR for #485, `registerResourceType` in `app-registrations/sharing.ts`).
165
+ orgId String @map("org_id") @db.Uuid
166
+ title String
167
+ language String?
168
+
169
+ status TranscriptStatus @default(uploading)
170
+ transcriptionStatus TranscriptionStatus @default(waiting_input) @map("transcription_status")
171
+ playbackStatus PlaybackStatus @default(pending) @map("playback_status")
172
+
173
+ // The storage triple. See the block comment above for why every one of
174
+ // these three is `onDelete: Restrict`, and why that is the opposite choice
175
+ // from `ownerId` just above despite the same `String @db.Uuid` shape.
176
+ sourceObjectId String @map("source_object_id") @db.Uuid
177
+ playbackObjectId String? @map("playback_object_id") @db.Uuid
178
+ rawResultObjectId String? @map("raw_result_object_id") @db.Uuid
179
+
180
+ // Which `TranscriptionProvider` (spec §2.1) this transcript was submitted
181
+ // to, and that provider's own job id once submitted. Plain strings, not an
182
+ // enum/FK — the provider registry is code-owned and open (a future
183
+ // provider is one new class, per spec §2.1), the same reasoning `Job.type`
184
+ // and `storage_objects.managed_by` already use for an open, code-owned set.
185
+ provider String
186
+ providerJobId String? @map("provider_job_id")
187
+ // `{ speakersExpected?, language? }` (spec §2.1/§3.1) — the per-transcript
188
+ // overrides a submitter chose, opaque JSONB for the same reason every
189
+ // other provider-shaped payload in this schema (`Job.payload`) is: a
190
+ // provider-specific option set must not force a migration per provider.
191
+ providerOptions Json? @map("provider_options") @db.JsonB
192
+
193
+ submittedAt DateTime? @map("submitted_at") @db.Timestamptz
194
+ lastPolledAt DateTime? @map("last_polled_at") @db.Timestamptz
195
+ completedAt DateTime? @map("completed_at") @db.Timestamptz
196
+ // Set once `provider.deleteRemote` succeeds (spec §1.5.2–4, §10) — either
197
+ // right after `transcription.ingest` commits (the default,
198
+ // `deleteRemoteAfterIngest: true`) or by `transcript.purge` if it had not
199
+ // happened yet. NULL means the provider may still hold a copy.
200
+ remoteDeletedAt DateTime? @map("remote_deleted_at") @db.Timestamptz
201
+ // Set when `DELETE /api/transcripts/:id` moves this row to `deleting`
202
+ // (spec §1.1) — i.e. WHEN REMOVAL WAS REQUESTED, not when it finished:
203
+ // spec §10 is explicit that `transcript.purge` deletes the SQL row once it
204
+ // is done ("only then deletes the SQL rows"), and the `TranscriptStatus`
205
+ // enum has no terminal `deleted` member for a finished purge to move to.
206
+ // This column therefore answers "how long has this been in `deleting`" —
207
+ // the same question `database_backup_runs.lastHeartbeatAt` answers for a
208
+ // running dump — for as long as the row exists to ask it of; it never
209
+ // survives to describe a completed deletion, because nothing does.
210
+ deletedAt DateTime? @map("deleted_at") @db.Timestamptz
211
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
212
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
213
+ // When the recording was made — defaults to the upload instant; the Meeting
214
+ // date the knowledge graph reads (docs/specs/ontology.md §5.4). Issue #352:
215
+ // backfilled from `created_at`, and `TranscriptsService.create` stamps it
216
+ // with the same `Date` instance as `createdAt`. Editable by the owner or an
217
+ // editor through `PATCH /api/transcripts/:id`; not versioned, like `title`.
218
+ recordedAt DateTime @default(now()) @map("recorded_at") @db.Timestamptz
219
+
220
+ durationMs Int? @map("duration_ms")
221
+ failureReason String? @map("failure_reason")
222
+ // Bumped by the SAME conditional `UPDATE ... WHERE current_version = $n`
223
+ // spec §5 describes for the batch-save race — 0 until `transcription
224
+ // .ingest` writes version 1 inside its own transaction (spec §1.5.4).
225
+ currentVersion Int @default(0) @map("current_version")
226
+ // Denormalized off `transcript_speakers`/`transcript_segments` so a list
227
+ // view never joins and counts on every row, matching the "cheap to read
228
+ // often, recomputed by the one write path that changes the true count"
229
+ // shape `worker_nodes` and `job_stats_rollup` already use for their own
230
+ // derived numbers elsewhere in this schema.
231
+ speakerCount Int @default(0) @map("speaker_count")
232
+ wordCount Int @default(0) @map("word_count")
233
+
234
+ // A map `speakerId → displayName` recording the first time a user named an
235
+ // AI-detected speaker (e.g. "Speaker A" -> "Oscar") — issue #323. That
236
+ // naming is an IDENTIFICATION, metadata about the recording like `title`,
237
+ // not a correction to its content, so it is deliberately NOT versioned:
238
+ // `materialize()` overlays this map onto any speaker still at its ingest
239
+ // default name (`Speaker <label>`) at ANY version, the same way a rename
240
+ // applies uniformly regardless of which version is being viewed. Keys are
241
+ // speaker ids with no FK — an entry for a speaker later merged or deleted
242
+ // is simply ignored on overlay, which is harmless and needs no cleanup.
243
+ speakerIdentities Json @default("{}") @map("speaker_identities") @db.JsonB
244
+
245
+ // GENERATED ALWAYS AS (to_tsvector('english'::regconfig, coalesce(title,
246
+ // ''))) STORED, plus a GIN index — issue #174, epic #164. Hand-written in
247
+ // `20260915120000_add_search_vectors/migration.sql` (Prisma has no DSL for
248
+ // a generated column's expression or an index's access method); see that
249
+ // migration's header for the full argument, including why it is its OWN
250
+ // column rather than folded into `TranscriptSegment.searchVector` (a
251
+ // title match must surface a transcript with no matching segment at all)
252
+ // and why the two-argument `to_tsvector` form is required (IMMUTABLE,
253
+ // unlike the one-argument form). `Unsupported` and nullable-optional even
254
+ // though the database column is generation-populated on every row —
255
+ // Prisma has no syntax for "the database writes this," and an
256
+ // `Unsupported` field is never selectable or writable through the client
257
+ // regardless: every read of this column goes through raw SQL, by design.
258
+ titleSearchVector Unsupported("tsvector")? @map("title_search_vector")
259
+
260
+ owner User @relation("TranscriptOwner", fields: [ownerId], references: [id], onDelete: Cascade)
261
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
262
+ sourceObject StorageObject @relation("TranscriptSourceObject", fields: [sourceObjectId], references: [id], onDelete: Restrict)
263
+ playbackObject StorageObject? @relation("TranscriptPlaybackObject", fields: [playbackObjectId], references: [id], onDelete: Restrict)
264
+ rawResultObject StorageObject? @relation("TranscriptRawResultObject", fields: [rawResultObjectId], references: [id], onDelete: Restrict)
265
+
266
+ speakers TranscriptSpeaker[]
267
+ segments TranscriptSegment[]
268
+ versions TranscriptVersion[]
269
+ exports TranscriptExport[]
270
+ // Every note that named this transcript as its source (issue #48, epic
271
+ // #45) — Restrict on `notes.source_transcript_id`, so a transcript with a
272
+ // dependent note cannot be deleted until `notes.service.ts`'s own delete
273
+ // guard clears it. See the block comment above the `Note` model.
274
+ notesAsSource Note[] @relation("NoteSourceTranscript")
275
+ // AI name-check runs against this transcript (issue #328, epic #326). See
276
+ // the block comment above the `TranscriptNameCheck` model.
277
+ nameChecks TranscriptNameCheck[]
278
+
279
+ // Connected Knowledge (epic #344, issue #351). Both SetNull: an evidence
280
+ // row is a citation that survives losing what it points to (`quote` is
281
+ // what keeps it readable — see the block comment above `KgEvidence`), and
282
+ // a mention Cascades instead (see the block comment above `KgMention`).
283
+ kgEvidence KgEvidence[]
284
+ kgMentions KgMention[]
285
+
286
+ // "This owner's transcripts, newest first" — the transcript list view.
287
+ @@index([ownerId, updatedAt(sort: Desc)])
288
+ // "This owner's transcripts by when they were recorded" (issue #352).
289
+ @@index([ownerId, recordedAt(sort: Desc)])
290
+ // "Every transcript in state X" — `transcripts.housekeeping`'s scans
291
+ // (spec §1.5.8) and any future admin-adjacent health check.
292
+ @@index([status])
293
+ // The organization column's foreign key (issue #485).
294
+ @@index([orgId])
295
+ @@map("transcripts")
296
+ }
297
+
298
+ model TranscriptSpeaker {
299
+ id String @id @default(uuid()) @db.Uuid
300
+ transcriptId String @map("transcript_id") @db.Uuid
301
+
302
+ // The provider's own diarization label (e.g. `"A"`). NULL for a speaker a
303
+ // user created directly (`speaker.create`, spec §4.1), which has none.
304
+ // Unique per transcript AMONG LABELLED ROWS ONLY — see the block comment
305
+ // above the enums for why that constraint is hand-written in
306
+ // `migration.sql` rather than a `@@unique` here.
307
+ label String?
308
+ displayName String @map("display_name")
309
+ colorIndex Int @map("color_index")
310
+ // Optimistic-concurrency counter (spec §5). Starts at 1 — the row's first
311
+ // write (ingest, or `speaker.create`) is already its first version.
312
+ rev Int @default(1)
313
+
314
+ transcript Transcript @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
315
+ segments TranscriptSegment[]
316
+
317
+ // Connected Knowledge (epic #344, issue #351). The `IDENTIFIED_AS` edge a
318
+ // diarized speaker gets linked to a real entity through — Cascade, so a
319
+ // deleted (merged-away) speaker's link goes with it. See the block
320
+ // comment above `KgRelation` for why this is a real FK to a speaker
321
+ // rather than a polymorphic `from_kind`/`from_id` pair.
322
+ kgRelationsAsSpeaker KgRelation[]
323
+
324
+ @@map("transcript_speakers")
325
+ }
326
+
327
+ enum WordsAlignment {
328
+ exact
329
+ interpolated
330
+ none
331
+ }
332
+
333
+ enum SegmentOrigin {
334
+ ai
335
+ user
336
+ }
337
+
338
+ model TranscriptSegment {
339
+ // STABLE ACROSS EDITS (spec §3.5, §4.1's `segment.split` test vector): a
340
+ // split keeps this id on the earlier half specifically so a bookmark, an
341
+ // export reference, or a concurrent editor's stale `rev` for this segment
342
+ // still names something real.
343
+ id String @id @default(uuid()) @db.Uuid
344
+ transcriptId String @map("transcript_id") @db.Uuid
345
+ // Restrict, not Cascade or SetNull — see the block comment above the
346
+ // enums: a speaker may not be deleted while a segment still names it;
347
+ // `speaker.merge` re-points every segment away first.
348
+ speakerId String @map("speaker_id") @db.Uuid
349
+
350
+ startMs Int @map("start_ms")
351
+ endMs Int @map("end_ms")
352
+ // Gap-based float, NOT a dense integer sequence (spec §3.4): a
353
+ // `segment.split` only needs the midpoint between its two neighbours and
354
+ // touches no other row, versus an integer sequence that would need to
355
+ // renumber every later segment on every single split.
356
+ ordinal Float
357
+
358
+ text String
359
+ // `[{t, s, e, c}]` — text, start, end, confidence per word (spec §3.3),
360
+ // kept terse because a 10-hour transcript's word array is the single
361
+ // largest thing in this schema. Opaque JSONB, not a child table: word rows
362
+ // are never queried or updated independently of their segment.
363
+ words Json @db.JsonB
364
+ // How much of `words[]` to trust after an edit (spec §3.5). `exact` is the
365
+ // default because a freshly ingested (`ai_original`) segment, and either
366
+ // half of a `segment.split`/`segment.join`, are always exact — nothing
367
+ // about dividing or concatenating an existing array invents a timing.
368
+ wordsAlignment WordsAlignment @default(exact) @map("words_alignment")
369
+ confidence Float?
370
+
371
+ // Whether this segment's TEXT originated from the provider or was created
372
+ // by a person. Defaults `ai` because every segment `transcription.ingest`
373
+ // writes is exactly that; nothing in spec §4.1's op table currently
374
+ // creates a segment from nothing (only `speaker.create` has a from-nothing
375
+ // shape), so `user` exists for a future op without one today.
376
+ origin SegmentOrigin @default(ai)
377
+ // Optimistic-concurrency counter (spec §5), same starting value and same
378
+ // reasoning as `TranscriptSpeaker.rev` above.
379
+ rev Int @default(1)
380
+ editedById String? @map("edited_by_id") @db.Uuid
381
+ editedAt DateTime? @map("edited_at") @db.Timestamptz
382
+
383
+ // GENERATED ALWAYS AS (to_tsvector('english'::regconfig, coalesce(text,
384
+ // ''))) STORED, plus a GIN index — issue #174, epic #164. Unweighted,
385
+ // unlike `Note.searchVector` below: a segment is one line of speech with
386
+ // no title of its own to weight against. Hand-written in
387
+ // `20260915120000_add_search_vectors/migration.sql`; see that migration's
388
+ // header for the full argument (why the two-argument `to_tsvector` form
389
+ // is required, why `'english'` is a stated v1 limitation, why a generated
390
+ // column rather than a trigger — a trigger does not fire under the
391
+ // `session_replication_role = replica` a database restore runs under).
392
+ // `Unsupported` and nullable-optional for the same reason
393
+ // `Transcript.titleSearchVector` is: Prisma cannot express "the database
394
+ // writes this," and this field is never selectable or writable through
395
+ // the client — every read goes through raw SQL.
396
+ searchVector Unsupported("tsvector")? @map("search_vector")
397
+
398
+ transcript Transcript @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
399
+ speaker TranscriptSpeaker @relation(fields: [speakerId], references: [id], onDelete: Restrict)
400
+ // SetNull, matching `AuditEvent.actorUserId`/`Credential.updatedByUserId`:
401
+ // "who last hand-edited this line" is an audit fact about the segment, not
402
+ // something the segment's own existence depends on.
403
+ editedBy User? @relation("TranscriptSegmentEditor", fields: [editedById], references: [id], onDelete: SetNull)
404
+
405
+ // AI name-check suggestions proposed against this segment (issue #328,
406
+ // epic #326). See the block comment above the `TranscriptNameCheck` model.
407
+ nameSuggestions TranscriptNameSuggestion[]
408
+
409
+ // Connected Knowledge (epic #344, issue #351). SetNull — an evidence row
410
+ // is a citation that survives losing the segment it pointed to; `quote`
411
+ // is what keeps it readable afterwards. See the block comment above
412
+ // `KgEvidence`.
413
+ kgEvidence KgEvidence[]
414
+
415
+ // The compound order both a segment list and a time-window query
416
+ // (`GET /:id/words?fromMs&toMs`) need (spec §3.3).
417
+ @@index([transcriptId, startMs, ordinal])
418
+ @@map("transcript_segments")
419
+ }
420
+
421
+ enum TranscriptVersionKind {
422
+ ai_original
423
+ edit
424
+ restore
425
+ }
426
+
427
+ model TranscriptVersion {
428
+ id String @id @default(uuid()) @db.Uuid
429
+ transcriptId String @map("transcript_id") @db.Uuid
430
+ version Int
431
+ kind TranscriptVersionKind
432
+
433
+ // NULL means "the AI" — the ingest handler is the only writer that ever
434
+ // leaves this null (spec §4.5); every human-triggered version (`edit`,
435
+ // `restore`) always has a real author. SetNull, not Restrict/Cascade: a
436
+ // version is permanent history (spec §4.5 — "nothing in this design ever
437
+ // deletes a `transcript_versions` row"; only `transcript.purge` deleting
438
+ // the whole transcript does) and must survive the author's own account
439
+ // being deleted, the same audit posture as `DatabaseBackupRun.createdById`.
440
+ authorId String? @map("author_id") @db.Uuid
441
+ summary String?
442
+ // The concrete ops this version applied (spec §4.1) — e.g. the expanded
443
+ // `segment.update_text` ops a `transcript.find_replace` call becomes
444
+ // (spec §4.2), NEVER the abstract find/replace call itself, so replaying
445
+ // history stays correct even if this application's matching logic changes
446
+ // later.
447
+ ops Json @db.JsonB
448
+
449
+ // The version number this row restored FROM, when `kind: restore` (spec
450
+ // §4.5). A plain version number, not a self-relation FK: it names another
451
+ // row's `(transcriptId, version)` pair, which is not a column any FK can
452
+ // point at directly, and a restore's target version is immutable history
453
+ // that is never itself deleted (short of purging the whole transcript).
454
+ restoredFromVersion Int? @map("restored_from_version")
455
+ // The gzipped materialized state at this version, when this version was
456
+ // snapshotted (spec §4.3 — always for v1 and every restore, otherwise on a
457
+ // size-or-count trigger). NULL for a version materialized purely by
458
+ // replaying `ops` from the nearest earlier snapshot.
459
+ snapshotObjectId String? @map("snapshot_object_id") @db.Uuid
460
+ // Idempotency key for a retried save (spec §5): a client that saved but
461
+ // never saw the response retries with the SAME `clientBatchId`, and
462
+ // `@@unique([transcriptId, clientBatchId])` below is what lets the write
463
+ // path detect the retry and return the original version instead of
464
+ // creating a second one. NULL for a version this application created
465
+ // itself (ingest, restore) rather than in response to a client batch —
466
+ // Postgres's standard NULLS-DISTINCT behaviour is exactly what is wanted
467
+ // there: any number of null-`clientBatchId` versions may coexist.
468
+ clientBatchId String? @map("client_batch_id")
469
+
470
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
471
+
472
+ transcript Transcript @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
473
+ author User? @relation("TranscriptVersionAuthor", fields: [authorId], references: [id], onDelete: SetNull)
474
+ // Restrict, matching every other storage_objects FK on this feature — see
475
+ // the block comment above the enums.
476
+ snapshotObject StorageObject? @relation("TranscriptVersionSnapshotObject", fields: [snapshotObjectId], references: [id], onDelete: Restrict)
477
+
478
+ // The version sequence itself, and what `materialize()` (spec §4.4) looks
479
+ // up by.
480
+ @@unique([transcriptId, version])
481
+ // The idempotent-retry key described above. Plain `@@unique`, not a
482
+ // hand-written partial index: Postgres's default NULLS DISTINCT behaviour
483
+ // already gives exactly the wanted semantics for a nullable column, unlike
484
+ // `transcript_speakers`' label uniqueness above.
485
+ @@unique([transcriptId, clientBatchId])
486
+ @@map("transcript_versions")
487
+ }
488
+
489
+ enum TranscriptExportStatus {
490
+ pending
491
+ ready
492
+ failed
493
+ }
494
+
495
+ model TranscriptExport {
496
+ id String @id @default(uuid()) @db.Uuid
497
+ transcriptId String @map("transcript_id") @db.Uuid
498
+ version Int
499
+
500
+ // The exporter's own registry key (`markdown`, `pdf`, or the permanent
501
+ // published JSON format spec §8.2 names). Plain string, not an enum, for
502
+ // the same self-registering-registry reason `Job.type` is: adding an
503
+ // exporter (spec §8.1 — "one new class registering itself") must cost
504
+ // zero migrations to this table.
505
+ format String
506
+ // The requester's export options, as passed to the exporter's own
507
+ // `optionsSchema` (spec §8.1) — opaque JSONB, since the option shape is
508
+ // defined by the exporter, not by this table.
509
+ options Json @db.JsonB
510
+ // sha256 (or similar) of `{ format, version, options }` (spec §8.5) — the
511
+ // content-addressed key `POST /:id/exports` looks up to decide whether an
512
+ // existing, unexpired export can be returned instead of rendering again.
513
+ optionsHash String @map("options_hash")
514
+ status TranscriptExportStatus @default(pending)
515
+
516
+ // The rendered file, once `status: ready`. Restrict, matching every other
517
+ // storage_objects FK on this feature (block comment above the enums) —
518
+ // only `transcripts.housekeeping`'s expiry sweep (spec §8.5) may remove
519
+ // it, and only after deleting this row first.
520
+ objectId String? @map("object_id") @db.Uuid
521
+ // The `transcript.export` job that rendered this row, when one did (spec
522
+ // §1.5.6). `@unique`/SetNull, mirroring `DatabaseBackupRun.jobId` exactly:
523
+ // a second render for the SAME job is unrepresentable, this row's own
524
+ // 7-day retention is independent of `job.history.purge`'s schedule for the
525
+ // underlying `jobs` row, and losing the link on a history purge must never
526
+ // mean losing the export.
527
+ jobId String? @unique @map("job_id") @db.Uuid
528
+ // Who asked for this export. NOT NULL and Cascade — unlike every SetNull
529
+ // audit column elsewhere in this schema, an export is a disposable,
530
+ // byte-for-byte reproducible artifact with a 7-day expiry already built in
531
+ // (spec §8.5): nothing is lost by deleting a pending/ready export row
532
+ // alongside the account that requested it, the same way nothing is lost
533
+ // by `transcripts.housekeeping` expiring it on its own schedule.
534
+ requestedById String @map("requested_by_id") @db.Uuid
535
+
536
+ error String?
537
+ // 7 days out from creation by default (spec §8.5) — what
538
+ // `transcripts.housekeeping`'s sweep compares against.
539
+ expiresAt DateTime @map("expires_at") @db.Timestamptz
540
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
541
+
542
+ transcript Transcript @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
543
+ object StorageObject? @relation("TranscriptExportObject", fields: [objectId], references: [id], onDelete: Restrict)
544
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
545
+ requestedBy User @relation("TranscriptExportRequestedBy", fields: [requestedById], references: [id], onDelete: Cascade)
546
+
547
+ // The reuse-by-content-hash lookup spec §8.5 names explicitly ("the index
548
+ // issue #24 declares for exactly this lookup"). Not unique: an expired or
549
+ // failed row may briefly coexist with a fresh one sharing the same key
550
+ // before housekeeping removes the stale one.
551
+ //
552
+ // `map:` pins an explicit name: Prisma's own default-generated name for
553
+ // this four-column index is 64 bytes, one over PostgreSQL's 63-byte
554
+ // identifier limit, which would otherwise leave the exact truncated name
555
+ // to guess at in the hand-written migration.sql this issue ships without a
556
+ // live database to diff against.
557
+ @@index([transcriptId, version, format, optionsHash], map: "transcript_exports_lookup_idx")
558
+ @@map("transcript_exports")
559
+ }
560
+
561
+ // =============================================================================
562
+ // Transcript Name Checks (issue #328, epic #326)
563
+ // =============================================================================
564
+ //
565
+ // AI-assisted detection of misspelled/mis-transcribed proper names and terms
566
+ // across a transcript's segments. A `TranscriptNameCheck` is one AI run —
567
+ // the request, its cost accounting, and its terminal state — and a
568
+ // `TranscriptNameSuggestion` is one PROPOSED edit that run produced. Both
569
+ // tables are deliberately never a second source of truth for transcript
570
+ // content: accepting a suggestion turns it into an ordinary
571
+ // `segment.update_text` op recorded through the exact same `/operations`
572
+ // path and `TranscriptVersion` history every other correction uses (see the
573
+ // block comment above `TranscriptVersion`) — these rows never write
574
+ // `transcript_segments.text` directly, only propose a change to it.
575
+ // `segmentRev` on a suggestion pins the `TranscriptSegment.rev` its offsets
576
+ // were computed against, the identical optimistic-concurrency shape
577
+ // `TranscriptVersion`'s `rev` split already uses elsewhere in this schema,
578
+ // so a suggestion whose segment was edited out from under it is detectable
579
+ // at apply time rather than silently misapplied at the wrong offset —
580
+ // `status: stale` is that outcome, not a bug report.
581
+
582
+ enum TranscriptNameCheckMode {
583
+ standard
584
+ thorough
585
+ }
586
+
587
+ enum TranscriptNameCheckStatus {
588
+ pending
589
+ running
590
+ ready
591
+ failed
592
+ }
593
+
594
+ model TranscriptNameCheck {
595
+ id String @id @default(uuid()) @db.Uuid
596
+ transcriptId String @map("transcript_id") @db.Uuid
597
+
598
+ // Who asked for this run. SetNull, not Cascade/Restrict — unlike
599
+ // `TranscriptExport.requestedById` (a disposable, re-renderable artifact
600
+ // deleted with its requester), a name-check run and the suggestions it
601
+ // produced are worth keeping as transcript history even after the
602
+ // requesting account is gone, the same audit posture
603
+ // `TranscriptVersion.authorId` already takes.
604
+ requestedById String? @map("requested_by_id") @db.Uuid
605
+
606
+ // `standard` (segments only) vs `thorough` (a wider discovery pass) — a
607
+ // closed, spec-defined pair, worth a real enum rather than a string.
608
+ mode TranscriptNameCheckMode
609
+ status TranscriptNameCheckStatus @default(pending)
610
+
611
+ // The `Transcript.currentVersion` this run read segment text from,
612
+ // matching `TranscriptExport.version`'s reasoning: a run started against
613
+ // one version must not be silently reinterpreted against a later one once
614
+ // edits land mid-run.
615
+ basedOnVersion Int @map("based_on_version")
616
+
617
+ // The target names/terms this run checked for — a string array, opaque
618
+ // JSONB for the same reason `TranscriptSegment.words` and
619
+ // `Transcript.providerOptions` are: the caller-supplied or AI-discovered
620
+ // term list has no shape this table needs to enforce.
621
+ terms Json @db.JsonB
622
+
623
+ // Which AI provider/model produced this run's suggestions — plain
624
+ // strings, not an enum/FK, matching `Transcript.provider`'s own
625
+ // open-registry reasoning: a new provider must cost zero migrations here.
626
+ providerId String? @map("provider_id")
627
+ model String?
628
+
629
+ // Denormalized off `transcript_name_suggestions` so a list/detail read
630
+ // never joins and counts on every request, matching
631
+ // `Transcript.speakerCount`/`.wordCount`'s "cheap to read often,
632
+ // recomputed by the one write path that changes the true count" shape.
633
+ candidateCount Int @default(0) @map("candidate_count")
634
+ suggestionCount Int @default(0) @map("suggestion_count")
635
+
636
+ // Cost accounting for this run's own AI call(s), the same pair
637
+ // `NoteGeneration` tracks per generation.
638
+ inputTokens Int @default(0) @map("input_tokens")
639
+ outputTokens Int @default(0) @map("output_tokens")
640
+
641
+ // A closed-in-spirit taxonomy kept as a plain string rather than a second
642
+ // copy of `NoteGenerationErrorClass`'s enum, matching `Job.type`'s own
643
+ // "plain string, code-owned set" reasoning.
644
+ errorClass String? @map("error_class")
645
+ error String?
646
+
647
+ // The `transcript.name_check` job that ran this check, when one did. `@unique`/
648
+ // nullable/`SetNull`, mirroring `TranscriptExport.jobId`/
649
+ // `DatabaseBackupRun.jobId` exactly: this row's own lifetime (read by the
650
+ // review UI long after job history is purged) is independent of
651
+ // `job.history.purge`'s schedule for the underlying `jobs` row.
652
+ jobId String? @unique @map("job_id") @db.Uuid
653
+
654
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
655
+ startedAt DateTime? @map("started_at") @db.Timestamptz
656
+ completedAt DateTime? @map("completed_at") @db.Timestamptz
657
+
658
+ transcript Transcript @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
659
+ requestedBy User? @relation("TranscriptNameCheckRequestedBy", fields: [requestedById], references: [id], onDelete: SetNull)
660
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
661
+
662
+ suggestions TranscriptNameSuggestion[]
663
+
664
+ // "This transcript's name-check runs, newest first" — the run history
665
+ // list.
666
+ @@index([transcriptId, createdAt(sort: Desc)])
667
+ @@map("transcript_name_checks")
668
+ }
669
+
670
+ enum TranscriptNameSuggestionStatus {
671
+ pending
672
+ accepted
673
+ rejected
674
+ stale
675
+ }
676
+
677
+ model TranscriptNameSuggestion {
678
+ id String @id @default(uuid()) @db.Uuid
679
+ checkId String @map("check_id") @db.Uuid
680
+
681
+ // Cascade, matching `TranscriptSegment`'s own relation to `Transcript`: a
682
+ // suggestion against a segment that no longer exists (a later
683
+ // `segment.split`/`segment.join`/delete) has nothing left to apply to.
684
+ segmentId String @map("segment_id") @db.Uuid
685
+ // The `TranscriptSegment.rev` these offsets were computed against — see
686
+ // the block comment above for why a mismatch at apply time means `stale`,
687
+ // never a silently wrong offset.
688
+ segmentRev Int @map("segment_rev")
689
+
690
+ // UTF-16 offsets into `TranscriptSegment.text` at `segmentRev` — matching
691
+ // JavaScript string indexing end to end, so the editor, the API and this
692
+ // column all agree on one encoding.
693
+ start Int
694
+ end Int
695
+
696
+ original String
697
+ replacement String
698
+
699
+ confidence Float?
700
+ reason String?
701
+ // `'phonetic' | 'discovery'` today — plain string, not an enum, matching
702
+ // `Transcript.provider`'s own open-registry reasoning: which detection
703
+ // strategies exist is code, not schema.
704
+ source String
705
+
706
+ status TranscriptNameSuggestionStatus @default(pending)
707
+ decidedAt DateTime? @map("decided_at") @db.Timestamptz
708
+ // SetNull, matching `TranscriptSegment.editedById`: "who accepted or
709
+ // rejected this suggestion" is an audit fact about the suggestion, not
710
+ // something its own existence depends on.
711
+ decidedById String? @map("decided_by_id") @db.Uuid
712
+
713
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
714
+
715
+ check TranscriptNameCheck @relation(fields: [checkId], references: [id], onDelete: Cascade)
716
+ segment TranscriptSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade)
717
+ decidedBy User? @relation("TranscriptNameSuggestionDecidedBy", fields: [decidedById], references: [id], onDelete: SetNull)
718
+
719
+ // "This run's suggestions, filtered by status" — the review UI's default
720
+ // list.
721
+ @@index([checkId, status])
722
+ // "Every suggestion touching this segment" — applying/staling suggestions
723
+ // when a segment is edited elsewhere.
724
+ @@index([segmentId])
725
+ @@map("transcript_name_suggestions")
726
+ }