171 lines
6.9 KiB
Python
171 lines
6.9 KiB
Python
"""Describe recorded provenance without turning a source into a verified assertion."""
|
|
|
|
import hashlib
|
|
from uuid import UUID
|
|
import json
|
|
|
|
|
|
SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"}
|
|
|
|
|
|
def android_action_source(device_id: object) -> dict | None:
|
|
"""Stamp a native action at dispatch, using its actual selected device."""
|
|
if not isinstance(device_id, str) or not device_id or device_id == "computer:local":
|
|
return None
|
|
return {"kind": "android", "target_id": device_id, "label": "安卓屏幕"}
|
|
|
|
|
|
def _android_screenshot_records(step: dict) -> list[dict]:
|
|
"""Project explicit capture receipts; never infer provenance from tool args."""
|
|
records = []
|
|
seen = set()
|
|
for tool in step.get("generic_tools") or []:
|
|
if not isinstance(tool, dict) or (
|
|
tool.get("type"),
|
|
tool.get("name"),
|
|
tool.get("status"),
|
|
) != ("tool", "android_file", "success"):
|
|
continue
|
|
payload = tool.get("payload")
|
|
receipt = payload.get("result") if isinstance(payload, dict) else None
|
|
if not isinstance(receipt, dict) or (
|
|
receipt.get("ok") is not True
|
|
or receipt.get("verified") is not True
|
|
or receipt.get("action") != "screenshot"
|
|
):
|
|
continue
|
|
serial, path, digest = (
|
|
receipt.get("device_serial"),
|
|
receipt.get("trace_path"),
|
|
receipt.get("sha256"),
|
|
)
|
|
if not all(isinstance(value, str) and value for value in (serial, path, digest)):
|
|
continue
|
|
captured_at = receipt.get("captured_at")
|
|
captured_at = captured_at if isinstance(captured_at, str) else None
|
|
key = (serial, path, digest, captured_at)
|
|
if key in seen:
|
|
# Nested tool decorators can record the same completed capture twice.
|
|
continue
|
|
seen.add(key)
|
|
records.append(
|
|
{
|
|
"source": {"kind": "android", "target_id": serial, "label": "安卓屏幕"},
|
|
"trace_id": tool.get("trace_id"),
|
|
"artifact_path": path,
|
|
"sha256": digest,
|
|
"captured_at": captured_at,
|
|
}
|
|
)
|
|
return records
|
|
|
|
|
|
def _step_reference(step: dict) -> dict:
|
|
value = step.get("step_id")
|
|
return {
|
|
"step_id": str(value) if isinstance(value, (str, UUID)) else None,
|
|
"step_number": step.get("step_number"),
|
|
}
|
|
|
|
|
|
def _represented_capture(action: dict, captures: list[dict]) -> bool:
|
|
if action.get("action") != "android_file":
|
|
return False
|
|
args = action.get("args") or {}
|
|
if not isinstance(args, dict) or args.get("action") != "screenshot":
|
|
return False
|
|
filename = args.get("filename")
|
|
return any(
|
|
capture["artifact_path"].replace("\\", "/").rsplit("/", 1)[-1] == filename
|
|
for capture in captures
|
|
)
|
|
|
|
|
|
def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
|
sources = {}
|
|
unattributed = []
|
|
for step in steps:
|
|
result = step.get("last_execution_result")
|
|
execution = result.get("execution") if isinstance(result, dict) else None
|
|
records = execution if isinstance(execution, list) else step.get("action_taken")
|
|
records = records if isinstance(records, list) else [records] if records else []
|
|
captures = _android_screenshot_records(step)
|
|
records = [
|
|
record
|
|
for record in records
|
|
if not isinstance(record, dict)
|
|
or (
|
|
record.get("action")
|
|
not in {"save_note", "read_note", "list_notes", "report_task_status"}
|
|
and not _represented_capture(record, captures)
|
|
)
|
|
]
|
|
records = [*records, *captures]
|
|
if not records:
|
|
# A planning or completion turn with no recorded action is not a
|
|
# missing action source. Screenshots alone do not supply provenance.
|
|
continue
|
|
found = False
|
|
missing = False
|
|
for record in records:
|
|
if not isinstance(record, dict) or not isinstance(record.get("source"), dict):
|
|
missing = True
|
|
continue
|
|
source = record["source"]
|
|
raw_kind = source.get("kind")
|
|
kind = raw_kind if isinstance(raw_kind, str) and raw_kind in SOURCE_KINDS else "other"
|
|
target = source.get("target_id")
|
|
if not isinstance(target, str) or not target:
|
|
missing = True
|
|
continue
|
|
found = True
|
|
plugin = str(record.get("plugin_id") or "")[:100]
|
|
label = str(source.get("label") or "")[:500]
|
|
key = (plugin, kind, target[:256])
|
|
if key not in sources:
|
|
sources[key] = {
|
|
"id": hashlib.sha256(json.dumps(key).encode()).hexdigest()[:20],
|
|
"kind": kind,
|
|
"plugin_id": plugin,
|
|
"target_id": target[:256],
|
|
"label": label,
|
|
"category": "interface" if kind in {"android", "computer"} else "auxiliary",
|
|
"references": [],
|
|
"reference_count": 0,
|
|
}
|
|
reference = _step_reference(step)
|
|
for field in (
|
|
"call_id",
|
|
"source_id",
|
|
"trace_id",
|
|
"artifact_path",
|
|
"sha256",
|
|
"captured_at",
|
|
):
|
|
if isinstance(record.get(field), str) and record[field]:
|
|
reference[field] = record[field][: 2048 if field == "artifact_path" else 256]
|
|
if isinstance(record.get("read_only"), bool):
|
|
reference["read_only"] = record["read_only"]
|
|
entry = sources[key]
|
|
if not entry.get("label_varies") and entry["label"] != label:
|
|
# The same window can navigate between tabs/pages. Its first
|
|
# title is not a valid label for all later evidence.
|
|
entry["label"] = ""
|
|
entry["label_varies"] = True
|
|
entry["reference_count"] += 1
|
|
if len(entry["references"]) < 8:
|
|
entry["references"].append(reference)
|
|
if not found or missing:
|
|
# A screenshot or a model-authored URL does not supply missing provenance.
|
|
unattributed.append(_step_reference(step))
|
|
values = list(sources.values())
|
|
return {
|
|
"schema_version": 1,
|
|
"sources": values[:limit],
|
|
"total_sources": len(values),
|
|
"sources_omitted": max(0, len(values) - limit),
|
|
"unattributed_step_count": len(unattributed),
|
|
"unattributed_references": unattributed[:8],
|
|
"note": "来源清单仅来自执行记录,不代表断言已验证。界面证据与接口、终端、文件来源分开;界面结果不能单独证明后端持久状态。逐项结论及未验证边界以正式验收依据为准。缺失来源不按网址或业务名称推断执行平台。",
|
|
}
|