diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index fa80c64..328d992 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -3,8 +3,8 @@ from __future__ import annotations from dataclasses import asdict from html import escape -from fastapi import APIRouter, Depends, Form, HTTPException -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi import APIRouter, Depends, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from repo_registry.core.service import RegistryService from repo_registry.storage.sqlite import NotFoundError @@ -127,6 +127,7 @@ def page(title: str, body: str) -> HTMLResponse: Repository Ability Registry @@ -163,7 +164,10 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes
-

Registry

+
+

Registry

+ Discovery +
{rows or ''} @@ -174,6 +178,110 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes return page("Repositories", body) +@router.get("/ui/discovery") +def discovery_page(service: RegistryService = Depends(get_service)) -> HTMLResponse: + repositories = service.list_repositories() + return page( + "Discovery", + f""" +
+

Discovery

+ Repositories +
+
+
+

Compare Repositories

+
+ {render_repository_checkbox_list(service, repositories)} + + +
+
+

Capability Gap Report

+
+ + + {render_repository_checkbox_list(service, repositories)} + + +
+
+ """, + ) + + +@router.get("/ui/discovery/compare") +def discovery_compare_page( + repository_ids: list[int] = Query(default=[]), + service: RegistryService = Depends(get_service), +) -> HTMLResponse: + if len(repository_ids) < 2: + return page( + "Repository Comparison", + """ +
+

Repository Comparison

+ Discovery +
+
+

Select at least two repositories with approved profiles.

+
+ """, + ) + try: + comparison = service.compare_repositories(repository_ids) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + body = f""" +
+

Repository Comparison

+ Discovery +
+
+

Compared Repositories

+ {render_compared_repositories(comparison["repositories"])} +
+
+

Shared Abilities

+ {render_compared_abilities(comparison["abilities"])} +
+
+

Unique Capabilities

+ {render_unique_capabilities(comparison["unique_capabilities"])} +
+ """ + return page("Repository Comparison", body) + + +@router.post("/ui/discovery/gaps") +def discovery_gap_report_page( + desired_ability: str = Form(...), + desired_capabilities: str = Form(...), + repository_ids: list[int] = Form(default=[]), + service: RegistryService = Depends(get_service), +) -> HTMLResponse: + capabilities = split_capability_lines(desired_capabilities) + try: + report = service.detect_capability_gaps( + desired_ability=desired_ability, + desired_capabilities=capabilities, + repository_ids=repository_ids or None, + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + body = f""" +
+

Capability Gap Report

+ Discovery +
+
+

{escape(report["desired_ability"])}

+ {render_gap_report(report)} +
+ """ + return page("Capability Gap Report", body) + + @router.get("/ui/search") def search_page( q: str = "", @@ -290,6 +398,7 @@ def repository_detail( body = f"""

{escape(repository.name)}

+ Export Back

{escape(repository.description or '')}

@@ -374,6 +483,18 @@ def repository_detail( return page(repository.name, body) +@router.get("/ui/repos/{repository_id}/export") +def export_repository_from_ui( + repository_id: int, + service: RegistryService = Depends(get_service), +) -> PlainTextResponse: + try: + content = service.export_registry_entry(repository_id) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return PlainTextResponse(content, media_type="application/x-yaml") + + @router.post("/ui/repos/{repository_id}/edit") def edit_repository_from_form( repository_id: int, @@ -1156,6 +1277,200 @@ def render_diff_payload(payload: dict | None) -> str: return "".join(parts) or 'No display fields.' +def render_repository_checkbox_list( + service: RegistryService, + repositories: list, +) -> str: + if not repositories: + return '

No repositories registered.

' + rows = [] + for repository in repositories: + ability_map = service.ability_map(repository.id) + approved_count = len(ability_map.abilities) + disabled = " disabled" if approved_count == 0 else "" + status = ( + f"{approved_count} approved " + f"{'ability' if approved_count == 1 else 'abilities'}" + if approved_count + else "No approved profile" + ) + rows.append( + f""" + + """ + ) + return f'
{"".join(rows)}
' + + +def render_compared_repositories(repositories: list[dict]) -> str: + if not repositories: + return '

No repositories selected.

' + rows = "\n".join( + f""" + + + + + + """ + for repository in repositories + ) + return f""" +
NameStatusBranchSource
No repositories yet.
{escape(repository['name'])}{escape(repository['status'])}{escape(repository['branch'])}
+ + {rows} +
NameStatusBranch
+ """ + + +def render_compared_abilities(abilities: list[dict]) -> str: + if not abilities: + return '

No shared approved abilities.

' + rows = [] + for ability in abilities: + repositories = "".join( + f""" +
  • + {escape(repository['repository_name'])} + {repository['confidence']:.2f} {escape(repository['confidence_label'])} + {render_compared_capability_pills(repository['capabilities'])} +
  • + """ + for repository in ability["repositories"] + ) + rows.append( + f""" +
  • + {escape(ability['name'])} + +
  • + """ + ) + return f'
    ' + + +def render_compared_capability_pills(capabilities: list[dict]) -> str: + if not capabilities: + return "" + return " ".join( + f'{escape(capability["name"])} ยท {capability["evidence_count"]} evidence' + for capability in capabilities + ) + + +def render_unique_capabilities(capabilities: list[dict]) -> str: + if not capabilities: + return '

    No unique approved capabilities.

    ' + rows = "\n".join( + f""" + + {escape(capability['repository_name'])} + {escape(capability['ability_name'])} + {escape(capability['capability_name'])} + + """ + for capability in capabilities + ) + return f""" + + + {rows} +
    RepositoryAbilityCapability
    + """ + + +def render_gap_report(report: dict) -> str: + return f""" +
    +
    +

    Matched

    + {render_gap_matches(report["matched_capabilities"])} +
    +
    +

    Missing

    + {render_gap_list(report["missing_capabilities"], "No missing capabilities.")} +
    +
    +

    Weak Evidence

    + {render_weak_evidence(report["weakly_evidenced_capabilities"])} +
    +
    +

    Duplicates

    + {render_duplicate_capabilities(report["duplicate_capabilities"])} +
    +
    + """ + + +def render_gap_matches(matches: list[dict]) -> str: + if not matches: + return '

    No desired capabilities matched.

    ' + rows = "\n".join( + f""" + + {escape(match['capability'])} + {escape(', '.join(match['repositories']))} + + """ + for match in matches + ) + return f"{rows}
    " + + +def render_gap_list(values: list[str], empty: str) -> str: + if not values: + return f'

    {escape(empty)}

    ' + return "" + + +def render_weak_evidence(items: list[dict]) -> str: + if not items: + return '

    No weak evidence flags.

    ' + rows = "\n".join( + f""" + + {escape(item['capability'])} + {escape(item['repository_name'])} + {escape(str(item['strongest_evidence'] or 'none'))} + {item['confidence']:.2f} {escape(item['confidence_label'])} + + """ + for item in items + ) + return f""" + + + {rows} +
    CapabilityRepositoryEvidenceConfidence
    + """ + + +def render_duplicate_capabilities(items: list[dict]) -> str: + if not items: + return '

    No duplicate capabilities.

    ' + rows = "\n".join( + f""" + + {escape(item['capability'])} + {escape(', '.join(item['repositories']))} + + """ + for item in items + ) + return f"{rows}
    " + + +def split_capability_lines(value: str) -> list[str]: + normalized = value.replace(",", "\n") + return [line.strip() for line in normalized.splitlines() if line.strip()] + + 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 f6bce39..a43ad22 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1129,6 +1129,162 @@ def test_ui_manual_registry_entry_loop(tmp_path): app.dependency_overrides.clear() +def test_ui_discovery_compare_gap_and_export(tmp_path): + def override_settings(): + return Settings( + database_path=str(tmp_path / "ui-discovery.sqlite3"), + checkout_root=str(tmp_path / "ui-discovery-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + first = client.post( + "/repos", + json={ + "name": "MailRouter", + "url": "https://example.com/mail-router-ui.git", + "description": "Routes customer email.", + }, + ).json() + second = client.post( + "/repos", + json={ + "name": "SupportRouter", + "url": "https://example.com/support-router-ui.git", + "description": "Routes support requests.", + }, + ).json() + empty = client.post( + "/repos", + json={ + "name": "EmptyProfile", + "url": "https://example.com/empty-profile-ui.git", + "description": "No approved entries yet.", + }, + ).json() + + first_ability = client.post( + f"/repos/{first['id']}/abilities", + json={ + "name": "Business Email Routing", + "description": "Route inbound messages.", + "confidence": 0.9, + }, + ).json()["id"] + first_capability = client.post( + f"/repos/{first['id']}/capabilities", + json={ + "ability_id": first_ability, + "name": "Classify Incoming Email", + "description": "Classify messages by intent.", + "confidence": 0.8, + }, + ).json()["id"] + client.post( + f"/repos/{first['id']}/evidence", + json={ + "capability_id": first_capability, + "type": "unit_test", + "reference": "tests/test_classify.py", + "strength": "strong", + }, + ) + client.post( + f"/repos/{first['id']}/capabilities", + json={ + "ability_id": first_ability, + "name": "Route Email to Team", + "description": "Route messages to owning teams.", + }, + ) + + second_ability = client.post( + f"/repos/{second['id']}/abilities", + json={ + "name": "Business Email Routing", + "description": "Support routing workflows.", + "confidence": 0.7, + }, + ).json()["id"] + second_capability = client.post( + f"/repos/{second['id']}/capabilities", + json={ + "ability_id": second_ability, + "name": "Classify Incoming Email", + "description": "Classify support requests.", + "confidence": 0.6, + }, + ).json()["id"] + client.post( + f"/repos/{second['id']}/evidence", + json={ + "capability_id": second_capability, + "type": "documentation", + "reference": "README.md", + "strength": "medium", + }, + ) + client.post( + f"/repos/{second['id']}/capabilities", + json={ + "ability_id": second_ability, + "name": "Archive Email", + "description": "Archive resolved messages.", + }, + ) + + discovery = client.get("/ui/discovery") + assert discovery.status_code == 200 + assert "Compare Repositories" in discovery.text + assert "Capability Gap Report" in discovery.text + assert "EmptyProfile" in discovery.text + assert "No approved profile" in discovery.text + + comparison = client.get( + "/ui/discovery/compare", + params=[ + ("repository_ids", first["id"]), + ("repository_ids", second["id"]), + ], + ) + assert comparison.status_code == 200 + assert "Repository Comparison" in comparison.text + assert "Business Email Routing" in comparison.text + assert "Route Email to Team" in comparison.text + assert "Archive Email" in comparison.text + + gaps = client.post( + "/ui/discovery/gaps", + data={ + "desired_ability": "Business Email Routing", + "desired_capabilities": ( + "Classify Incoming Email\n" + "Route Email to Team\n" + "German Benchmark Evaluation" + ), + "repository_ids": [str(first["id"]), str(second["id"])], + }, + ) + assert gaps.status_code == 200 + assert "Capability Gap Report" in gaps.text + assert "German Benchmark Evaluation" in gaps.text + assert "Weak Evidence" in gaps.text + assert "Duplicates" in gaps.text + + detail = client.get(f"/ui/repos/{first['id']}") + assert detail.status_code == 200 + assert f'/ui/repos/{first["id"]}/export' in detail.text + + export = client.get(f"/ui/repos/{first['id']}/export") + assert export.status_code == 200 + assert export.headers["content-type"].startswith("application/x-yaml") + assert 'name: "MailRouter"' in export.text + assert 'name: "Classify Incoming Email"' in export.text + finally: + app.dependency_overrides.clear() + + def test_api_rejects_candidate_capability_feature_and_evidence(tmp_path): source = tmp_path / "repo" source.mkdir() diff --git a/workplans/RREG-WP-0002-production-hardening.md b/workplans/RREG-WP-0002-production-hardening.md index d034fd2..db88463 100644 --- a/workplans/RREG-WP-0002-production-hardening.md +++ b/workplans/RREG-WP-0002-production-hardening.md @@ -56,7 +56,7 @@ search behavior must remain stable. ```task id: RREG-WP-0002-T03 -status: todo +status: done priority: medium state_hub_task_id: "aee945eb-ea25-49f7-b755-4ec451c1d05a" ```