generated from coulomb/repo-seed
Classification for abilities, capabilities, features and filters for list view
This commit is contained in:
43
docs/classification-strategy.md
Normal file
43
docs/classification-strategy.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user