Characteristics model ui

This commit is contained in:
2026-04-29 17:20:06 +02:00
parent 8d6a9f7050
commit 0bb0c61f75
3 changed files with 164 additions and 1 deletions

View File

@@ -1960,10 +1960,17 @@ def render_graph_counts(
for capability in capabilities
for feature in capability.get("features", [])
]
supports = [
evidence
for capability in capabilities
for evidence in capability.get("evidence", [])
]
counts: dict[str, int] = {
"scopes": 1 if graph.get("scope") else 0,
"abilities": len(abilities),
"capabilities": len(capabilities),
"features": len(features),
"supports": len(supports),
}
if facts_count is not None:
counts["facts"] = facts_count
@@ -1983,9 +1990,11 @@ def render_count_pills(
**counts: int,
) -> str:
labels = {
"scopes": "scope",
"abilities": "abilities",
"capabilities": "capabilities",
"features": "features",
"supports": "supports",
"facts": "facts",
}
prefix = f"{label_prefix} " if label_prefix else ""
@@ -2025,6 +2034,21 @@ def graph_element_rows(
entry_state: str = "",
) -> list[dict]:
rows: list[dict] = []
scope = graph.get("scope")
if item_type in {"scopes", "scope"} and scope and entry_state == "approved":
rows.append(
element_row(
"scope",
scope["name"],
"",
[],
item_id=scope.get("id"),
item_kind="scope",
description=scope.get("description", ""),
confidence=scope.get("confidence", 1.0),
entry_state=entry_state,
)
)
for ability in graph.get("abilities", []):
if item_type == "abilities":
rows.append(
@@ -2075,6 +2099,27 @@ def graph_element_rows(
entry_state=entry_state,
)
)
for evidence in capability.get("evidence", []):
if item_type in {"supports", "evidence"}:
rows.append(
element_row(
evidence.get("strength", "medium"),
f"{evidence.get('type', 'support')}: {evidence.get('reference', '')}",
capability["name"],
evidence.get("source_refs", []),
item_id=evidence.get("id"),
item_kind="evidence",
support_type=evidence.get("type", ""),
reference=evidence.get("reference", ""),
strength=evidence.get("strength", "medium"),
status=evidence.get("status", ""),
target_kind=evidence.get("target_kind", "capability"),
target_id=evidence.get("target_id"),
reference_kind=evidence.get("reference_kind", "source"),
reference_id=evidence.get("reference_id"),
entry_state=entry_state,
)
)
return rows
@@ -2083,6 +2128,8 @@ def element_listing_title(repository_name: str, scope: str, item_type: str) -> s
"abilities": "Abilities",
"capabilities": "Capabilities",
"features": "Features",
"scopes": "Scopes",
"supports": "Supports",
"facts": "Facts",
}
if scope == "facts":
@@ -2159,6 +2206,13 @@ def filter_element_rows(
str(row["parent"]),
str(row.get("entry_state", "")),
str(row.get("status", "")),
str(row.get("support_type", "")),
str(row.get("reference", "")),
str(row.get("strength", "")),
str(row.get("target_kind", "")),
str(row.get("target_id", "")),
str(row.get("reference_kind", "")),
str(row.get("reference_id", "")),
source_refs_text(row["source_refs"]),
]
).lower()
@@ -2203,12 +2257,26 @@ def render_element_row(
<td><span class="pill">{escape(str(row["primary_class"]))}</span></td>
<td>{escape(str(row["name"]))}</td>
<td>{escape(str(row["parent"]))}</td>
<td>{render_sources(row["source_refs"])}</td>
<td>{render_element_source_detail(row)}</td>
<td>{render_element_actions(row, repository_id, analysis_run_id)}</td>
</tr>
"""
def render_element_source_detail(row: dict) -> str:
if row.get("item_kind") == "evidence":
target = escape(str(row.get("target_kind") or "capability"))
target_id = row.get("target_id")
reference_kind = escape(str(row.get("reference_kind") or "source"))
reference_id = row.get("reference_id")
return (
f'<p><span class="pill">supports {target}{f" #{target_id}" if target_id else ""}</span>'
f' <span class="pill">references {reference_kind}{f" #{reference_id}" if reference_id else ""}</span></p>'
f'{render_sources(row["source_refs"])}'
)
return render_sources(row["source_refs"])
def render_element_actions(
row: dict,
repository_id: int,
@@ -2220,6 +2288,17 @@ def render_element_actions(
return ""
if row.get("entry_state") == "approved":
return render_approved_element_actions(row, repository_id)
if (
row.get("entry_state") == "candidate"
and row.get("item_kind") == "evidence"
and analysis_run_id is not None
and row.get("status", "candidate") == "candidate"
):
return render_candidate_support_element_actions(
row,
repository_id,
analysis_run_id,
)
if (
row.get("entry_state") == "candidate"
and analysis_run_id is not None
@@ -2264,6 +2343,13 @@ def render_entry_filter(entry_filter: str) -> str:
def render_approved_element_actions(row: dict, repository_id: int) -> str:
item_id = row["item_id"]
item_kind = row["item_kind"]
if item_kind == "scope":
return f"""
<form class="stack" method="post" action="/ui/repos/{repository_id}/scope/edit">
{render_element_edit_fields(row)}
<button class="secondary" type="submit">Save</button>
</form>
"""
edit_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/edit"
delete_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/delete"
hidden_fields = render_element_hidden_fields(row)
@@ -2280,6 +2366,39 @@ def render_approved_element_actions(row: dict, repository_id: int) -> str:
"""
def render_candidate_support_element_actions(
row: dict,
repository_id: int,
analysis_run_id: int,
) -> str:
item_id = row["item_id"]
reject_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/reject"
)
relink_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/relink"
)
merge_action = (
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
f"/candidate-evidence/{item_id}/merge"
)
return f"""
<form method="post" action="{reject_action}">
<button class="secondary" type="submit">Remove</button>
</form>
<form style="display:inline-grid; grid-template-columns: 120px auto; gap: 6px; align-items: end;" method="post" action="{relink_action}">
<label>Target capability ID<input name="target_capability_id" type="number" min="1" required></label>
<button class="secondary" type="submit">Relink</button>
</form>
<form style="display:inline-grid; grid-template-columns: 140px auto; gap: 6px; align-items: end;" method="post" action="{merge_action}">
<label>Merge into support ID<input name="target_evidence_id" type="number" min="1" required></label>
<button class="secondary" type="submit">Merge</button>
</form>
"""
def render_candidate_element_actions(
row: dict,
repository_id: int,
@@ -2352,6 +2471,12 @@ def render_candidate_status_filter(candidate_status_filter: str) -> str:
def render_element_edit_fields(row: dict) -> str:
item_kind = row["item_kind"]
name = escape(str(row["name"]))
if item_kind == "scope":
return f"""
<label>Name <input name="name" value="{name}" required></label>
<label>Description <textarea name="description" rows="2">{escape(str(row.get("description", "")))}</textarea></label>
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{float(row.get("confidence", 1.0)):.2f}" required></label>
"""
if item_kind == "features":
feature_type = escape(str(row["primary_class"]))
location = escape(str(row.get("location", "")))
@@ -2360,6 +2485,16 @@ def render_element_edit_fields(row: dict) -> str:
<label>Type <input name="type" value="{feature_type}" required></label>
<label>Location <input name="location" value="{location}"></label>
"""
if item_kind == "evidence":
return f"""
<label>Supported characteristic kind <input name="target_kind" value="{escape(str(row.get("target_kind", "capability")))}" required></label>
<label>Supported characteristic ID <input name="target_id" type="number" min="1" value="{row.get('target_id') or ''}"></label>
<label>Support type <input name="type" value="{escape(str(row.get("support_type", "")))}" required></label>
<label>Reference <input name="reference" value="{escape(str(row.get("reference", "")))}" required></label>
<label>Reference kind <input name="reference_kind" value="{escape(str(row.get("reference_kind", "source")))}" required></label>
<label>Reference ID <input name="reference_id" type="number" min="1" value="{row.get('reference_id') or ''}"></label>
<label>Strength <input name="strength" value="{escape(str(row.get("strength", "medium")))}" required></label>
"""
return f'<label>Name <input name="name" value="{name}" required></label>'
@@ -2371,6 +2506,10 @@ def render_element_hidden_fields(row: dict) -> str:
fields.append(
f'<input type="hidden" name="description" value="{escape(str(row.get("description", "")))}">'
)
if item_kind == "scope":
fields.append(
f'<input type="hidden" name="description" value="{escape(str(row.get("description", "")))}">'
)
if item_kind == "capabilities":
inputs = escape(", ".join(row.get("inputs", [])))
outputs = escape(", ".join(row.get("outputs", [])))

View File

@@ -1217,6 +1217,8 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Approved Characteristic Tree" in approved_detail.text
assert "scope" in approved_detail.text
assert "Evidence supporting this capability" in approved_detail.text
assert "1 scope" in approved_detail.text
assert "supports" in approved_detail.text
assert "1 abilities" in approved_detail.text
assert "2 capabilities" in approved_detail.text
assert "2 features" in approved_detail.text
@@ -1261,6 +1263,23 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Save" in approved_listing.text
assert "Delete" in approved_listing.text
scope_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={"scope": "all", "entry_filter": "approved", "type": "scopes"},
)
assert scope_listing.status_code == 200
assert "Registry Scopes" in scope_listing.text
assert "Save" in scope_listing.text
support_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={"scope": "all", "entry_filter": "approved", "type": "supports"},
)
assert support_listing.status_code == 200
assert "Registry Supports" in support_listing.text
assert "supports capability" in support_listing.text
assert "references source" in support_listing.text
combined_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={

View File

@@ -236,3 +236,8 @@ Implementation note 2026-04-29: repository scope is now first-class. A
`repository_scopes` row is created for new repositories and backfilled lazily for
existing repositories. The ability-map model and API include `scope`, and the UI
allows editing the scope root above approved abilities.
Implementation note 2026-04-29: the element browser now includes approved scope
and support/evidence rows. Count badges link to scope and support listings, and
support rows show both the supported characteristic target and the referenced
source/fact/characteristic metadata.