Run diagnostics panel

This commit is contained in:
2026-04-29 19:09:32 +02:00
parent 5c9a0f0614
commit d1048a3177
3 changed files with 296 additions and 5 deletions

View File

@@ -78,6 +78,16 @@ def page(title: str, body: str) -> HTMLResponse:
background: var(--danger-bg); background: var(--danger-bg);
color: var(--danger); 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; }} .stack {{ display: grid; gap: 12px; }}
.muted {{ color: var(--muted); }} .muted {{ color: var(--muted); }}
.pill {{ .pill {{
@@ -356,6 +366,148 @@ def discovery_gap_report_page(
return page("Capability Gap Report", body) 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"""
<div class="notice {escape(level)}">
<strong>{escape(title)}</strong>
<p>{escape(message)}</p>
</div>
"""
for level, title, message in notices
)
error_detail = (
f'<p class="source">{escape(error_message)}</p>' if error_message else ""
)
return f"""
<section class="panel" style="margin-bottom:18px">
<div class="actions">
<h2 style="margin-right:auto">Run Diagnostics</h2>
<span class="pill">{escape(analysis_run_status)}</span>
</div>
{cards}
{error_detail}
<div class="actions">
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=facts&analysis_run_id={analysis_run_id}&type=facts">{facts_count} facts</a>
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={analysis_run_id}&type=abilities">{ability_count} abilities</a>
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={analysis_run_id}&type=capabilities">{capability_count} capabilities</a>
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={analysis_run_id}&type=features">{feature_count} features</a>
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={analysis_run_id}&type=supports">{support_count} supports</a>
<span class="pill">{chunk_count} content chunks</span>
</div>
</section>
"""
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") @router.get("/ui/search")
def search_page( def search_page(
q: str = "", q: str = "",
@@ -929,6 +1081,7 @@ def analysis_run_detail(
) -> HTMLResponse: ) -> HTMLResponse:
repository = service.get_repository(repository_id) repository = service.get_repository(repository_id)
candidate_graph = service.candidate_graph(repository_id, analysis_run_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) facts = service.list_observed_facts(repository_id, analysis_run_id)
chunks = service.list_content_chunks(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) 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))} {render_run_detail_compare_link(repository_id, analysis_run_id, service.list_analysis_runs(repository_id))}
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a> <a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</div> </div>
{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),
)}
<div class="grid"> <div class="grid">
<section class="panel"> <section class="panel">
<div class="actions"> <div class="actions">
<h2 style="margin-right:auto">Candidate Graph</h2> <h2 style="margin-right:auto">Candidate Graph</h2>
{render_graph_counts( {render_graph_counts(
asdict(candidate_graph), candidate_graph_data,
facts_count=len(facts), facts_count=len(facts),
base_href=( base_href=(
f"/ui/repos/{repository_id}/elements?scope=all" f"/ui/repos/{repository_id}/elements?scope=all"
@@ -971,7 +1133,7 @@ def analysis_run_detail(
<button type="submit">Approve</button> <button type="submit">Approve</button>
</form> </form>
</div> </div>
{render_candidate_graph(asdict(candidate_graph), repository_id, analysis_run_id)} {render_candidate_graph(candidate_graph_data, repository_id, analysis_run_id)}
</section> </section>
<section class="panel"> <section class="panel">
<div class="actions"> <div class="actions">
@@ -1048,6 +1210,7 @@ def repository_element_listing(
class_filter: str = Query(""), class_filter: str = Query(""),
entry_filter: str = Query(""), entry_filter: str = Query(""),
candidate_status_filter: str = Query("active"), candidate_status_filter: str = Query("active"),
support_orientation_filter: str = Query(""),
analysis_run_id: int | None = Query(default=None), analysis_run_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> HTMLResponse: ) -> HTMLResponse:
@@ -1097,6 +1260,7 @@ def repository_element_listing(
q, q,
class_filter, class_filter,
candidate_status_filter="all", candidate_status_filter="all",
support_orientation_filter=support_orientation_filter,
) )
rows = render_element_rows( rows = render_element_rows(
filtered, filtered,
@@ -1121,6 +1285,7 @@ def repository_element_listing(
<label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label> <label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label>
{render_entry_filter(entry_filter) if scope != "facts" else ""} {render_entry_filter(entry_filter) if scope != "facts" else ""}
{render_candidate_status_filter(candidate_status_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 ""}
</div> </div>
{render_class_datalist(entry_scoped_elements)} {render_class_datalist(entry_scoped_elements)}
<div class="actions"> <div class="actions">
@@ -2204,17 +2369,21 @@ def filter_element_rows(
class_filter: str, class_filter: str,
entry_filter: str = "", entry_filter: str = "",
candidate_status_filter: str = "", candidate_status_filter: str = "",
support_orientation_filter: str = "",
) -> list[dict]: ) -> list[dict]:
query = query.strip().lower() query = query.strip().lower()
class_filter = class_filter.strip().lower() class_filter = class_filter.strip().lower()
entry_filter = entry_filter.strip().lower() entry_filter = entry_filter.strip().lower()
candidate_status_filter = candidate_status_filter.strip().lower() candidate_status_filter = candidate_status_filter.strip().lower()
support_orientation_filter = support_orientation_filter.strip().lower()
filtered = [] filtered = []
for row in rows: for row in rows:
if entry_filter and row.get("entry_state", "").lower() != entry_filter: if entry_filter and row.get("entry_state", "").lower() != entry_filter:
continue continue
if not candidate_status_matches(row, candidate_status_filter): if not candidate_status_matches(row, candidate_status_filter):
continue continue
if support_orientation_filter and support_orientation_label(row) != support_orientation_filter:
continue
row_class = str(row["primary_class"]).lower() row_class = str(row["primary_class"]).lower()
if class_filter and class_filter not in row_class: if class_filter and class_filter not in row_class:
continue 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'<option value="{escape(value)}"{" selected" if support_orientation_filter == value else ""}>{escape(label)}</option>'
for value, label in options
)
return f"""
<label>Support orientation
<select name="support_orientation_filter">
{rendered_options}
</select>
</label>
"""
def render_element_edit_fields(row: dict) -> str: def render_element_edit_fields(row: dict) -> str:
item_kind = row["item_kind"] item_kind = row["item_kind"]
name = escape(str(row["name"])) name = escape(str(row["name"]))

View File

@@ -1167,6 +1167,8 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
run_detail = client.get(run_path) run_detail = client.get(run_path)
assert run_detail.status_code == 200 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 "Candidate Graph" in run_detail.text
assert "1 abilities" in run_detail.text assert "1 abilities" in run_detail.text
assert "2 capabilities" 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() 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): def test_ui_register_and_explore_lands_on_analysis_result(tmp_path):
source = tmp_path / "explore-repo" source = tmp_path / "explore-repo"
source.mkdir() source.mkdir()
@@ -1715,6 +1775,21 @@ def test_ui_manual_registry_entry_loop(tmp_path):
) )
assert evidence_response.status_code == 303 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) detail_response = client.get(repository_path)
assert "Manual Ability" in detail_response.text assert "Manual Ability" in detail_response.text
assert "Manual Capability" 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 "supports capability" in detail_response.text
assert "references source" in detail_response.text assert "references source" in detail_response.text
assert "downward support" 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 "ID " in detail_response.text
assert "Save Ability" 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 f"references feature #{feature_id}" in detail_response.text
assert "downward support" 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( delete_feature_response = client.post(
f"{repository_path}/features/{feature_id}/delete", f"{repository_path}/features/{feature_id}/delete",
follow_redirects=False, follow_redirects=False,

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Repository Ability Registry — Automatic Repository Exploration" title: "Repository Ability Registry — Automatic Repository Exploration"
domain: capabilities domain: capabilities
repo: repo-registry repo: repo-registry
status: active status: done
owner: codex owner: codex
topic_slug: foerster-capabilities topic_slug: foerster-capabilities
created: "2026-04-26" created: "2026-04-26"
@@ -120,7 +120,7 @@ review.
```task ```task
id: RREG-WP-0003-T05 id: RREG-WP-0003-T05
status: todo status: done
priority: low priority: low
state_hub_task_id: "b812a7fb-19ef-418a-83a2-15bf26fd3f4a" 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 Acceptance: trying the product on repo-registry itself feels understandable and
useful even when a scan finds gaps or weak evidence. 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 ## P1: Expectation Gap Feedback Loop
```task ```task
@@ -206,7 +212,7 @@ nested capabilities/features/evidence.
```task ```task
id: RREG-WP-0003-T10 id: RREG-WP-0003-T10
status: in_progress status: done
priority: medium priority: medium
state_hub_task_id: "0d3fa9e0-bb3e-4bf2-bf8d-4681c5b7bdf5" 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 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 support is marked for review, and upward support is marked for review because it
usually indicates an abstraction or organization problem. 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.