2026-09-29 14:20:39 +08:00
|
|
|
|
"""兴邦预审 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-29 18:30:34 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-29 14:20:39 +08:00
|
|
|
|
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"
|
2026-09-29 20:33:36 +08:00
|
|
|
|
JUDGMENT_CONFIG_INVALID = "JUDGMENT_CONFIG_INVALID"
|
|
|
|
|
|
EGRESS_NOT_APPROVED = "EGRESS_NOT_APPROVED"
|
|
|
|
|
|
# 运行级编码:记录在运行体(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"
|
|
|
|
|
|
RUN_BUDGET_EXCEEDED = "RUN_BUDGET_EXCEEDED"
|
2026-09-29 14:20:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# HTTP 语义沿用既有 translate()(backend/api/project_assistant.py:36-46),
|
2026-09-29 14:49:03 +08:00
|
|
|
|
# 不另立一套。
|
|
|
|
|
|
# E-17 FACT_CONFLICT_UNRESOLVED 有**两个面**,不要混为一谈:
|
|
|
|
|
|
# · 确认动作被拒(本映射):422 —— 客户端必须先裁定冲突,属可立即纠正的请求问题
|
|
|
|
|
|
# · 运行级结果(不在此映射内):HTTP 200,冲突标识保存在 degradations/finding,
|
|
|
|
|
|
# "HTTP 200 仅表示结果可读取,不表示核查通过"(01 §5 末)
|
2026-09-29 14:20:39 +08:00
|
|
|
|
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,
|
2026-09-29 14:49:03 +08:00
|
|
|
|
ErrorCode.FACT_CONFLICT_UNRESOLVED: 422,
|
2026-09-29 20:33:36 +08:00
|
|
|
|
ErrorCode.JUDGMENT_CONFIG_INVALID: 422,
|
|
|
|
|
|
ErrorCode.EGRESS_NOT_APPROVED: 403,
|
2026-09-29 14:20:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 20:33:36 +08:00
|
|
|
|
# 这些编码只出现在运行结果里("HTTP 200 仅表示结果可读取"),永远不会作为
|
|
|
|
|
|
# 错误信封返回,因此与 S1_ERROR_HTTP 互不相交。
|
|
|
|
|
|
RUN_LEVEL_CODES = frozenset({
|
|
|
|
|
|
ErrorCode.JUDGMENT_UNAVAILABLE,
|
|
|
|
|
|
ErrorCode.JUDGMENT_CAPABILITY_UNSUPPORTED,
|
|
|
|
|
|
ErrorCode.JUDGMENT_OUTPUT_INVALID,
|
|
|
|
|
|
ErrorCode.RUN_BUDGET_EXCEEDED,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-29 14:20:39 +08:00
|
|
|
|
# 403/422 不自动重试;409 需客户端改载荷或刷新。见 03 §5。
|
2026-09-29 20:33:36 +08:00
|
|
|
|
S1_ERROR_RETRYABLE: dict[ErrorCode, bool] = {
|
|
|
|
|
|
code: code in (ErrorCode.JUDGMENT_UNAVAILABLE, ErrorCode.RUN_BUDGET_EXCEEDED)
|
|
|
|
|
|
for code in ErrorCode
|
|
|
|
|
|
}
|
2026-09-29 14:20:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-29 18:30:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 数据集(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
|
2026-09-29 18:41:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 历史过程(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
|
2026-09-29 18:47:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 知识卡片(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
|
2026-09-29 20:16:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 评审方案维度(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)
|
2026-09-29 20:20:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 评审方案(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
|
2026-09-29 20:33:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 专项判断(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
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
@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
|