reuse-surface/reuse_surface/plan_check.py
tegwick d181043717
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 10s
ci / validate-registry (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 1m16s
REUSE-WP-0019-T04: reuse telemetry store and recording
Implements the hub side of the shared reuse-event schema (already drafted
in WP-0018-T01, schemas/reuse-event.schema.json): a SQLite reuse_events
table, POST /v1/reuse-events (token-auth), GET /v1/reuse-events?capability_id=
(read-only).

reuse_surface/plan_check.py: refactored record_outcome around a new
shared post_or_fallback_reuse_event() helper -- tries the hub first, falls
back to the local JSONL only on failure/unreachability, never both. New
record_manual_reuse_event() backs a new CLI command, reuse-surface
record-reuse, for retroactive facts recorded outside plan-check.

Privacy/scope (repo slugs and capability ids only, no code, no secrets) is
enforced structurally via the schema's additionalProperties: false, not
just by convention.

21 new pytest cases, 145 total pass. Live-verified against a real running
hub instance: POST/GET /v1/reuse-events directly, record-reuse and
plan-check --record-outcome both posting successfully to the hub, and --
after actually killing the hub process -- confirmed the fallback path
writes correctly to the local JSONL instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 22:32:19 +02:00

460 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import re
import urllib.error
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from jsonschema import Draft202012Validator
from reuse_surface import hub_client
from reuse_surface.federation import FEDERATED_INDEX_PATH
from reuse_surface.llm_bridge import execute_prompt, extract_json_object
from reuse_surface.overlaps import TOKEN_RE
from reuse_surface.registry import ROOT, load_index
from reuse_surface.statehub_bridge import file_capability_request
TELEMETRY_PATH = ROOT / "registry" / "telemetry" / "plan-check-events.jsonl"
RERANK_SCHEMA_PATH = ROOT / "schemas" / "plan-check-rerank.schema.json"
REUSE_EVENT_SCHEMA_PATH = ROOT / "schemas" / "reuse-event.schema.json"
STALE_DAYS = 14
DEFAULT_REUSE_THRESHOLD = 0.45
DEFAULT_EXTEND_THRESHOLD = 0.22
DEFAULT_TIE_WINDOW = 0.05
@dataclass
class MatchQuery:
source: str
text: str
workplan_id: str | None = None
workplan_path: str | None = None
tokens: set[str] = field(default_factory=set)
@dataclass
class Match:
id: str
score: float
kind: str
vector: str | None = None
owner: str | None = None
summary: str | None = None
def _tokens(text: str) -> set[str]:
return set(TOKEN_RE.findall(text.lower()))
def load_query_from_workplan(path: Path) -> MatchQuery:
text = path.read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---\n?(.*)$", text, re.DOTALL)
if not match:
raise ValueError(f"{path}: missing YAML front matter")
front = yaml.safe_load(match.group(1)) or {}
body = match.group(2)
parts = [str(front.get("title") or front.get("id") or path.stem)]
for heading in ("Core Idea", "Problem statement", "One-liner"):
section = re.search(
rf"^##\s+{re.escape(heading)}\s*\n(.*?)(?:\n##\s|\Z)",
body,
re.DOTALL | re.MULTILINE,
)
if section:
parts.append(section.group(1).strip())
if len(parts) == 1:
intro = body.strip().split("\n\n", 1)[0]
parts.append(intro)
blob = "\n".join(p for p in parts if p)
return MatchQuery(
source="workplan",
text=blob,
workplan_id=front.get("id"),
workplan_path=str(path),
tokens=_tokens(blob),
)
def load_query_from_intent(intent: str) -> MatchQuery:
return MatchQuery(source="intent", text=intent, tokens=_tokens(intent))
def _federated_entry_blob(item: dict[str, Any]) -> str:
parts = [
item.get("name", ""),
item.get("summary", ""),
" ".join(item.get("tags", [])),
]
return " ".join(str(p) for p in parts if p)
def load_federated_capabilities() -> tuple[list[dict[str, Any]], str | None]:
"""Returns (capabilities, updated_date). Falls back to the local index
if the composed federated index doesn't exist yet."""
if FEDERATED_INDEX_PATH.exists():
data = yaml.safe_load(FEDERATED_INDEX_PATH.read_text(encoding="utf-8"))
return data.get("capabilities", []), data.get("updated")
data = load_index()
return data.get("capabilities", []), data.get("updated")
def _staleness_warning(updated: str | None) -> str | None:
if not updated:
return None
try:
updated_date = datetime.strptime(updated, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
except ValueError:
return None
age_days = (datetime.now(timezone.utc) - updated_date).days
if age_days > STALE_DAYS:
return f"federated index is {age_days} days old (last composed {updated})"
return None
def _vector_rank(vector: str | None) -> tuple[int, int]:
"""Higher discovery/availability levels rank first among near-tied scores."""
if not vector:
return (0, 0)
m = re.match(r"D(\d+)\s*/\s*A(\d+)", vector)
if not m:
return (0, 0)
return (int(m.group(1)), int(m.group(2)))
def match_query(
query: MatchQuery,
capabilities: list[dict[str, Any]],
*,
tie_window: float = DEFAULT_TIE_WINDOW,
) -> list[Match]:
if not query.tokens or not capabilities:
return []
scored: list[Match] = []
for item in capabilities:
blob = _federated_entry_blob(item)
tokens = _tokens(blob)
if not tokens:
continue
score = len(query.tokens & tokens) / len(query.tokens | tokens)
if score <= 0:
continue
scored.append(
Match(
id=item["id"],
score=round(score, 4),
kind="deterministic",
vector=item.get("vector"),
owner=item.get("owner"),
summary=item.get("summary"),
)
)
if not scored:
return []
scored.sort(key=lambda m: m.score, reverse=True)
top_score = scored[0].score
tied = [m for m in scored if top_score - m.score <= tie_window]
rest = [m for m in scored if top_score - m.score > tie_window]
tied.sort(key=lambda m: (_vector_rank(m.vector), m.score), reverse=True)
return tied + rest
def load_rerank_schema() -> dict[str, Any]:
return json.loads(RERANK_SCHEMA_PATH.read_text(encoding="utf-8"))
def build_rerank_prompt(query: MatchQuery, matches: list[Match]) -> str:
candidates = [
{"id": m.id, "score": m.score, "vector": m.vector, "summary": m.summary}
for m in matches
]
return (
"You are reranking capability-registry search candidates for a "
"query-before-build check. Score how well each candidate semantically "
"matches the query intent, on a 0-1 confidence scale. You may ONLY "
"return ids from the candidate list below -- do not invent new ones.\n\n"
f"Query intent:\n{query.text}\n\n"
f"Candidates (JSON):\n{json.dumps(candidates, indent=2)}\n\n"
"Respond with a JSON object: "
'{"candidates": [{"id": "...", "confidence": 0.0-1.0, "rationale": "..."}]}. '
"Include every candidate id from the list, in any order."
)
def request_rerank(
query: MatchQuery,
matches: list[Match],
*,
llm_url: str | None = None,
) -> list[dict[str, Any]]:
prompt = build_rerank_prompt(query, matches)
content = execute_prompt(prompt, base_url=llm_url, config={"temperature": 0.0, "max_tokens": 1500})
payload = extract_json_object(content)
validator = Draft202012Validator(load_rerank_schema())
errors = sorted(validator.iter_errors(payload), key=lambda err: list(err.path))
if errors:
messages = "; ".join(error.message for error in errors[:3])
raise ValueError(f"rerank schema validation failed: {messages}")
known_ids = {m.id for m in matches}
candidates = [c for c in payload["candidates"] if c["id"] in known_ids]
if not candidates:
raise ValueError("rerank response contained no known candidate ids")
return candidates
def apply_rerank(matches: list[Match], rerank_result: list[dict[str, Any]]) -> list[Match]:
"""Per spec design principle 3: deterministic matches always rank first.
LLM rerank results are appended after as separately-labeled (kind='llm')
entries carrying the semantic confidence score -- never reordering or
replacing the deterministic base result, so the output is trustworthy
even when a caller ignores the LLM-kind entries entirely (e.g. --no-llm
runs never produce them in the first place)."""
by_id = {m.id: m for m in matches}
llm_matches = [
Match(
id=c["id"],
score=c["confidence"],
kind="llm",
vector=by_id[c["id"]].vector,
owner=by_id[c["id"]].owner,
summary=c.get("rationale") or by_id[c["id"]].summary,
)
for c in rerank_result
if c["id"] in by_id
]
llm_matches.sort(key=lambda m: m.score, reverse=True)
return matches + llm_matches
def verdict_for_score(
top_score: float,
*,
reuse_threshold: float = DEFAULT_REUSE_THRESHOLD,
extend_threshold: float = DEFAULT_EXTEND_THRESHOLD,
) -> str:
if top_score >= reuse_threshold:
return "reuse"
if top_score >= extend_threshold:
return "extend"
return "new"
def run_plan_check(
query: MatchQuery,
*,
reuse_threshold: float = DEFAULT_REUSE_THRESHOLD,
extend_threshold: float = DEFAULT_EXTEND_THRESHOLD,
tie_window: float = DEFAULT_TIE_WINDOW,
top_n: int = 5,
use_llm: bool = True,
llm_url: str | None = None,
) -> dict[str, Any]:
capabilities, updated = load_federated_capabilities()
matches = match_query(query, capabilities, tie_window=tie_window)
top_score = matches[0].score if matches else 0.0
verdict = verdict_for_score(
top_score, reuse_threshold=reuse_threshold, extend_threshold=extend_threshold
)
top_matches = matches[:top_n]
notes: list[str] = []
if use_llm and top_matches:
try:
rerank_result = request_rerank(query, top_matches, llm_url=llm_url)
top_matches = apply_rerank(top_matches, rerank_result)
except ValueError as exc:
if "LLM backend not configured" in str(exc):
notes.append("LLM rerank skipped: LLM_CONNECT_URL not set")
else:
notes.append(f"LLM rerank skipped: {exc}")
result = {
"query": {
"source": query.source,
"text": query.text,
"workplan_id": query.workplan_id,
"workplan_path": query.workplan_path,
},
"verdict": verdict,
"top_score": top_score,
"matches": [
{
"id": m.id,
"score": m.score,
"vector": m.vector,
"owner": m.owner,
"summary": m.summary,
"kind": m.kind,
}
for m in top_matches
],
"federated_index_updated": updated,
"federated_index_stale_warning": _staleness_warning(updated),
}
if notes:
result["notes"] = notes
return result
def format_plan_check_markdown(result: dict[str, Any]) -> str:
lines = [f"# Plan check: {result['verdict']}", ""]
query = result["query"]
label = query.get("workplan_id") or query["text"][:80]
lines.append(f"**Query ({query['source']}):** {label}")
lines.append("")
matches = result.get("matches", [])
if matches:
lines.append("## Matches")
for m in matches:
vec = f" ({m['vector']})" if m.get("vector") else ""
owner = f"{m['owner']}" if m.get("owner") else ""
lines.append(f"- `{m['id']}`{vec}{owner} — score {m['score']:.2f} [{m['kind']}]")
if m.get("summary"):
lines.append(f" > {m['summary']}")
else:
lines.append("_No matches found in the federated index._")
lines.append("")
verdict = result["verdict"]
if verdict == "reuse":
lines.append("**Verdict: REUSE** — an existing capability already covers this; link it instead of building new.")
elif verdict == "extend":
lines.append("**Verdict: EXTEND** — scope overlaps closely enough that extending the top match is likely cheaper than a new capability.")
else:
lines.append("**Verdict: NEW** — no close match in the federated index; proceed.")
warning = result.get("federated_index_stale_warning")
if warning:
lines.append("")
lines.append(f"{warning}")
for note in result.get("notes", []):
lines.append("")
lines.append(f" {note}")
return "\n".join(lines) + "\n"
def format_plan_check_json(result: dict[str, Any]) -> str:
return json.dumps(result, indent=2, sort_keys=True)
def maybe_file_capability_request(
result: dict[str, Any],
*,
requesting_domain: str,
requesting_agent: str,
) -> dict[str, Any] | None:
"""On a 'new' verdict, file a State Hub capability request for the gap.
Returns None (and does nothing) for any other verdict, or if the hub is
unreachable -- this never blocks plan-check's primary output."""
if result["verdict"] != "new":
return None
query = result["query"]
title = (query.get("workplan_id") or query["text"])[:120]
return file_capability_request(
title=f"plan-check gap: {title}",
description=query["text"],
requesting_domain=requesting_domain,
requesting_agent=requesting_agent,
requesting_workplan_id=query.get("workplan_id"),
)
def load_reuse_event_schema() -> dict[str, Any]:
return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
def validate_reuse_event(event: dict[str, Any]) -> None:
validator = Draft202012Validator(load_reuse_event_schema())
errors = sorted(validator.iter_errors(event), key=lambda err: list(err.path))
if errors:
messages = "; ".join(error.message for error in errors[:3])
raise ValueError(f"reuse event schema validation failed: {messages}")
def append_local_reuse_event(event: dict[str, Any]) -> Path:
TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
with TELEMETRY_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
return TELEMETRY_PATH
def post_or_fallback_reuse_event(
event: dict[str, Any], *, hub_url: str | None = None
) -> dict[str, Any]:
"""Records one reuse event: the shared schema (schemas/reuse-event.schema.json)
is the same whether the fact ends up in the hub's reuse_events table or the
local JSONL fallback. Tries POST /v1/reuse-events first; on any
misconfiguration or unreachability, falls back to the local file rather
than losing the fact -- never both, per the design ("+ local JSONL
fallback when hub unreachable", not a dual-write)."""
validate_reuse_event(event)
try:
status, _payload = hub_client.hub_record_reuse_event(event, hub_url)
except (ValueError, urllib.error.URLError, TimeoutError, OSError):
status = None
if status == 201:
return {"event": event, "recorded_to": "hub", "path": None}
path = append_local_reuse_event(event)
return {"event": event, "recorded_to": "local", "path": str(path)}
def record_outcome(
result: dict[str, Any],
outcome: str,
*,
consumer_repo: str = "reuse-surface",
capability_id: str | None = None,
verdict: str | None = None,
source: str = "plan-check",
hub_url: str | None = None,
) -> dict[str, Any]:
top_match = result["matches"][0]["id"] if result.get("matches") else None
event = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"consumer_repo": consumer_repo,
"capability_id": capability_id if capability_id is not None else top_match,
"verdict": verdict if verdict is not None else result["verdict"],
"outcome": outcome,
"source": source,
}
return post_or_fallback_reuse_event(event, hub_url=hub_url)
def record_manual_reuse_event(
*,
consumer_repo: str,
capability_id: str | None,
verdict: str,
outcome: str | None = None,
hub_url: str | None = None,
) -> dict[str, Any]:
"""Retroactive manual fact recording (`reuse-surface record-reuse`) --
for reuse decisions made outside plan-check, e.g. discovered after the
fact during a review."""
event = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"consumer_repo": consumer_repo,
"capability_id": capability_id,
"verdict": verdict,
"outcome": outcome,
"source": "manual",
}
return post_or_fallback_reuse_event(event, hub_url=hub_url)