File size: 20,609 Bytes
b68816f f07443e b68816f f07443e b68816f f07443e b68816f f07443e b68816f f07443e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | -- Knowledge pipeline tables β dedorch
--
-- HANDOFF TO HARRY. Updated 2026-09-14. Source of record:
-- docs/knowledge/KNOWLEDGE_PERSISTENCE_CONTRACT.md Β§2.
--
-- β οΈ OWNERSHIP. Go owns every dedorch migration (CLAUDE.md Β§2.2). Python
-- executes NO DDL, ever: `SKIP_INIT_DB` stays true, there is no `create_all`
-- and no Alembic against this database. Everything in this file was run ONCE by
-- an operator to unblock the build, following the 2026-07-13 `message_charts`
-- precedent β and that precedent's mistake is the one this file exists to avoid:
-- the schema was run by hand and never handed over, so a fresh environment built
-- from Go's migrations comes up WITHOUT those tables, and because both write
-- paths are never-throw seams, nothing errors. They just stop recording.
--
-- Run by hand, then hand over. Never run by hand only.
--
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- WHAT IS LIVE IN DEDORCH RIGHT NOW, AND WHAT YOU ALREADY HAVE
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
--
-- Β§1-Β§5 five tables run 2026-09-07 sent β
-- Β§6 4 columns + 2 indexes run 2026-09-14 sent β
-- Β§7 3 columns (review modes) run 2026-09-14 NOT SENT β¬
new
--
-- **If you adopted the copy sent before 2026-09-14, it is now incomplete in two
-- ways, and one of them is silent:**
--
-- 1. Β§7 is missing entirely. Without it, `POST /knowledge/ingest` with
-- `review_mode=llm` fails on insert and no entry is ever auto-approved.
-- 2. Β§1-Β§5 DRIFTED AFTER the 2026-09-07 run, and `CREATE TABLE IF NOT EXISTS`
-- will not repair an existing table. Two edits landed:
-- * `knowledge_entries.kind` CHECK gained 'document'
-- * `knowledge_entries.run_id` / `.knowledge_document_id` became NULLABLE
-- for kind='domain' only, guarded by knowledge_entries_parent_check
-- Both are reflected in Β§3 below. **Take the current file, not the old one.**
--
-- Nothing here needs designing. The ask is only that the migration set
-- reproduce what is already running, so a fresh environment matches.
--
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- STILL OUTSTANDING FROM AN EARLIER ROUND β not in this file
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
--
-- `message_traceability` (80 rows) and `message_charts` (6 rows) have been live
-- since July, created the same way, and are still absent from the migration set.
-- Their DDL is in KNOWLEDGE_PERSISTENCE_CONTRACT.md Β§appendix rather than here.
-- Say the word and it gets folded into this file so one artifact covers every
-- Python-written table you are missing.
--
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- VERIFIED AGAINST LIVE β 2026-09-14, read-only
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
--
-- Not 'what we believe ran'. Read back out of dedorch after the fact:
--
-- 5 tables knowledge_documents 19 cols Β· knowledge_entries 18 Β·
-- knowledge_extraction_runs 20 Β· knowledge_jobs 16 Β·
-- knowledge_reviews 13
-- Β§6+Β§7 columns 7 of 7 present (doc_ids, content, provenance,
-- withdrawn_at, review_mode, decided_by, authorised_by)
-- indexes 19 on knowledge_* (12 named here + 7 PK/unique)
--
-- The ORM also reads every one of them against live, so the Python models
-- and this file agree with the database rather than only with each other.
--
-- Idempotent throughout (IF NOT EXISTS) Β· Additive only: no DROP, no type
-- change, nothing that rewrites an existing column.
-- 1. Parsed document artifact. Chunks live INSIDE `artifact` (jsonb), deliberately
-- not a child table: documents are small (9 pages = 31 chunks) and the artifact
-- is versioned as a unit.
CREATE TABLE IF NOT EXISTS knowledge_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id text NOT NULL, -- pipeline key, e.g. 'STD_2026_006_MNO' (see 5 Q1)
document_id text REFERENCES documents(id), -- Q1 CLOSED 2026-09-07: documents.id is text PK.
-- Null for local CLI parses; set when ingested from an uploaded doc.
scope_id text NOT NULL, -- TENANT KEY: the company. Provisional value -
-- normalised users.company, else 'user:<user_id>'. See revision note 3.
user_id text NOT NULL, -- the uploader, not the owner of the knowledge
source_path text NOT NULL,
content_hash text NOT NULL, -- sha256 of the SOURCE FILE
n_pages integer NOT NULL,
version integer NOT NULL DEFAULT 1,
schema_version text NOT NULL, -- ParsedDocument contract version, e.g. '0.3.1'
parser_name text NOT NULL DEFAULT 'mistral', -- Mistral OCR is the primary parser since 2026-09-02
parser_version text, -- '3.4.4'
parser_backend text, -- 'mistral' | 'pipeline' | 'hybrid' | 'vlm' (see section 3)
parser_config text, -- fingerprint of output-affecting settings
normaliser_version text, -- fingerprint of our post-processing code
raw_output_dir text, -- blob key for the untouched parser output
artifact jsonb NOT NULL, -- full ParsedDocument, chunks[] included
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (doc_id, content_hash, version)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_scope ON knowledge_documents (scope_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_user ON knowledge_documents (user_id);
-- 2. One row per extraction run.
CREATE TABLE IF NOT EXISTS knowledge_extraction_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
knowledge_document_id uuid NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key, see revision note 3
user_id text NOT NULL,
status text NOT NULL DEFAULT 'running', -- running | succeeded | failed
branches jsonb NOT NULL DEFAULT '[]', -- ['glossary','rule','formula','summary']
model_deployment text, -- OUR name for it; stable
model_version text, -- returned by the API; CAN move under a stable deployment
system_fingerprint text,
n_calls integer NOT NULL DEFAULT 0,
prompt_tokens integer NOT NULL DEFAULT 0,
cached_tokens integer NOT NULL DEFAULT 0,
completion_tokens integer NOT NULL DEFAULT 0,
usage jsonb NOT NULL DEFAULT '[]', -- CallUsage[]
rejected jsonb NOT NULL DEFAULT '[]', -- RejectedField[] - what the span check refused
counts jsonb NOT NULL DEFAULT '{}', -- cluster/entry counts, link dangle counts
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_knowledge_runs_doc ON knowledge_extraction_runs (knowledge_document_id);
-- 3. Candidate entries - all four kinds in one table.
CREATE TABLE IF NOT EXISTS knowledge_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Nullable since 2026-09-08, and only for kind='domain'. Every other entry
-- is produced BY a run FROM a document, but the scope-level domain
-- declaration is composed across all of them and belongs to neither. The
-- CHECK below is what keeps that from becoming a loophole for the rest.
run_id uuid REFERENCES knowledge_extraction_runs(id) ON DELETE CASCADE,
knowledge_document_id uuid REFERENCES knowledge_documents(id) ON DELETE CASCADE,
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key; every read filters on it
user_id text NOT NULL,
-- 'document' added 2026-09-08, BEFORE this file was adopted, so it costs an
-- edit rather than a migration. Under the v4 plan (KNOWLEDGE_DOMAIN_CONTEXT_V4.md)
-- 'domain' becomes the SCOPE-level semantic model - measures, entities,
-- conventions, boundary - and 'document' takes over the per-document brief
-- that 'domain' holds today. Both values are legal now so the split can land
-- without a second schema change.
kind text NOT NULL
CHECK (kind IN ('glossary','rule','formula','domain','document')),
entity_id text NOT NULL, -- term_id | rule_id | formula_id | domain_id
label text, -- term / formula name / rule head, for listing without opening jsonb
mention_count integer NOT NULL DEFAULT 0,
extraction_status text, -- ok | no_definition_found | escalated
diff_status text, -- new | duplicate | conflicting
definition_conflict boolean NOT NULL DEFAULT false,
payload jsonb NOT NULL, -- full GlossaryEntry | RuleEntry | FormulaEntry | DomainContext
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (run_id, kind, entity_id),
-- Only a domain declaration may be parentless; anything else without a run
-- and a document is a bug, not a scope-level object.
CONSTRAINT knowledge_entries_parent_check CHECK (
kind = 'domain' OR (run_id IS NOT NULL AND knowledge_document_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_scope ON knowledge_entries (scope_id, kind);
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_doc_kind ON knowledge_entries (doc_id, kind);
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_entity ON knowledge_entries (entity_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_queue
ON knowledge_entries (run_id, definition_conflict DESC, mention_count DESC);
-- 4. Expert decisions. APPEND-ONLY - no UPDATE, no DELETE.
-- A changed mind is a NEW row; current state = latest row per entity_id.
CREATE TABLE IF NOT EXISTS knowledge_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id uuid NOT NULL REFERENCES knowledge_entries(id) ON DELETE RESTRICT,
-- RESTRICT, not CASCADE: a cascade would let entry pruning silently delete an
-- expert's rulings, destroying the audit trail the entity_id keying protects. See 3.5.
entity_id text NOT NULL, -- the DURABLE key: survives re-runs, unlike entry_id
doc_id text NOT NULL,
scope_id text NOT NULL, -- tenant key, see revision note 3
content_hash text NOT NULL, -- WHICH artifact version this decision was made against
decision text NOT NULL CHECK (decision IN ('approved','edited','rejected')),
edited_payload jsonb, -- populated only when decision = 'edited'
note text,
reviewer_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_knowledge_reviews_entity ON knowledge_reviews (entity_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_knowledge_reviews_entry ON knowledge_reviews (entry_id);
-- 5. Ingestion jobs. ASYNC ENVELOPE, and the batch unit: Data Eyond's flow is
-- one click over MANY documents, so one job spans N of them. Whether the
-- batch is the clicker's documents or the whole company's is open (surface plan).
-- A run row exists only once extraction starts, so a PARSE failure has no
-- other home; without this table the caller polls for a row never created.
CREATE TABLE IF NOT EXISTS knowledge_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_id text NOT NULL, -- tenant key, see revision note 3
user_id text NOT NULL, -- who clicked the button
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','running','succeeded','partial','failed','blocked')),
-- 'blocked' = the pre-flight estimate exceeded the spend ceiling and the job
-- declined to continue. NOT a failure: it is the one outcome an operator can
-- act on (raise the ceiling, or ingest fewer documents at a time).
stage text CHECK (stage IN ('parsing','filtering','estimating','extracting','persisting')),
-- Ordered by cost. Everything up to and including 'estimating' is free or
-- near-free; 'extracting' is where the LLM spend happens, which is why the
-- ceiling is enforced between them.
document_ids jsonb NOT NULL DEFAULT '[]', -- documents(id)[] requested in this batch
n_requested integer NOT NULL DEFAULT 0,
n_succeeded integer NOT NULL DEFAULT 0,
n_failed integer NOT NULL DEFAULT 0,
results jsonb NOT NULL DEFAULT '[]', -- per-document {document_id, knowledge_document_id,
-- run_id, status, error} - what the poll route renders
estimated_cost jsonb, -- pre-flight estimate_cost() output; the spend gate
error_message text, -- set only when the JOB failed, not a member document
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_knowledge_jobs_user ON knowledge_jobs (user_id, created_at DESC);
-- ============================================================================
-- Β§6. EXECUTED 2026-09-14 β from the 2026-09-08 discussion (m3, m4, m5, m8, i7).
--
-- Verified after the run: 4 new columns and 2 new indexes present, and the
-- ORM reads all of them against live. Harry still needs this in the
-- migration set so a fresh environment reproduces it.
--
-- Additive only: three columns and one index on `knowledge_entries`. Nothing
-- is dropped and no column changes type, so a pre-migration reader keeps
-- working and Python's reads stay tolerant of both shapes while this lands.
-- ============================================================================
-- m3. "doc_id jadi list". One entry fuses meaning from several documents, but
-- every source stays recorded. `doc_id` REMAINS the primary source β every
-- existing filter and index uses it, and a single extracted assertion
-- always has exactly one β while `doc_ids` carries the full list. Additive
-- rather than a type change: rewriting `doc_id text` into jsonb in place
-- would break every read at once, for a value that is still needed.
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS doc_ids jsonb NOT NULL DEFAULT '[]';
-- m4. "kolom content". The definition or rule body, lifted out of `payload` so
-- a consumer does not have to open jsonb to use it β the objection raised
-- on 2026-09-08 was that too much JSON makes the data contract hard and
-- easy to break. `payload` stays the source of truth; this is a promoted
-- copy written from the same object in the same insert, exactly the
-- convention `label`, `mention_count` and `diff_status` already follow.
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS content text;
-- m5. "kolom provenance". Which document, which page β a list of objects:
-- [{"doc_id": "STD_2026_006_MNO", "page": 3, "page_no": 4,
-- "section_no": "2.1.3", "chunk_id": "STD_2026_006_MNO::0007"}]
-- Both page forms travel together on purpose: `page` is 0-based as the
-- parser reports it, `page_no` is the 1-based number a human reads, and
-- they are derived from one value so they cannot disagree.
-- `section_no` is included because the review queue already renders it β
-- whether it earns a place was left open in the meeting, so drop it from
-- this shape if the answer is no.
ALTER TABLE knowledge_entries ADD COLUMN IF NOT EXISTS provenance jsonb NOT NULL DEFAULT '[]';
-- m8. Makes the readable-key lookup a hit instead of a scan within the tenant.
-- `label` now carries the TYPE_SUBJECT key (`GLOSSARY_PA`), and
-- `KnowledgeStore.get_by_key` filters on exactly this pair. Without the
-- index the lookup is still correct, just not the O(1) the design intends.
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_key ON knowledge_entries (scope_id, label);
-- i7. "kasus dokumen dihapus" β ruling 2026-09-14: mark withdrawn, keep the
-- rows, flag what is left. A withdrawn document stops counting towards the
-- knowledge without anything being destroyed: an approval is an act someone
-- took, and the record of what it referred to must survive the source going
-- away. Null = live. Set = withdrawn.
-- Entries whose last remaining source is withdrawn are surfaced to the
-- expert to rule on rather than deleted β whether the knowledge leaves with
-- the document is their call, not the pipeline's.
ALTER TABLE knowledge_documents ADD COLUMN IF NOT EXISTS withdrawn_at timestamptz;
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_live
ON knowledge_documents (scope_id, doc_id) WHERE withdrawn_at IS NULL;
-- ============================================================================
-- Β§7. EXECUTED 2026-09-14 β the review system (r1, r2, r3) from the
-- 2026-09-08 discussion. Verified after the run: the ORM reads
-- review_mode, decided_by and authorised_by against live.
--
-- Additive only: three columns, no drops, no type changes.
--
-- The point of these, in Mas's words: an entry approved without a human
-- looking at it must still say WHO allowed that to happen. *"Kalau ada apa-apa
-- jangan sampai kita yang disalahin⦠ya udah itu by mereka aja."* It is a
-- risk-mitigation requirement, not bookkeeping β which is why it belongs in
-- columns rather than in a free-text note.
-- ============================================================================
-- r1. Which review mode a batch was ingested under. Chosen by the caller at
-- ingest time: 'expert' leaves entries waiting for a human; 'llm' takes
-- them straight to approved with no reviewer step at all.
-- Nullable so every job written before this column existed stays legal.
ALTER TABLE knowledge_jobs ADD COLUMN IF NOT EXISTS review_mode text
CHECK (review_mode IS NULL OR review_mode IN ('expert','llm'));
-- r2. WHO decided. 'human' is the existing behaviour and the default, so every
-- review already recorded keeps its meaning without a backfill.
ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS decided_by text
NOT NULL DEFAULT 'human'
CHECK (decided_by IN ('human','llm'));
-- r3. On WHOSE AUTHORITY. The admin who clicked ingest and the expert who
-- reviews can be different people, and in 'llm' mode there is no reviewer
-- at all β so the clicker's id has to reach this row or the decision has
-- no owner. `reviewer_id` stays "who performed the review";
-- `authorised_by` is "who permitted it to happen this way".
-- Together they render: approved by LLM with permission of <person>.
ALTER TABLE knowledge_reviews ADD COLUMN IF NOT EXISTS authorised_by text;
|