feat(v2): T11 改进治理与演示交付——对照门禁、泄漏作废、三模块演示脚本
覆盖 AC-15/16/34/37/41(BR-15/16/40、E-11/24)。
后端:
- contracts/improvement.py:ImprovementCandidate + ContrastReport/ContrastMetrics。
零分母不得给比率或覆盖率(样本不足不是 0%);非独立评测不得给改进/退化结论;
已发布必须引用既有不可变版本;没有对照证据不得进入待审核或已发布。
- services/improvements.py:
· evaluate_contrast 在服务端判定泄漏——预测锁定前读到标签即整次评测作废(E-11),
保留集来源进开发集的样本作废并单列,指标按分子/可观察分母/覆盖率/未观察分开报;
· transition 状态机:draft→evaluating→ready→in_review→approved→released,
自批拒绝、退化与结论不明拒绝发布、发布幂等且只引用既有版本、撤回只影响新运行。
- api/prescreen.py:候选列表、提出与状态机三个端点;评测请求体提交样本,
泄漏与指标一律由服务端计算,不接受客户端交来的"成品报告"。
- contracts/prescreen.py:补 E-11/E-24 错误码与 409 映射。
前端一致性修正:
- src/lib/prescreen-run.ts 的呈现判定改用后端既有词汇
(empty/list/grouped/radar/faceted,与 services/review_plan.presentation 一致),
避免前后端各一套判定导致界面画出后端判定为不该画雷达的图。
演示交付:
- scripts/xingbang_prescreen_demo.py:按 08 §7 八步演示,只驱动已实现入口,
未实现的步骤(批量清单、历史提炼、卡片治理)如实标注原因;
工程演示与模型效果分列,本地专项/Jev/真实概率一律标未验证或未接通。
- backend/test_demo_script.py:冒烟断言八步状态、未实现原因与分列口径。
验证:backend 全量 unittest 519 项 OK;前端 npm run build 通过。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from api.project_assistant import enabled
|
||||
from api.risk_agent_access import authorize, check_project
|
||||
from contracts.prescreen import (
|
||||
DESC_MAX,
|
||||
EVIDENCE_MAX,
|
||||
ID_MAX,
|
||||
MAX_BATCH_FILES,
|
||||
@@ -42,7 +43,8 @@ from contracts.prescreen import (
|
||||
S1_ERROR_RETRYABLE,
|
||||
)
|
||||
from services import assistant_store as store
|
||||
from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store, trajectory
|
||||
from services import (fact_conflict, improvements, material_import, prescreen_sandbox,
|
||||
prescreen_store, trajectory)
|
||||
from services.assistant_settings import flags
|
||||
from services.fact_conflict import FactConflictUnresolved
|
||||
from services.project_assistant import ProjectAssistantService
|
||||
@@ -92,6 +94,28 @@ class SimulationInput(Input):
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class ImprovementInput(Input):
|
||||
"""T11 改进候选。必须说明改什么、依据哪些运行或结果。"""
|
||||
|
||||
target: str = Field(default="knowledge", max_length=40)
|
||||
target_ref: dict
|
||||
proposed_revision_ref: dict
|
||||
change_summary: str = Field(min_length=1, max_length=DESC_MAX)
|
||||
source_run_ids: list[str] = Field(default_factory=list, max_length=200)
|
||||
outcome_ids: list[str] = Field(default_factory=list, max_length=200)
|
||||
|
||||
|
||||
class TransitionInput(Input):
|
||||
"""状态机动作。评测动作必须随请求提交对照结果,不能只改状态。"""
|
||||
|
||||
action: str = Field(min_length=1, max_length=40)
|
||||
expected_revision: int | None = Field(default=None, ge=1)
|
||||
evaluation: dict | None = None
|
||||
contrast: dict | None = None
|
||||
released_ref: dict | None = None
|
||||
reason: str | None = Field(default=None, max_length=DESC_MAX)
|
||||
|
||||
|
||||
class OutcomeInput(Input):
|
||||
"""T10 会议结果。原始结果码必填;未提供时不得推断成通过或否决。"""
|
||||
|
||||
@@ -518,3 +542,57 @@ def record_review_outcome(project_id: str, payload: OutcomeInput, request: Reque
|
||||
known_snapshots=snapshots)
|
||||
|
||||
return run(request, action)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 改进治理(T11)
|
||||
@router.get("/projects/{project_id}/improvements")
|
||||
def list_improvements(project_id: str, request: Request, status: str | None = None):
|
||||
blocked = scope(request, project_id)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
def action():
|
||||
with _db() as db:
|
||||
return improvements.listing(db, project_id, status)
|
||||
|
||||
return run(request, action)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/improvements", status_code=201)
|
||||
def propose_improvement(project_id: str, payload: ImprovementInput, request: Request):
|
||||
"""提出改进候选(草稿)。发布仍须经过对照、独立审核两个门。"""
|
||||
blocked = scope(request, project_id)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
def action():
|
||||
with _db() as db:
|
||||
project = _project(db, project_id)
|
||||
return improvements.propose(db, project_id, payload.model_dump(),
|
||||
_actor(request), project["data_domain"])
|
||||
|
||||
return run(request, action)
|
||||
|
||||
|
||||
@router.post("/improvements/{candidate_id}/transitions")
|
||||
def transition_improvement(candidate_id: str, payload: TransitionInput, request: Request):
|
||||
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。"""
|
||||
def action():
|
||||
with _db() as db:
|
||||
probe = store.get(db, improvements.KIND, candidate_id)
|
||||
blocked = scope(request, probe["project_id"])
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
try:
|
||||
with _db() as db:
|
||||
return improvements.transition(
|
||||
db, candidate_id, payload.action, _actor(request),
|
||||
payload.expected_revision, evaluation=payload.evaluation,
|
||||
contrast=payload.contrast, released_ref=payload.released_ref,
|
||||
reason=payload.reason)
|
||||
except improvements.LeakageDetected as exc:
|
||||
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
|
||||
except improvements.NotValidated as exc:
|
||||
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
|
||||
|
||||
return run(request, action)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""T11 改进候选契约:对照证据、独立审核与发布。
|
||||
|
||||
Owner:本文件(docs/v2/03-detailed-design.md §9.4 ImprovementCandidate 与状态机、
|
||||
docs/v2/14-experience-loop.md §8、06-validation.md §5)。
|
||||
|
||||
三条不可让渡的规则:
|
||||
1. **没有对照证据不能发布**:候选必须带一次冻结版本的旧/新对照,且不是退化
|
||||
(AC-34 / 06 §5"未评测、自批、退化均不能发布")。
|
||||
2. **自批不算审核**:提交人与审核人必须是不同的人;同模型自评也不能当独立评测
|
||||
(AC-41)。
|
||||
3. **发布是引用不可变版本**:发布只把候选指向一个既有的、不可变的发布结果,
|
||||
不允许"标成已发布却没有对应版本"(03 §9.4 末)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from contracts.prescreen import (
|
||||
DESC_MAX,
|
||||
ID_MAX,
|
||||
AwareDatetime,
|
||||
Contract,
|
||||
DataDomain,
|
||||
ErrorCode,
|
||||
Ref,
|
||||
)
|
||||
|
||||
CANDIDATE_STATUSES = ("draft", "evaluating", "ready", "in_review", "approved",
|
||||
"rejected", "released", "withdrawn")
|
||||
TARGETS = ("knowledge", "orchestration", "question_profile")
|
||||
|
||||
|
||||
class ContrastMetrics(Contract):
|
||||
"""高预测组指标口径(AC-37):分子、可观察分母、覆盖率与未观察数分开报。
|
||||
|
||||
零分母时必须留空而不是填 0%——"没有样本"与"全都没通过"是两件事。
|
||||
"""
|
||||
|
||||
label: str = Field(min_length=1, max_length=DESC_MAX)
|
||||
numerator: int = Field(ge=0)
|
||||
observable_denominator: int = Field(ge=0)
|
||||
unobserved: int = Field(ge=0)
|
||||
coverage: float | None = Field(default=None, ge=0, le=1)
|
||||
rate: float | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_metrics(self) -> ContrastMetrics:
|
||||
if self.numerator > self.observable_denominator:
|
||||
raise ValueError("分子不得大于可观察分母")
|
||||
if self.observable_denominator == 0:
|
||||
if self.rate is not None or self.coverage is not None:
|
||||
raise ValueError("零分母时不得给出比率或覆盖率;样本不足不是 0%")
|
||||
elif self.rate is None:
|
||||
raise ValueError("有可观察样本时必须给出比率,避免用空白代替结果")
|
||||
return self
|
||||
|
||||
|
||||
class ContrastReport(Contract):
|
||||
"""一次旧/新对照的结果。versions 冻结后才可比较(06 §5)。"""
|
||||
|
||||
id: str = Field(min_length=1, max_length=ID_MAX)
|
||||
baseline_ref: Ref
|
||||
candidate_ref: Ref
|
||||
dataset_ref: str = Field(min_length=1, max_length=DESC_MAX)
|
||||
split_ref: str = Field(min_length=1, max_length=DESC_MAX)
|
||||
baseline_metrics: list[ContrastMetrics] = Field(default_factory=list, max_length=20)
|
||||
candidate_metrics: list[ContrastMetrics] = Field(default_factory=list, max_length=20)
|
||||
sample_size: int = Field(ge=0)
|
||||
contaminated: list[str] = Field(default_factory=list, max_length=200)
|
||||
independent: bool = False
|
||||
verdict: Literal["improved", "unchanged", "regressed", "inconclusive"]
|
||||
reviewer_id: str | None = Field(default=None, max_length=ID_MAX)
|
||||
created_at: AwareDatetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_report(self) -> ContrastReport:
|
||||
if self.verdict != "inconclusive" and not self.independent:
|
||||
raise ValueError("非独立评测不得给出改进/退化结论,只能记 inconclusive")
|
||||
if self.sample_size == 0 and self.verdict == "improved":
|
||||
raise ValueError("没有样本不得声称改进")
|
||||
return self
|
||||
|
||||
|
||||
class ImprovementCandidate(Contract):
|
||||
"""改进候选。发布引用既有不可变版本;撤回只影响新运行。"""
|
||||
|
||||
id: str = Field(min_length=1, max_length=ID_MAX)
|
||||
project_id: str = Field(min_length=1, max_length=ID_MAX)
|
||||
data_domain: DataDomain
|
||||
revision: int = Field(ge=1)
|
||||
target: Literal["knowledge", "orchestration", "question_profile"]
|
||||
target_ref: Ref
|
||||
source_run_ids: list[str] = Field(default_factory=list, max_length=200)
|
||||
outcome_ids: list[str] = Field(default_factory=list, max_length=200)
|
||||
change_summary: str = Field(min_length=1, max_length=DESC_MAX)
|
||||
proposed_revision_ref: Ref
|
||||
status: Literal["draft", "evaluating", "ready", "in_review", "approved",
|
||||
"rejected", "released", "withdrawn"]
|
||||
evaluation_ref: Ref | None = None
|
||||
contrast: ContrastReport | None = None
|
||||
reviewer_id: str | None = Field(default=None, max_length=ID_MAX)
|
||||
released_ref: Ref | None = None
|
||||
proposed_by: str = Field(min_length=1, max_length=ID_MAX)
|
||||
rejection_reason: str | None = Field(default=None, max_length=DESC_MAX)
|
||||
created_at: AwareDatetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_candidate(self) -> ImprovementCandidate:
|
||||
if self.status in ("approved", "released") and self.reviewer_id is None:
|
||||
raise ValueError("通过审核的候选必须记录审核人")
|
||||
if self.status == "released" and self.released_ref is None:
|
||||
raise ValueError("已发布必须引用一个既有的不可变发布版本,不得只改状态")
|
||||
if self.status == "rejected" and not self.rejection_reason:
|
||||
raise ValueError("拒绝必须留下原因")
|
||||
if self.status in ("ready", "in_review", "approved", "released") \
|
||||
and self.contrast is None:
|
||||
raise ValueError("没有对照证据的候选不得进入待审核或已发布状态")
|
||||
return self
|
||||
|
||||
def must_not_self_approve(self, actor: str) -> None:
|
||||
if actor == self.proposed_by:
|
||||
raise PermissionError("改进候选不能由提出者自己审核(自批不算独立审核)")
|
||||
|
||||
def release_blockers(self) -> list[str]:
|
||||
"""发布前必须为空的阻塞项列表;可读的失败原因,便于界面展示。"""
|
||||
blockers = []
|
||||
if self.contrast is None:
|
||||
blockers.append("缺少旧/新对照证据")
|
||||
else:
|
||||
if self.contrast.contaminated:
|
||||
blockers.append(f"对照样本存在泄漏:{self.contrast.contaminated}")
|
||||
if not self.contrast.independent:
|
||||
blockers.append("对照不是独立评测(同模型自评不算证据)")
|
||||
if self.contrast.verdict == "regressed":
|
||||
blockers.append("对照显示退化,不得发布")
|
||||
if self.contrast.verdict == "inconclusive":
|
||||
blockers.append("对照结论不明确,不得发布")
|
||||
if self.status != "approved":
|
||||
blockers.append(f"当前状态为 {self.status},未通过审核")
|
||||
return blockers
|
||||
|
||||
|
||||
CONTAMINATION_CODE = ErrorCode.EVALUATION_CONTAMINATED
|
||||
NOT_VALIDATED_CODE = ErrorCode.IMPROVEMENT_NOT_VALIDATED
|
||||
@@ -109,6 +109,9 @@ class ErrorCode(StrEnum):
|
||||
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"
|
||||
@@ -140,6 +143,8 @@ S1_ERROR_HTTP: dict[ErrorCode, int] = {
|
||||
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 仅表示结果可读取"),永远不会作为
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""T11 改进治理:旧/新对照、独立审核与发布。
|
||||
|
||||
依据:docs/v2/06-validation.md §5/§7、14-experience-loop.md §8、
|
||||
03-detailed-design.md §9.4;BR-15/16/40、E-11/24、AC-15/16/34/37/41。
|
||||
|
||||
四条不可让渡的规则:
|
||||
1. **预测先锁定,标签后揭示**:任何一条在锁定前就拿到目标标签的样本都算泄漏,
|
||||
该样本作废(AC-15);保留集来源不得进入开发集(AC-36)。
|
||||
2. **没有对照不发布**:缺证据、非独立评测、退化、结论不明,任一项即为阻塞(AC-34)。
|
||||
3. **自批不算审核**:提出者不能审核自己的候选(AC-41)。
|
||||
4. **发布引用不可变版本**:发布只指向既有发布结果;旧版本始终可回放。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contracts.improvement import ContrastMetrics, ContrastReport, ImprovementCandidate
|
||||
from services import assistant_store as store
|
||||
|
||||
KIND = "improvement_candidate"
|
||||
|
||||
|
||||
class LeakageDetected(RuntimeError):
|
||||
"""对照样本发生泄漏(E-11):样本作废,不得据此得出改进结论。"""
|
||||
|
||||
|
||||
class NotValidated(RuntimeError):
|
||||
"""未通过对照/审核(E-24)。"""
|
||||
|
||||
|
||||
def _rate(numerator: int, denominator: int) -> float | None:
|
||||
return None if denominator == 0 else round(numerator / denominator, 4)
|
||||
|
||||
|
||||
def evaluate_contrast(samples, baseline_ref: dict, candidate_ref: dict, *,
|
||||
dataset_ref: str, split_ref: str, reviewer_id: str | None,
|
||||
independent: bool) -> dict:
|
||||
"""按冻结样本算旧/新对照(AC-15/37)。
|
||||
|
||||
样本字段:id、split(dev/holdout)、predicted_at、label_revealed_at、
|
||||
observed、baseline_pass、candidate_pass、source_in_dev。
|
||||
泄漏样本(锁定前已见标签,或保留集来源进了开发集)一律作废并单列。
|
||||
"""
|
||||
premature = [item["id"] for item in samples
|
||||
if item.get("label_revealed_at") and item.get("predicted_at")
|
||||
and str(item["label_revealed_at"]) < str(item["predicted_at"])]
|
||||
if premature:
|
||||
raise LeakageDetected(
|
||||
f"以下样本在预测锁定前已读取目标标签,评测作废:{sorted(premature)}")
|
||||
|
||||
contaminated = sorted(item["id"] for item in samples
|
||||
if item.get("split") == "holdout" and item.get("source_in_dev"))
|
||||
usable = [item for item in samples if item["id"] not in set(contaminated)]
|
||||
|
||||
def arm(key: str, label: str) -> ContrastMetrics:
|
||||
observed = [item for item in usable if item.get("observed")]
|
||||
passed = [item for item in observed if item.get(key) is True]
|
||||
denominator = len(observed)
|
||||
return ContrastMetrics(
|
||||
label=label, numerator=len(passed), observable_denominator=denominator,
|
||||
unobserved=len(usable) - denominator,
|
||||
coverage=round(denominator / len(usable), 4) if (usable and denominator) else None,
|
||||
rate=_rate(len(passed), denominator))
|
||||
|
||||
baseline = arm("baseline_pass", "现有助手")
|
||||
candidate = arm("candidate_pass", "主审 + 历史经验(候选)")
|
||||
|
||||
if not independent:
|
||||
# 同模型自评不算独立评测:只能记"结论不明",不得声称改进(AC-41)
|
||||
verdict = "inconclusive"
|
||||
elif baseline.observable_denominator == 0 or candidate.observable_denominator == 0:
|
||||
verdict = "inconclusive"
|
||||
elif (candidate.rate or 0) > (baseline.rate or 0):
|
||||
verdict = "improved"
|
||||
elif (candidate.rate or 0) < (baseline.rate or 0):
|
||||
verdict = "regressed"
|
||||
else:
|
||||
verdict = "unchanged"
|
||||
|
||||
report = ContrastReport(
|
||||
id=store.uid("contrast"), baseline_ref=baseline_ref, candidate_ref=candidate_ref,
|
||||
dataset_ref=dataset_ref, split_ref=split_ref,
|
||||
baseline_metrics=[baseline], candidate_metrics=[candidate],
|
||||
sample_size=len(usable), contaminated=contaminated, independent=independent,
|
||||
verdict=verdict, reviewer_id=reviewer_id, created_at=store.now())
|
||||
return report.model_dump(mode="json")
|
||||
|
||||
|
||||
def _candidate(db, candidate_id: str) -> dict:
|
||||
return store.get(db, KIND, candidate_id)
|
||||
|
||||
|
||||
def propose(db, project_id: str, body: dict, actor: str,
|
||||
data_domain: str = "demo") -> dict:
|
||||
"""提出候选。必须写明改什么、依据哪些运行/结果,以及预期的发布版本引用。"""
|
||||
if not body.get("change_summary"):
|
||||
raise ValueError("改进候选必须说明改了什么,不能只有标题")
|
||||
if not body.get("source_run_ids") and not body.get("outcome_ids"):
|
||||
raise ValueError("改进候选必须引用来源运行或会议结果,凭空提出的改动不进入审核")
|
||||
candidate = ImprovementCandidate(
|
||||
id=store.uid("improvement"), project_id=project_id, data_domain=data_domain,
|
||||
revision=1, target=body.get("target") or "knowledge",
|
||||
target_ref=body["target_ref"], source_run_ids=list(body.get("source_run_ids") or ()),
|
||||
outcome_ids=list(body.get("outcome_ids") or ()),
|
||||
change_summary=body["change_summary"], proposed_revision_ref=body["proposed_revision_ref"],
|
||||
status="draft", proposed_by=actor, created_at=store.now())
|
||||
saved = candidate.model_dump(mode="json")
|
||||
store.put(db, KIND, saved)
|
||||
store.event(db, project_id, "improvement.proposed", actor,
|
||||
{"candidate_id": saved["id"], "target": saved["target"]}, None)
|
||||
return saved
|
||||
|
||||
|
||||
def _save(db, body: dict, actor: str, action: str) -> dict:
|
||||
body["revision"] = int(body.get("revision") or 1) + 1
|
||||
saved = ImprovementCandidate(**body).model_dump(mode="json")
|
||||
store.put(db, KIND, saved, expected=None)
|
||||
store.event(db, saved["project_id"], f"improvement.{action}", actor,
|
||||
{"candidate_id": saved["id"], "status": saved["status"]}, None)
|
||||
return saved
|
||||
|
||||
|
||||
def transition(db, candidate_id: str, action: str, actor: str,
|
||||
expected_revision: int | None = None, *, evaluation: dict | None = None,
|
||||
contrast: dict | None = None, released_ref: dict | None = None,
|
||||
publisher=None, reason: str | None = None) -> dict:
|
||||
"""候选状态机。发布委托给注入的 publisher,并要求引用既有发布结果。"""
|
||||
body = dict(_candidate(db, candidate_id))
|
||||
if expected_revision is not None and body.get("revision") != expected_revision:
|
||||
raise store.Conflict("候选已有新版本,请刷新后重试")
|
||||
|
||||
if action == "evaluate":
|
||||
if evaluation is not None:
|
||||
# 泄漏与指标一律在服务端判定:不接受客户端直接交来的"成品报告"
|
||||
contrast = evaluate_contrast(
|
||||
evaluation.get("samples") or (), evaluation["baseline_ref"],
|
||||
evaluation["candidate_ref"], dataset_ref=evaluation["dataset_ref"],
|
||||
split_ref=evaluation["split_ref"], reviewer_id=evaluation.get("reviewer_id"),
|
||||
independent=bool(evaluation.get("independent")))
|
||||
elif contrast is not None:
|
||||
contrast = ContrastReport(**contrast).model_dump(mode="json")
|
||||
else:
|
||||
raise ValueError("评测必须提交样本与版本引用,不能只改状态")
|
||||
body["contrast"] = contrast
|
||||
body["evaluation_ref"] = {"id": contrast["id"], "version": "1"}
|
||||
if contrast.get("contaminated"):
|
||||
body["status"] = "evaluating" # 有泄漏:留在评测中,不得进待审核
|
||||
body["rejection_reason"] = "存在泄漏样本,已作废;请修正数据划分后重测"
|
||||
elif contrast["verdict"] in ("improved", "unchanged") and contrast["independent"]:
|
||||
body["status"] = "ready"
|
||||
else:
|
||||
body["status"] = "evaluating"
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
if action == "submit":
|
||||
if body.get("contrast") is None:
|
||||
raise NotValidated("没有对照证据的候选不得提交审核")
|
||||
body["status"] = "in_review"
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
if action == "approve":
|
||||
ImprovementCandidate(**{**body, "status": "approved",
|
||||
"reviewer_id": actor}).must_not_self_approve(actor)
|
||||
if body.get("contrast") is None:
|
||||
raise NotValidated("没有对照证据的候选不得通过审核")
|
||||
body["status"] = "approved"
|
||||
body["reviewer_id"] = actor
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
if action == "reject":
|
||||
body["status"] = "rejected"
|
||||
body["rejection_reason"] = reason or "审核未通过"
|
||||
body["reviewer_id"] = actor
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
if action == "release":
|
||||
if body.get("status") == "released":
|
||||
return body # 幂等:重复发布返回同一版本,不新建
|
||||
blockers = ImprovementCandidate(**{**body, "released_ref": released_ref or
|
||||
{"id": "pending", "version": "0"}}).release_blockers()
|
||||
if blockers:
|
||||
raise NotValidated(";".join(blockers))
|
||||
if publisher is not None and released_ref is None:
|
||||
released_ref = publisher(body)
|
||||
if not released_ref:
|
||||
raise NotValidated("发布必须引用一个既有的不可变版本,不得只改状态")
|
||||
body["status"] = "released"
|
||||
body["released_ref"] = released_ref
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
if action == "withdraw":
|
||||
if body.get("status") != "released":
|
||||
raise NotValidated("只有已发布的版本可以撤回;撤回只影响新运行")
|
||||
body["status"] = "withdrawn"
|
||||
return _save(db, body, actor, action)
|
||||
|
||||
raise ValueError(f"未登记的改进动作:{action}")
|
||||
|
||||
|
||||
def listing(db, project_id: str, status: str | None = None) -> dict:
|
||||
rows = sorted(store.listing(db, KIND, project_id),
|
||||
key=lambda item: item.get("created_at") or "")
|
||||
if status:
|
||||
rows = [item for item in rows if item.get("status") == status]
|
||||
return {"items": rows}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""T11 演示脚本冒烟测试:八步都出现、未实现处不伪装、工程与模型效果分列。"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import xingbang_prescreen_demo as demo # noqa: E402
|
||||
|
||||
|
||||
class DemoScriptTests(unittest.TestCase):
|
||||
def output(self) -> str:
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
code = demo.main()
|
||||
self.assertEqual(code, 0)
|
||||
return buffer.getvalue()
|
||||
|
||||
def test_every_step_is_reported_with_its_real_status(self):
|
||||
text = self.output()
|
||||
for index, title, status, _ in demo.STEPS:
|
||||
self.assertIn(f"{index}. [{status}] {title}", text)
|
||||
|
||||
def test_unimplemented_steps_say_why(self):
|
||||
text = self.output()
|
||||
self.assertIn("未实现", text)
|
||||
self.assertIn("需要真实历史材料", text)
|
||||
|
||||
def test_engineering_demo_and_model_effects_are_separate_sections(self):
|
||||
text = self.output()
|
||||
self.assertIn("工程演示", text)
|
||||
self.assertIn("模型效果", text)
|
||||
self.assertIn("未验证(无权重与授权)", text)
|
||||
self.assertIn("Jev:未接通", text)
|
||||
self.assertIn("不能作为真实业务效果或审批依据", text)
|
||||
|
||||
def test_real_probability_stays_empty_and_demo_values_are_labeled(self):
|
||||
facts = demo.run_demo()
|
||||
self.assertTrue(facts["probability_is_null"], "真实概率必须为空")
|
||||
self.assertEqual(facts["sandbox"]["baseline"], 0.42)
|
||||
self.assertEqual(facts["sandbox"]["combined"], 0.64)
|
||||
self.assertIn("人工设定", facts["sandbox"]["label"])
|
||||
self.assertEqual(facts["sandbox"]["unchanged_dimensions"], ["DC-DEAL"])
|
||||
|
||||
def test_trajectory_keeps_failures_and_excludes_unmatched_outcomes(self):
|
||||
facts = demo.run_demo()
|
||||
self.assertTrue(facts["trajectory"]["failed_preserved"])
|
||||
self.assertEqual(facts["trajectory"]["unmatched"], 1)
|
||||
self.assertEqual(facts["trajectory"]["observed_denominator"], 0,
|
||||
"未匹配材料版本的结果不得进入已观察分母")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,286 @@
|
||||
"""T11 改进治理测试:对照、泄漏、独立审核与发布门禁。
|
||||
|
||||
覆盖 AC-15/16/34/37/41(BR-15/16/40、E-11/24)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api import prescreen as api
|
||||
from contracts.improvement import ContrastMetrics, ImprovementCandidate
|
||||
from services import assistant_store as store
|
||||
from services import improvements
|
||||
from services.project_assistant import ProjectAssistantService
|
||||
|
||||
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
|
||||
PROJECT = "DEMO-B"
|
||||
BASELINE = {"id": "assistant.current", "version": "1.0.0"}
|
||||
CANDIDATE = {"id": "prescreen.v2", "version": "0.2.0"}
|
||||
|
||||
|
||||
def sample(sample_id, *, split="dev", observed=True, baseline_pass=True,
|
||||
candidate_pass=True, predicted="2026-09-29T01:00:00+00:00",
|
||||
revealed="2026-09-29T03:00:00+00:00", source_in_dev=False):
|
||||
return {"id": sample_id, "split": split, "observed": observed,
|
||||
"baseline_pass": baseline_pass, "candidate_pass": candidate_pass,
|
||||
"predicted_at": predicted, "label_revealed_at": revealed,
|
||||
"source_in_dev": source_in_dev}
|
||||
|
||||
|
||||
def contrast(samples, *, independent=True, reviewer="reviewer-1"):
|
||||
return improvements.evaluate_contrast(
|
||||
samples, BASELINE, CANDIDATE, dataset_ref="fixtures/cases/manifest.json",
|
||||
split_ref="split.v1", reviewer_id=reviewer, independent=independent)
|
||||
|
||||
|
||||
class ContrastTests(unittest.TestCase):
|
||||
def test_labels_read_before_the_lock_void_the_evaluation(self):
|
||||
with self.assertRaises(improvements.LeakageDetected):
|
||||
contrast([sample("S-1", predicted="2026-09-29T05:00:00+00:00")])
|
||||
|
||||
def test_holdout_source_used_in_dev_is_contaminated(self):
|
||||
report = contrast([sample("S-1"), sample("S-2", split="holdout", source_in_dev=True)])
|
||||
self.assertEqual(report["contaminated"], ["S-2"])
|
||||
self.assertEqual(report["sample_size"], 1, "泄漏样本作废,不参与指标")
|
||||
|
||||
def test_metrics_keep_numerator_denominator_and_unobserved_apart(self):
|
||||
report = contrast([sample("S-1"), sample("S-2", observed=False),
|
||||
sample("S-3", baseline_pass=False, candidate_pass=True)])
|
||||
baseline = report["baseline_metrics"][0]
|
||||
candidate = report["candidate_metrics"][0]
|
||||
self.assertEqual((baseline["numerator"], baseline["observable_denominator"],
|
||||
baseline["unobserved"]), (1, 2, 1))
|
||||
self.assertEqual((candidate["numerator"], candidate["observable_denominator"]), (2, 2))
|
||||
self.assertEqual(baseline["coverage"], 0.6667)
|
||||
self.assertEqual(report["verdict"], "improved")
|
||||
|
||||
def test_zero_denominator_is_not_reported_as_zero_percent(self):
|
||||
report = contrast([sample("S-1", observed=False)])
|
||||
self.assertIsNone(report["candidate_metrics"][0]["rate"])
|
||||
self.assertIsNone(report["candidate_metrics"][0]["coverage"])
|
||||
self.assertEqual(report["verdict"], "inconclusive")
|
||||
|
||||
def test_non_independent_evaluation_cannot_claim_improvement(self):
|
||||
report = contrast([sample("S-1")], independent=False)
|
||||
self.assertEqual(report["verdict"], "inconclusive")
|
||||
|
||||
def test_contract_refuses_rate_without_a_denominator(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ContrastMetrics(label="高预测组", numerator=0, observable_denominator=0,
|
||||
unobserved=3, rate=0.0)
|
||||
|
||||
def test_metrics_cannot_have_more_passes_than_samples(self):
|
||||
with self.assertRaises(ValueError):
|
||||
ContrastMetrics(label="高预测组", numerator=3, observable_denominator=2,
|
||||
unobserved=0, rate=1.0)
|
||||
|
||||
|
||||
class ImprovementLifecycleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||||
self.published: list[dict] = []
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def publish(self, body):
|
||||
ref = {"id": f"card-{len(self.published) + 1}", "version": "2.0.0"}
|
||||
self.published.append(ref)
|
||||
return ref
|
||||
|
||||
def propose(self, actor="manager-1"):
|
||||
with store.transaction(self.service.db_path) as db:
|
||||
return improvements.propose(db, PROJECT, {
|
||||
"target": "knowledge", "target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "把回款核验的经验扩到制造业设备直租",
|
||||
"source_run_ids": ["run-1"]}, actor, "demo")
|
||||
|
||||
def transition(self, candidate, action, actor="reviewer-9", **over):
|
||||
with store.transaction(self.service.db_path) as db:
|
||||
return improvements.transition(db, candidate["id"], action, actor,
|
||||
over.pop("expected_revision", None), **over)
|
||||
|
||||
def test_proposal_without_sources_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
with store.transaction(self.service.db_path) as db:
|
||||
improvements.propose(db, PROJECT, {
|
||||
"target": "knowledge", "target_ref": {"id": "c", "version": "1"},
|
||||
"proposed_revision_ref": {"id": "c", "version": "2"},
|
||||
"change_summary": "改一下"}, "manager-1", "demo")
|
||||
|
||||
def test_release_requires_contrast_evidence(self):
|
||||
candidate = self.propose()
|
||||
with self.assertRaises(improvements.NotValidated) as ctx:
|
||||
self.transition(candidate, "release", released_ref={"id": "x", "version": "1"})
|
||||
self.assertIn("未通过审核", str(ctx.exception))
|
||||
|
||||
def test_improved_candidate_can_be_approved_and_released(self):
|
||||
candidate = self.propose()
|
||||
report = contrast([sample("S-1", candidate_pass=True),
|
||||
sample("S-2", baseline_pass=False, candidate_pass=True)])
|
||||
evaluated = self.transition(candidate, "evaluate", contrast=report)
|
||||
self.assertEqual(evaluated["status"], "ready")
|
||||
self.transition(evaluated, "submit")
|
||||
approved = self.transition(evaluated, "approve", actor="reviewer-9")
|
||||
self.assertEqual(approved["status"], "approved")
|
||||
released = self.transition(approved, "release", publisher=self.publish)
|
||||
self.assertEqual(released["status"], "released")
|
||||
self.assertEqual(released["released_ref"], {"id": "card-1", "version": "2.0.0"})
|
||||
self.assertEqual(len(self.published), 1)
|
||||
|
||||
def test_self_approval_is_refused(self):
|
||||
candidate = self.propose(actor="manager-1")
|
||||
report = contrast([sample("S-1")])
|
||||
self.transition(candidate, "evaluate", contrast=report)
|
||||
self.transition(candidate, "submit")
|
||||
with self.assertRaises(PermissionError):
|
||||
self.transition(candidate, "approve", actor="manager-1")
|
||||
|
||||
def test_regressed_candidate_cannot_be_released(self):
|
||||
candidate = self.propose()
|
||||
report = contrast([sample("S-1", baseline_pass=True, candidate_pass=False)])
|
||||
self.assertEqual(report["verdict"], "regressed")
|
||||
evaluated = self.transition(candidate, "evaluate", contrast=report)
|
||||
self.assertEqual(evaluated["status"], "evaluating")
|
||||
with self.assertRaises(improvements.NotValidated):
|
||||
self.transition(evaluated, "release", released_ref={"id": "x", "version": "1"})
|
||||
|
||||
def test_contaminated_evaluation_does_not_advance_the_candidate(self):
|
||||
candidate = self.propose()
|
||||
report = contrast([sample("S-1"), sample("S-2", split="holdout", source_in_dev=True)])
|
||||
evaluated = self.transition(candidate, "evaluate", contrast=report)
|
||||
self.assertEqual(evaluated["status"], "evaluating")
|
||||
self.assertIn("泄漏", evaluated["rejection_reason"])
|
||||
|
||||
def test_release_is_idempotent(self):
|
||||
candidate = self.propose()
|
||||
report = contrast([sample("S-1")])
|
||||
evaluated = self.transition(candidate, "evaluate", contrast=report)
|
||||
self.transition(evaluated, "submit")
|
||||
approved = self.transition(evaluated, "approve")
|
||||
released = self.transition(approved, "release", publisher=self.publish)
|
||||
again = self.transition(released, "release", publisher=self.publish)
|
||||
self.assertEqual(again, released, "重复发布不得新建版本")
|
||||
self.assertEqual(len(self.published), 1)
|
||||
|
||||
def test_withdraw_only_applies_to_released_versions(self):
|
||||
candidate = self.propose()
|
||||
with self.assertRaises(improvements.NotValidated):
|
||||
self.transition(candidate, "withdraw")
|
||||
|
||||
def test_contract_refuses_released_without_a_version_reference(self):
|
||||
body = {"id": "imp-1", "project_id": PROJECT, "data_domain": "demo", "revision": 1,
|
||||
"target": "knowledge", "target_ref": {"id": "c", "version": "1"},
|
||||
"change_summary": "改", "proposed_revision_ref": {"id": "c", "version": "2"},
|
||||
"status": "released", "reviewer_id": "r", "proposed_by": "m",
|
||||
"created_at": "2026-09-30T01:00:00+00:00"}
|
||||
with self.assertRaises(ValueError):
|
||||
ImprovementCandidate(**body)
|
||||
|
||||
|
||||
class ImprovementApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||||
self.patches = [
|
||||
patch.object(api, "_service", lambda: self.service),
|
||||
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
|
||||
"XINGBANG_LOCAL_MODEL_URL": ""}),
|
||||
]
|
||||
for item in self.patches:
|
||||
item.start()
|
||||
with store.transaction(self.service.db_path) as db:
|
||||
store.put(db, "project", {"id": PROJECT, "data_domain": "demo", "revision": 1})
|
||||
app = FastAPI()
|
||||
app.add_exception_handler(RequestValidationError, api.validation_handler)
|
||||
app.include_router(api.router)
|
||||
self.client = TestClient(app)
|
||||
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
for item in reversed(self.patches):
|
||||
item.stop()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_propose_then_blocked_release(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"source_run_ids": ["run-1"]}, headers=HEADERS)
|
||||
self.assertEqual(created.status_code, 201, created.text)
|
||||
candidate = created.json()
|
||||
self.assertEqual(candidate["status"], "draft")
|
||||
|
||||
blocked = self.client.post(
|
||||
f"{self.base}/improvements/{candidate['id']}/transitions",
|
||||
json={"action": "release",
|
||||
"released_ref": {"id": "card-1", "version": "2.0.0"}}, headers=HEADERS)
|
||||
self.assertEqual(blocked.status_code, 409)
|
||||
self.assertEqual(blocked.json()["error"]["code"], "IMPROVEMENT_NOT_VALIDATED")
|
||||
|
||||
def test_leakage_is_reported_as_e11(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"outcome_ids": ["outcome-1"]}, headers=HEADERS).json()
|
||||
# 泄漏在服务端判定:预测锁定前已读到标签的样本使整次评测作废
|
||||
leaked = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "evaluate", "evaluation": {
|
||||
"baseline_ref": BASELINE, "candidate_ref": CANDIDATE,
|
||||
"dataset_ref": "fixtures/cases/manifest.json", "split_ref": "split.v1",
|
||||
"independent": True,
|
||||
"samples": [sample("S-1", predicted="2026-09-29T05:00:00+00:00")]}},
|
||||
headers=HEADERS)
|
||||
self.assertEqual(leaked.status_code, 409, leaked.text)
|
||||
self.assertEqual(leaked.json()["error"]["code"], "EVALUATION_CONTAMINATED")
|
||||
|
||||
clean = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "evaluate", "evaluation": {
|
||||
"baseline_ref": BASELINE, "candidate_ref": CANDIDATE,
|
||||
"dataset_ref": "fixtures/cases/manifest.json", "split_ref": "split.v1",
|
||||
"independent": True, "samples": [sample("S-1")]}},
|
||||
headers=HEADERS)
|
||||
self.assertEqual(clean.status_code, 200, clean.text)
|
||||
self.assertEqual(clean.json()["status"], "ready")
|
||||
|
||||
def test_evaluation_without_samples_is_rejected(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"source_run_ids": ["run-1"]}, headers=HEADERS).json()
|
||||
response = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "evaluate", "contrast": {}}, headers=HEADERS)
|
||||
self.assertEqual(response.status_code, 422, response.text)
|
||||
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||||
|
||||
def test_unknown_action_is_rejected(self):
|
||||
created = self.client.post(f"{self.base}/projects/{PROJECT}/improvements",
|
||||
json={"target_ref": {"id": "card-1", "version": "1.0.0"},
|
||||
"proposed_revision_ref": {"id": "card-1", "version": "2.0.0"},
|
||||
"change_summary": "扩适用范围",
|
||||
"source_run_ids": ["run-1"]}, headers=HEADERS).json()
|
||||
response = self.client.post(
|
||||
f"{self.base}/improvements/{created['id']}/transitions",
|
||||
json={"action": "promote"}, headers=HEADERS)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user