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);
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"""
<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")
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))}
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</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">
<section class="panel">
<div class="actions">
<h2 style="margin-right:auto">Candidate Graph</h2>
{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(
<button type="submit">Approve</button>
</form>
</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 class="panel">
<div class="actions">
@@ -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(
<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_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""}
{render_support_orientation_filter(support_orientation_filter) if type in {"supports", "evidence"} else ""}
</div>
{render_class_datalist(entry_scoped_elements)}
<div class="actions">
@@ -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'<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:
item_kind = row["item_kind"]
name = escape(str(row["name"]))