assistant/scripts/measure-prospecting-baselin...

274 lines
16 KiB
Python

"""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()