"""兴邦预审 v2 · 首个实施切片(S1)契约:材料集合导入与事实确认。 唯一职责 owner:[docs/v2/03-detailed-design.md](../../../docs/v2/03-detailed-design.md) §1/§9.2 指定的 `backend/contracts/prescreen.py`。本文件是该 owner 的**首版实现**,字段命名以本文件为准; `claude_use/T00/04-first-slice-contracts.md` 是提案记录,不是第二份契约定义。 S1 范围(T00/04 §4/§5): SourceLocator · ImportItem · ImportBatch · FactConflict + 共用约束 + 错误信封 首版只含 S1 对象;三模块闭环所需类型(CaseTimeline、DecisionEvent、KnowledgeCard、 KnowledgeExtraction、ReviewPlan、DimensionSpec、ScreeningRun、Judgment、Simulation、 Evaluation、TrajectoryStep、ReviewOutcome、ImprovementCandidate、DatasetManifest、 ProbabilityView)随后续任务包在本文件续写——**本文件始终是唯一 owner**, 新增类型写在这里,不另立平行契约文件。 依赖方向:本模块**不导入** `services/`。标准字段白名单(`assistant_materials.FIELDS`) 的校验属 service 层,避免 contracts → services 的反向依赖。 """ from __future__ import annotations import re from datetime import datetime from enum import StrEnum from typing import Annotated, Literal from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator # ---------------------------------------------------------------- 共用约束 ID_MAX = 128 PATH_MAX = 512 NOTE_MAX = 2000 EVIDENCE_MAX = 100 DESC_MAX = 2000 # P0 初始工程限制(**非业务阈值**,见 docs/v2/03-detailed-design.md §1 末)。 # 单文件上限沿用既有 assistant_materials.LIMIT = 10 MiB。 # 单批总量取保守值:现有实现把整份文件 base64 存进 SQLite 单行, # 设计文档曾提 100 MiB/批 ≈ 133 MiB base64 入库,可直接造成演示故障 # (claude_use/T00/04 §3.1)。**降低**无需重新验证;**放宽**须重新验证并更新规格。 MAX_ITEM_BYTES = 10 * 1024 * 1024 MAX_BATCH_BYTES = 20 * 1024 * 1024 MAX_BATCH_FILES = 20 _SHA256 = re.compile(r"^[0-9a-f]{64}$") _WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:") def _aware(value: datetime) -> datetime: """时间必须带时区(docs/v2/03-detailed-design.md §9.1)。""" if value.tzinfo is None or value.utcoffset() is None: raise ValueError("时间必须带时区(ISO-8601)") return value AwareDatetime = Annotated[datetime, AfterValidator(_aware)] Identifier = Annotated[str, Field(min_length=1, max_length=ID_MAX)] class Contract(BaseModel): """所有 S1 对象共用:禁止未知字段(对应 E-07 INVALID_REQUEST)。""" model_config = ConfigDict(extra="forbid") class DataDomain(StrEnum): demo = "demo" production = "production" class EventOutcome(StrEnum): """评审会结果编码(docs/v2/03-detailed-design.md §9.1)。 `conditional`(附条件通过)是否计入目标由 MappingProfile 固定;P0 **不得**默认 当作 approved(E-26 / BR-21)。 """ approved = "approved" rejected = "rejected" deferred = "deferred" withdrawn = "withdrawn" not_submitted = "not_submitted" conditional = "conditional" unknown = "unknown" class Ref(Contract): """通用版本引用(docs/v2/03-detailed-design.md §9.1)。""" id: Identifier version: str = Field(min_length=1, max_length=ID_MAX) # ---------------------------------------------------------------- 错误契约 class ErrorCode(StrEnum): """S1 涉及的错误码。取值与 docs/v2/01-requirements.md §5 一一对应,不另造。""" NOT_FOUND = "NOT_FOUND" ACCESS_DENIED = "ACCESS_DENIED" INVALID_REQUEST = "INVALID_REQUEST" INVALID_EVIDENCE = "INVALID_EVIDENCE" REVISION_CONFLICT = "REVISION_CONFLICT" IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT" MATERIAL_PARSE_FAILED = "MATERIAL_PARSE_FAILED" IMPORT_BATCH_INVALID = "IMPORT_BATCH_INVALID" FACT_CONFLICT_UNRESOLVED = "FACT_CONFLICT_UNRESOLVED" JUDGMENT_CONFIG_INVALID = "JUDGMENT_CONFIG_INVALID" EGRESS_NOT_APPROVED = "EGRESS_NOT_APPROVED" # T09 沙盘与维度:E-20/21/22 都是"可立即纠正的请求问题",走 422。 DIMENSION_SCHEMA_CONFLICT = "DIMENSION_SCHEMA_CONFLICT" DEMO_ESTIMATE_FORBIDDEN = "DEMO_ESTIMATE_FORBIDDEN" SCENARIO_ASSUMPTION_CONFLICT = "SCENARIO_ASSUMPTION_CONFLICT" # T11 改进治理:泄漏与未验证都是"先改数据再重试"的冲突,走 409。 EVALUATION_CONTAMINATED = "EVALUATION_CONTAMINATED" IMPROVEMENT_NOT_VALIDATED = "IMPROVEMENT_NOT_VALIDATED" # 运行级编码:记录在运行体(degradations / judgment)里,**不构成 HTTP 错误**, # 因此刻意不进 S1_ERROR_HTTP;见下方 RUN_LEVEL_CODES。 JUDGMENT_UNAVAILABLE = "JUDGMENT_UNAVAILABLE" JUDGMENT_CAPABILITY_UNSUPPORTED = "JUDGMENT_CAPABILITY_UNSUPPORTED" JUDGMENT_OUTPUT_INVALID = "JUDGMENT_OUTPUT_INVALID" # E-25:输入超出模型已验证预算(BR-39 / AC-40)。T08 适配层使用。 JUDGMENT_CONTEXT_EXCEEDED = "JUDGMENT_CONTEXT_EXCEEDED" # E-27:步骤未完成(非预算原因)。纠偏第一批使用。 RUN_STEP_FAILED = "RUN_STEP_FAILED" RUN_BUDGET_EXCEEDED = "RUN_BUDGET_EXCEEDED" # E-28:经验提炼不可用(未配置模型或调用失败)。纠偏第二批使用。 # 与 E-27 同属运行级:记在提炼记录的 degradations 里,**不是** HTTP 错误—— # "HTTP 200 只表示记录可读取,不表示提炼成功"(01 §5 末)。 EXTRACTION_UNAVAILABLE = "EXTRACTION_UNAVAILABLE" # HTTP 语义沿用既有 translate()(backend/api/project_assistant.py:36-46), # 不另立一套。 # E-17 FACT_CONFLICT_UNRESOLVED 有**两个面**,不要混为一谈: # · 确认动作被拒(本映射):422 —— 客户端必须先裁定冲突,属可立即纠正的请求问题 # · 运行级结果(不在此映射内):HTTP 200,冲突标识保存在 degradations/finding, # "HTTP 200 仅表示结果可读取,不表示核查通过"(01 §5 末) S1_ERROR_HTTP: dict[ErrorCode, int] = { ErrorCode.NOT_FOUND: 404, ErrorCode.ACCESS_DENIED: 403, ErrorCode.INVALID_REQUEST: 422, ErrorCode.INVALID_EVIDENCE: 422, ErrorCode.REVISION_CONFLICT: 409, ErrorCode.IDEMPOTENCY_CONFLICT: 409, ErrorCode.MATERIAL_PARSE_FAILED: 422, ErrorCode.IMPORT_BATCH_INVALID: 422, ErrorCode.FACT_CONFLICT_UNRESOLVED: 422, ErrorCode.JUDGMENT_CONFIG_INVALID: 422, ErrorCode.EGRESS_NOT_APPROVED: 403, ErrorCode.DIMENSION_SCHEMA_CONFLICT: 422, ErrorCode.DEMO_ESTIMATE_FORBIDDEN: 422, ErrorCode.SCENARIO_ASSUMPTION_CONFLICT: 422, ErrorCode.EVALUATION_CONTAMINATED: 409, ErrorCode.IMPROVEMENT_NOT_VALIDATED: 409, } # 这些编码只出现在运行结果里("HTTP 200 仅表示结果可读取"),永远不会作为 # 错误信封返回,因此与 S1_ERROR_HTTP 互不相交。 RUN_LEVEL_CODES = frozenset({ ErrorCode.JUDGMENT_UNAVAILABLE, ErrorCode.JUDGMENT_CAPABILITY_UNSUPPORTED, ErrorCode.JUDGMENT_OUTPUT_INVALID, ErrorCode.JUDGMENT_CONTEXT_EXCEEDED, ErrorCode.RUN_STEP_FAILED, ErrorCode.RUN_BUDGET_EXCEEDED, ErrorCode.EXTRACTION_UNAVAILABLE, }) # 403/422 不自动重试;409 需客户端改载荷或刷新。见 03 §5。 S1_ERROR_RETRYABLE: dict[ErrorCode, bool] = { code: code in (ErrorCode.JUDGMENT_UNAVAILABLE, ErrorCode.RUN_BUDGET_EXCEEDED) for code in ErrorCode } class ErrorBody(Contract): code: ErrorCode message: str = Field(min_length=1, max_length=DESC_MAX) retryable: bool request_id: str = Field(min_length=1, max_length=ID_MAX) class ErrorResponse(Contract): """新 prescreen 路由的**独立**错误信封(T00/04 §2 决策 D2)。 既有路由仍是 FastAPI 默认 `{"detail": ...}`,本次不改,避免影响 backend/api/project_assistant.py 的 translate() 及其全部消费方。 """ error: ErrorBody # ---------------------------------------------------------------- 来源定位 class SourceLocator(Contract): """证据在原件中的定位。定位**不等于**真实性核验(01 §1)。 kind 决定必填字段,且三种寻址方式互斥: pdf → page table → sheet + cell_range manual → note """ kind: Literal["pdf", "table", "manual"] document_id: Identifier document_version: int = Field(ge=1) page: int | None = Field(default=None, ge=1) sheet: str | None = Field(default=None, min_length=1, max_length=200) cell_range: str | None = Field(default=None, min_length=1, max_length=200) note: str | None = Field(default=None, min_length=1, max_length=PATH_MAX) excerpt: str | None = Field(default=None, max_length=4000) _REQUIRED: dict[str, tuple[str, ...]] = { "pdf": ("page",), "table": ("sheet", "cell_range"), "manual": ("note",), } _ADDRESS_FIELDS = ("page", "sheet", "cell_range", "note") @model_validator(mode="after") def _check_kind_fields(self) -> SourceLocator: required = self._REQUIRED[self.kind] missing = [name for name in required if getattr(self, name) is None] if missing: raise ValueError(f"{self.kind} 定位缺少必填字段:{', '.join(missing)}") extra = [name for name in self._ADDRESS_FIELDS if name not in required and getattr(self, name) is not None] if extra: raise ValueError(f"{self.kind} 定位不允许字段:{', '.join(extra)}") return self # ---------------------------------------------------------------- 导入清单 class ImportItem(Contract): """集合中的一个文件条目。`relative_path` 仅表达用户选择的文件集合, 绝不作服务器写入路径(docs/v2/14-experience-loop.md §3)。 """ id: Identifier document_id: Identifier | None = None # 解析成功后由既有 upload 链回填 relative_path: str = Field(min_length=1, max_length=PATH_MAX) sha256: str = Field(pattern=_SHA256.pattern) size_bytes: int = Field(ge=0, le=MAX_ITEM_BYTES) mime_type: str = Field(min_length=1, max_length=200) status: Literal["queued", "parsing", "parsed", "failed"] error_code: ErrorCode | None = None observed_at: AwareDatetime | None = None # 材料**发生**时间;未知为 null received_at: AwareDatetime @model_validator(mode="after") def _check_path_and_status(self) -> ImportItem: # 路径安全:不允许绝对路径、盘符或上级引用。校验用规范化副本, # 但**保留原值**,使客户端提交的内容与落库内容一致。 normalized = self.relative_path.replace("\\", "/") if normalized.startswith("/") or _WINDOWS_DRIVE.match(normalized): raise ValueError("relative_path 不得为绝对路径") if any(part == ".." for part in normalized.split("/")): raise ValueError("relative_path 不得包含上级引用") if self.status == "failed" and self.error_code is None: raise ValueError("failed 的条目必须有 error_code") if self.status == "parsed" and self.error_code is not None: raise ValueError("parsed 的条目不得带 error_code") if self.status == "parsed" and self.document_id is None: raise ValueError("parsed 的条目必须已关联 document_id") return self class ImportBatch(Contract): """一次「文件集合」导入的容器对象。 状态机(T00/04 §6.1): queued → parsing → ready | partial | failed queued | parsing → cancelled partial | failed → queued(retry,仅重置失败项,revision++) """ id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) status: Literal["queued", "parsing", "ready", "partial", "failed", "cancelled"] created_at: AwareDatetime created_by: Identifier parser_version: str = Field(min_length=1, max_length=64) items: list[ImportItem] = Field(min_length=1, max_length=MAX_BATCH_FILES) # 不在此处加 le=MAX_BATCH_BYTES:字段级约束会先于模型级校验触发, # 使用户看到 pydantic 的英文默认文案而非下面的中文提示。 total_bytes: int = Field(ge=0) warnings: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) @model_validator(mode="after") def _check_consistency(self) -> ImportBatch: if len({item.id for item in self.items}) != len(self.items): raise ValueError("同一批次内 item id 必须唯一") # 单项上限已由 ImportItem.size_bytes 的 le 约束保证,此处不重复检查。 total = sum(item.size_bytes for item in self.items) if total != self.total_bytes: raise ValueError("total_bytes 必须等于各 item size_bytes 之和") if total > MAX_BATCH_BYTES: raise ValueError( f"单批总量 {total} 超过上限 {MAX_BATCH_BYTES} 字节;请拆分后重试" ) return self # ---------------------------------------------------------------- 事实冲突 class ConflictCandidate(Contract): """并列展示的候选值之一。未知用 null,**不用 0 或空串代替**(03 §9.1)。""" evidence_id: Identifier value: str | None = None source: str = Field(min_length=1, max_length=PATH_MAX) locator: str = Field(min_length=1, max_length=PATH_MAX) unit: str | None = Field(default=None, max_length=64) currency: str | None = Field(default=None, max_length=16) period: str | None = Field(default=None, max_length=64) class FactConflict(Contract): """同一字段在多来源上给出不同值时的待处理对象(BR-23 / E-17 / AC-24)。 只允许 unresolved → resolved。再次冲突时**新建**对象并用 supersedes_id 指向旧对象,不覆写历史。 """ id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) # 标准字段名(assistant_materials.FIELDS.values())。白名单校验在 service 层, # 见本模块顶部「依赖方向」说明。 field: str = Field(min_length=1, max_length=ID_MAX) candidate_evidence_ids: list[Identifier] = Field(min_length=2, max_length=EVIDENCE_MAX) candidates: list[ConflictCandidate] = Field(min_length=2, max_length=EVIDENCE_MAX) status: Literal["unresolved", "resolved"] resolution_note: str | None = Field(default=None, min_length=1, max_length=NOTE_MAX) selected_evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) resolved_by: Identifier | None = None resolved_at: AwareDatetime | None = None created_at: AwareDatetime supersedes_id: Identifier | None = None @model_validator(mode="after") def _check_candidates_and_resolution(self) -> FactConflict: ids = self.candidate_evidence_ids if len(set(ids)) != len(ids): raise ValueError("candidate_evidence_ids 不得重复") if [c.evidence_id for c in self.candidates] != ids: raise ValueError("candidates 必须与 candidate_evidence_ids 同序同集合") # 「至少两个不同值」:全部候选值相同说明没有分歧,不应建冲突对象。 if len({c.value for c in self.candidates}) < 2: raise ValueError("候选值全部相同,不构成事实冲突") if self.status == "resolved": missing = [name for name, value in ( ("resolution_note", self.resolution_note), ("resolved_by", self.resolved_by), ("resolved_at", self.resolved_at), ) if value is None] if missing: raise ValueError(f"resolved 必须提供:{', '.join(missing)}") if not self.selected_evidence_ids: raise ValueError("resolved 必须提供 selected_evidence_ids") unknown = set(self.selected_evidence_ids) - set(ids) if unknown: raise ValueError(f"选择的值不属于该冲突的候选:{sorted(unknown)}") else: stray = [name for name, value in ( ("resolution_note", self.resolution_note), ("resolved_by", self.resolved_by), ("resolved_at", self.resolved_at), ) if value is not None] if stray or self.selected_evidence_ids: raise ValueError("unresolved 不得带解决字段") return self # ---------------------------------------------------------------- 数据集(T04) class OutcomeCount(Contract): outcome: EventOutcome count: int = Field(ge=0) class DatasetManifest(Contract): """数据集清单(docs/v2/03-detailed-design.md §9.2「数据集服务」)。 用于证明"同 seed / 同版本生成两次,规范化清单一致"(AC-35),并承载划分信息。 """ id: Identifier version: str = Field(min_length=1, max_length=ID_MAX) data_domain: DataDomain seed: int | None = Field(default=None, ge=0) generator_version: str | None = Field(default=None, max_length=ID_MAX) template_version: str | None = Field(default=None, max_length=ID_MAX) source_hash: str = Field(pattern=_SHA256.pattern) split_id: str = Field(min_length=1, max_length=ID_MAX) split_policy_version: str = Field(min_length=1, max_length=ID_MAX) group_assignments_ref: str = Field(min_length=1, max_length=ID_MAX) counts: list[OutcomeCount] = Field(default_factory=list, max_length=32) created_at: AwareDatetime synthetic: bool @model_validator(mode="after") def _check_labels_and_domain(self) -> DatasetManifest: outcomes = [item.outcome for item in self.counts] if len(set(outcomes)) != len(outcomes): raise ValueError("同一结果编码不得重复计数") if any(item.count == 0 for item in self.counts): raise ValueError("计数为 0 的结果不应出现;空结果用不适用表达") # 合成数据必须标 demo:不得让模拟数据冒充生产域(BR-34) if self.synthetic and self.data_domain != DataDomain.demo: raise ValueError("合成数据必须为 demo 数据域") return self # ---------------------------------------------------------------- 历史过程(T05) class DecisionEvent(Contract): """历史过程中的一次问询/证据/动作/意见/决定(docs/v2/03-detailed-design.md §3)。 时间语义严格区分(BR-02): - `occurred_at` 是材料里**记载**的发生时间,缺失时保持 None,**不得**用录入时间填充 - `recorded_at` 是录入时间,必填,但**不能**被当作发生时间 """ id: Identifier event_type: Literal["question", "evidence", "action", "opinion", "decision"] occurred_at: AwareDatetime | None = None recorded_at: AwareDatetime question: str = Field(default="", max_length=DESC_MAX) evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) judgment_before: str | None = Field(default=None, max_length=DESC_MAX) judgment_after: str | None = Field(default=None, max_length=DESC_MAX) conditions: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) @model_validator(mode="after") def _check_journal(self) -> DecisionEvent: # 事件本身必须有可读内容,否则只是空壳 if not (self.question or self.judgment_before or self.judgment_after): raise ValueError("决策事件必须至少包含问题、判断前或判断后之一") return self class CaseTimeline(Contract): """历史过程时间线。`partial` 表示过程材料不完整(如只有最终报告)。""" id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) status: Literal["processing", "ready", "partial", "failed"] created_at: AwareDatetime events: list[DecisionEvent] = Field(default_factory=list, max_length=500) warnings: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) @model_validator(mode="after") def _check_integrity(self) -> CaseTimeline: ids = [event.id for event in self.events] if len(set(ids)) != len(ids): raise ValueError("同一时间线内事件 id 必须唯一") if self.status == "ready" and any(e.occurred_at is None for e in self.events): raise ValueError("存在缺发生时间的事件时状态不得为 ready") return self # ---------------------------------------------------------------- 知识卡片(T05-b) class Origin(StrEnum): """知识创建来源,与 data_domain 正交(docs/v2/14-experience-loop.md §4)。""" history_derived = "history_derived" manual = "manual" policy_import = "policy_import" class GovernanceStatus(StrEnum): draft = "draft" in_review = "in_review" published = "published" rejected = "rejected" withdrawn = "withdrawn" class RuleEffect(StrEnum): advisory = "advisory" hard = "hard" class Condition(Contract): """安全条件表达:只允许字段白名单 + 有限算子,不执行表达式(03 §9.1)。""" field: str = Field(min_length=1, max_length=ID_MAX) operator: Literal["eq", "in", "present", "absent", "gt", "gte", "lt", "lte"] value: str | bool | list[str] | None = None @model_validator(mode="after") def _check_value_shape(self) -> Condition: if self.operator == "in": if not isinstance(self.value, list) or not self.value: raise ValueError("in 算子需要非空列表") elif self.operator in ("present", "absent"): if self.value is not None: raise ValueError("present/absent 不接受取值") elif self.operator in ("gt", "gte", "lt", "lte"): # 数值算子必须声明可解析的数值;比较口径由程序校验,不做表达式求值 try: float(str(self.value)) except (TypeError, ValueError): raise ValueError("数值算子需要可解析的数值") from None return self class RuleContent(Contract): """规则内容。hard 必须具备正式依据与独立审核(BR-26 / E-18)。""" effect: RuleEffect predicate: list[Condition] = Field(min_length=1, max_length=32) on_match: Literal["flag", "block"] basis_evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) policy_ref: Ref | None = None authorization_ref: Ref | None = None @model_validator(mode="after") def _check_hard_basis(self) -> RuleContent: if self.effect == RuleEffect.hard: if self.policy_ref is None and self.authorization_ref is None: raise ValueError("硬性规则需要正式制度或授权依据,仅凭历史频次不足") if not self.basis_evidence_ids: raise ValueError("硬性规则必须附依据证据") return self class KnowledgeCard(Contract): """Skill/Rule 卡片的目标投影。 **命名冲突提示**:本类的 `card_scope` 是 project/team(03 §9.2),而既有 `component_registry` 里的 `scope` 表示数据域 demo/production。二者语义不同, 投影层必须显式映射,不得互相顶替。 """ id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) version: str = Field(min_length=1, max_length=70) kind: str = Field(min_length=1, max_length=40) title: str = Field(min_length=1, max_length=200) origin: Origin status: GovernanceStatus source_event_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) source_evidence_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) parent_version: Ref | None = None created_by: Identifier edited_by: Identifier card_scope: Literal["project", "team"] team_id: str | None = Field(default=None, max_length=ID_MAX) conditions: list[Condition] = Field(default_factory=list, max_length=32) exceptions: list[Condition] = Field(default_factory=list, max_length=32) effective_at: AwareDatetime expires_at: AwareDatetime | None = None reviewer_id: Identifier | None = None evaluation_ref: Ref | None = None rule: RuleContent | None = None @model_validator(mode="after") def _check_card(self) -> KnowledgeCard: if self.card_scope == "team" and not self.team_id: raise ValueError("team 范围的卡片必须有 team_id") if self.origin == Origin.history_derived and not ( self.source_event_ids or self.source_evidence_ids): raise ValueError("历史提炼卡片必须保留来源(事件或证据)") if self.status == GovernanceStatus.published and self.reviewer_id is None: raise ValueError("已发布卡片必须有审核人") if self.effective_at and self.expires_at and self.expires_at <= self.effective_at: raise ValueError("有效期必须晚于生效时间") return self class KnowledgeExtraction(Contract): """一次历史经验提炼**作业**的记录(BR-48 / AC-50 / E-28)。 为什么需要单独一条记录,而不是只返回草稿:纠偏规格 §§0 禁止两种假接通—— "把固定返回值包装成模型提炼成功"与"模型不可用时照样产出候选"。只返回草稿时, 界面无法区分"真的调用了模型"和"模型没跑起来但草稿还在"。把作业本身落成对象后, 调用是否发生、失败原因、候选是否存在,在同一条记录里可核对。 `failed` 时 **candidate_id 必须为空**:这是 AC-50「模型不可用 ⇒ 不产生候选」的 机械表达,不是文档约定。 """ id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) status: Literal["completed", "failed"] timeline_id: Identifier source_event_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) requirement: str = Field(default="", max_length=DESC_MAX) prompt_version: str = Field(min_length=1, max_length=40) candidate_id: Identifier | None = None model: str | None = Field(default=None, max_length=ID_MAX) engine: str | None = Field(default=None, max_length=ID_MAX) degradations: list[dict] = Field(default_factory=list, max_length=EVIDENCE_MAX) created_at: AwareDatetime created_by: Identifier @model_validator(mode="after") def _check_extraction(self) -> KnowledgeExtraction: if self.status == "failed": if not self.degradations: raise ValueError("提炼失败必须记录降级原因(E-28),不得静默结束") if self.candidate_id is not None: raise ValueError("提炼失败不得产生候选草稿") elif self.candidate_id is None: raise ValueError("提炼完成必须引用产出的候选草稿") if not self.requirement.strip() and not self.source_event_ids: raise ValueError("提炼须至少给出要求或来源事件之一") return self # ---------------------------------------------------------------- 评审方案维度(T06) class ScaleLevel(Contract): """刻度的一级。刻度由版本化标准驱动,**不由模型任意打分**(14 §5)。""" value: int label: str = Field(min_length=1, max_length=80) criterion: str = Field(min_length=1, max_length=DESC_MAX) class DimensionSpec(Contract): """核查维度定义(docs/v2/03-detailed-design.md §9.2「方案维度解析」)。 维度是 Skills/Rules 的**元数据**,不新增"维度卡"(14 §5)。同 ID 必须标签、 量纲与刻度一致;不一致时**拒绝合并**而不是让模型临时猜(BR-28 / E-20)。 """ id: Identifier label: str = Field(min_length=1, max_length=80) order: int = Field(ge=0) scale_id: str = Field(min_length=1, max_length=ID_MAX) scale_version: str = Field(min_length=1, max_length=70) levels: list[ScaleLevel] = Field(min_length=2, max_length=10) component_refs: list[Ref] = Field(default_factory=list, max_length=64) required: bool = False @model_validator(mode="after") def _check_scale(self) -> DimensionSpec: values = [level.value for level in self.levels] if len(set(values)) != len(values): raise ValueError("同一刻度的层级取值不得重复") if values != sorted(values): raise ValueError("刻度层级必须按取值升序声明,避免展示时语义倒置") ref_ids = [ref.id for ref in self.component_refs] if len(set(ref_ids)) != len(ref_ids): raise ValueError("同一维度引用的组件不得重复") return self def scale_key(self) -> tuple[str, str]: """跨维度可比性的唯一判据:刻度标识 + 版本。""" return (self.scale_id, self.scale_version) # ---------------------------------------------------------------- 评审方案(T06-b) class JudgmentBinding(Contract): """把某个业务任务绑定到具体的专项判断配置修订(03 §9.3)。""" task: str = Field(min_length=1, max_length=ID_MAX) profile_id: Identifier profile_revision: int = Field(ge=1) class ReviewPlan(Contract): """评审方案:数据卡 / Skills / Rules / 经验与核查维度的版本化装配。 方案固定引用与维度定义,发起运行时验证依赖在当前项目/域获准并**冻结版本** (BR-27 / E-19 / AC-28)。草稿或已撤回组件不得进入新运行。 """ id: Identifier project_id: Identifier data_domain: DataDomain version: str = Field(min_length=1, max_length=70) revision: int = Field(ge=1) title: str = Field(min_length=1, max_length=200) status: GovernanceStatus data_card_refs: list[Ref] = Field(default_factory=list, max_length=64) skill_refs: list[Ref] = Field(default_factory=list, max_length=64) rule_refs: list[Ref] = Field(default_factory=list, max_length=64) experience_refs: list[Ref] = Field(default_factory=list, max_length=64) dimensions: list[DimensionSpec] = Field(default_factory=list, max_length=32) critical_check_ids: list[Identifier] = Field(default_factory=list, max_length=64) judgment_bindings: list[JudgmentBinding] = Field(default_factory=list, max_length=16) created_by: Identifier reviewer_id: Identifier | None = None @model_validator(mode="after") def _check_plan(self) -> ReviewPlan: # 维度同 ID 不得定义两次(真正的一致性由 review_plan.resolve_dimensions 把关) ids = [spec.id for spec in self.dimensions] if len(set(ids)) != len(ids): raise ValueError("同一方案内维度 ID 不得重复") tasks = [binding.task for binding in self.judgment_bindings] if len(set(tasks)) != len(tasks): raise ValueError("同一业务任务只能绑定一个专项判断配置") if self.status == GovernanceStatus.published and self.reviewer_id is None: raise ValueError("已发布方案必须有审核人") return self # ---------------------------------------------------------------- 专项判断(T07-b) class JudgmentAnswer(Contract): """可辨识联合:正常答案的 kind 必须与题型一致(11 §5)。""" kind: Literal["choice", "score", "binary", "unknown"] value: str | float | bool | None = None reason_code: Literal["missing_evidence", "inconclusive"] | None = None @model_validator(mode="after") def _check_answer(self) -> JudgmentAnswer: if self.kind == "unknown": if self.reason_code is None: raise ValueError("unknown 必须给出 reason_code") if self.value is not None: raise ValueError("unknown 不得带取值") return self if self.reason_code is not None: raise ValueError("非 unknown 的答案不得带 reason_code") if self.kind == "choice" and not isinstance(self.value, str): raise ValueError("choice 的取值必须是字符串") if self.kind == "binary" and not isinstance(self.value, bool): raise ValueError("binary 的取值必须是布尔值") if self.kind == "score": if isinstance(self.value, bool) or not isinstance(self.value, (int, float)): raise ValueError("score 的取值必须是数值") if self.value != self.value or self.value in (float("inf"), float("-inf")): raise ValueError("score 必须是有限数值") return self class NativeMetric(Contract): """提供方原生指标。保留语义,**不得**互换阈值或补造概率(BR-18)。""" name: str = Field(min_length=1, max_length=80) value: float semantics: str = Field(min_length=1, max_length=DESC_MAX) source: Literal["provider_native", "self_reported"] @model_validator(mode="after") def _check_finite(self) -> NativeMetric: if self.value != self.value or self.value in (float("inf"), float("-inf")): raise ValueError("原生指标必须是有限数值;缺失应留空而不是填 0/1") return self class Judgment(Contract): """单题专项判断结果。`completed` 只表示完成该题处理与校验,不表示支持结论。""" id: Identifier run_id: Identifier question_id: Identifier question_version: str = Field(min_length=1, max_length=70) type: Literal["choice", "score", "binary"] status: Literal["completed", "skipped", "failed"] answer: JudgmentAnswer | None = None evidence_ids: list[Identifier] = Field(default_factory=list, max_length=20) provider: Literal["jev", "local", "disabled"] profile_id: Identifier | None = None profile_revision: int | None = Field(default=None, ge=1) model_version: str | None = Field(default=None, max_length=ID_MAX) native_metrics: list[NativeMetric] = Field(default_factory=list, max_length=32) error_code: ErrorCode | None = None skip_reason: Literal["disabled", "not_configured", "not_authorized", "unsupported"] | None = None # 本次实际使用的权重修订(T08,AC-39):与冻结值不符即拒绝,不做隐式路由。 actual_checkpoint_ref: str | None = Field(default=None, max_length=ID_MAX) @model_validator(mode="after") def _check_status_consistency(self) -> Judgment: if self.status == "completed": if self.answer is None: raise ValueError("completed 必须有合法 answer") if self.error_code or self.skip_reason: raise ValueError("completed 不得带 error_code 或 skip_reason") if self.status == "skipped": if self.skip_reason is None: raise ValueError("skipped 必须有 skip_reason") if self.answer is not None: raise ValueError("skipped 的 answer 必须为空") if self.status == "failed": if self.error_code is None: raise ValueError("failed 必须有 error_code") if self.answer is not None: raise ValueError("failed 的 answer 必须为空") # 关闭专项时不得声称有提供方配置(避免"关了却像在跑") if self.provider == "disabled" and self.status == "completed": raise ValueError("专项已关闭时不得产出 completed 判断") return self class JudgmentConfig(Contract): """运行创建时冻结的专项配置(BR-20:切换只影响新运行)。""" enabled: bool provider: Literal["jev", "local", "disabled"] profile_id: Identifier | None = None profile_revision: int | None = Field(default=None, ge=1) model_version: str | None = Field(default=None, max_length=ID_MAX) adapter_version: str | None = Field(default=None, max_length=ID_MAX) prompt_version: str | None = Field(default=None, max_length=ID_MAX) mapping_version: str | None = Field(default=None, max_length=ID_MAX) disabled_reason: str | None = Field(default=None, max_length=DESC_MAX) # T08:local 必须固定权重修订与摘要;不可得时为 "unresolved"(11 §8.1)。 checkpoint_ref: str | None = Field(default=None, max_length=ID_MAX) @model_validator(mode="after") def _check_freeze(self) -> JudgmentConfig: if self.enabled: if self.provider == "disabled": raise ValueError("启用专项时提供方不得为 disabled") if not self.profile_id or self.profile_revision is None: raise ValueError("启用专项时必须冻结配置 ID 与修订") else: if self.provider != "disabled": raise ValueError("关闭专项时提供方必须是 disabled") if self.profile_id or self.profile_revision is not None: raise ValueError("关闭专项时不得冻结任何配置修订") if not self.disabled_reason: raise ValueError("关闭专项必须记录关闭原因,不能静默") return self # ---------------------------------------------------------------- 预审运行(T07-c) class Degradation(Contract): """降级记录:未完成的检查必须留痕,不能静默(BR-09)。""" code: ErrorCode message: str = Field(min_length=1, max_length=DESC_MAX) class DimensionResult(Contract): """维度在本次运行中的状态。`unknown` 不等于不成立(03 §9.1)。""" id: Identifier stage: int | None = None state: Literal["unknown", "checked", "blocked"] basis_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX) class ProbabilityView(Contract): """概率呈现。P0 的 real_estimate **必须为空**,演示值只能走 demo_estimate(BR-29)。""" target_event: Literal["company_review_meeting_pass"] = "company_review_meeting_pass" snapshot_id: Identifier as_of: AwareDatetime real_estimate: None = None reason: Literal["not_calibrated"] = "not_calibrated" demo_estimate: dict | None = None @model_validator(mode="after") def _check_demo_isolated(self) -> ProbabilityView: demo = self.demo_estimate if demo is None: return self if demo.get("source") != "synthetic_demo": raise ValueError("演示估计必须标明来源 synthetic_demo") if not demo.get("label"): raise ValueError("演示估计必须带固定的『人工设定』说明,避免被当作真实概率") value = demo.get("value") if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1: raise ValueError("演示估计须为 0–1 的数值") return self class ScreeningRun(Contract): """预审运行视图(docs/v2/03-detailed-design.md §9.2 + §9.3 增量)。 这是既有 run 对象的**受约束投影**,不是第二份运行存储:plan_ref、snapshot、 judgment_config 与 versions 在创建时冻结(BR-13 / BR-20)。 """ id: Identifier project_id: Identifier data_domain: DataDomain revision: int = Field(ge=1) snapshot_id: Identifier plan_ref: Ref status: Literal["queued", "running", "awaiting_input", "completed", "partial", "failed", "cancelled"] recommendation: Literal["advance", "supplement", "stop", "insufficient_information"] | None = None findings: list[dict] = Field(default_factory=list, max_length=500) missing_inputs: list[str] = Field(default_factory=list, max_length=200) applied_experiences: list[dict] = Field(default_factory=list, max_length=200) # 未校准时恒为空——契约层直接禁止填入数值 probability: None = None probability_reason: Literal["not_calibrated"] = "not_calibrated" probability_view: ProbabilityView | None = None degradations: list[Degradation] = Field(default_factory=list, max_length=100) judgment_config: JudgmentConfig versions: dict = Field(default_factory=dict) dimension_results: list[DimensionResult] = Field(default_factory=list, max_length=32) # 本次核查实际引用的证据(纠偏第一批:界面要能"查看依据",不能只有 id) evidence: list[dict] = Field(default_factory=list, max_length=200) run_instruction: str = Field(default="", max_length=2000) created_at: AwareDatetime finished_at: AwareDatetime | None = None @model_validator(mode="after") def _check_run(self) -> ScreeningRun: if self.status in ("queued", "running") and self.recommendation is not None: raise ValueError("运行未结束时不得给出建议") if self.status == "failed" and not self.degradations: raise ValueError("failed 运行必须有降级记录,不能只留空结果") if self.probability_view and self.status in ("queued", "running"): raise ValueError("运行未结束时不得产出概率视图") return self