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