diff --git a/docs/classification-strategy.md b/docs/classification-strategy.md new file mode 100644 index 0000000..362be62 --- /dev/null +++ b/docs/classification-strategy.md @@ -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. diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 8ec2dc1..6f0492b 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -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 = 'No completed candidate graph.' + 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 = 'Unknown listing scope.' + elements = [] + filtered = filter_element_rows(elements, q, class_filter) + rows = render_element_rows(filtered) + filter_action = f"/ui/repos/{repository_id}/elements" body = f"""

{escape(title)}

Repository
+
+
+ + + {render_optional_hidden("analysis_run_id", analysis_run_id)} +
+ + +
+ {render_class_datalist(elements)} +
+ + Clear + {len(filtered)} of {len(elements)} shown +
+
+
- + {rows}
TypeNameParentSource
ClassNameParentSource
@@ -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 'No matching elements.' + 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 'No matching facts.' -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 'No matching elements.' + return "\n".join(render_element_row(row) for row in rows) + + +def render_element_row(row: dict) -> str: return f""" - {escape(item_type)} - {escape(name)} - {escape(parent)} - {render_sources(source_refs)} + {escape(str(row["primary_class"]))} + {escape(str(row["name"]))} + {escape(str(row["parent"]))} + {render_sources(row["source_refs"])} """ +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'' + for item in classes + ) + return f'{options}' + + +def render_optional_hidden(name: str, value: int | None) -> str: + if value is None: + return "" + return f'' + + +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: diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 13bd3dd..e3586f3 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -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"