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,
|
repository_id: int,
|
||||||
scope: str = Query("approved"),
|
scope: str = Query("approved"),
|
||||||
type: str = Query("abilities"),
|
type: str = Query("abilities"),
|
||||||
|
q: str = Query(""),
|
||||||
|
class_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:
|
||||||
@@ -943,33 +945,53 @@ def repository_element_listing(
|
|||||||
title = element_listing_title(repository.name, scope, type)
|
title = element_listing_title(repository.name, scope, type)
|
||||||
if scope == "approved":
|
if scope == "approved":
|
||||||
graph = asdict(service.ability_map(repository_id))
|
graph = asdict(service.ability_map(repository_id))
|
||||||
rows = render_graph_element_rows(graph, type)
|
elements = graph_element_rows(graph, type)
|
||||||
elif scope == "candidate":
|
elif scope == "candidate":
|
||||||
if analysis_run_id is None:
|
if analysis_run_id is None:
|
||||||
runs = service.list_analysis_runs(repository_id)
|
runs = service.list_analysis_runs(repository_id)
|
||||||
latest = latest_completed_candidate_graph(service, repository_id, runs)
|
latest = latest_completed_candidate_graph(service, repository_id, runs)
|
||||||
if latest is None:
|
if latest is None:
|
||||||
rows = '<tr><td class="muted">No completed candidate graph.</td></tr>'
|
elements = []
|
||||||
else:
|
else:
|
||||||
analysis_run_id, candidate_graph = latest
|
analysis_run_id, candidate_graph = latest
|
||||||
rows = render_graph_element_rows(asdict(candidate_graph), type)
|
elements = graph_element_rows(asdict(candidate_graph), type)
|
||||||
else:
|
else:
|
||||||
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
|
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":
|
elif scope == "facts":
|
||||||
facts = service.list_observed_facts(repository_id, analysis_run_id)
|
facts = service.list_observed_facts(repository_id, analysis_run_id)
|
||||||
rows = render_fact_element_rows(facts)
|
elements = fact_element_rows(facts)
|
||||||
else:
|
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"""
|
body = f"""
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<h1 style="margin-right:auto">{escape(title)}</h1>
|
<h1 style="margin-right:auto">{escape(title)}</h1>
|
||||||
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||||
</div>
|
</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">
|
<section class="panel">
|
||||||
<table>
|
<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>
|
<tbody>{rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -1746,18 +1768,23 @@ def render_count_pills(
|
|||||||
return "".join(items)
|
return "".join(items)
|
||||||
|
|
||||||
|
|
||||||
def render_graph_element_rows(graph: dict, item_type: str) -> str:
|
def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
|
||||||
rows: list[str] = []
|
rows: list[dict] = []
|
||||||
for ability in graph.get("abilities", []):
|
for ability in graph.get("abilities", []):
|
||||||
if item_type == "abilities":
|
if item_type == "abilities":
|
||||||
rows.append(
|
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", []):
|
for capability in ability.get("capabilities", []):
|
||||||
if item_type == "capabilities":
|
if item_type == "capabilities":
|
||||||
rows.append(
|
rows.append(
|
||||||
render_element_row(
|
element_row(
|
||||||
"capability",
|
capability.get("primary_class", "capability"),
|
||||||
capability["name"],
|
capability["name"],
|
||||||
ability["name"],
|
ability["name"],
|
||||||
capability.get("source_refs", []),
|
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", []):
|
for feature in capability.get("features", []):
|
||||||
if item_type == "features":
|
if item_type == "features":
|
||||||
rows.append(
|
rows.append(
|
||||||
render_element_row(
|
element_row(
|
||||||
"feature",
|
feature.get("type", "feature"),
|
||||||
feature["name"],
|
feature["name"],
|
||||||
capability["name"],
|
capability["name"],
|
||||||
feature.get("source_refs", []),
|
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:
|
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}"
|
return f"{repository_name} · {scope_label} {type_label}"
|
||||||
|
|
||||||
|
|
||||||
def render_fact_element_rows(facts: list) -> str:
|
def fact_element_rows(facts: list) -> list[dict]:
|
||||||
rows = [
|
return [
|
||||||
render_element_row(
|
element_row(
|
||||||
fact.kind,
|
fact.kind,
|
||||||
fact.name,
|
fact.name,
|
||||||
fact.path,
|
fact.path,
|
||||||
@@ -1811,25 +1838,96 @@ def render_fact_element_rows(facts: list) -> str:
|
|||||||
)
|
)
|
||||||
for fact in facts
|
for fact in facts
|
||||||
]
|
]
|
||||||
return "\n".join(rows) or '<tr><td colspan="4" class="muted">No matching facts.</td></tr>'
|
|
||||||
|
|
||||||
|
|
||||||
def render_element_row(
|
def element_row(
|
||||||
item_type: str,
|
primary_class: str,
|
||||||
name: str,
|
name: str,
|
||||||
parent: str,
|
parent: str,
|
||||||
source_refs: list[dict],
|
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"""
|
return f"""
|
||||||
<tr>
|
<tr>
|
||||||
<td>{escape(item_type)}</td>
|
<td><span class="pill">{escape(str(row["primary_class"]))}</span></td>
|
||||||
<td>{escape(name)}</td>
|
<td>{escape(str(row["name"]))}</td>
|
||||||
<td>{escape(parent)}</td>
|
<td>{escape(str(row["parent"]))}</td>
|
||||||
<td>{render_sources(source_refs)}</td>
|
<td>{render_sources(row["source_refs"])}</td>
|
||||||
</tr>
|
</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:
|
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
|
||||||
abilities = graph.get("abilities", [])
|
abilities = graph.get("abilities", [])
|
||||||
if not 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_listing.status_code == 200
|
||||||
assert "Candidate Features" in candidate_listing.text
|
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
|
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(
|
fact_listing = client.get(
|
||||||
f"/ui/repos/{repository_id}/elements",
|
f"/ui/repos/{repository_id}/elements",
|
||||||
params={
|
params={
|
||||||
@@ -1216,6 +1233,20 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
|||||||
assert "Observed Facts" in fact_listing.text
|
assert "Observed Facts" in fact_listing.text
|
||||||
assert "python route decorator" 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(
|
(source / "app.py").write_text(
|
||||||
"from fastapi import FastAPI\n"
|
"from fastapi import FastAPI\n"
|
||||||
"app = FastAPI()\n"
|
"app = FastAPI()\n"
|
||||||
|
|||||||
Reference in New Issue
Block a user