From d1048a31778cc1d6d722b546efe557189af2e2c7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Apr 2026 19:09:32 +0200 Subject: [PATCH] Run diagnostics panel --- src/repo_registry/web_ui/views.py | 194 +++++++++++++++++- tests/test_web_api.py | 91 ++++++++ ...P-0003-automatic-repository-exploration.md | 16 +- 3 files changed, 296 insertions(+), 5 deletions(-) diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 3e5f3e4..70ee465 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -78,6 +78,16 @@ def page(title: str, body: str) -> HTMLResponse: background: var(--danger-bg); color: var(--danger); }} + .notice.warn {{ + border-color: #fed7aa; + background: #fff7ed; + color: var(--warn); + }} + .notice.success {{ + border-color: #a7f3d0; + background: #ecfdf5; + color: #065f46; + }} .stack {{ display: grid; gap: 12px; }} .muted {{ color: var(--muted); }} .pill {{ @@ -356,6 +366,148 @@ def discovery_gap_report_page( return page("Capability Gap Report", body) +def render_analysis_diagnostics( + *, + repository_id: int, + analysis_run_id: int, + analysis_run_status: str, + error_message: str | None, + candidate_graph: dict, + facts_count: int, + chunk_count: int, +) -> str: + ability_count = len(candidate_graph.get("abilities", [])) + capability_count = sum( + len(ability.get("capabilities", [])) + for ability in candidate_graph.get("abilities", []) + ) + feature_count = sum( + len(capability.get("features", [])) + for ability in candidate_graph.get("abilities", []) + for capability in ability.get("capabilities", []) + ) + support_count = sum( + len(capability.get("evidence", [])) + for ability in candidate_graph.get("abilities", []) + for capability in ability.get("capabilities", []) + ) + confidences = [ + float(ability.get("confidence") or 0) + for ability in candidate_graph.get("abilities", []) + ] + only_weak_candidates = bool(confidences) and max(confidences) < 0.6 + notices: list[tuple[str, str, str]] = [] + if analysis_run_status == "failed": + error = error_message or "Analysis failed without a detailed error." + notices.append( + ( + "error", + "Analysis failed.", + first_run_failure_hint(error), + ) + ) + elif facts_count == 0: + notices.append( + ( + "warn", + "No observed facts were found.", + ( + "This usually means the repository is empty, the selected path " + "does not contain source files, or the deterministic scanner does " + "not yet understand the project shape." + ), + ) + ) + elif ability_count == 0: + notices.append( + ( + "warn", + "No candidate abilities were produced.", + ( + "The scanner found facts, but could not turn them into a useful " + "ability graph yet. Record an expectation gap for the concepts " + "you expected to see so the deterministic scanner can learn." + ), + ) + ) + elif only_weak_candidates: + notices.append( + ( + "warn", + "Only weak candidate abilities were produced.", + ( + "Review these cautiously, accept only what is clearly supported, " + "and record expectation gaps for missing core concepts." + ), + ) + ) + else: + notices.append( + ( + "success", + "Analysis completed with reviewable results.", + ( + "Use the candidate graph and element lists to approve, edit, " + "reject, or record expectation gaps." + ), + ) + ) + cards = "\n".join( + f""" +
+ {escape(title)} +

{escape(message)}

+
+ """ + for level, title, message in notices + ) + error_detail = ( + f'

{escape(error_message)}

' if error_message else "" + ) + return f""" +
+
+

Run Diagnostics

+ {escape(analysis_run_status)} +
+ {cards} + {error_detail} + +
+ """ + + +def first_run_failure_hint(error_message: str) -> str: + error = error_message.lower() + if any(marker in error for marker in ("authentication", "credential", "password")): + return ( + "The source appears to require credentials. Re-run analysis with a " + "username and password or access token." + ) + if any(marker in error for marker in ("not found", "no such file", "does not exist")): + return ( + "Verify the local path or Git URL, then re-run analysis. If the " + "upstream is unavailable but a checkout exists, use the cached " + "checkout option." + ) + if any(marker in error for marker in ("timeout", "timed out", "could not read", "unable to access")): + return ( + "The repository could not be reached in time. Check network access, " + "credentials, and whether the upstream service is available." + ) + return ( + "Review the error detail below, adjust the source or credentials, and " + "re-run analysis from the repository page." + ) + + @router.get("/ui/search") def search_page( q: str = "", @@ -929,6 +1081,7 @@ def analysis_run_detail( ) -> HTMLResponse: repository = service.get_repository(repository_id) candidate_graph = service.candidate_graph(repository_id, analysis_run_id) + candidate_graph_data = asdict(candidate_graph) facts = service.list_observed_facts(repository_id, analysis_run_id) chunks = service.list_content_chunks(repository_id, analysis_run_id) decisions = service.list_review_decisions(repository_id, analysis_run_id) @@ -950,12 +1103,21 @@ def analysis_run_detail( {render_run_detail_compare_link(repository_id, analysis_run_id, service.list_analysis_runs(repository_id))} Repository + {render_analysis_diagnostics( + repository_id=repository_id, + analysis_run_id=analysis_run_id, + analysis_run_status=candidate_graph.analysis_run.status, + error_message=candidate_graph.analysis_run.error_message, + candidate_graph=candidate_graph_data, + facts_count=len(facts), + chunk_count=len(chunks), + )}

Candidate Graph

{render_graph_counts( - asdict(candidate_graph), + candidate_graph_data, facts_count=len(facts), base_href=( f"/ui/repos/{repository_id}/elements?scope=all" @@ -971,7 +1133,7 @@ def analysis_run_detail(
- {render_candidate_graph(asdict(candidate_graph), repository_id, analysis_run_id)} + {render_candidate_graph(candidate_graph_data, repository_id, analysis_run_id)}
@@ -1048,6 +1210,7 @@ def repository_element_listing( class_filter: str = Query(""), entry_filter: str = Query(""), candidate_status_filter: str = Query("active"), + support_orientation_filter: str = Query(""), analysis_run_id: int | None = Query(default=None), service: RegistryService = Depends(get_service), ) -> HTMLResponse: @@ -1097,6 +1260,7 @@ def repository_element_listing( q, class_filter, candidate_status_filter="all", + support_orientation_filter=support_orientation_filter, ) rows = render_element_rows( filtered, @@ -1121,6 +1285,7 @@ def repository_element_listing( {render_entry_filter(entry_filter) if scope != "facts" else ""} {render_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""} + {render_support_orientation_filter(support_orientation_filter) if type in {"supports", "evidence"} else ""}
{render_class_datalist(entry_scoped_elements)}
@@ -2204,17 +2369,21 @@ def filter_element_rows( class_filter: str, entry_filter: str = "", candidate_status_filter: str = "", + support_orientation_filter: str = "", ) -> list[dict]: query = query.strip().lower() class_filter = class_filter.strip().lower() entry_filter = entry_filter.strip().lower() candidate_status_filter = candidate_status_filter.strip().lower() + support_orientation_filter = support_orientation_filter.strip().lower() filtered = [] for row in rows: if entry_filter and row.get("entry_state", "").lower() != entry_filter: continue if not candidate_status_matches(row, candidate_status_filter): continue + if support_orientation_filter and support_orientation_label(row) != support_orientation_filter: + continue row_class = str(row["primary_class"]).lower() if class_filter and class_filter not in row_class: continue @@ -2529,6 +2698,27 @@ def render_candidate_status_filter(candidate_status_filter: str) -> str: """ +def render_support_orientation_filter(support_orientation_filter: str) -> str: + options = [ + ("", "Any orientation"), + ("downward support", "Downward support"), + ("same-level support review", "Same-level review"), + ("upward support review", "Upward review"), + ("unclassified support", "Unclassified"), + ] + rendered_options = "".join( + f'' + for value, label in options + ) + return f""" + + """ + + def render_element_edit_fields(row: dict) -> str: item_kind = row["item_kind"] name = escape(str(row["name"])) diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 2ac55b7..7490acb 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1167,6 +1167,8 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): run_detail = client.get(run_path) assert run_detail.status_code == 200 + assert "Run Diagnostics" in run_detail.text + assert "Analysis completed with reviewable results." in run_detail.text assert "Candidate Graph" in run_detail.text assert "1 abilities" in run_detail.text assert "2 capabilities" in run_detail.text @@ -1538,6 +1540,64 @@ def test_ui_element_listing_hides_rejected_candidates_by_default(tmp_path): app.dependency_overrides.clear() +def test_ui_analysis_run_diagnostics_explain_failures_and_empty_results(tmp_path): + empty_source = tmp_path / "empty-repo" + empty_source.mkdir() + + def override_settings(): + return Settings( + database_path=str(tmp_path / "ui-diagnostics.sqlite3"), + checkout_root=str(tmp_path / "ui-diagnostics-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + create_response = client.post( + "/repos", + json={ + "url": str(empty_source), + "name": "Diagnostics Repo", + "description": "Used for UI diagnostics.", + "branch": "main", + }, + ) + assert create_response.status_code == 201 + repository_id = create_response.json()["id"] + + failed_run = client.post( + f"/ui/repos/{repository_id}/analysis-runs", + data={ + "source_path": str(tmp_path / "missing-repo"), + "use_llm_assistance": "", + }, + follow_redirects=False, + ) + assert failed_run.status_code == 303 + failed_detail = client.get(failed_run.headers["location"]) + assert failed_detail.status_code == 200 + assert "Run Diagnostics" in failed_detail.text + assert "Analysis failed." in failed_detail.text + assert "Verify the local path or Git URL" in failed_detail.text + + empty_run = client.post( + f"/ui/repos/{repository_id}/analysis-runs", + data={ + "source_path": "", + "use_llm_assistance": "", + }, + follow_redirects=False, + ) + assert empty_run.status_code == 303 + empty_detail = client.get(empty_run.headers["location"]) + assert empty_detail.status_code == 200 + assert "No observed facts were found." in empty_detail.text + assert "0 facts" in empty_detail.text + assert "0 abilities" in empty_detail.text + finally: + app.dependency_overrides.clear() + + def test_ui_register_and_explore_lands_on_analysis_result(tmp_path): source = tmp_path / "explore-repo" source.mkdir() @@ -1715,6 +1775,21 @@ def test_ui_manual_registry_entry_loop(tmp_path): ) assert evidence_response.status_code == 303 + upward_support_response = client.post( + f"{repository_path}/evidence", + data={ + "capability_id": str(capability_id), + "target_kind": "feature", + "target_id": "999", + "type": "review-smell", + "reference": "Manual Capability", + "reference_kind": "capability", + "strength": "weak", + }, + follow_redirects=False, + ) + assert upward_support_response.status_code == 303 + detail_response = client.get(repository_path) assert "Manual Ability" in detail_response.text assert "Manual Capability" in detail_response.text @@ -1723,6 +1798,7 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert "supports capability" in detail_response.text assert "references source" in detail_response.text assert "downward support" in detail_response.text + assert "upward support review" in detail_response.text assert "ID " in detail_response.text assert "Save Ability" in detail_response.text @@ -1789,6 +1865,21 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert f"references feature #{feature_id}" in detail_response.text assert "downward support" in detail_response.text + upward_support_listing = client.get( + f"/ui/repos/{repository_id}/elements", + params={ + "scope": "all", + "entry_filter": "approved", + "type": "supports", + "support_orientation_filter": "upward support review", + }, + ) + assert upward_support_listing.status_code == 200 + assert "Support orientation" in upward_support_listing.text + assert "1 of 2 shown" in upward_support_listing.text + assert "review-smell" in upward_support_listing.text + assert "tests/test_manual.py" not in upward_support_listing.text + delete_feature_response = client.post( f"{repository_path}/features/{feature_id}/delete", follow_redirects=False, diff --git a/workplans/RREG-WP-0003-automatic-repository-exploration.md b/workplans/RREG-WP-0003-automatic-repository-exploration.md index a995749..54580e0 100644 --- a/workplans/RREG-WP-0003-automatic-repository-exploration.md +++ b/workplans/RREG-WP-0003-automatic-repository-exploration.md @@ -4,7 +4,7 @@ type: workplan title: "Repository Ability Registry — Automatic Repository Exploration" domain: capabilities repo: repo-registry -status: active +status: done owner: codex topic_slug: foerster-capabilities created: "2026-04-26" @@ -120,7 +120,7 @@ review. ```task id: RREG-WP-0003-T05 -status: todo +status: done priority: low state_hub_task_id: "b812a7fb-19ef-418a-83a2-15bf26fd3f4a" ``` @@ -132,6 +132,12 @@ that produce only weak candidates. Acceptance: trying the product on repo-registry itself feels understandable and useful even when a scan finds gaps or weak evidence. +Implementation note 2026-04-29: analysis result pages now include a Run +Diagnostics panel with explicit success, failure, empty-result, no-candidate, and +weak-candidate states. The panel links directly to fact and candidate element +lists and gives first-run recovery hints for bad paths, inaccessible sources, +credential issues, and upstream timeouts. + ## P1: Expectation Gap Feedback Loop ```task @@ -206,7 +212,7 @@ nested capabilities/features/evidence. ```task id: RREG-WP-0003-T10 -status: in_progress +status: done priority: medium state_hub_task_id: "0d3fa9e0-bb3e-4bf2-bf8d-4681c5b7bdf5" ``` @@ -252,3 +258,7 @@ Implementation note 2026-04-29: support rows now show an orientation label based on target/reference abstraction levels. Downward support is normal, same-level support is marked for review, and upward support is marked for review because it usually indicates an abstraction or organization problem. + +Implementation note 2026-04-29: support listings now include a support +orientation filter so reviewers can isolate downward support, same-level review +items, upward review items, or unclassified support.