Classification for abilities, capabilities, features and filters for list view

This commit is contained in:
2026-04-28 23:38:31 +02:00
parent 852eb082d9
commit b9731449a2
3 changed files with 198 additions and 26 deletions

View File

@@ -0,0 +1,43 @@
# Classification Strategy
Repo-registry needs classification for orientation without pretending repository
behavior is always cleanly separable. The review UI should therefore support two
layers of classification:
- `primary_class`: the main class-defining attribute used for grouping, filtering,
and first-glance orientation.
- `attributes`: additional labels that can overlap, qualify, or challenge the
primary class.
Observed facts already follow this shape informally: `kind` is the primary class
(`interface`, `framework`, `test`, `documentation`, and so on), while path, name,
value, and metadata provide secondary attributes.
For approved and candidate graph elements, the target model is:
- Ability primary classes describe the repository's core domain or operational
purpose, such as `repository-intelligence`, `workflow-automation`, `content-
publishing`, or `developer-tooling`.
- Capability primary classes describe the kind of behavior exposed, such as
`ingestion`, `analysis`, `review`, `search`, `export`, `coordination`, or
`deployment`.
- Feature primary classes describe the delivery surface or user-visible feature
family, such as `business-usecase`, `ui`, `api`, `cli`, `backend`, `storage`,
`integration`, or `diagnostic`.
Features are the most fluid layer. A single feature can be both `ui` and `review`,
or both `api` and `ingestion`. The primary class should answer "where should a
curator first look for this?" Secondary attributes should preserve the overlap:
surface (`ui`, `api`, `cli`), intent (`review`, `search`, `diagnostic`), and
domain (`repository-intelligence`, `capability-mapping`) can all be true at once.
Current implementation status:
- Facts can be searched and filtered by `kind` in the element listing UI.
- Features can be searched and filtered by their existing single `type`.
- Abilities and capabilities currently use placeholder classes (`ability`,
`capability`) until the schema gains `primary_class` and `attributes`.
Next schema step: add `primary_class` plus a JSON/list `attributes` field to
candidate and approved abilities, capabilities, and features. Candidate generation
can then propose classifications, while review keeps the right to correct them.

View File

@@ -936,6 +936,8 @@ def repository_element_listing(
repository_id: int,
scope: str = Query("approved"),
type: str = Query("abilities"),
q: str = Query(""),
class_filter: str = Query(""),
analysis_run_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
@@ -943,33 +945,53 @@ def repository_element_listing(
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)
elements = 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 = '<tr><td class="muted">No completed candidate graph.</td></tr>'
elements = []
else:
analysis_run_id, candidate_graph = latest
rows = render_graph_element_rows(asdict(candidate_graph), type)
elements = 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)
elements = 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)
elements = fact_element_rows(facts)
else:
rows = '<tr><td class="muted">Unknown listing scope.</td></tr>'
elements = []
filtered = filter_element_rows(elements, q, class_filter)
rows = render_element_rows(filtered)
filter_action = f"/ui/repos/{repository_id}/elements"
body = f"""
<div class="actions">
<h1 style="margin-right:auto">{escape(title)}</h1>
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</div>
<section class="panel" style="margin-bottom:18px">
<form class="stack" method="get" action="{filter_action}">
<input type="hidden" name="scope" value="{escape(scope)}">
<input type="hidden" name="type" value="{escape(type)}">
{render_optional_hidden("analysis_run_id", analysis_run_id)}
<div class="grid">
<label>Search <input name="q" value="{escape(q)}" placeholder="Name, parent, source, or class"></label>
<label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label>
</div>
{render_class_datalist(elements)}
<div class="actions">
<button type="submit">Filter</button>
<a class="button secondary" href="{filter_action}?scope={escape(scope)}&type={escape(type)}{render_analysis_run_query_suffix(analysis_run_id)}">Clear</a>
<span class="muted">{len(filtered)} of {len(elements)} shown</span>
</div>
</form>
</section>
<section class="panel">
<table>
<thead><tr><th>Type</th><th>Name</th><th>Parent</th><th>Source</th></tr></thead>
<thead><tr><th>Class</th><th>Name</th><th>Parent</th><th>Source</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</section>
@@ -1746,18 +1768,23 @@ def render_count_pills(
return "".join(items)
def render_graph_element_rows(graph: dict, item_type: str) -> str:
rows: list[str] = []
def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
rows: list[dict] = []
for ability in graph.get("abilities", []):
if item_type == "abilities":
rows.append(
render_element_row("ability", ability["name"], "", ability.get("source_refs", []))
element_row(
ability.get("primary_class", "ability"),
ability["name"],
"",
ability.get("source_refs", []),
)
)
for capability in ability.get("capabilities", []):
if item_type == "capabilities":
rows.append(
render_element_row(
"capability",
element_row(
capability.get("primary_class", "capability"),
capability["name"],
ability["name"],
capability.get("source_refs", []),
@@ -1766,14 +1793,14 @@ def render_graph_element_rows(graph: dict, item_type: str) -> str:
for feature in capability.get("features", []):
if item_type == "features":
rows.append(
render_element_row(
"feature",
element_row(
feature.get("type", "feature"),
feature["name"],
capability["name"],
feature.get("source_refs", []),
)
)
return "\n".join(rows) or '<tr><td colspan="4" class="muted">No matching elements.</td></tr>'
return rows
def element_listing_title(repository_name: str, scope: str, item_type: str) -> str:
@@ -1794,9 +1821,9 @@ def element_listing_title(repository_name: str, scope: str, item_type: str) -> s
return f"{repository_name} · {scope_label} {type_label}"
def render_fact_element_rows(facts: list) -> str:
rows = [
render_element_row(
def fact_element_rows(facts: list) -> list[dict]:
return [
element_row(
fact.kind,
fact.name,
fact.path,
@@ -1811,25 +1838,96 @@ def render_fact_element_rows(facts: list) -> str:
)
for fact in facts
]
return "\n".join(rows) or '<tr><td colspan="4" class="muted">No matching facts.</td></tr>'
def render_element_row(
item_type: str,
def element_row(
primary_class: str,
name: str,
parent: str,
source_refs: list[dict],
) -> str:
) -> dict:
return {
"primary_class": primary_class,
"name": name,
"parent": parent,
"source_refs": source_refs,
}
def filter_element_rows(
rows: list[dict],
query: str,
class_filter: str,
) -> list[dict]:
query = query.strip().lower()
class_filter = class_filter.strip().lower()
filtered = []
for row in rows:
row_class = str(row["primary_class"]).lower()
if class_filter and class_filter not in row_class:
continue
haystack = " ".join(
[
str(row["primary_class"]),
str(row["name"]),
str(row["parent"]),
source_refs_text(row["source_refs"]),
]
).lower()
if query and query not in haystack:
continue
filtered.append(row)
return filtered
def render_element_rows(rows: list[dict]) -> str:
if not rows:
return '<tr><td colspan="4" class="muted">No matching elements.</td></tr>'
return "\n".join(render_element_row(row) for row in rows)
def render_element_row(row: dict) -> str:
return f"""
<tr>
<td>{escape(item_type)}</td>
<td>{escape(name)}</td>
<td>{escape(parent)}</td>
<td>{render_sources(source_refs)}</td>
<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>
</tr>
"""
def render_class_datalist(rows: list[dict]) -> str:
classes = sorted({str(row["primary_class"]) for row in rows if row["primary_class"]})
options = "".join(
f'<option value="{escape(item)}"></option>'
for item in classes
)
return f'<datalist id="element-classes">{options}</datalist>'
def render_optional_hidden(name: str, value: int | None) -> str:
if value is None:
return ""
return f'<input type="hidden" name="{escape(name)}" value="{value}">'
def render_analysis_run_query_suffix(analysis_run_id: int | None) -> str:
if analysis_run_id is None:
return ""
return f"&analysis_run_id={analysis_run_id}"
def source_refs_text(source_refs: list[dict]) -> str:
return " ".join(
" ".join(
str(ref.get(key, ""))
for key in ("kind", "name", "path", "line")
)
for ref in source_refs
)
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
abilities = graph.get("abilities", [])
if not abilities:

View File

@@ -1202,8 +1202,25 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
)
assert candidate_listing.status_code == 200
assert "Candidate Features" in candidate_listing.text
assert "Search" in candidate_listing.text
assert "Class" in candidate_listing.text
assert "GET /status" in candidate_listing.text
filtered_candidate_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={
"scope": "candidate",
"analysis_run_id": first_run_id,
"type": "features",
"q": "status",
"class_filter": "API",
},
)
assert filtered_candidate_listing.status_code == 200
assert "1 of 2 shown" in filtered_candidate_listing.text
assert "GET /status" in filtered_candidate_listing.text
assert "CLI command status" not in filtered_candidate_listing.text
fact_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={
@@ -1216,6 +1233,20 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Observed Facts" in fact_listing.text
assert "python route decorator" in fact_listing.text
filtered_fact_listing = client.get(
f"/ui/repos/{repository_id}/elements",
params={
"scope": "facts",
"analysis_run_id": first_run_id,
"type": "facts",
"class_filter": "framework",
},
)
assert filtered_fact_listing.status_code == 200
assert "1 of 7 shown" in filtered_fact_listing.text
assert "FastAPI" in filtered_fact_listing.text
assert "python route decorator" not in filtered_fact_listing.text
(source / "app.py").write_text(
"from fastapi import FastAPI\n"
"app = FastAPI()\n"