From 360537ef0570fee22a911c38822268344a0b0696 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Apr 2026 03:58:34 +0200 Subject: [PATCH] Count badges now with navigation --- docs/abstraction-strategy.md | 72 ++++++ src/repo_registry/web_ui/views.py | 242 +++++++++++++++++- tests/test_web_api.py | 50 ++++ ...P-0003-automatic-repository-exploration.md | 6 +- 4 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 docs/abstraction-strategy.md diff --git a/docs/abstraction-strategy.md b/docs/abstraction-strategy.md new file mode 100644 index 0000000..08fd611 --- /dev/null +++ b/docs/abstraction-strategy.md @@ -0,0 +1,72 @@ +# Abstraction Strategy + +The registry has three layers with different trust levels: + +1. Observed facts are deterministic scanner output: files, manifests, framework + hints, tests, docs, routes, commands, and source locations. +2. Candidate claims are abstractions proposed from those facts. They are useful + review seeds, not registry truth. +3. Approved entries are curated truth after human review or an explicit trusted + automation mode. + +## Granularity + +Features should describe a user-visible or operational behavior surface, not mirror +individual scanner facts. A one-to-one pattern such as one route fact becoming one +feature is a smell unless the repository truly exposes only one behavior. + +Current deterministic grouping: + +- Multiple HTTP route facts become one `HTTP API surface` feature with several + source references. +- Multiple CLI command facts become one `CLI command surface` feature with several + source references. +- Facts remain available as drilldown evidence through `source_refs`. + +This gives reviewers orientation at the behavior level while keeping provenance. + +## What Deterministic Logic Can Do + +Deterministic scanners can reliably identify: + +- repository structure and languages +- package manifests and framework hints +- API/CLI entry-point surfaces +- docs, examples, and tests as corroborating evidence +- stable source references for review and approval + +Deterministic candidate generation can group these into conservative capabilities +such as interface exposure and repository structure. It should avoid pretending it +understands domain intent when the evidence is thin. + +## Where LLM Assistance Helps + +LLMs are most useful for naming and explaining intent: + +- turning `HTTP API surface` into a domain capability such as repository ingestion, + review workflow, or search +- separating administrative, operational, and product-facing capabilities +- summarizing README and code context into clearer ability descriptions +- suggesting merges or relinks when deterministic names are too generic + +LLM output remains candidate material. It should cite source paths and be reviewed +or explicitly auto-approved by a trusted mode before becoming approved registry +truth. + +## Trial Repo Observations + +`repo-registry` demonstrates the current boundary well: deterministic scanning sees +FastAPI routes, tests, docs, and Python structure, but the meaningful abstractions +are repository ingestion, deterministic analysis, candidate review, discovery, and +State Hub coordination. Those names likely require either review edits or LLM +assistance. + +The other trial repos reinforce the same point: fact lists are useful audit trails, +but the primary UI should lead with candidate or approved ability maps and expose +facts as drilldown evidence. + +## Regression Guard + +`tests/test_candidate_graph.py` includes a guard that multiple interface facts are +grouped into behavioral surface features with multiple source refs. This protects +against falling back to one feature per observed fact. diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 42fe8e8..8ec2dc1 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -464,6 +464,11 @@ def repository_detail( raise HTTPException(status_code=404, detail=str(exc)) from exc runs = service.list_analysis_runs(repository_id) ability_map = service.ability_map(repository_id) + latest_candidate = latest_completed_candidate_graph( + service, + repository_id, + runs, + ) decisions = service.list_review_decisions(repository_id) facts = service.list_observed_facts(repository_id) languages = sorted({fact.name for fact in facts if fact.kind == "language"}) @@ -517,7 +522,13 @@ def repository_detail(

Approved Ability Map

- {render_graph_counts(asdict(ability_map), facts_count=None)} + {render_graph_counts( + asdict(ability_map), + facts_count=None, + base_href=f"/ui/repos/{repository_id}/elements?scope=approved", + )} +

Latest Candidate Graph

+ {render_latest_candidate_counts(repository_id, latest_candidate, service)} {render_ability_map(asdict(ability_map), repository_id)}
@@ -873,7 +884,18 @@ def analysis_run_detail(

Candidate Graph

- {render_graph_counts(asdict(candidate_graph), facts_count=len(facts))} + {render_graph_counts( + asdict(candidate_graph), + facts_count=len(facts), + base_href=( + f"/ui/repos/{repository_id}/elements?scope=candidate" + f"&analysis_run_id={analysis_run_id}" + ), + facts_href=( + f"/ui/repos/{repository_id}/elements?scope=facts" + f"&analysis_run_id={analysis_run_id}&type=facts" + ), + )}
@@ -883,7 +905,15 @@ def analysis_run_detail(

Observed Facts

- {render_count_pills(facts=len(facts))} + {render_count_pills( + hrefs={ + "facts": ( + f"/ui/repos/{repository_id}/elements?scope=facts" + f"&analysis_run_id={analysis_run_id}&type=facts" + ) + }, + facts=len(facts), + )}
@@ -901,6 +931,52 @@ def analysis_run_detail( return page(f"{repository.name} Run {analysis_run_id}", body) +@router.get("/ui/repos/{repository_id}/elements") +def repository_element_listing( + repository_id: int, + scope: str = Query("approved"), + type: str = Query("abilities"), + analysis_run_id: int | None = Query(default=None), + service: RegistryService = Depends(get_service), +) -> HTMLResponse: + repository = service.get_repository(repository_id) + title = element_listing_title(repository.name, scope, type) + if scope == "approved": + graph = asdict(service.ability_map(repository_id)) + rows = render_graph_element_rows(graph, type) + elif scope == "candidate": + if analysis_run_id is None: + runs = service.list_analysis_runs(repository_id) + latest = latest_completed_candidate_graph(service, repository_id, runs) + if latest is None: + rows = '' + else: + analysis_run_id, candidate_graph = latest + rows = render_graph_element_rows(asdict(candidate_graph), type) + else: + candidate_graph = service.candidate_graph(repository_id, analysis_run_id) + rows = render_graph_element_rows(asdict(candidate_graph), type) + elif scope == "facts": + facts = service.list_observed_facts(repository_id, analysis_run_id) + rows = render_fact_element_rows(facts) + else: + rows = '' + + body = f""" +
+

{escape(title)}

+ Repository +
+
+
KindNamePathValue
No completed candidate graph.
Unknown listing scope.
+ + {rows} +
TypeNameParentSource
+
+ """ + return page(title, body) + + @router.get("/ui/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}") def analysis_run_diff_detail( repository_id: int, @@ -1573,7 +1649,52 @@ def split_capability_lines(value: str) -> list[str]: return [line.strip() for line in normalized.splitlines() if line.strip()] -def render_graph_counts(graph: dict, facts_count: int | None = None) -> str: +def latest_completed_candidate_graph( + service: RegistryService, + repository_id: int, + runs: list, +): + for run in sorted(runs, key=lambda item: item.id, reverse=True): + if run.status != "completed": + continue + try: + return run.id, service.candidate_graph(repository_id, run.id) + except NotFoundError: + continue + return None + + +def render_latest_candidate_counts( + repository_id: int, + latest_candidate, + service: RegistryService, +) -> str: + if latest_candidate is None: + return '

No completed candidate graph yet.

' + analysis_run_id, candidate_graph = latest_candidate + facts_count = len(service.list_observed_facts(repository_id, analysis_run_id)) + return render_graph_counts( + asdict(candidate_graph), + facts_count=facts_count, + label_prefix="candidate", + base_href=( + f"/ui/repos/{repository_id}/elements?scope=candidate" + f"&analysis_run_id={analysis_run_id}" + ), + facts_href=( + f"/ui/repos/{repository_id}/elements?scope=facts" + f"&analysis_run_id={analysis_run_id}&type=facts" + ), + ) + + +def render_graph_counts( + graph: dict, + facts_count: int | None = None, + label_prefix: str = "", + base_href: str | None = None, + facts_href: str | None = None, +) -> str: abilities = graph.get("abilities", []) capabilities = [ capability @@ -1592,20 +1713,121 @@ def render_graph_counts(graph: dict, facts_count: int | None = None) -> str: } if facts_count is not None: counts["facts"] = facts_count - return render_count_pills(**counts) + hrefs = ( + {name: f"{base_href}&type={name}" for name in counts if name != "facts"} + if base_href + else {} + ) + if facts_href: + hrefs["facts"] = facts_href + return render_count_pills(label_prefix=label_prefix, hrefs=hrefs, **counts) -def render_count_pills(**counts: int) -> str: +def render_count_pills( + label_prefix: str = "", + hrefs: dict[str, str] | None = None, + **counts: int, +) -> str: labels = { "abilities": "abilities", "capabilities": "capabilities", "features": "features", "facts": "facts", } - return "".join( - f'{count} {labels[name]}' - for name, count in counts.items() - ) + prefix = f"{label_prefix} " if label_prefix else "" + hrefs = hrefs or {} + items = [] + for name, count in counts.items(): + label = f"{count} {prefix}{labels[name]}" + if name in hrefs: + items.append(f'{label}') + else: + items.append(f'{label}') + return "".join(items) + + +def render_graph_element_rows(graph: dict, item_type: str) -> str: + rows: list[str] = [] + for ability in graph.get("abilities", []): + if item_type == "abilities": + rows.append( + render_element_row("ability", ability["name"], "", ability.get("source_refs", [])) + ) + for capability in ability.get("capabilities", []): + if item_type == "capabilities": + rows.append( + render_element_row( + "capability", + capability["name"], + ability["name"], + capability.get("source_refs", []), + ) + ) + for feature in capability.get("features", []): + if item_type == "features": + rows.append( + render_element_row( + "feature", + feature["name"], + capability["name"], + feature.get("source_refs", []), + ) + ) + return "\n".join(rows) or 'No matching elements.' + + +def element_listing_title(repository_name: str, scope: str, item_type: str) -> str: + type_labels = { + "abilities": "Abilities", + "capabilities": "Capabilities", + "features": "Features", + "facts": "Facts", + } + if scope == "facts": + return f"{repository_name} · Observed Facts" + scope_labels = { + "approved": "Approved", + "candidate": "Candidate", + } + scope_label = scope_labels.get(scope, scope.title()) + type_label = type_labels.get(item_type, item_type.title()) + return f"{repository_name} · {scope_label} {type_label}" + + +def render_fact_element_rows(facts: list) -> str: + rows = [ + render_element_row( + fact.kind, + fact.name, + fact.path, + [ + { + "path": fact.path, + "kind": fact.kind, + "name": fact.name, + "line": fact.metadata.get("line"), + } + ], + ) + for fact in facts + ] + return "\n".join(rows) or 'No matching facts.' + + +def render_element_row( + item_type: str, + name: str, + parent: str, + source_refs: list[dict], +) -> str: + return f""" + + {escape(item_type)} + {escape(name)} + {escape(parent)} + {render_sources(source_refs)} + + """ def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str: diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 21a6d78..a322fe2 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1100,6 +1100,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): ) assert create_response.status_code == 303 repository_path = create_response.headers["location"] + repository_id = int(repository_path.rsplit("/", 1)[-1]) detail_response = client.get(repository_path) assert detail_response.status_code == 200 @@ -1159,12 +1160,61 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "1 abilities" in approved_detail.text assert "2 capabilities" in approved_detail.text assert "2 features" in approved_detail.text + assert "Latest Candidate Graph" in approved_detail.text + assert "1 candidate abilities" in approved_detail.text + assert "2 candidate capabilities" in approved_detail.text + assert "2 candidate features" in approved_detail.text + assert "7 candidate facts" in approved_detail.text + assert ( + f"/ui/repos/{repository_id}/elements?scope=approved&type=abilities" + in approved_detail.text + ) + assert ( + f"/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={first_run_id}&type=features" + in approved_detail.text + ) + assert ( + f"/ui/repos/{repository_id}/elements?scope=facts&analysis_run_id={first_run_id}&type=facts" + in approved_detail.text + ) assert "Review UI Repo Edited Repository Usefulness" in approved_detail.text assert "Language: Python" in approved_detail.text assert "Framework: FastAPI" in approved_detail.text assert "interface:app.py:3" in approved_detail.text assert "approve_candidate_graph" in approved_detail.text + approved_listing = client.get( + f"/ui/repos/{repository_id}/elements", + params={"scope": "approved", "type": "capabilities"}, + ) + assert approved_listing.status_code == 200 + assert "Approved Capabilities" in approved_listing.text + assert "Expose Repository Interface" in approved_listing.text + + candidate_listing = client.get( + f"/ui/repos/{repository_id}/elements", + params={ + "scope": "candidate", + "analysis_run_id": first_run_id, + "type": "features", + }, + ) + assert candidate_listing.status_code == 200 + assert "Candidate Features" in candidate_listing.text + assert "GET /status" in candidate_listing.text + + fact_listing = client.get( + f"/ui/repos/{repository_id}/elements", + params={ + "scope": "facts", + "analysis_run_id": first_run_id, + "type": "facts", + }, + ) + assert fact_listing.status_code == 200 + assert "Observed Facts" in fact_listing.text + assert "python route decorator" in fact_listing.text + (source / "app.py").write_text( "from fastapi import FastAPI\n" "app = FastAPI()\n" diff --git a/workplans/RREG-WP-0003-automatic-repository-exploration.md b/workplans/RREG-WP-0003-automatic-repository-exploration.md index 4ded3cf..2173bd1 100644 --- a/workplans/RREG-WP-0003-automatic-repository-exploration.md +++ b/workplans/RREG-WP-0003-automatic-repository-exploration.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: foerster-capabilities created: "2026-04-26" -updated: "2026-04-26" +updated: "2026-04-28" state_hub_workstream_id: "c121d462-f2e4-45d3-9d2d-9c04a3556953" --- @@ -66,7 +66,7 @@ facts as drilldown evidence, not mirror individual scanner facts. ```task id: RREG-WP-0003-T06 -status: todo +status: done priority: high state_hub_task_id: "51890aff-511d-4635-85c4-fe4db0b7dd01" ``` @@ -85,7 +85,7 @@ against feature granularity collapsing into one feature per observed fact. ```task id: RREG-WP-0003-T03 -status: todo +status: in_progress priority: medium state_hub_task_id: "5c4b5bb1-390c-4782-bb70-104b0006fe67" ```