| Name | Status | Branch | Source |
{rows or '| No repositories yet. |
'}
@@ -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"""
+
+
+
+ Compare Repositories
+
+
+
+ Capability Gap Report
+
+
+
+ """,
+ )
+
+
+@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",
+ """
+
+
+ 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"""
+
+
+ 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"""
+
+
+ {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.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"""
+
+ | {escape(repository['name'])} |
+ {escape(repository['status'])} |
+ {escape(repository['branch'])} |
+
+ """
+ for repository in repositories
+ )
+ return f"""
+
+ | Name | Status | Branch |
+ {rows}
+
+ """
+
+
+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"""
+
+ | Repository | Ability | Capability |
+ {rows}
+
+ """
+
+
+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""
+
+
+def render_gap_list(values: list[str], empty: str) -> str:
+ if not values:
+ return f'{escape(empty)}
'
+ return "" + "".join(f"- {escape(value)}
" for value in values) + "
"
+
+
+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"""
+
+ | Capability | Repository | Evidence | Confidence |
+ {rows}
+
+ """
+
+
+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""
+
+
+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"
```