-- 4. document:文档、页、出处、解析任务(FR-M2-01、02、06,FR-M3-01) -- 来源:docs/architecture/schema-draft.sql(2026-11-03 验证过的草案) CREATE TABLE source_document ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL, project_id uuid NOT NULL, role text NOT NULL CHECK (role IN ('tender', 'addendum', 'clarification', 'bid_volume')), seq_no int NOT NULL, -- 招标侧:补遗 N 的 N;投标侧:分册内的版本号 volume_name text, -- 投标分册:商务 / 技术 / 报价 / 资格证明 lineage_id uuid NOT NULL, -- 同一分册的各个版本共用;招标侧等于 id original_filename text NOT NULL, detected_format text NOT NULL CHECK (detected_format IN ('pdf', 'docx', 'doc', 'wps', 'ofd', 'encrypted_bid', 'unknown')), size_bytes bigint NOT NULL, original_key text NOT NULL, -- 对象存储键 original_sha256 bytea NOT NULL, -- 规范化 PDF:只生成一次,写入后不再改(由服务层和触发器保证) normalized_key text, normalized_sha256 bytea, page_count int, text_layer_key text, -- 逐字坐标、格式、句子,存对象存储(DM1) pipeline_profile jsonb, -- 转换器、字体包、切句算法的版本,复盘用 status text NOT NULL DEFAULT 'uploaded' CHECK (status IN ('uploaded', 'normalizing', 'ready', 'failed')), uploaded_by uuid NOT NULL REFERENCES user_account (id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (tenant_id, id), UNIQUE (tenant_id, project_id, role, lineage_id, seq_no), FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE ); CREATE FUNCTION freeze_normalized_pdf() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.normalized_sha256 IS NOT NULL AND (NEW.normalized_sha256 IS DISTINCT FROM OLD.normalized_sha256 OR NEW.normalized_key IS DISTINCT FROM OLD.normalized_key) THEN RAISE EXCEPTION 'normalized PDF of document % is immutable', OLD.id; END IF; RETURN NEW; END $$; CREATE TRIGGER source_document_freeze BEFORE UPDATE ON source_document FOR EACH ROW EXECUTE FUNCTION freeze_normalized_pdf(); CREATE TABLE document_page ( -- 逐页摘要:覆盖率检查、OCR 决策、阅读器尺寸 tenant_id uuid NOT NULL, document_id uuid NOT NULL, page_no int NOT NULL, width_pt real NOT NULL, height_pt real NOT NULL, rotation int NOT NULL DEFAULT 0, text_source text NOT NULL CHECK (text_source IN ('text', 'ocr', 'mixed', 'empty')), char_count int NOT NULL, sentence_count int NOT NULL, PRIMARY KEY (tenant_id, document_id, page_no), FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE ); CREATE TABLE parse_job ( -- 招标文件 / 补遗 / 澄清的解析;重试是新的一行 id uuid PRIMARY KEY, tenant_id uuid NOT NULL, project_id uuid NOT NULL, document_id uuid NOT NULL, attempt_no int NOT NULL, status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), stage text CHECK (stage IN ('detect', 'convert', 'text_layer', 'ocr', 'structure', 'extract', 'verify', 'merge')), checkpoints jsonb NOT NULL DEFAULT '{}', -- 每个阶段完成时写入,失败后从这里续跑 progress int NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), cancel_requested_at timestamptz, error_class text CHECK (error_class IN ('infra', 'input', 'content', 'cancelled')), error_code text, error_detail text, -- 不含文档内容 prompt_versions jsonb, -- {"extract.qualification": "v3", ...};逐次调用见 ai_call quota_hold_id uuid, queue_job_id bigint, -- Procrastinate 的任务编号 created_by uuid NOT NULL REFERENCES user_account (id), created_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz, finished_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (tenant_id, id), UNIQUE (tenant_id, document_id, attempt_no), FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE, FOREIGN KEY (tenant_id, quota_hold_id) REFERENCES quota_entry (tenant_id, id) ); -- 一份文档同时只能有一个在跑的解析 CREATE UNIQUE INDEX ON parse_job (tenant_id, document_id) WHERE status IN ('queued', 'running'); -- 出处:不可变。清单项、问题、项目字段、标注规则都引用它 CREATE TABLE citation ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL, project_id uuid NOT NULL, document_id uuid NOT NULL, normalized_sha256 bytea NOT NULL, -- 和文档上的哈希一致才能渲染 page_no int NOT NULL, sentence_keys text[] NOT NULL, -- 同一页上连续的句子,如 {:p12:s3, :p12:s4} char_start int, -- 只引用句子的一部分时用,相对于首句 char_end int, quads jsonb NOT NULL, -- 坐标框快照,阅读器直接画 quote text NOT NULL, -- 原句快照 created_by_kind text NOT NULL CHECK (created_by_kind IN ('ai', 'rule', 'human')), checks_passed text[] NOT NULL DEFAULT '{}', -- {id, quote, basis}:三道校验(架构 §3.6) created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (tenant_id, id), CHECK (cardinality(sentence_keys) >= 1), FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE ); CREATE INDEX ON citation (tenant_id, document_id, page_no); CREATE TRIGGER citation_immutable BEFORE UPDATE ON citation FOR EACH ROW EXECUTE FUNCTION forbid_change(); -- 项目基本信息字段(FR-M2-03):AI 值和人工值分开,每个字段带出处 CREATE TABLE project_field ( tenant_id uuid NOT NULL, project_id uuid NOT NULL, field_key text NOT NULL CHECK (field_key IN ('name', 'code', 'purchaser', 'agency', 'procurement_method', 'legal_regime', 'budget_total', 'ceiling_total', 'eval_method', 'accept_consortium', 'accept_imported', 'sme_only', 'deadline_at')), ai_value jsonb, ai_citation_id uuid, ai_document_id uuid, -- 最新一次提取来自哪份文件(补遗覆盖时变) human_value jsonb, human_set_by uuid REFERENCES user_account (id), human_set_at timestamptz, pending_value jsonb, -- 补遗带来的新值,等用户确认(FR-M2-10) pending_citation_id uuid, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, project_id, field_key), FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE, FOREIGN KEY (tenant_id, ai_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (ai_citation_id), FOREIGN KEY (tenant_id, pending_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (pending_citation_id), FOREIGN KEY (tenant_id, ai_document_id) REFERENCES source_document (tenant_id, id) ON DELETE SET NULL (ai_document_id) );