"""Read program ledgers and write a task-local P1 baseline, without running it. Usage: node scripts/python.mjs scripts/measure-prospecting-baseline.py TASK_ID No model summary, source text, account identity or signed URL is exported. """ from __future__ import annotations import argparse from collections import Counter from datetime import datetime import hashlib import json from pathlib import Path import re import sys from zoneinfo import ZoneInfo ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from backend.prospecting.people import group_candidates, target_progress from backend.prospecting import usage TZ = ZoneInfo("Asia/Shanghai") TOKENS = ("input_tokens", "output_tokens", "total_tokens", "cache_hit_tokens", "cache_miss_tokens") PHASES = ("intent", "browser", "compression", "analysis") NOTE_PATH = re.compile(r"/(?:explore|discovery/item|search_result)/([a-fA-F0-9]{24})(?:[/?#\s]|$)") def timestamp(value): if not isinstance(value, str): return None try: result = datetime.fromisoformat(value) return result.replace(tzinfo=TZ) if result.tzinfo is None else result except ValueError: return None def read_json(path: Path, default): if not path.exists(): return default return json.loads(path.read_text(encoding="utf-8")) def usage_report(events: list[dict], damaged: bool) -> dict: calls = {} for event in events: cid = event.get("call_id") if not cid: continue row = calls.setdefault(cid, {"phase": event["phase"], "model": event["model"]}) if event["kind"] == "response": row["usage"] = event["usage"] elif event["kind"] == "finished": row["status"] = event["status"] complete = bool(events and events[0].get("kind") == "task_start" and not damaged) def aggregate(rows): result = usage._aggregate(rows) known = {key: result.pop(key) for key in TOKENS} if not rows and complete: known = {key: 0 for key in TOKENS} totals = {key: known[key] if complete and all( type((row.get("usage") or {}).get(key)) is int for row in rows) else None for key in TOKENS} return {"calls_observed": len(rows), "calls_total": len(rows) if complete else None, "known_tokens": known, "tokens_total": totals, **{key: value for key, value in result.items() if key != "requests"}} grouped = {key: [] for key in PHASES} for row in calls.values(): phase = "analysis" if row["phase"] == "analysis_incremental" else row["phase"] grouped.setdefault(phase, []).append(row) return {"ledger_complete_from_task_start": complete, "ledger_damaged": damaged, "models": sorted({row["model"] for row in calls.values()}), "total": aggregate(list(calls.values())), "phases": {key: aggregate(rows) for key, rows in grouped.items()}, "phase_mapping": {"analysis": ["analysis", "analysis_incremental"], "review": "reported separately; included in total cost"}} def callback_records(progress: str): for block in re.split(r"(?m)^### ", progress)[1:]: if not re.match(r"[^\n]*\] 步骤 \d+\s*\n", block): continue matches = re.findall(r" \| 动作:([^|\n]*) \| 页面:([^|\n]*)", block) if matches: yield matches[-1] def step_report(progress: str, history: dict, budget: dict, screenshot_count: int) -> dict: # These are program-written fields, but the actions are model proposals; # a subsequent action boundary can reject them before execution. proposals = Counter() callback_count = 0 for names, _page in callback_records(progress): callback_count += 1 for name in names.split("、"): if re.fullmatch(r"[a-z][a-z0-9_]*", name): proposals[name] += 1 total = budget.get("total_steps_used") total = total if type(total) is int and total >= 0 else None return {"total_steps_attempted": total, "budget_version": budget.get("version"), "last_run_steps": budget.get("last_run_steps"), "step_callback_records": callback_count, "screenshots_saved": screenshot_count, "proposed_tool_call_counts": dict(sorted(proposals.items())), "proposed_tool_calls": sum(proposals.values()), "tool_distribution_basis": "progress step callback proposals, not proof of execution", "compact_history_action_count": len(history.get("actions") or []), "compact_history_may_be_truncated": len(history.get("actions") or []) >= 200} def executed_tools(path: Path) -> dict: """Optional safe boundary ledger; absence never becomes zero executions.""" if not path.exists(): return {"available": False, "invoked": None, "invoked_total": None, "status_counts": None, "limitation": "No per-invocation execution ledger; proposed actions cannot prove execution."} calls, damaged = {}, False for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: row = json.loads(line) cid, name, status = row["action_id"], row["action_name"], row["status"] if (not re.fullmatch(r"[a-f0-9]{32}", cid) or not re.fullmatch(r"[a-z][a-z0-9_]*", name) or row.get("version") != 1 or row.get("kind") != "browser_action" or status not in {"invoked", "blocked", "returned", "error"} or not timestamp(row.get("at"))): raise ValueError("invalid action event") call = calls.setdefault(cid, {"action": name, "statuses": set()}) if call["action"] != name: raise ValueError("action identity changed") call["statuses"].add(status) except (ValueError, TypeError, KeyError): damaged = True counts = Counter(row["action"] for row in calls.values() if "invoked" in row["statuses"]) statuses = {status: dict(sorted(Counter(row["action"] for row in calls.values() if status in row["statuses"]).items())) for status in ("invoked", "blocked", "returned", "error")} return {"available": True, "ledger_damaged": damaged, "invoked": dict(sorted(counts.items())), "invoked_total": sum(counts.values()), "status_counts": statuses, "pending_calls": sum("invoked" in row["statuses"] and not row["statuses"] & {"returned", "error"} for row in calls.values()), "basis": "distinct action_id with invoked; blocked was not invoked; returned is not proof of business success"} def request_contracts(path: Path, events: list[dict]) -> dict: if not path.exists(): return {"available": False, "requests_prepared": None, "requests_with_response": None, "disabled_tools_exposed": None} contracts, damaged = {}, False for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue try: row = json.loads(line) if row.get("kind") != "request_prepared" or not re.fullmatch(r"[a-f0-9]{32}", row["call_id"]): raise ValueError("invalid contract event") names = row["action_names"] if not isinstance(names, list) or any(not isinstance(name, str) or not re.fullmatch(r"[a-z][a-z0-9_]*", name) for name in names): raise ValueError("invalid action names") contracts[row["call_id"]] = names except (ValueError, TypeError, KeyError): damaged = True responses = {row["call_id"] for row in events if row["kind"] == "response"} names = set(name for items in contracts.values() for name in items) return {"available": True, "ledger_damaged": damaged, "requests_prepared": len(contracts), "requests_with_response": len(set(contracts) & responses), "disabled_tools_exposed": sorted(names & {"inspect_reply_relations", "resolve_reply_target"}), "basis": "prepared schema joined to provider usage response by call_id; no messages or raw schema included"} def build_baseline(task: dict, directory: Path) -> dict: evidence = read_json(directory / "evidence.json", {}) artifacts = dict(task.get("artifacts") or {}) # Add checkpoint-only records without silently replacing the DB's current # same-ID observations. No source or task is rewritten by measurement. for key, identity in (("sources", "source_id"), ("profiles", "source_id"), ("operation_evidence", "id")): merged = {row[identity]: row for row in artifacts.get(key, []) if isinstance(row, dict) and row.get(identity)} for row in evidence.get(key, []): if isinstance(row, dict) and row.get(identity): merged.setdefault(row[identity], row) artifacts[key] = list(merged.values()) current = {**task, "artifacts": artifacts} groups = group_candidates(current) target = target_progress(current) sources = artifacts.get("sources", []) observations = [*(artifacts.get("source_observations") or []), *(evidence.get("source_observations") or [])] note_ids = {row.get("note_id") for row in sources if row.get("note_id")} inspected_ids = {row.get("note_id") for row in observations if isinstance(row, dict) and row.get("note_id")} progress_path = directory / "progress.md" progress = progress_path.read_text(encoding="utf-8") if progress_path.exists() else "" history = artifacts.get("browser_history") or {} actual_pages = [page for _names, page in callback_records(progress)] observed_visits = {match.group(1).lower() for value in [*actual_pages, *(history.get("urls") or [])] if isinstance(value, str) for match in NOTE_PATH.finditer(value)} events, damaged = usage._read_events(directory) llm = usage_report(events, damaged) budget = read_json(directory / "browser-budget.json", {}) times = [value for raw in [task.get("created_at"), task.get("updated_at"), budget.get("updated_at"), *[row.get("at") for row in events]] if (value := timestamp(raw))] origin = next((timestamp(row["at"]) for row in events if row["kind"] == "task_start"), timestamp(task.get("created_at"))) end = max(times) if times else None seconds = round((end - origin).total_seconds(), 3) if end and origin and end >= origin else None delivered = target["current"] total = llm["total"] per_candidate = {"denominator": "eligible stable-identity candidates under current requirements", "count": delivered, "calls": total["calls_total"] / delivered if delivered and total["calls_total"] is not None else None, "tokens": total["tokens_total"]["total_tokens"] / delivered if delivered and total["tokens_total"]["total_tokens"] is not None else None, "wall_clock_seconds": seconds / delivered if delivered and seconds is not None else None} facts = {row["id"]: row for row in artifacts.get("operation_evidence", []) if row.get("id")} counts = Counter(row.get("source_type", "unknown") for row in sources) ledger_names = ["model-usage.jsonl", "browser-budget.json", "progress.md", "evidence.json", "browser-actions.jsonl", "model-request-contracts.jsonl"] return {"version": 1, "stage": "P1", "task_id": task["id"], "measured_at": datetime.now(TZ).isoformat(timespec="seconds"), "task_status": task.get("status"), "scope": {key: task.get("inputs", {}).get(key) for key in ("intent", "target_candidates", "max_notes", "max_comments_per_note", "_goal_version")}, "candidates": {"delivered_stable_identity": delivered, "stable_identity_all_kept": groups["counts"]["candidates"], "unknown_identity_groups": sum(row["identity_status"] == "unresolved" for row in groups["groups"]), "unknown_identity_sources": groups["counts"]["unresolved_sources"], "supplemental": target["supplemental"], "target": target["target"], "remaining": target["remaining"], "target_reached": target["reached"]}, "reading": {"notes_with_saved_quotes": len(note_ids), "notes_dom_inspected": len(inspected_ids), "notes_visited_observed": len(observed_visits), "note_visit_coverage": "observed callback/history URLs only; may be incomplete", "saved_sources": len(sources), "saved_notes": counts["note"], "saved_comments": counts["comment"], "saved_replies": counts["reply"]}, "llm": llm, "model_tool_contracts": request_contracts(directory / "model-request-contracts.jsonl", events), "browser": {**step_report(progress, history, budget, len(list((directory / "steps").glob("step_*.png")))), "executed_tools": executed_tools(directory / "browser-actions.jsonl"), "verified_distinct_operation_facts": dict(sorted(Counter(row.get("capability", "unknown") for row in facts.values()).items()))}, "timing": {"start": origin.isoformat() if origin else None, "end_last_program_record": end.isoformat() if end else None, "wall_clock_seconds": seconds, "includes_pauses_between_runs": True, "basis": "task_start (fallback DB created_at) to latest DB update / usage event / browser checkpoint; excludes idle after recorded stop", "final": task.get("status") in {"completed", "blocked", "failed", "cancelled"}}, "stop": {"reason": artifacts.get("stop_reason"), "browser_verdict": artifacts.get("browser_verdict"), "basis": "task artifacts program stop_reason; model final_result/summary not used"}, "per_candidate": per_candidate, "source_ledgers": {name: {"present": (directory / name).exists(), "sha256": hashlib.sha256((directory / name).read_bytes()).hexdigest() if (directory / name).exists() else None} for name in ledger_names}, "limitations": ["Visited or DOM-inspected notes are not a claim that every comment was read.", "Unknown identity is not counted toward the target.", "Known token sums exclude unavailable usage; complete totals and per-candidate tokens remain null when incomplete.", "Tool proposals and verified distinct facts are not exact execution counts; use executed_tools only when its ledger is available."]} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("task_id") args = parser.parse_args() if not re.fullmatch(r"task_[a-f0-9]{12}", args.task_id): parser.error("Expected an existing task_<12 hex characters> identifier") from backend.config import TASKS_DIR from backend.db import database as db from backend.prospecting.contracts import KNOWLEDGE_ID task = db.get_task(args.task_id) if not task or task.get("knowledge_id") != KNOWLEDGE_ID: parser.error("No prospecting task found in the current workspace") directory = TASKS_DIR / args.task_id if not directory.is_dir(): parser.error("Task ledger directory does not exist") report = build_baseline(task, directory) destination = directory / "baseline.json" temporary = directory / ".baseline.json.tmp" temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") temporary.replace(destination) print(json.dumps({"task_id": task["id"], "baseline_path": str(destination.resolve()), "candidates": report["candidates"], "llm": report["llm"]["total"], "browser_steps": report["browser"]["total_steps_attempted"], "wall_clock_seconds": report["timing"]["wall_clock_seconds"], "stop": report["stop"]}, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()