| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- #!/usr/bin/env python3
- """Read-only validator for every visible data-source Inspector in real runs."""
- from __future__ import annotations
- import argparse
- import hashlib
- import json
- import sys
- import urllib.error
- import urllib.parse
- import urllib.request
- from collections import Counter
- from concurrent.futures import ThreadPoolExecutor, as_completed
- from typing import Any
- HIDDEN_BUSINESS_KEYS = {"technical", "changes"}
- def request_json(base_url: str, path: str) -> tuple[int, Any]:
- url = f"{base_url.rstrip('/')}{path}"
- try:
- with urllib.request.urlopen(url, timeout=60) as response:
- return response.status, json.load(response)
- except urllib.error.HTTPError as exc:
- try:
- payload = json.load(exc)
- except Exception:
- payload = {"detail": str(exc)}
- return exc.code, payload
- def quote_ref(value: str) -> str:
- return urllib.parse.quote(value, safe="")
- def collect_detail_refs(value: Any) -> list[str]:
- found: list[str] = []
- def visit(node: Any) -> None:
- if isinstance(node, dict):
- for key, child in node.items():
- if key == "detailRef" and isinstance(child, str) and child:
- found.append(child)
- visit(child)
- elif isinstance(node, list):
- for child in node:
- visit(child)
- visit(value)
- return list(dict.fromkeys(found))
- def pointer_lookup(value: Any, pointer: str) -> tuple[bool, Any]:
- if not pointer:
- return True, value
- current = value
- for raw in pointer.lstrip("/").split("/"):
- part = raw.replace("~1", "/").replace("~0", "~")
- if isinstance(current, list):
- try:
- current = current[int(part)]
- except (ValueError, IndexError):
- return False, None
- elif isinstance(current, dict) and part in current:
- current = current[part]
- else:
- return False, None
- return True, current
- def digest(value: Any) -> str:
- serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
- return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
- def validate_binding(binding: dict[str, Any], sources: dict[str, Any]) -> list[str]:
- errors: list[str] = []
- source_id = str(binding.get("sourceId") or "")
- selector = binding.get("selector") or {}
- resolution = binding.get("resolution")
- source = sources.get(source_id)
- if source is None:
- return [f"binding {binding.get('id')} 的 sourceId {source_id} 未返回"]
- if resolution == "resolved" and source.get("resultState") in {"missing", "unavailable"}:
- errors.append(
- f"binding {binding.get('id')} 标记 resolved,但来源状态为 {source.get('resultState')}"
- )
- if selector.get("kind") == "unresolved":
- code = str(selector.get("code") or "")
- reason = str(selector.get("reason") or "")
- if not reason:
- errors.append(f"binding {binding.get('id')} 的 unresolved 原因为空")
- if not code:
- errors.append(f"binding {binding.get('id')} 的 unresolved 缺少原因码")
- if code in {"binding-map-missing", "field-path-mismatch"}:
- errors.append(f"binding {binding.get('id')} 存在可视化映射错误 {code}")
- elif code not in {
- "source-record-not-saved",
- "event-body-not-saved",
- "unsafe-association",
- "reliable-log-anchor-unavailable",
- }:
- errors.append(f"binding {binding.get('id')} 使用了未允许的缺失原因码 {code}")
- if code in {"source-record-not-saved", "event-body-not-saved"} and source.get("resultState") not in {"missing", "unavailable"}:
- errors.append(
- f"binding {binding.get('id')} 声称原始记录未保存,"
- f"但来源状态为 {source.get('resultState')}"
- )
- return errors
- if selector.get("kind") == "json-pointer":
- found, _selected = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
- if not found:
- errors.append(f"binding {binding.get('id')} 无法反向定位 {selector.get('path')}")
- elif selector.get("kind") == "text-span":
- found, text = pointer_lookup(source.get("rawRecord"), selector.get("path") or "")
- if not found or not isinstance(text, str):
- errors.append(f"binding {binding.get('id')} 的原文字段不存在")
- else:
- characters = list(text)
- start = int(selector.get("startCodePoint") or 0)
- end = int(selector.get("endCodePoint") or 0)
- if "".join(characters[start:end]) != selector.get("exactText"):
- errors.append(f"binding {binding.get('id')} 的原文区间不匹配")
- if digest(text) != selector.get("sourceDigest"):
- errors.append(f"binding {binding.get('id')} 的 sourceDigest 不匹配")
- elif selector.get("kind") == "members":
- members = selector.get("members") or []
- if not members:
- errors.append(f"binding {binding.get('id')} 的聚合成员为空")
- for member in members:
- errors.extend(validate_binding({
- "id": f"{binding.get('id')}:member",
- "sourceId": member.get("sourceId"),
- "selector": member.get("selector"),
- "resolution": "resolved",
- }, sources))
- return errors
- def selected_value(binding: dict[str, Any], source: dict[str, Any]) -> tuple[bool, Any]:
- selector = binding.get("selector") or {}
- kind = selector.get("kind")
- raw = source.get("rawRecord")
- if kind == "json-pointer":
- return pointer_lookup(raw, str(selector.get("path") or ""))
- if kind == "whole-record":
- return True, raw
- if kind == "text-span":
- found, text = pointer_lookup(raw, str(selector.get("path") or ""))
- if not found or not isinstance(text, str):
- return False, None
- start = int(selector.get("startCodePoint") or 0)
- end = int(selector.get("endCodePoint") or 0)
- return True, "".join(list(text)[start:end])
- return False, None
- def equivalent(left: Any, right: Any) -> bool:
- return json.dumps(left, ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
- right, ensure_ascii=False, sort_keys=True, default=str
- )
- def validate_business_value(
- binding: dict[str, Any], source: dict[str, Any], business_value: Any
- ) -> list[str]:
- """Direct bindings must select the exact value shown in column one."""
- if (binding.get("transform") or {}).get("kind") != "direct":
- return []
- found, selected = selected_value(binding, source)
- if found and not equivalent(selected, business_value):
- return [
- f"binding {binding.get('id')} 声明直接展示,但 selector 值与业务项不一致"
- ]
- return []
- def validate_empty_sources(sources: dict[str, Any]) -> list[str]:
- errors: list[str] = []
- for source_id, source in sources.items():
- if source.get("resultState") != "empty":
- continue
- kind = source.get("kind")
- locator = source.get("locator") or {}
- raw = source.get("rawRecord")
- if kind == "database" and not (
- raw == [] and int(locator.get("resultCount") or 0) == 0
- ):
- errors.append(f"空结果来源 {source_id} 无法证明数据库查询返回 0 条")
- if kind == "runtime-event" and not isinstance(raw, dict):
- errors.append(f"空结果来源 {source_id} 没有保存运行 Event")
- return errors
- def validate_view(payload: dict[str, Any]) -> tuple[Counter, list[str]]:
- counts: Counter = Counter()
- errors: list[str] = []
- if payload.get("schemaVersion") != "inspector-source-v2":
- return counts, ["schemaVersion 不是 inspector-source-v2"]
- business = payload.get("businessProjection") or {}
- sources = payload.get("sources") or {}
- for source in sources.values():
- counts[f"source:{source.get('resultState') or 'present'}"] += 1
- actual_selectors: list[str] = []
- for module in payload.get("modules") or []:
- bindings = {item.get("id"): item for item in module.get("bindings") or []}
- for row in module.get("rows") or []:
- counts["rows"] += 1
- business_selector = str(row.get("businessSelector") or "")
- actual_selectors.append(business_selector)
- found, business_value = pointer_lookup(business, business_selector)
- if not found:
- errors.append(f"业务行 {row.get('id')} 无法定位 {row.get('businessSelector')}")
- ids = row.get("bindingIds") or []
- if not ids:
- errors.append(f"业务行 {row.get('id')} 没有 binding")
- for binding_id in ids:
- binding = bindings.get(binding_id)
- if binding is None:
- errors.append(f"业务行 {row.get('id')} 引用了不存在的 binding {binding_id}")
- continue
- counts[str(binding.get("resolution") or "unknown")] += 1
- errors.extend(validate_binding(binding, sources))
- source = sources.get(str(binding.get("sourceId") or "")) or {}
- errors.extend(validate_business_value(binding, source, business_value))
- errors.extend(validate_log_sources(sources))
- errors.extend(validate_empty_sources(sources))
- expected_selectors = visible_business_selectors(business)
- missing_selectors = [value for value in expected_selectors if value not in actual_selectors]
- if missing_selectors:
- errors.append(f"旧业务 Inspector 的可见项没有生成对照行: {missing_selectors}")
- return counts, errors
- def validate_log_sources(sources: dict[str, Any]) -> list[str]:
- errors: list[str] = []
- for source_id, source in sources.items():
- if source.get("kind") != "log-anchor":
- continue
- locator = source.get("locator") or {}
- event_id = locator.get("eventId")
- if event_id is None:
- errors.append(f"日志来源 {source_id} 没有 eventId")
- continue
- event = sources.get(f"event:{event_id}") or {}
- event_raw = event.get("rawRecord") or {}
- expected_msg_id = str(event_raw.get("msg_id") or locator.get("msgId") or "")
- if not expected_msg_id:
- errors.append(f"Event {event_id} 没有 msg_id,不应附带日志来源")
- continue
- raw_log = source.get("rawRecord")
- rendered = raw_log if isinstance(raw_log, str) else json.dumps(raw_log, ensure_ascii=False)
- if f"msg_id={expected_msg_id}" not in rendered and f"msg_id:{expected_msg_id}" not in rendered:
- errors.append(f"日志来源 {source_id} 与 Event {event_id} 的 msg_id 不一致")
- return errors
- def visible_business_selectors(projection: dict[str, Any]) -> list[str]:
- """Mirror the readable Inspector's visible leaves, including fixed empty notices."""
- selectors: list[str] = []
- if projection.get("question") not in (None, ""):
- selectors.append("/question")
- for section_index, section in enumerate(projection.get("businessSections") or []):
- if not isinstance(section, dict):
- continue
- root = f"/businessSections/{section_index}"
- if isinstance(section.get("items"), list):
- selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(section["items"]))
- elif section.get("content") is not None:
- selectors.append(f"{root}/content")
- for block_index, block in enumerate(projection.get("blocks") or []):
- if not isinstance(block, dict):
- continue
- root = f"/blocks/{block_index}"
- kind = str(block.get("type") or "summary")
- if kind in {"summary", "notice"}:
- selectors.append(f"{root}/value")
- elif kind == "items":
- selectors.extend(f"{root}/items/{index}" for index, _ in enumerate(block.get("items") or []))
- elif kind == "implementation-plan":
- if block.get("mode") is not None:
- selectors.append(f"{root}/mode")
- for route_index, route in enumerate(block.get("routes") or []):
- if not isinstance(route, dict):
- continue
- for key in ("target", "method", "action", "emphasis", "pathType"):
- if route.get(key) is not None:
- selectors.append(f"{root}/routes/{route_index}/{key}")
- if block.get("reasoning") is not None:
- selectors.append(f"{root}/reasoning")
- elif kind == "creative-process":
- for step_index, step in enumerate(block.get("steps") or []):
- if not isinstance(step, dict):
- continue
- for key in ("action", "summary", "plan", "reasoning"):
- if step.get(key) is not None:
- selectors.append(f"{root}/steps/{step_index}/{key}")
- elif kind == "evaluation-branches":
- for branch_index, branch in enumerate(block.get("branches") or []):
- if not isinstance(branch, dict):
- continue
- for key in ("conclusion", "achievements", "problems", "evidence"):
- if branch.get(key) not in (None, [], ""):
- selectors.append(f"{root}/branches/{branch_index}/{key}")
- elif kind in {"evaluation-matrix", "comparison"}:
- selectors.extend(f"{root}/rows/{index}" for index, _ in enumerate(block.get("rows") or []))
- elif kind == "artifact":
- selectors.extend(f"{root}/refs/{index}" for index, _ in enumerate(block.get("refs") or []))
- else:
- selectors.append(root)
- if projection.get("detailKind") == "retrieval-event":
- conditions = projection.get("queryConditions") or []
- selectors.extend(
- [f"/queryConditions/{index}/value" for index, _ in enumerate(conditions)]
- or ["/queryConditions"]
- )
- outcome = projection.get("outcome") or {}
- selectors.extend(
- f"/outcome/{key}"
- for key in ("message", "count", "errorKind")
- if outcome.get(key) is not None
- )
- selectors.extend(f"/items/{index}" for index, _ in enumerate(projection.get("items") or []))
- completeness = projection.get("completeness") or {}
- if completeness.get("bodyAvailable") is False:
- selectors.append("/completeness/bodyAvailable")
- if completeness.get("truncated"):
- selectors.append(
- "/completeness/omittedCharacters"
- if completeness.get("omittedCharacters") is not None
- else "/completeness/truncated"
- )
- if isinstance(projection.get("activities"), list):
- activities = projection.get("activities") or []
- selectors.extend(
- [f"/activities/{index}" for index, _ in enumerate(activities)]
- or ["/missingActivityNotice" if projection.get("missingActivityNotice") else "/activities"]
- )
- return selectors
- def validate_detail(base_url: str, run_id: int, detail_ref: str) -> tuple[str, Counter, list[str], bool]:
- encoded = quote_ref(detail_ref)
- status, payload = request_json(
- base_url, f"/api/script-builds/{run_id}/inspector-view/{encoded}"
- )
- if status == 404:
- return detail_ref, Counter(), [], False
- if status != 200:
- return detail_ref, Counter(), [f"inspector-view HTTP {status}: {payload}"], True
- counts, errors = validate_view(payload)
- old_status, old = request_json(
- base_url, f"/api/script-builds/{run_id}/activities/{encoded}"
- )
- if old_status == 200:
- expected = {key: value for key, value in old.items() if key not in HIDDEN_BUSINESS_KEYS}
- if payload.get("businessProjection") != expected:
- errors.append("第一列与旧业务 Inspector 投影不一致")
- return detail_ref, counts, errors, True
- def validate_run(base_url: str, run_id: int, *, workers: int) -> dict[str, Any]:
- status, execution = request_json(base_url, f"/api/script-builds/{run_id}/execution-view")
- if status != 200:
- return {"runId": run_id, "fatal": f"execution-view HTTP {status}: {execution}"}
- refs = collect_detail_refs(execution)
- totals: Counter = Counter()
- failures: list[dict[str, Any]] = []
- checked = 0
- with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
- futures = {
- executor.submit(validate_detail, base_url, run_id, detail_ref): detail_ref
- for detail_ref in refs
- }
- for future in as_completed(futures):
- detail_ref, counts, errors, did_check = future.result()
- checked += int(did_check)
- totals.update(counts)
- if errors:
- failures.append({"detailRef": detail_ref, "errors": errors})
- failures.sort(key=lambda item: refs.index(item["detailRef"]))
- return {
- "runId": run_id,
- "visibleRefs": len(refs),
- "checkedInspectors": checked,
- "counts": dict(totals),
- "failures": failures,
- }
- def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument("--base-url", default="http://127.0.0.1:8788")
- parser.add_argument("--runs", type=int, nargs="+", default=[422, 443, 444, 446])
- parser.add_argument("--output")
- parser.add_argument("--workers", type=int, default=8)
- args = parser.parse_args()
- results = [validate_run(args.base_url, run_id, workers=args.workers) for run_id in args.runs]
- report = {"baseUrl": args.base_url, "runs": results}
- rendered = json.dumps(report, ensure_ascii=False, indent=2)
- print(rendered)
- if args.output:
- with open(args.output, "w", encoding="utf-8") as handle:
- handle.write(rendered + "\n")
- return 1 if any(result.get("fatal") or result.get("failures") for result in results) else 0
- if __name__ == "__main__":
- sys.exit(main())
|