assistant/apps/server/learning_checks.py

153 lines
11 KiB
Python

"""Pure checks adapted from A2A operation knowledge and test readback boundaries.
Reference: learning/packages/contracts/python/penguin_learning_v1/operation_chain.py
(`_references`, `graph_integrity`, `validate_operation_candidate`) and
learning/src/learning_m0/test_readback.py. No source package or database is imported.
This reduced contract covers ordered, read-only steps, not the A2A operation DTO.
"""
from __future__ import annotations
from urllib.parse import urlsplit
from .db import digest
from .learning_contracts import valid_selector
def content_value_valid(value, field: dict) -> bool:
if not isinstance(value, dict) or set(value) != {"kind", "text", "media", "media_truncated"}:
return False
kind, text, media, truncated = (value[key] for key in ("kind", "text", "media", "media_truncated"))
if not isinstance(kind, str) or kind not in {"text", "media", "mixed"} or not (text is None or isinstance(text, str) and bool(text.strip())) or not isinstance(media, list) or len(media) > field.get("max_media", 4) or type(truncated) is not bool:
return False
if any(not isinstance(item, dict) or set(item) != {"kind", "selector"} or not isinstance(item.get("kind"), str) or item["kind"] not in {"image", "video"} or not isinstance(item.get("selector"), str) or not valid_selector(item["selector"], relative=True) for item in media):
return False
if len({item["selector"] for item in media}) != len(media) or (truncated and len(media) != field.get("max_media", 4)):
return False
return ((kind == "text" and text is not None and not media) or (kind == "media" and text is None and bool(media)) or (kind == "mixed" and text is not None and bool(media)))
def http_url(value) -> bool:
try:
parsed = urlsplit(value) if isinstance(value, str) else None
return bool(parsed and parsed.scheme in {"http", "https"} and parsed.hostname and not parsed.username and not parsed.password)
except ValueError:
return False
def step_evidence_references(steps: list[dict]) -> set[str]:
values = [step.get(key) for step in steps for key in ("evidence_id", "after_evidence_id", "selector_evidence_id")]
values.extend(reference for step in steps if isinstance(step.get("batch_evidence_ids"), list) for reference in step["batch_evidence_ids"])
return {reference for reference in values if isinstance(reference, str) and reference}
def evidence_graph_errors(steps: list[dict], bindings: list[dict], current: dict[str, dict]) -> list[str]:
"""Check the closed set of immutable before/after evidence references."""
errors = []
expected = step_evidence_references(steps)
ids = [item.get("observation_id") for item in bindings]
if any(not isinstance(value, str) or not value for value in ids) or len(ids) != len(set(ids)):
errors.append("冻结证据标识缺失或重复")
if set(ids) != expected:
errors.append("候选步骤与冻结证据引用不完整对应")
for position, step in enumerate(steps, 1):
if not step.get("evidence_id") or not step.get("after_evidence_id"):
errors.append(f"第 {position} 步缺少动作前后证据")
elif step["evidence_id"] == step["after_evidence_id"]:
errors.append(f"第 {position} 步动作前后必须是分别采集的证据")
batches = step.get("batch_evidence_ids")
if step.get("batch"):
config = step["batch"] if isinstance(step["batch"], dict) else {}
maximum = config.get("max_batches")
if type(maximum) is not int or not 1 <= maximum <= 5 or not isinstance(batches, list) or not 1 <= len(batches) <= maximum or any(not isinstance(item, str) or not item for item in batches) or len(set(batches)) != len(batches):
errors.append(f"第 {position} 步缺少真实批次证据")
else:
for index, reference in enumerate(batches, 1):
data = (current.get(reference) or {}).get("data", {})
batch = data.get("batch") or {}
if data.get("source") != "action_batch" or batch.get("batch_index") != index or any(type(batch.get(key)) is not int or batch[key] < 0 for key in ("added_count", "duplicate_count")):
errors.append(f"第 {position} 步批次证据不完整或来源不符")
elif batches is not None:
errors.append(f"第 {position} 步没有批次配置却引用批次证据")
for binding in bindings:
reference = binding.get("observation_id")
actual = current.get(reference)
if actual is None:
errors.append("冻结证据缺少同任务的原始记录")
elif digest(actual) != binding.get("content_digest"):
errors.append("来源证据与候选冻结摘要不一致")
return errors
def output_assertion_checks(actual: dict, expected: dict, contracts: dict | None = None) -> list[dict]:
"""Deterministic assertions on saved outputs; this is not an independent read."""
checks = []
for name, expectation in expected.items():
value = actual.get(name) if isinstance(actual, dict) else None
contract = (contracts or {}).get(name) or {}
if contract.get("kind") == "extract":
fields = contract.get("fields") or []
names = {field["name"] for field in fields}
items = value.get("items") if isinstance(value, dict) else None
complete_only = contract.get("complete_only") is True
batch = contract.get("batch")
allow_empty = contract.get("allow_empty") is True
has_empty = isinstance(value, dict) and "empty_state" in value
expected_keys = {"items", "count", "truncated", "source_url", "coverage"} | ({"skipped_incomplete_count"} if complete_only else set())
if batch:
expected_keys.add("batch_summary")
if has_empty:
expected_keys.add("empty_state")
skipped = value.get("skipped_incomplete_count", 0) if isinstance(value, dict) else 0
valid = (isinstance(value, dict) and set(value) == expected_keys
and isinstance(items, list) and len(items) <= contract.get("limit", 50)
and type(value.get("count")) is int and value["count"] == len(items)
and type(value.get("truncated")) is bool
and type(skipped) is int and skipped >= 0
and (not skipped or value["truncated"])
and isinstance(value.get("coverage"), str) and value["coverage"] in {"bounded", "limit_reached", "empty"}
and http_url(value.get("source_url"))
and ((value["coverage"] == "empty") == (not items))
and (value["coverage"] != "limit_reached" or (value["truncated"] and len(items) == contract.get("limit", 50)))
and all(isinstance(item, dict) and set(item) == names and all(content_value_valid(item[field["name"]], field) if field.get("source") == "content" else isinstance(item[field["name"]], str) or item[field["name"]] is None for field in fields) for item in items))
if valid:
valid = all(not field.get("required") or all(content_value_valid(item[field["name"]], field) if field.get("source") == "content" else isinstance(item[field["name"]], str) and item[field["name"]].strip() for item in items) for field in fields)
if valid and any(item[field["name"]]["media_truncated"] for item in items for field in fields if field.get("source") == "content"):
valid = value["truncated"] is True
if valid and batch:
summary = value.get("batch_summary")
identities = [item.get(batch["dedupe_field"]) for item in items]
valid = (value["truncated"] is True and isinstance(summary, dict) and set(summary) == {"batches_read", "duplicates_removed", "stop_reason"}
and type(summary.get("batches_read")) is int and 1 <= summary["batches_read"] <= batch["max_batches"]
and type(summary.get("duplicates_removed")) is int and summary["duplicates_removed"] >= 0
and isinstance(summary.get("stop_reason"), str) and summary["stop_reason"] in {"limit_reached", "boundary", "batch_budget", "time_budget"}
and (summary["stop_reason"] != "limit_reached" or len(items) == contract.get("limit", 50))
and all(isinstance(item, str) and item.strip() for item in identities) and len(set(identities)) == len(identities))
if valid and has_empty:
proof = value["empty_state"]
expected_proof = contract.get("empty_state") or {}
valid = (allow_empty and not batch and not items and not skipped and value["truncated"] is False
and isinstance(proof, dict) and set(proof) == {"confirmed", "selector", "text"} and proof.get("confirmed") is True
and proof.get("selector") == expected_proof.get("selector") and proof.get("text") == expected_proof.get("text"))
if valid and allow_empty and not items and not has_empty:
valid = False
values = [item[field["name"]].get("text") if field.get("source") == "content" else item[field["name"]] for item in items for field in fields if field.get("source", "text") != "href"] if valid else []
text = "\n".join(value for value in values if isinstance(value, str))
else:
valid = isinstance(value, list) and all(isinstance(item, dict) and isinstance(item.get("text"), str) and set(item) <= {"text", "href"} and ("href" not in item or isinstance(item["href"], str)) for item in value)
text = "\n".join(item["text"] for item in value) if valid else ""
checks.append({"name": f"output_schema:{name}", "passed": valid, "message": "读取输出结构符合固定契约" if valid else f"输出 {name} 不符合文本读取结构"})
if isinstance(expectation, dict):
if expectation == {"state": "nonempty"}:
nonempty = valid and contract.get("kind") == "extract" and isinstance(value, dict) and type(value.get("count")) is int and value["count"] > 0
checks.append({"name": f"output_nonempty:{name}", "passed": nonempty, "message": "结构化结果非空,不代表语义判断正确" if nonempty else "未取得有效的非空结构化结果"})
continue
confirmed = valid and expectation == {"state": "empty"} and contract.get("allow_empty") is True and isinstance(value, dict) and value.get("empty_state", {}).get("confirmed") is True and value.get("count") == 0
checks.append({"name": f"output_empty:{name}", "passed": confirmed, "message": "已核对明确空态证据" if confirmed else "未取得符合预期的真实空态证据"})
continue
# A URL containing the keyword is not evidence that the rendered result
# text contains it. Only the explicitly read text participates.
checks.append({"name": f"output_present:{name}", "passed": bool(text.strip()), "message": f"输出 {name} 未读到内容" if not text.strip() else "已读到输出"})
for item in [expectation] if isinstance(expectation, str) else expectation:
checks.append({"name": f"output_contains:{name}", "passed": item in text, "message": "预期文本匹配" if item in text else f"输出 {name} 未包含预期文本:{item[:100]}"})
return checks