被多个 Skill 或看板消费的连接、模型、嵌入、Claude 运行时与声音账入口 各只保留一份实现;Skill 只留 CLI/落库,看板只读 muse-db,门禁锁死跨域注入。 Co-authored-by: Cursor <cursoragent@cursor.com>
1065 lines
58 KiB
Python
1065 lines
58 KiB
Python
#!/usr/bin/env python3
|
||
"""SemanticDetection v3 的无状态模型适配器与确定性绑定。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import pathlib
|
||
import re
|
||
from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
|
||
|
||
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||
try:
|
||
from claude_runtime import ExecutionProfile, contains_path_traversal, run_claude, sha256_json # type: ignore[import-not-found] # noqa: E402
|
||
except ImportError:
|
||
ExecutionProfile = Any # type: ignore[misc,assignment]
|
||
run_claude = None # type: ignore[assignment]
|
||
sha256_json = None # type: ignore[assignment]
|
||
|
||
def contains_path_traversal(value: str) -> bool: # type: ignore[misc]
|
||
# WHY: claude_runtime 缺席时失败关闭——任何引用一律按路径穿越处理、触发泄露拦截,
|
||
# 绝不在精确判定缺失时放行引用。
|
||
return True
|
||
|
||
|
||
INPUT_VERSION = "semantic-detector-input-v3"
|
||
MODEL_OUTPUT_VERSION = "semantic-detection-draft-v3"
|
||
REPORT_VERSION = "semantic-detection-v3"
|
||
HASH_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||
REAL_ARM_NAMES = frozenset({"A", "B", "C"})
|
||
RAW_PATH_PATTERN = re.compile(r"(?:^|[/\\])(?:private[/\\]tmp|tmp|raw)(?:[/\\]|$)", re.IGNORECASE)
|
||
VERDICTS = frozenset({"pass", "fail", "unknown"})
|
||
SEVERITIES = frozenset({"high", "medium", "low"})
|
||
PRIORITIES = frozenset({"high", "medium", "low"})
|
||
FINDING_CATEGORIES = frozenset({
|
||
"hard_constraint", "fact_conflict", "evidence_reference", "temporal_spatial",
|
||
"knowledge_scope", "ability_cost", "output_contract",
|
||
})
|
||
INPUT_FIELDS = frozenset({
|
||
"schemaVersion", "runId", "sampleId", "opaqueArmId", "candidateVersion",
|
||
"candidateSha256", "candidateBody", "contextSnapshotSha256", "fineOutline",
|
||
"hardConstraints", "factEvidence", "proseEvidence", "asOf",
|
||
"authorizationSnapshotId", "inputSha256",
|
||
})
|
||
REPORT_FIELDS = frozenset({
|
||
"schemaVersion", "runId", "sampleId", "opaqueArmId", "inputSha256",
|
||
"candidateVersion", "candidateSha256", "contextSnapshotSha256",
|
||
"modelReceiptSha256", "claims", "findings", "assertionVerdicts",
|
||
"hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps", "status",
|
||
"reportSha256",
|
||
})
|
||
SAFE_DIAGNOSTIC_VERSION = "semantic-diagnostic-v1"
|
||
SAFE_DIAGNOSTIC_MAX_COUNT = 10_000
|
||
SAFE_DIAGNOSTIC_SECTIONS = frozenset({
|
||
"input", "model_output", "claims", "findings", "assertion_verdicts",
|
||
"hard_constraint_verdicts", "new_setting_candidates", "evidence_gaps",
|
||
"report", "runtime",
|
||
})
|
||
SAFE_DIAGNOSTIC_REASON_CODES = frozenset({
|
||
"ARRAY_REQUIRED", "CONTRACT_INVALID", "DUPLICATE_ID", "ENUM_INVALID",
|
||
"EVIDENCE_ID_INVALID", "EVIDENCE_REQUIRED", "FIELD_SET_INVALID",
|
||
"GAP_REASON_FORBIDDEN", "GAP_REASON_REQUIRED", "HASH_INVALID",
|
||
"ID_ORDER_MISMATCH", "INTEGER_RANGE_INVALID", "MODEL_VERSION_INVALID",
|
||
"NON_EMPTY_STRING_REQUIRED", "OBJECT_REQUIRED", "QUOTE_NOT_FOUND",
|
||
"REFERENCE_LEAKAGE", "RUNTIME_FAILED", "SEMANTIC_BLOCKED",
|
||
})
|
||
SAFE_PRIMARY_CODE_PATTERN = re.compile(r"^SEMANTIC_[A-Z0-9_]{1,95}$")
|
||
SAFE_BLOCKING_COUNT_FIELDS = (
|
||
"highFindings", "failedAssertions", "failedHardConstraints",
|
||
"conflictingClaims", "evidenceGaps", "unknownAssertions",
|
||
"unknownHardConstraints", "unknownClaims",
|
||
)
|
||
|
||
|
||
@runtime_checkable
|
||
class ModelRunner(Protocol):
|
||
def run(self, *, adapter_role: str, model_input: Mapping[str, Any], output_schema: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||
|
||
|
||
def _diagnostic_section(path: str) -> str:
|
||
"""把 adapter 自己生成的字段路径投影成固定区段,不保留索引或原始路径。"""
|
||
|
||
prefixes = (
|
||
("$.claims", "claims"),
|
||
("$.findings", "findings"),
|
||
("$.assertionVerdicts", "assertion_verdicts"),
|
||
("$.hardConstraintVerdicts", "hard_constraint_verdicts"),
|
||
("$.newSettingCandidates", "new_setting_candidates"),
|
||
("$.evidenceGaps", "evidence_gaps"),
|
||
)
|
||
for prefix, section in prefixes:
|
||
if path.startswith(prefix):
|
||
return section
|
||
return "model_output"
|
||
|
||
|
||
def _diagnostic_section_for_error(path: str, code: str) -> str:
|
||
_reason, default_section = _default_diagnostic(code)
|
||
if default_section in {"input", "report", "runtime"}:
|
||
return default_section
|
||
return _diagnostic_section(path)
|
||
|
||
|
||
def _default_diagnostic(code: str) -> tuple[str, str]:
|
||
if code in {"SEMANTIC_DETECTOR_RUNTIME_FAILED", "SEMANTIC_DETECTOR_RUNNER_INVALID"}:
|
||
return "RUNTIME_FAILED", "runtime"
|
||
if code == "SEMANTIC_DETECTOR_LEAKAGE_DETECTED":
|
||
return "REFERENCE_LEAKAGE", "input"
|
||
if code == "SEMANTIC_DETECTOR_QUOTE_NOT_FOUND":
|
||
return "QUOTE_NOT_FOUND", "model_output"
|
||
if "INPUT" in code or "CANDIDATE_HASH" in code:
|
||
return "CONTRACT_INVALID", "input"
|
||
if "REPORT" in code or "STATUS" in code or "OFFSET" in code:
|
||
return "CONTRACT_INVALID", "report"
|
||
return "CONTRACT_INVALID", "model_output"
|
||
|
||
|
||
def _safe_primary_code(value: Any) -> str:
|
||
if isinstance(value, str) and SAFE_PRIMARY_CODE_PATTERN.fullmatch(value):
|
||
return value
|
||
return "SEMANTIC_DETECTOR_INVALID"
|
||
|
||
|
||
class SemanticDetectorContractError(ValueError):
|
||
def __init__(
|
||
self,
|
||
code: str,
|
||
message: str,
|
||
*,
|
||
causes: Sequence[str] = (),
|
||
reason_code: str | None = None,
|
||
section: str | None = None,
|
||
) -> None:
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.causes = tuple(cause for cause in causes if cause != code)
|
||
self.acceptance_eligible = False
|
||
default_reason, default_section = _default_diagnostic(code)
|
||
self.reason_code = reason_code if reason_code in SAFE_DIAGNOSTIC_REASON_CODES else default_reason
|
||
self.section = section if section in SAFE_DIAGNOSTIC_SECTIONS else default_section
|
||
|
||
|
||
def _canonical_json(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
|
||
|
||
def canonical_sha256(value: Any) -> str:
|
||
return "sha256:" + hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _object(value: Any, path: str, required: frozenset[str], optional: frozenset[str] = frozenset(), *, code: str) -> Mapping[str, Any]:
|
||
if not isinstance(value, Mapping):
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 必须是对象", reason_code="OBJECT_REQUIRED",
|
||
section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
missing = sorted(required - set(value))
|
||
extra = sorted(set(value) - required - optional)
|
||
if missing or extra:
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 字段非法,missing={missing}, extra={extra}",
|
||
reason_code="FIELD_SET_INVALID", section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
return value
|
||
|
||
|
||
def _array(value: Any, path: str, *, code: str) -> list[Any]:
|
||
if not isinstance(value, list):
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 必须是数组", reason_code="ARRAY_REQUIRED",
|
||
section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
return value
|
||
|
||
|
||
def _string(value: Any, path: str, *, code: str) -> str:
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 必须是非空字符串", reason_code="NON_EMPTY_STRING_REQUIRED",
|
||
section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
return value
|
||
|
||
|
||
def _integer(value: Any, path: str, *, minimum: int, code: str) -> int:
|
||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 必须是大于等于 {minimum} 的整数",
|
||
reason_code="INTEGER_RANGE_INVALID", section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
return value
|
||
|
||
|
||
def _hash(value: Any, path: str, *, code: str) -> str:
|
||
text = _string(value, path, code=code)
|
||
if not HASH_PATTERN.fullmatch(text):
|
||
raise SemanticDetectorContractError(
|
||
code, f"{path} 必须是规范 SHA-256", reason_code="HASH_INVALID",
|
||
section=_diagnostic_section_for_error(path, code),
|
||
)
|
||
return text
|
||
|
||
|
||
def _safe_reference(value: Any, path: str, *, code: str) -> str:
|
||
text = _string(value, path, code=code)
|
||
# WHY: 路径穿越用 contains_path_traversal 做「路径分量级」精确判定,不用 `".." in text` 子串匹配——
|
||
# 子串匹配会把省略号 `...`(含子串 `..`)误判为路径穿越,冤杀含省略号的合法引用;
|
||
# 精确判定只拦 a/../b 这类真实穿越,臂名与 raw 路径仍由其余条件保留拦截。
|
||
if text in REAL_ARM_NAMES or text.startswith(("/", "file:")) or contains_path_traversal(text) or RAW_PATH_PATTERN.search(text):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_LEAKAGE_DETECTED",
|
||
f"{path} 含真实臂名或 raw 路径",
|
||
reason_code="REFERENCE_LEAKAGE",
|
||
section="input",
|
||
)
|
||
return text
|
||
|
||
|
||
def _source_ref(value: Any, path: str, *, code: str, as_of: int | None = None) -> Mapping[str, Any]:
|
||
ref = _object(
|
||
value,
|
||
path,
|
||
frozenset({"sourceId", "sourceVersion"}),
|
||
frozenset({"chapter", "blockId", "startCodePoint", "endCodePoint", "contentSha256"}),
|
||
code=code,
|
||
)
|
||
_safe_reference(ref["sourceId"], f"{path}.sourceId", code=code)
|
||
_safe_reference(ref["sourceVersion"], f"{path}.sourceVersion", code=code)
|
||
if "chapter" in ref:
|
||
chapter = _integer(ref["chapter"], f"{path}.chapter", minimum=1, code=code)
|
||
if as_of is not None and chapter > as_of:
|
||
raise SemanticDetectorContractError(code, f"{path}.chapter 越过冻结线")
|
||
if "blockId" in ref:
|
||
block_id = ref["blockId"]
|
||
if isinstance(block_id, int) and not isinstance(block_id, bool):
|
||
_integer(block_id, f"{path}.blockId", minimum=1, code=code)
|
||
else:
|
||
_safe_reference(block_id, f"{path}.blockId", code=code)
|
||
if "contentSha256" in ref:
|
||
_hash(ref["contentSha256"], f"{path}.contentSha256", code=code)
|
||
has_start = "startCodePoint" in ref
|
||
has_end = "endCodePoint" in ref
|
||
if has_start != has_end:
|
||
raise SemanticDetectorContractError(code, f"{path} 字符区间必须成对出现")
|
||
if has_start:
|
||
start = _integer(ref["startCodePoint"], f"{path}.startCodePoint", minimum=0, code=code)
|
||
end = _integer(ref["endCodePoint"], f"{path}.endCodePoint", minimum=1, code=code)
|
||
if end <= start:
|
||
raise SemanticDetectorContractError(code, f"{path} 字符区间倒置")
|
||
return ref
|
||
|
||
|
||
def _stable_ids(items: list[Any], path: str, id_field: str, *, code: str) -> list[str]:
|
||
result: list[str] = []
|
||
for index, raw in enumerate(items):
|
||
if not isinstance(raw, Mapping):
|
||
raise SemanticDetectorContractError(code, f"{path}[{index}] 必须是对象")
|
||
result.append(_string(raw.get(id_field), f"{path}[{index}].{id_field}", code=code))
|
||
if len(result) != len(set(result)):
|
||
raise SemanticDetectorContractError(code, f"{path}.{id_field} 不得重复")
|
||
return result
|
||
|
||
|
||
def validate_semantic_detector_input(value: Any) -> dict[str, Any]:
|
||
code = "SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID"
|
||
payload = _object(value, "$", INPUT_FIELDS, code=code)
|
||
if payload["schemaVersion"] != INPUT_VERSION:
|
||
raise SemanticDetectorContractError(code, "$.schemaVersion 版本不受支持")
|
||
for field in ("runId", "sampleId", "authorizationSnapshotId"):
|
||
_string(payload[field], f"$.{field}", code=code)
|
||
_safe_reference(payload["opaqueArmId"], "$.opaqueArmId", code=code)
|
||
_integer(payload["candidateVersion"], "$.candidateVersion", minimum=1, code=code)
|
||
body = _string(payload["candidateBody"], "$.candidateBody", code=code)
|
||
candidate_hash = _hash(payload["candidateSha256"], "$.candidateSha256", code=code)
|
||
if candidate_hash != "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest():
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_CANDIDATE_HASH_MISMATCH", "候选哈希与正文不一致")
|
||
_hash(payload["contextSnapshotSha256"], "$.contextSnapshotSha256", code=code)
|
||
as_of = _integer(payload["asOf"], "$.asOf", minimum=1, code=code)
|
||
outline = _object(
|
||
payload["fineOutline"], "$.fineOutline",
|
||
frozenset({"sourceRef", "hardConstraints", "adjustableBeats", "declaredNewFacts"}), code=code,
|
||
)
|
||
_source_ref(outline["sourceRef"], "$.fineOutline.sourceRef", code=code, as_of=None)
|
||
constraints = _array(payload["hardConstraints"], "$.hardConstraints", code=code)
|
||
constraint_ids = _stable_ids(constraints, "$.hardConstraints", "constraintId", code=code)
|
||
outline_ids = _stable_ids(_array(outline["hardConstraints"], "$.fineOutline.hardConstraints", code=code), "$.fineOutline.hardConstraints", "constraintId", code=code)
|
||
if constraint_ids != outline_ids:
|
||
raise SemanticDetectorContractError(code, "细纲与顶层硬约束 ID/顺序不一致")
|
||
for index, item in enumerate(constraints):
|
||
_string(item.get("text"), f"$.hardConstraints[{index}].text", code=code)
|
||
for index, beat in enumerate(_array(outline["adjustableBeats"], "$.fineOutline.adjustableBeats", code=code)):
|
||
_string(beat, f"$.fineOutline.adjustableBeats[{index}]", code=code)
|
||
declared_ids: list[str] = []
|
||
for index, raw in enumerate(_array(outline["declaredNewFacts"], "$.fineOutline.declaredNewFacts", code=code)):
|
||
item = _object(raw, f"$.fineOutline.declaredNewFacts[{index}]", frozenset({"factId", "text", "sourceRef"}), code=code)
|
||
declared_ids.append(_string(item["factId"], f"$.fineOutline.declaredNewFacts[{index}].factId", code=code))
|
||
_string(item["text"], f"$.fineOutline.declaredNewFacts[{index}].text", code=code)
|
||
_source_ref(item["sourceRef"], f"$.fineOutline.declaredNewFacts[{index}].sourceRef", code=code)
|
||
if len(declared_ids) != len(set(declared_ids)):
|
||
raise SemanticDetectorContractError(code, "declaredNewFacts.factId 不得重复")
|
||
facts = _array(payload["factEvidence"], "$.factEvidence", code=code)
|
||
fact_ids = _stable_ids(facts, "$.factEvidence", "evidenceId", code=code)
|
||
for index, item in enumerate(facts):
|
||
_object(item, f"$.factEvidence[{index}]", frozenset({"evidenceId", "fact", "sourceType", "sourceRef", "contentSha256", "riskLevel"}), code=code)
|
||
_string(item.get("fact"), f"$.factEvidence[{index}].fact", code=code)
|
||
if item.get("sourceType") not in {"historical_prose", "formal_setting", "canonical_state", "fine_outline_declared_new"}:
|
||
raise SemanticDetectorContractError(code, f"$.factEvidence[{index}].sourceType 非法")
|
||
_source_ref(item.get("sourceRef"), f"$.factEvidence[{index}].sourceRef", code=code, as_of=as_of)
|
||
_hash(item.get("contentSha256"), f"$.factEvidence[{index}].contentSha256", code=code)
|
||
if item.get("riskLevel") not in {"low", "medium", "high"}:
|
||
raise SemanticDetectorContractError(code, f"$.factEvidence[{index}].riskLevel 非法")
|
||
prose = _array(payload["proseEvidence"], "$.proseEvidence", code=code)
|
||
prose_ids = _stable_ids(prose, "$.proseEvidence", "evidenceId", code=code)
|
||
for index, item in enumerate(prose):
|
||
_object(item, f"$.proseEvidence[{index}]", frozenset({"evidenceId", "chapter", "sourceRef", "contentSha256", "purpose", "text", "isRecentBaseline"}), code=code)
|
||
chapter = _integer(item.get("chapter"), f"$.proseEvidence[{index}].chapter", minimum=1, code=code)
|
||
if chapter > as_of:
|
||
raise SemanticDetectorContractError(code, f"$.proseEvidence[{index}] 越过冻结线")
|
||
_source_ref(item.get("sourceRef"), f"$.proseEvidence[{index}].sourceRef", code=code, as_of=as_of)
|
||
text = _string(item.get("text"), f"$.proseEvidence[{index}].text", code=code)
|
||
content_hash = _hash(item.get("contentSha256"), f"$.proseEvidence[{index}].contentSha256", code=code)
|
||
if content_hash != "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest():
|
||
raise SemanticDetectorContractError(code, f"$.proseEvidence[{index}] 正文哈希不一致")
|
||
_string(item.get("purpose"), f"$.proseEvidence[{index}].purpose", code=code)
|
||
if not isinstance(item.get("isRecentBaseline"), bool):
|
||
raise SemanticDetectorContractError(code, f"$.proseEvidence[{index}].isRecentBaseline 非法")
|
||
supplied = _hash(payload["inputSha256"], "$.inputSha256", code=code)
|
||
expected = canonical_sha256({key: payload[key] for key in payload if key != "inputSha256"})
|
||
if supplied != expected:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_INPUT_HASH_MISMATCH", "inputSha256 与输入不一致")
|
||
normalized = json.loads(_canonical_json(payload))
|
||
normalized["_expectedAssertionIds"] = fact_ids
|
||
normalized["_expectedConstraintIds"] = constraint_ids
|
||
normalized["_evidenceIds"] = sorted(set(fact_ids + prose_ids + constraint_ids))
|
||
return normalized
|
||
|
||
|
||
def build_semantic_model_input(value: Any, correction: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||
# WHY: correction 只在「模型输入层」可选透传,绝不进入 detector_input——
|
||
# validate_semantic_detector_input 对 detector_input 执行固定 INPUT_FIELDS 闭集校验,
|
||
# 多塞 correction 会被当成非法 extra 字段拒掉。把 correction 放在这里拼进模型输入,
|
||
# 既不破坏正常输入的闭集校验,又能把「上一轮不合格产出 + 出错原因」喂回模型做自我纠错。
|
||
payload = validate_semantic_detector_input(value)
|
||
model_input: dict[str, Any] = {
|
||
"candidateBody": payload["candidateBody"],
|
||
"fineOutline": payload["fineOutline"],
|
||
"hardConstraints": payload["hardConstraints"],
|
||
"factEvidence": [
|
||
{
|
||
"assertionId": item["evidenceId"],
|
||
"evidenceId": item["evidenceId"],
|
||
"fact": item["fact"],
|
||
"sourceType": item.get("sourceType", "fact_evidence"),
|
||
"riskLevel": item.get("riskLevel", "low"),
|
||
}
|
||
for item in payload["factEvidence"]
|
||
],
|
||
"proseEvidence": [
|
||
{"evidenceId": item["evidenceId"], "chapter": item.get("chapter"), "purpose": item.get("purpose"), "text": item["text"]}
|
||
for item in payload["proseEvidence"]
|
||
],
|
||
"asOf": payload["asOf"],
|
||
}
|
||
if correction is not None:
|
||
model_input["correction"] = dict(correction)
|
||
return model_input
|
||
|
||
|
||
def _quote_location(body: str, quote: Any, path: str) -> tuple[str, int, int]:
|
||
text = _string(quote, path, code="SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID")
|
||
# 0 次 = 引文根本不在候选里 = 编造证据,必须失败关闭(防编造核心不动)。
|
||
if text not in body:
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_QUOTE_NOT_FOUND",
|
||
f"{path} 引文未在候选正文中出现(不得编造证据)",
|
||
reason_code="QUOTE_NOT_FOUND",
|
||
section=_diagnostic_section(path),
|
||
)
|
||
# ≥1 次:由确定性代码绑定到首次出现,不再因多处出现而失败关闭——
|
||
# 大模型无法可靠数出一句话在长文里出现几次,精确唯一计数不是可信门槛。
|
||
start = body.index(text)
|
||
return text, start, start + len(text)
|
||
|
||
|
||
def _evidence_ids(value: Any, path: str, allowed: set[str]) -> list[str]:
|
||
ids = [_string(item, f"{path}[{index}]", code="SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID") for index, item in enumerate(_array(value, path, code="SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID"))]
|
||
if len(ids) != len(set(ids)) or any(item not in allowed for item in ids):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID",
|
||
f"{path} 含重复或越界证据 ID",
|
||
reason_code="EVIDENCE_ID_INVALID",
|
||
section=_diagnostic_section(path),
|
||
)
|
||
return ids
|
||
|
||
|
||
def _validate_model_output(value: Any, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||
code = "SEMANTIC_DETECTOR_MODEL_OUTPUT_INVALID"
|
||
draft = _object(value, "$", frozenset({"schemaVersion", "claims", "findings", "assertionVerdicts", "hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps"}), code=code)
|
||
if draft["schemaVersion"] != MODEL_OUTPUT_VERSION:
|
||
raise SemanticDetectorContractError(
|
||
code, "模型输出版本非法", reason_code="MODEL_VERSION_INVALID",
|
||
section="model_output",
|
||
)
|
||
body = payload["candidateBody"]
|
||
allowed_evidence = set(payload["_evidenceIds"])
|
||
|
||
claims: list[dict[str, Any]] = []
|
||
claim_ids: list[str] = []
|
||
coverage_states = {"supported", "declared_new", "unknown", "conflict"}
|
||
for index, raw in enumerate(_array(draft["claims"], "$.claims", code=code)):
|
||
item = _object(
|
||
raw,
|
||
f"$.claims[{index}]",
|
||
frozenset({"claimId", "factType", "text", "candidateQuote", "coverageState", "evidenceIds"}),
|
||
frozenset({"gapReason"}),
|
||
code=code,
|
||
)
|
||
claim_id = _string(item["claimId"], f"$.claims[{index}].claimId", code=code)
|
||
claim_ids.append(claim_id)
|
||
if item["coverageState"] not in coverage_states:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.claims[{index}].coverageState 非法",
|
||
reason_code="ENUM_INVALID", section="claims",
|
||
)
|
||
if item["coverageState"] == "unknown" and "gapReason" not in item:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.claims[{index}] unknown 必须携带 gapReason",
|
||
reason_code="GAP_REASON_REQUIRED", section="claims",
|
||
)
|
||
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.claims[{index}].candidateQuote")
|
||
bound = {
|
||
"claimId": claim_id,
|
||
"factType": _string(item["factType"], f"$.claims[{index}].factType", code=code),
|
||
"text": _string(item["text"], f"$.claims[{index}].text", code=code),
|
||
"candidateSha256": payload["candidateSha256"],
|
||
"candidateQuote": quote,
|
||
"startCodePoint": start,
|
||
"endCodePoint": end,
|
||
"coverageState": item["coverageState"],
|
||
"evidenceIds": _evidence_ids(item["evidenceIds"], f"$.claims[{index}].evidenceIds", allowed_evidence),
|
||
}
|
||
# WHY: 模型可能在已判定的 claim 上残留解释性 gapReason;它不改变闭集状态,
|
||
# 绑定时确定性丢弃,避免把无害冗余升级为整份报告失败。unknown 仍须在上方校验非空原因。
|
||
if item["coverageState"] == "unknown":
|
||
bound["gapReason"] = _string(item["gapReason"], f"$.claims[{index}].gapReason", code=code)
|
||
claims.append(bound)
|
||
if len(claim_ids) != len(set(claim_ids)):
|
||
raise SemanticDetectorContractError(
|
||
code, "claimId 不得重复", reason_code="DUPLICATE_ID", section="claims"
|
||
)
|
||
|
||
findings: list[dict[str, Any]] = []
|
||
finding_ids: list[str] = []
|
||
for index, raw in enumerate(_array(draft["findings"], "$.findings", code=code)):
|
||
item = _object(raw, f"$.findings[{index}]", frozenset({"findingId", "severity", "category", "candidateQuote", "evidenceIds", "message"}), code=code)
|
||
finding_id = _string(item["findingId"], f"$.findings[{index}].findingId", code=code)
|
||
finding_ids.append(finding_id)
|
||
if item["severity"] not in SEVERITIES or item["category"] not in FINDING_CATEGORIES:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.findings[{index}] 枚举非法",
|
||
reason_code="ENUM_INVALID", section="findings",
|
||
)
|
||
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.findings[{index}].candidateQuote")
|
||
findings.append({
|
||
"findingId": finding_id, "severity": item["severity"], "category": item["category"],
|
||
"candidateSha256": payload["candidateSha256"], "candidateQuote": quote,
|
||
"startCodePoint": start, "endCodePoint": end,
|
||
"evidenceIds": _evidence_ids(item["evidenceIds"], f"$.findings[{index}].evidenceIds", allowed_evidence),
|
||
"message": _string(item["message"], f"$.findings[{index}].message", code=code),
|
||
})
|
||
if len(finding_ids) != len(set(finding_ids)):
|
||
raise SemanticDetectorContractError(
|
||
code, "findingId 不得重复", reason_code="DUPLICATE_ID", section="findings"
|
||
)
|
||
|
||
def verdicts(field: str, id_field: str, expected_ids: list[str]) -> list[dict[str, Any]]:
|
||
result: list[dict[str, Any]] = []
|
||
actual_ids: list[str] = []
|
||
for index, raw in enumerate(_array(draft[field], f"$.{field}", code=code)):
|
||
item = _object(raw, f"$.{field}[{index}]", frozenset({id_field, "verdict", "candidateQuote", "evidenceIds"}), frozenset({"gapReason"}), code=code)
|
||
stable_id = _string(item[id_field], f"$.{field}[{index}].{id_field}", code=code)
|
||
actual_ids.append(stable_id)
|
||
if item["verdict"] not in VERDICTS:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.{field}[{index}].verdict 非法",
|
||
reason_code="ENUM_INVALID", section=_diagnostic_section(f"$.{field}"),
|
||
)
|
||
if item["verdict"] == "unknown" and "gapReason" not in item:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.{field}[{index}] unknown 必须携带 gapReason",
|
||
reason_code="GAP_REASON_REQUIRED", section=_diagnostic_section(f"$.{field}"),
|
||
)
|
||
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.{field}[{index}].candidateQuote")
|
||
bound = {
|
||
id_field: stable_id, "candidateSha256": payload["candidateSha256"], "verdict": item["verdict"],
|
||
"candidateQuote": quote, "startCodePoint": start, "endCodePoint": end,
|
||
"evidenceIds": _evidence_ids(item["evidenceIds"], f"$.{field}[{index}].evidenceIds", allowed_evidence),
|
||
}
|
||
# WHY: pass/fail 已是终态,额外 gapReason 不参与可信绑定;统一丢弃可消除模型格式噪声,
|
||
# 但 unknown 的非空原因仍由上方强制校验,其他字段和证据约束保持失败关闭。
|
||
if item["verdict"] == "unknown":
|
||
bound["gapReason"] = _string(item["gapReason"], f"$.{field}[{index}].gapReason", code=code)
|
||
result.append(bound)
|
||
# WHY: ID 集完整且无重复时,条目顺序不影响语义;由适配器按冻结输入顺序重排,
|
||
# 避免模型把同一组裁决按字典序返回而被误判为缺失。集合不完整、重复或越界仍失败关闭。
|
||
if (
|
||
len(actual_ids) != len(expected_ids)
|
||
or len(actual_ids) != len(set(actual_ids))
|
||
or set(actual_ids) != set(expected_ids)
|
||
):
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.{field} ID 集或顺序不一致",
|
||
reason_code="ID_ORDER_MISMATCH", section=_diagnostic_section(f"$.{field}"),
|
||
)
|
||
by_id = {item[id_field]: item for item in result}
|
||
return [by_id[item] for item in expected_ids]
|
||
|
||
assertion_verdicts = verdicts("assertionVerdicts", "assertionId", list(payload["_expectedAssertionIds"]))
|
||
constraint_verdicts = verdicts("hardConstraintVerdicts", "constraintId", list(payload["_expectedConstraintIds"]))
|
||
|
||
settings: list[dict[str, Any]] = []
|
||
setting_ids: list[str] = []
|
||
for index, raw in enumerate(_array(draft["newSettingCandidates"], "$.newSettingCandidates", code=code)):
|
||
item = _object(raw, f"$.newSettingCandidates[{index}]", frozenset({"settingId", "factType", "text", "candidateQuote"}), code=code)
|
||
setting_id = _string(item["settingId"], f"$.newSettingCandidates[{index}].settingId", code=code)
|
||
setting_ids.append(setting_id)
|
||
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.newSettingCandidates[{index}].candidateQuote")
|
||
settings.append({
|
||
"settingId": setting_id, "factType": _string(item["factType"], f"$.newSettingCandidates[{index}].factType", code=code),
|
||
"text": _string(item["text"], f"$.newSettingCandidates[{index}].text", code=code),
|
||
"candidateSha256": payload["candidateSha256"], "candidateQuote": quote,
|
||
"startCodePoint": start, "endCodePoint": end,
|
||
})
|
||
if len(setting_ids) != len(set(setting_ids)):
|
||
raise SemanticDetectorContractError(
|
||
code, "settingId 不得重复", reason_code="DUPLICATE_ID",
|
||
section="new_setting_candidates",
|
||
)
|
||
|
||
gaps: list[dict[str, Any]] = []
|
||
gap_ids: list[str] = []
|
||
for index, raw in enumerate(_array(draft["evidenceGaps"], "$.evidenceGaps", code=code)):
|
||
item = _object(raw, f"$.evidenceGaps[{index}]", frozenset({"gapId", "query", "reason", "priority", "candidateQuote"}), code=code)
|
||
gap_id = _string(item["gapId"], f"$.evidenceGaps[{index}].gapId", code=code)
|
||
gap_ids.append(gap_id)
|
||
if item["priority"] not in PRIORITIES:
|
||
raise SemanticDetectorContractError(
|
||
code, f"$.evidenceGaps[{index}].priority 非法",
|
||
reason_code="ENUM_INVALID", section="evidence_gaps",
|
||
)
|
||
quote, start, end = _quote_location(body, item["candidateQuote"], f"$.evidenceGaps[{index}].candidateQuote")
|
||
gaps.append({
|
||
"gapId": gap_id, "query": _string(item["query"], f"$.evidenceGaps[{index}].query", code=code),
|
||
"reason": _string(item["reason"], f"$.evidenceGaps[{index}].reason", code=code),
|
||
"priority": item["priority"], "candidateSha256": payload["candidateSha256"],
|
||
"candidateQuote": quote, "startCodePoint": start, "endCodePoint": end,
|
||
})
|
||
if len(gap_ids) != len(set(gap_ids)):
|
||
raise SemanticDetectorContractError(
|
||
code, "gapId 不得重复", reason_code="DUPLICATE_ID", section="evidence_gaps"
|
||
)
|
||
return {
|
||
"claims": claims, "findings": findings, "assertionVerdicts": assertion_verdicts,
|
||
"hardConstraintVerdicts": constraint_verdicts, "newSettingCandidates": settings,
|
||
"evidenceGaps": gaps,
|
||
}
|
||
|
||
|
||
def build_semantic_detection(value: Any, detector_input: Mapping[str, Any], *, model_receipt_sha256: str) -> dict[str, Any]:
|
||
payload = validate_semantic_detector_input(detector_input)
|
||
receipt_hash = _hash(model_receipt_sha256, "modelReceiptSha256", code="SEMANTIC_DETECTOR_RECEIPT_BINDING_MISMATCH")
|
||
content = _validate_model_output(value, payload)
|
||
has_unknown = any(item["verdict"] == "unknown" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in content[field]) or any(item["coverageState"] == "unknown" for item in content["claims"])
|
||
has_failure = any(item["severity"] == "high" for item in content["findings"]) or any(item["verdict"] == "fail" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in content[field]) or any(item["coverageState"] == "conflict" for item in content["claims"])
|
||
# 高危优先:有高危发现/失败裁决/冲突即 failed(不被证据缺口掩盖);无高危仅有证据缺口/未知才是 needs_evidence。
|
||
status = "failed" if has_failure else ("needs_evidence" if content["evidenceGaps"] or has_unknown else "passed")
|
||
report = {
|
||
"schemaVersion": REPORT_VERSION,
|
||
"runId": payload["runId"], "sampleId": payload["sampleId"], "opaqueArmId": payload["opaqueArmId"],
|
||
"inputSha256": payload["inputSha256"], "candidateVersion": payload["candidateVersion"],
|
||
"candidateSha256": payload["candidateSha256"], "contextSnapshotSha256": payload["contextSnapshotSha256"],
|
||
"modelReceiptSha256": receipt_hash, **content, "status": status,
|
||
}
|
||
report["reportSha256"] = canonical_sha256(report)
|
||
return report
|
||
|
||
|
||
def validate_semantic_detector_report(value: Any, detector_input: Mapping[str, Any], *, model_receipt_sha256: str | None = None) -> dict[str, Any]:
|
||
payload = validate_semantic_detector_input(detector_input)
|
||
code = "SEMANTIC_DETECTOR_REPORT_SCHEMA_INVALID"
|
||
report = _object(value, "$", REPORT_FIELDS, code=code)
|
||
if report["schemaVersion"] != REPORT_VERSION:
|
||
raise SemanticDetectorContractError(code, "报告版本非法")
|
||
expected = {
|
||
"runId": payload["runId"], "sampleId": payload["sampleId"], "opaqueArmId": payload["opaqueArmId"],
|
||
"inputSha256": payload["inputSha256"], "candidateVersion": payload["candidateVersion"],
|
||
"candidateSha256": payload["candidateSha256"], "contextSnapshotSha256": payload["contextSnapshotSha256"],
|
||
}
|
||
if any(report.get(field) != expected_value for field, expected_value in expected.items()):
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_INPUT_BINDING_MISMATCH", "报告未绑定当前输入")
|
||
_hash(report["modelReceiptSha256"], "$.modelReceiptSha256", code=code)
|
||
if model_receipt_sha256 is not None and report["modelReceiptSha256"] != model_receipt_sha256:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RECEIPT_BINDING_MISMATCH", "报告回执不一致")
|
||
for field in ("claims", "findings", "assertionVerdicts", "hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps"):
|
||
_array(report[field], f"$.{field}", code=code)
|
||
for index, item in enumerate(report[field]):
|
||
if not isinstance(item, Mapping):
|
||
raise SemanticDetectorContractError(code, f"$.{field}[{index}] 必须是对象")
|
||
if item.get("candidateSha256") != payload["candidateSha256"]:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_CANDIDATE_BINDING_MISMATCH", f"$.{field}[{index}] 未绑定候选")
|
||
quote, start, end = _quote_location(payload["candidateBody"], item.get("candidateQuote"), f"$.{field}[{index}].candidateQuote")
|
||
if item.get("startCodePoint") != start or item.get("endCodePoint") != end:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_OFFSET_BINDING_MISMATCH", f"$.{field}[{index}] offset 非 adapter 计算值")
|
||
nested_fields = {
|
||
"claims": (frozenset({"claimId", "factType", "text", "candidateSha256", "candidateQuote", "startCodePoint", "endCodePoint", "coverageState", "evidenceIds"}), frozenset({"gapReason"})),
|
||
"findings": (frozenset({"findingId", "severity", "category", "candidateSha256", "candidateQuote", "startCodePoint", "endCodePoint", "evidenceIds", "message"}), frozenset()),
|
||
"assertionVerdicts": (frozenset({"assertionId", "candidateSha256", "verdict", "candidateQuote", "startCodePoint", "endCodePoint", "evidenceIds"}), frozenset({"gapReason"})),
|
||
"hardConstraintVerdicts": (frozenset({"constraintId", "candidateSha256", "verdict", "candidateQuote", "startCodePoint", "endCodePoint", "evidenceIds"}), frozenset({"gapReason"})),
|
||
"newSettingCandidates": (frozenset({"settingId", "factType", "text", "candidateSha256", "candidateQuote", "startCodePoint", "endCodePoint"}), frozenset()),
|
||
"evidenceGaps": (frozenset({"gapId", "query", "reason", "priority", "candidateSha256", "candidateQuote", "startCodePoint", "endCodePoint"}), frozenset()),
|
||
}
|
||
for field, (required, optional) in nested_fields.items():
|
||
for index, item in enumerate(report[field]):
|
||
_object(item, f"$.{field}[{index}]", required, optional, code=code)
|
||
expected_ids = {
|
||
"assertionVerdicts": ("assertionId", payload["_expectedAssertionIds"]),
|
||
"hardConstraintVerdicts": ("constraintId", payload["_expectedConstraintIds"]),
|
||
}
|
||
for field, (id_field, expected_values) in expected_ids.items():
|
||
if [item[id_field] for item in report[field]] != list(expected_values):
|
||
raise SemanticDetectorContractError(code, f"$.{field} ID 集或顺序非法")
|
||
for field, id_field in (("claims", "claimId"), ("findings", "findingId"), ("newSettingCandidates", "settingId"), ("evidenceGaps", "gapId")):
|
||
values = [item[id_field] for item in report[field]]
|
||
if len(values) != len(set(values)):
|
||
raise SemanticDetectorContractError(code, f"$.{field}.{id_field} 不得重复")
|
||
expected_status = "failed" if any(item.get("severity") == "high" for item in report["findings"]) or any(item.get("verdict") == "fail" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "conflict" for item in report["claims"]) else ("needs_evidence" if report["evidenceGaps"] or any(item.get("verdict") == "unknown" for field in ("assertionVerdicts", "hardConstraintVerdicts") for item in report[field]) or any(item.get("coverageState") == "unknown" for item in report["claims"]) else "passed")
|
||
if report["status"] != expected_status:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_STATUS_MISMATCH", "报告状态与语义内容不一致")
|
||
supplied_hash = _hash(report["reportSha256"], "$.reportSha256", code=code)
|
||
if supplied_hash != canonical_sha256({key: report[key] for key in report if key != "reportSha256"}):
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_REPORT_HASH_MISMATCH", "报告哈希不一致")
|
||
return json.loads(_canonical_json(report))
|
||
|
||
|
||
def calculate_semantic_metrics(report: Mapping[str, Any], detector_input: Mapping[str, Any]) -> dict[str, int | float]:
|
||
validated = validate_semantic_detector_report(report, detector_input)
|
||
constraints = validated["hardConstraintVerdicts"]
|
||
return {
|
||
"highSeverityCount": sum(item["severity"] == "high" for item in validated["findings"]),
|
||
"hardConstraintCoverage": (sum(item["verdict"] == "pass" for item in constraints) / len(constraints) if constraints else 1.0),
|
||
"evidenceGapCount": len(validated["evidenceGaps"]),
|
||
}
|
||
|
||
|
||
def _bounded_count(value: int) -> int:
|
||
return min(max(int(value), 0), SAFE_DIAGNOSTIC_MAX_COUNT)
|
||
|
||
|
||
def _empty_blocking_counts() -> dict[str, int]:
|
||
return {field: 0 for field in SAFE_BLOCKING_COUNT_FIELDS}
|
||
|
||
|
||
def _contract_safe_diagnostic(
|
||
error: SemanticDetectorContractError, *, attempt_count: int, correction_count: int | None = None
|
||
) -> dict[str, Any]:
|
||
attempts = _bounded_count(attempt_count)
|
||
corrections = _bounded_count(
|
||
max(attempts - 1, 0) if correction_count is None else correction_count
|
||
)
|
||
return {
|
||
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
|
||
"outcome": "invalid",
|
||
"primaryCode": _safe_primary_code(error.code),
|
||
"reasonCode": error.reason_code,
|
||
"section": error.section,
|
||
"attemptCount": attempts,
|
||
"correctionCount": min(corrections, max(attempts - 1, 0)),
|
||
"blockingCounts": _empty_blocking_counts(),
|
||
}
|
||
|
||
|
||
def build_safe_semantic_diagnostic(value: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""从完整检测结果蒸馏固定闭集摘要;不复制任何模型文本、ID、引用或字段路径。"""
|
||
|
||
if value.get("ok") is not True or not isinstance(value.get("report"), Mapping):
|
||
candidate = value.get("safeDiagnostic")
|
||
if not isinstance(candidate, Mapping):
|
||
candidate = {}
|
||
reason = candidate.get("reasonCode")
|
||
section = candidate.get("section")
|
||
attempts = candidate.get("attemptCount")
|
||
corrections = candidate.get("correctionCount")
|
||
safe_attempts = _bounded_count(
|
||
attempts if isinstance(attempts, int) and not isinstance(attempts, bool) else 0
|
||
)
|
||
safe_corrections = _bounded_count(
|
||
corrections if isinstance(corrections, int) and not isinstance(corrections, bool) else 0
|
||
)
|
||
return {
|
||
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
|
||
"outcome": "invalid",
|
||
"primaryCode": _safe_primary_code(candidate.get("primaryCode") or value.get("primaryCode")),
|
||
"reasonCode": reason if reason in SAFE_DIAGNOSTIC_REASON_CODES else "CONTRACT_INVALID",
|
||
"section": section if section in SAFE_DIAGNOSTIC_SECTIONS else "model_output",
|
||
"attemptCount": safe_attempts,
|
||
"correctionCount": min(safe_corrections, max(safe_attempts - 1, 0)),
|
||
"blockingCounts": _empty_blocking_counts(),
|
||
}
|
||
|
||
report = value["report"]
|
||
status = str(report.get("status") or "")
|
||
if status not in {"failed", "needs_evidence"}:
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_STATUS_MISMATCH",
|
||
"安全诊断只接受阻断态报告",
|
||
section="report",
|
||
)
|
||
findings = report.get("findings") if isinstance(report.get("findings"), list) else []
|
||
assertions = report.get("assertionVerdicts") if isinstance(report.get("assertionVerdicts"), list) else []
|
||
constraints = report.get("hardConstraintVerdicts") if isinstance(report.get("hardConstraintVerdicts"), list) else []
|
||
claims = report.get("claims") if isinstance(report.get("claims"), list) else []
|
||
gaps = report.get("evidenceGaps") if isinstance(report.get("evidenceGaps"), list) else []
|
||
counts = {
|
||
"highFindings": _bounded_count(sum(isinstance(item, Mapping) and item.get("severity") == "high" for item in findings)),
|
||
"failedAssertions": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "fail" for item in assertions)),
|
||
"failedHardConstraints": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "fail" for item in constraints)),
|
||
"conflictingClaims": _bounded_count(sum(isinstance(item, Mapping) and item.get("coverageState") == "conflict" for item in claims)),
|
||
"evidenceGaps": _bounded_count(len(gaps)),
|
||
"unknownAssertions": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "unknown" for item in assertions)),
|
||
"unknownHardConstraints": _bounded_count(sum(isinstance(item, Mapping) and item.get("verdict") == "unknown" for item in constraints)),
|
||
"unknownClaims": _bounded_count(sum(isinstance(item, Mapping) and item.get("coverageState") == "unknown" for item in claims)),
|
||
}
|
||
attempts = value.get("attemptCount")
|
||
corrections = value.get("correctionCount")
|
||
safe_attempts = _bounded_count(
|
||
attempts if isinstance(attempts, int) and not isinstance(attempts, bool) else 1
|
||
)
|
||
safe_corrections = _bounded_count(
|
||
corrections
|
||
if isinstance(corrections, int) and not isinstance(corrections, bool)
|
||
else max(safe_attempts - 1, 0)
|
||
)
|
||
return {
|
||
"schemaVersion": SAFE_DIAGNOSTIC_VERSION,
|
||
"outcome": status,
|
||
"primaryCode": "SEMANTIC_EVIDENCE_REQUIRED" if status == "needs_evidence" else "SEMANTIC_CHECK_FAILED",
|
||
"reasonCode": "EVIDENCE_REQUIRED" if status == "needs_evidence" else "SEMANTIC_BLOCKED",
|
||
"section": "report",
|
||
"attemptCount": safe_attempts,
|
||
"correctionCount": min(safe_corrections, max(safe_attempts - 1, 0)),
|
||
"blockingCounts": counts,
|
||
}
|
||
|
||
|
||
class ClaudeRuntimeModelRunner:
|
||
def __init__(self, profile: ExecutionProfile, *, runtime_callable: Any = None) -> None:
|
||
self.profile = profile
|
||
self.runtime_callable = runtime_callable or run_claude
|
||
|
||
def run(self, *, adapter_role: str, model_input: Mapping[str, Any], output_schema: Mapping[str, Any]) -> Mapping[str, Any]:
|
||
if self.runtime_callable is None or sha256_json is None:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNNER_INVALID", "claude_runtime 不可用")
|
||
if getattr(self.profile, "adapter_role", None) != adapter_role or getattr(self.profile, "json_schema", None) != output_schema:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNNER_INVALID", "ExecutionProfile 与 adapter 不一致")
|
||
result = self.runtime_callable(self.profile, model_input)
|
||
output = getattr(result, "structured_output", None)
|
||
receipt = getattr(result, "receipt", None)
|
||
if not isinstance(output, Mapping) or receipt is None:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", "runtime 成功结果形状非法")
|
||
receipt_value = receipt.as_dict() if hasattr(receipt, "as_dict") else receipt
|
||
return {"structuredOutput": dict(output), "modelReceiptSha256": sha256_json(receipt_value)}
|
||
|
||
|
||
def _invoke_model_runner(model_runner: ModelRunner, model_input: Mapping[str, Any]) -> tuple[Any, str]:
|
||
method = getattr(model_runner, "run", None)
|
||
if not callable(method):
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNNER_INVALID", "model_runner 未实现 run")
|
||
result = method(adapter_role="semantic_detector", model_input=model_input, output_schema=SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA)
|
||
if not isinstance(result, Mapping) or not isinstance(result.get("modelReceiptSha256"), str) or "structuredOutput" not in result:
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RECEIPT_BINDING_MISMATCH", "runner 必须返回 structuredOutput 与回执哈希")
|
||
return result["structuredOutput"], result["modelReceiptSha256"]
|
||
|
||
|
||
def _failure(
|
||
error: SemanticDetectorContractError, *, attempt_count: int, correction_count: int
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"ok": False,
|
||
"acceptanceEligible": False,
|
||
"status": "failed",
|
||
"primaryCode": error.code,
|
||
"causes": list(error.causes),
|
||
"message": str(error),
|
||
"safeDiagnostic": _contract_safe_diagnostic(
|
||
error,
|
||
attempt_count=attempt_count,
|
||
correction_count=correction_count,
|
||
),
|
||
}
|
||
|
||
|
||
def run_writer_semantic_detector(
|
||
detector_input: Mapping[str, Any],
|
||
*,
|
||
model_runner: ModelRunner,
|
||
max_corrections: int = 2,
|
||
max_runtime_retries: int = 1,
|
||
) -> dict[str, Any]:
|
||
# WHY: 检测模型(opus high)最常见的不合格是引文校验挂——它引了一句正文里没有的话。
|
||
# 这类错误自我纠错最对症:把上一轮原始产出和出错原因回喂给模型,让它换一句正文里真实存在的原话。
|
||
# 盲重试(不带上一轮产出)不一定收敛,所以纠错反馈里携带 previousDraft + error。
|
||
# max_corrections 给出硬上界(默认 2 轮纠错 = 总共最多 3 次调用),防止无限循环;
|
||
# 每次调用都走同一个 model_runner(生产中是 _BudgetedModelRunner),各自过预算账本。
|
||
attempt_count = 0
|
||
correction_count = 0
|
||
runtime_retry_count = 0
|
||
try:
|
||
if (
|
||
isinstance(max_runtime_retries, bool)
|
||
or not isinstance(max_runtime_retries, int)
|
||
or not 0 <= max_runtime_retries <= 1
|
||
):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_RUNNER_INVALID",
|
||
"max_runtime_retries 必须是 0 或 1",
|
||
)
|
||
normalized = validate_semantic_detector_input(detector_input)
|
||
public_input = {key: value for key, value in normalized.items() if not key.startswith("_")}
|
||
base_model_input = build_semantic_model_input(public_input)
|
||
correction: dict[str, Any] | None = None
|
||
for attempt in range(max_corrections + 1):
|
||
attempt_count = attempt + 1
|
||
# 首轮不带 correction;纠错轮在原始模型输入基础上追加 correction 字段透传给模型。
|
||
model_input = base_model_input if correction is None else {**base_model_input, "correction": correction}
|
||
draft: Any = None
|
||
try:
|
||
try:
|
||
draft, receipt_hash = _invoke_model_runner(model_runner, model_input)
|
||
except SemanticDetectorContractError:
|
||
raise
|
||
except Exception as exc:
|
||
runtime_code = getattr(exc, "primary_code", None) or getattr(exc, "code", None)
|
||
if isinstance(runtime_code, str) and runtime_code.startswith("SEMANTIC_DETECTOR_"):
|
||
raise SemanticDetectorContractError(runtime_code, "模型运行底座失败", causes=getattr(exc, "causes", ())) from exc
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", f"模型运行失败: {type(exc).__name__}") from exc
|
||
report = build_semantic_detection(draft, public_input, model_receipt_sha256=receipt_hash)
|
||
report = validate_semantic_detector_report(report, public_input, model_receipt_sha256=receipt_hash)
|
||
return {
|
||
"ok": True,
|
||
"acceptanceEligible": False,
|
||
"status": report["status"],
|
||
"report": report,
|
||
"metrics": calculate_semantic_metrics(report, public_input),
|
||
"attemptCount": attempt_count,
|
||
"correctionCount": correction_count,
|
||
}
|
||
except SemanticDetectorContractError as error:
|
||
# 纠错只对有「模型原始产出」的不合格有意义(build/validate 抛错时 draft 已存在)。
|
||
# runner 底座失败没有 draft(draft 仍为 None),纠错帮不上忙,直接失败关闭;
|
||
# 已用尽纠错轮次时也直接抛出最后一轮错误,返回 ok=False。
|
||
if draft is None:
|
||
# WHY: 带可信失败回执的瞬时 API 错误可以重新发送同一输入一次;它占用
|
||
# 现有三次总调用额度,不携带伪造 correction,也不重试本地合同/认证错误。
|
||
if (
|
||
error.code == "SEMANTIC_DETECTOR_API_ERROR"
|
||
and runtime_retry_count < max_runtime_retries
|
||
and attempt < max_corrections
|
||
):
|
||
runtime_retry_count += 1
|
||
correction = None
|
||
continue
|
||
raise
|
||
if attempt >= max_corrections:
|
||
raise
|
||
correction = {"previousDraft": draft, "error": str(error)}
|
||
if error.reason_code == "ID_ORDER_MISMATCH":
|
||
# WHY: 纠错轮明确给出冻结输入要求的完整 ID 集;适配器仍严格校验
|
||
# 缺失、重复、越界和绑定,提示只帮助模型修正格式,不放宽覆盖门禁。
|
||
correction["expectedVerdictIds"] = {
|
||
"assertionVerdicts": list(normalized["_expectedAssertionIds"]),
|
||
"hardConstraintVerdicts": list(normalized["_expectedConstraintIds"]),
|
||
}
|
||
correction_count += 1
|
||
# 循环必然在 return 或 raise 处退出,此处不可达。
|
||
raise SemanticDetectorContractError("SEMANTIC_DETECTOR_RUNTIME_FAILED", "纠错环意外退出")
|
||
except SemanticDetectorContractError as error:
|
||
return _failure(
|
||
error,
|
||
attempt_count=attempt_count,
|
||
correction_count=correction_count,
|
||
)
|
||
|
||
|
||
def _closed(properties: Mapping[str, Any], required: Sequence[str], optional: Sequence[str] = ()) -> dict[str, Any]:
|
||
return {"type": "object", "properties": dict(properties), "required": list(required), "additionalProperties": False}
|
||
|
||
|
||
_QUOTE = {"type": "string", "minLength": 1}
|
||
_IDS = {"type": "array", "uniqueItems": True, "items": {"type": "string", "minLength": 1}}
|
||
_VERDICT_SCHEMA = _closed(
|
||
{
|
||
"verdict": {"type": "string", "enum": sorted(VERDICTS)}, "candidateQuote": _QUOTE,
|
||
"evidenceIds": _IDS, "gapReason": {"type": "string", "minLength": 1},
|
||
},
|
||
["verdict", "candidateQuote", "evidenceIds"],
|
||
)
|
||
SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA = _closed(
|
||
{
|
||
"schemaVersion": {"const": MODEL_OUTPUT_VERSION},
|
||
"claims": {"type": "array", "items": _closed({
|
||
"claimId": {"type": "string", "minLength": 1},
|
||
"factType": {"type": "string", "minLength": 1},
|
||
"text": {"type": "string", "minLength": 1},
|
||
"candidateQuote": _QUOTE,
|
||
"coverageState": {"type": "string", "enum": ["supported", "declared_new", "unknown", "conflict"]},
|
||
"evidenceIds": _IDS,
|
||
"gapReason": {"type": "string", "minLength": 1},
|
||
}, ["claimId", "factType", "text", "candidateQuote", "coverageState", "evidenceIds"])},
|
||
"findings": {"type": "array", "items": _closed({
|
||
"findingId": {"type": "string", "minLength": 1}, "severity": {"type": "string", "enum": sorted(SEVERITIES)},
|
||
"category": {"type": "string", "enum": sorted(FINDING_CATEGORIES)}, "candidateQuote": _QUOTE,
|
||
"evidenceIds": _IDS, "message": {"type": "string", "minLength": 1},
|
||
}, ["findingId", "severity", "category", "candidateQuote", "evidenceIds", "message"])},
|
||
"assertionVerdicts": {"type": "array", "items": _closed({"assertionId": {"type": "string", "minLength": 1}, **_VERDICT_SCHEMA["properties"]}, ["assertionId", "verdict", "candidateQuote", "evidenceIds"])},
|
||
"hardConstraintVerdicts": {"type": "array", "items": _closed({"constraintId": {"type": "string", "minLength": 1}, **_VERDICT_SCHEMA["properties"]}, ["constraintId", "verdict", "candidateQuote", "evidenceIds"])},
|
||
"newSettingCandidates": {"type": "array", "items": _closed({
|
||
"settingId": {"type": "string", "minLength": 1}, "factType": {"type": "string", "minLength": 1},
|
||
"text": {"type": "string", "minLength": 1}, "candidateQuote": _QUOTE,
|
||
}, ["settingId", "factType", "text", "candidateQuote"])},
|
||
"evidenceGaps": {"type": "array", "items": _closed({
|
||
"gapId": {"type": "string", "minLength": 1}, "query": {"type": "string", "minLength": 1},
|
||
"reason": {"type": "string", "minLength": 1}, "priority": {"type": "string", "enum": sorted(PRIORITIES)},
|
||
"candidateQuote": _QUOTE,
|
||
}, ["gapId", "query", "reason", "priority", "candidateQuote"])},
|
||
},
|
||
["schemaVersion", "claims", "findings", "assertionVerdicts", "hardConstraintVerdicts", "newSettingCandidates", "evidenceGaps"],
|
||
)
|
||
|
||
|
||
# 检测输入 sourceRef 闭集:与 _source_ref 合同逐字段对齐。writer 上下文的证据 sourceRef
|
||
# 允许携带 sourceType 等多余字段,投影给 detector 时必须清洗成闭集形状(上下文本身不动)。
|
||
_INPUT_SOURCE_REF_ALLOWED = frozenset(
|
||
{"sourceId", "sourceVersion", "chapter", "blockId", "startCodePoint", "endCodePoint", "contentSha256"}
|
||
)
|
||
|
||
|
||
def _clean_input_source_ref(ref: Any) -> Any:
|
||
"""把单个 sourceRef 深拷贝并清洗成检测输入闭集形状。"""
|
||
|
||
if not isinstance(ref, Mapping):
|
||
return copy.deepcopy(ref)
|
||
return {key: copy.deepcopy(ref[key]) for key in ref if key in _INPUT_SOURCE_REF_ALLOWED}
|
||
|
||
|
||
def _clean_input_evidence(evidence: Any) -> Any:
|
||
"""深拷贝证据列表,仅清洗每条证据的 sourceRef 子对象。"""
|
||
|
||
if not isinstance(evidence, list):
|
||
return copy.deepcopy(evidence)
|
||
cleaned: list[Any] = []
|
||
for item in evidence:
|
||
if not isinstance(item, Mapping):
|
||
cleaned.append(copy.deepcopy(item))
|
||
continue
|
||
new_item = {key: copy.deepcopy(value) for key, value in item.items() if key != "sourceRef"}
|
||
if "sourceRef" in item:
|
||
new_item["sourceRef"] = _clean_input_source_ref(item["sourceRef"])
|
||
cleaned.append(new_item)
|
||
return cleaned
|
||
|
||
|
||
def _project_outline_for_input(writer_context: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
||
"""把写手细纲投影成 detector 输入的稳定 ID 合同(constraint-N / declared-fact-N)。"""
|
||
|
||
outline = writer_context.get("fineOutline")
|
||
if not isinstance(outline, Mapping):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "writerContext.fineOutline 必须是对象"
|
||
)
|
||
raw_constraints = outline.get("hardConstraints") or []
|
||
if not isinstance(raw_constraints, list):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "fineOutline.hardConstraints 必须是数组"
|
||
)
|
||
constraints = [
|
||
{"constraintId": f"constraint-{index + 1}", "text": str(text)}
|
||
for index, text in enumerate(raw_constraints)
|
||
]
|
||
declared: list[dict[str, Any]] = []
|
||
raw_declared = outline.get("declaredNewFacts") or []
|
||
if not isinstance(raw_declared, list):
|
||
raise SemanticDetectorContractError(
|
||
"SEMANTIC_DETECTOR_INPUT_SCHEMA_INVALID", "fineOutline.declaredNewFacts 必须是数组"
|
||
)
|
||
for index, value in enumerate(raw_declared):
|
||
if isinstance(value, Mapping):
|
||
declared.append({
|
||
"factId": str(value.get("factId") or f"declared-fact-{index + 1}"),
|
||
"text": str(value.get("text") or ""),
|
||
"sourceRef": copy.deepcopy(value.get("sourceRef") or outline["sourceRef"]),
|
||
})
|
||
else:
|
||
declared.append({
|
||
"factId": f"declared-fact-{index + 1}",
|
||
"text": str(value),
|
||
"sourceRef": copy.deepcopy(outline["sourceRef"]),
|
||
})
|
||
projected = {
|
||
"sourceRef": copy.deepcopy(outline["sourceRef"]),
|
||
"hardConstraints": constraints,
|
||
"adjustableBeats": [str(item) for item in (outline.get("adjustableBeats") or [])],
|
||
"declaredNewFacts": declared,
|
||
}
|
||
return projected, constraints
|
||
|
||
|
||
def build_semantic_input_v3(
|
||
*,
|
||
run_id: str,
|
||
sample_id: str,
|
||
opaque_arm_id: str,
|
||
writer_context: Mapping[str, Any],
|
||
candidate: Mapping[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""把 WriterContext + 候选投影成严格 semantic-detector-input-v3(生产/回放共用)。
|
||
|
||
只依赖 writer_context 与 candidate 两个输入,不读库不读文件;投影后立即由
|
||
validate_semantic_detector_input 做闭集校验,合同漂移在此失败关闭。
|
||
"""
|
||
|
||
fine_outline, constraints = _project_outline_for_input(writer_context)
|
||
payload = {
|
||
"schemaVersion": INPUT_VERSION,
|
||
"runId": run_id,
|
||
"sampleId": sample_id,
|
||
"opaqueArmId": opaque_arm_id,
|
||
"candidateVersion": candidate["candidateVersion"],
|
||
"candidateSha256": candidate["candidateSha256"],
|
||
"candidateBody": candidate["candidateBody"],
|
||
"contextSnapshotSha256": writer_context["contextSnapshot"]["contextSha256"],
|
||
"fineOutline": fine_outline,
|
||
"hardConstraints": constraints,
|
||
"factEvidence": _clean_input_evidence(writer_context.get("factEvidence", [])),
|
||
"proseEvidence": _clean_input_evidence(writer_context.get("proseEvidence", [])),
|
||
"asOf": writer_context["asOf"],
|
||
"authorizationSnapshotId": writer_context["authorizationSnapshot"]["snapshotId"],
|
||
}
|
||
payload["inputSha256"] = canonical_sha256(payload)
|
||
# 先做闭集校验失败关闭,再返回不含内部 "_" 派生键的干净输入(供 run/detector 复用)。
|
||
validate_semantic_detector_input(payload)
|
||
return json.loads(_canonical_json(payload))
|
||
|
||
|
||
__all__ = [
|
||
"ModelRunner", "ClaudeRuntimeModelRunner", "SemanticDetectorContractError",
|
||
"INPUT_VERSION", "MODEL_OUTPUT_VERSION", "REPORT_VERSION", "SEVERITIES",
|
||
"FINDING_CATEGORIES", "SEMANTIC_DETECTOR_REPORT_JSON_SCHEMA", "canonical_sha256",
|
||
"validate_semantic_detector_input", "build_semantic_model_input",
|
||
"build_semantic_detection", "validate_semantic_detector_report",
|
||
"calculate_semantic_metrics", "build_safe_semantic_diagnostic",
|
||
"run_writer_semantic_detector", "build_semantic_input_v3",
|
||
]
|