Files
mini-openclaw-v2/backend/contracts/prescreen.py
T
qiaoqichaoandClaude Code c59fff68d3 feat(v2): S1 存储层——材料导入批次与事实冲突检测
T02 第二个子卡。复用 assistant_objects(新 kind),**未新建表**;旧库结构不变。

新增服务:
- services/material_import.py 批次生命周期:create/load/listing/mark_item/
  retry/cancel;状态由逐项状态派生(ready/partial/failed/parsing)
- services/fact_conflict.py 冲突检测/阻断/裁定;FactConflictUnresolved(E-17)

解决的**真实缺口**(BR-23 / AC-24):既有 apply_candidate 对 values/facts 是
后写覆盖,sources 虽累积多来源但无人检测分歧、无人阻断。本层在**新路径**前置
检测:冲突时证据仍入 sources(BR-22 留痕)但值不采用(BR-23),已确认值不被覆盖。

增量改动(均为可加性,未改既有语义与调用方行为):
- assistant_store.py:新增 idempotent_lookup / idempotent_remember 共享 helper。
  既有 enqueue 与 create_report 的内联实现**未改写**。用 INSERT OR REPLACE 而非
  裸 INSERT:重试路径上裸 INSERT 会抛 sqlite3.IntegrityError(非 Conflict,
  translate() 无法映射为 409)。
- assistant_materials.apply_candidate 新增 adopt=True 形参。adopt=False 只记证据
  不改值;既有调用方不传参,行为完全不变(有回归测试)。

设计决定:
- T00/04 曾提 3 个 kind;实现时发现契约中 ImportItem 无 revision 且内嵌于批次,
  items 是值对象,故收敛为 2 个 kind(import_batch / fact_conflict)
- E-17 有两个面:确认动作被拒 = 422;运行级结果 = HTTP 200 且冲突存于
  degradations。契约映射与注释均已写明,并有测试锁定

测试:新增 test_prescreen_storage.py(33 项)。全量 196 项 OK(124 → 196)。
scripts/verify.py 9/9 PASS(exit 0)。

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

314 lines
13 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 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