Files
mini-openclaw-v2/backend/contracts/prescreen.py
T
qiaoqichaoandClaude Code 02289661a8 feat(v2): 纠偏第一批——执行链产出新版预审记录(真实运行验收)
覆盖 AC-46/47/48/49(BR-45/46/47、E-27)。审计确认的 P1 缺口"正常发起评审不产生
新版预审记录"由此关闭:视图由执行链出口产生,不再依赖测试或脚本注入。

后端:
- services/prescreen.py:新增 screening_from_run(),把一次真实运行的执行结果装配成
  预审视图;维度与阶段按**本次实际执行的组件与核查项**投影
  (dimensions_and_evidence:冻结方案声明过、且本次真的执行过的技能/规则才算维度;
  经验核查等方案外伪步骤不算;阶段只表达证据成熟度,不看结论好坏)。
- services/assistant_worker.py:运行出口(含失败路径)统一调用 save_prescreen_view(),
  运行结束即落库视图;装配失败单独记事件,不让运行一起丢。
- contracts/prescreen.py:视图带 evidence(界面"查看依据"的来源实体);
  新增运行级错误码 RUN_STEP_FAILED(E-27)。
- 维度状态用本次执行的步骤与核查项算(含跑通但无问题的项),
  只看待办 finding 会把通过的维度误判成未核查。

前端:面板新增"核查结果"区(每条可打开依据抽屉,显示来源、定位、原文片段与组件版本)
与降级记录展示;没有证据时明确写"按缺证处理",不编造。

测试:backend/test_prescreen_from_run.py 12 项,用真实 enqueue + DurableWorker.process_next
(临时库)断言视图由执行链产生、失败与模型不可用如实留痕、重跑新增记录。
含反例:执行前不得存在任何记录。

实际验收(浏览器渲染 + 真实运行):状态 partial、10 维(5 已核查/2 待核实/3 阻断)、
4 条核查结果、14 条证据、1 条 RUN_STEP_FAILED 未完成项;记录见
docs/v2/17-batch-delivery-records.md(含剩余缺口:界面点击未自动化、材料确认未在浏览器走完、
演示角色需 XINGBANG_DEMO_ROLE_LOGIN=1、模型未配置)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 12:34:45 +08:00

866 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""兴邦预审 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 + 共用约束 + 错误信封
**不在本文件**(属后续任务包,不得在此提前定义):CaseTimeline、DecisionEvent、
ExperienceCard、KnowledgeCard、ReviewPlan、DimensionSpec、ScreeningRun、Judgment、
Simulation、Evaluation、TrajectoryStep、ReviewOutcome、ImprovementCandidate、
DatasetManifest、ProbabilityView。
依赖方向:本模块**不导入** `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"
# 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,
})
# 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
# ---------------------------------------------------------------- 评审方案维度(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