Files
mini-openclaw-v2/backend/contracts/prescreen.py
T
qiaoqichaoandClaude Code 8a9005036c feat(v2): T06-b 方案装配与依赖预检(可定位的 E-19 文案)
T06 第二块。核对后确认 component_registry.freeze() 已实现大部分 AC-28:
按 release 指针解析依赖、原生方法文本装配时固化、循环依赖与输出冲突检查、
数据域一致性("演示与正式组件不能混用")、content_hash。因此本批**不重复实现冻结**,
只补真正的缺口。

新增 contracts:JudgmentBinding、ReviewPlan(含维度、关键核查项、专项绑定)。

新增 review_plan.check_dependencies() / assemble():
- **缺口**:未发布/已撤回的依赖原先抛裸 KeyError,被 translate() 映射成 404
  "对象不存在",用户无法定位是哪一项。现改为逐个检查并**指明依赖名与原因**
  ("尚未发布或已撤回"/"版本状态为 X"/"发布指针指向的版本不存在")
- 只用 release 指针判定可用性——草稿与已撤回天然没有指针,这正是 BR-27
  "草稿或已撤回组件不得进入新运行"的机械表达
- 装配时接入 T06-a 的维度解析与呈现判定,并做不丢失断言

测试:test_review_plan.py 增至 25 项(新增 7 项装配)。其中版本换版后
content_hash 必须变化,用于证明版本确实被冻结进结果而不是每次重解析。
全量 340 项 OK(333 → 340);verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 20:20:58 +08:00

619 lines
26 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"
# 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,
}
# 403/422 不自动重试;409 需客户端改载荷或刷新。见 03 §5。
S1_ERROR_RETRYABLE: dict[ErrorCode, bool] = {code: False 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