Added Discovery UI

This commit is contained in:
2026-04-26 16:13:53 +02:00
parent 924efe67dc
commit e1139a89f1
3 changed files with 475 additions and 4 deletions

View File

@@ -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:
<a href="/ui"><strong>Repository Ability Registry</strong></a>
<nav class="actions">
<a href="/ui/search">Search</a>
<a href="/ui/discovery">Discovery</a>
<a href="/docs">API Docs</a>
</nav>
</header>
@@ -163,7 +164,10 @@ def repository_index(service: RegistryService = Depends(get_service)) -> HTMLRes
</form>
</section>
<section class="panel">
<h2>Registry</h2>
<div class="actions">
<h2 style="margin-right:auto">Registry</h2>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<table>
<thead><tr><th>Name</th><th>Status</th><th>Branch</th><th>Source</th></tr></thead>
<tbody>{rows or '<tr><td colspan="4" class="muted">No repositories yet.</td></tr>'}</tbody>
@@ -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"""
<div class="actions">
<h1 style="margin-right:auto">Discovery</h1>
<a class="button secondary" href="/ui">Repositories</a>
</div>
<div class="grid">
<section class="panel">
<h2>Compare Repositories</h2>
<form class="stack" method="get" action="/ui/discovery/compare">
{render_repository_checkbox_list(service, repositories)}
<button type="submit">Compare</button>
</form>
</section>
<section class="panel">
<h2>Capability Gap Report</h2>
<form class="stack" method="post" action="/ui/discovery/gaps">
<label>Desired ability <input name="desired_ability" required></label>
<label>Desired capabilities <textarea name="desired_capabilities" rows="7" placeholder="One capability per line or comma-separated" required></textarea></label>
{render_repository_checkbox_list(service, repositories)}
<button type="submit">Run Gap Report</button>
</form>
</section>
</div>
""",
)
@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",
"""
<div class="actions">
<h1 style="margin-right:auto">Repository Comparison</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<p class="muted">Select at least two repositories with approved profiles.</p>
</section>
""",
)
try:
comparison = service.compare_repositories(repository_ids)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
body = f"""
<div class="actions">
<h1 style="margin-right:auto">Repository Comparison</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<h2>Compared Repositories</h2>
{render_compared_repositories(comparison["repositories"])}
</section>
<section class="panel" style="margin-top:18px">
<h2>Shared Abilities</h2>
{render_compared_abilities(comparison["abilities"])}
</section>
<section class="panel" style="margin-top:18px">
<h2>Unique Capabilities</h2>
{render_unique_capabilities(comparison["unique_capabilities"])}
</section>
"""
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"""
<div class="actions">
<h1 style="margin-right:auto">Capability Gap Report</h1>
<a class="button secondary" href="/ui/discovery">Discovery</a>
</div>
<section class="panel">
<h2>{escape(report["desired_ability"])}</h2>
{render_gap_report(report)}
</section>
"""
return page("Capability Gap Report", body)
@router.get("/ui/search")
def search_page(
q: str = "",
@@ -290,6 +398,7 @@ def repository_detail(
body = f"""
<div class="actions">
<h1 style="margin-right:auto">{escape(repository.name)}</h1>
<a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a>
<a class="button secondary" href="/ui">Back</a>
</div>
<p class="muted">{escape(repository.description or '')}</p>
@@ -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 '<span class="muted">No display fields.</span>'
def render_repository_checkbox_list(
service: RegistryService,
repositories: list,
) -> str:
if not repositories:
return '<p class="muted">No repositories registered.</p>'
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"""
<label>
<span>
<input style="width:auto" type="checkbox" name="repository_ids" value="{repository.id}"{disabled}>
{escape(repository.name)}
</span>
<span class="muted">{escape(status)}</span>
</label>
"""
)
return f'<div class="stack">{"".join(rows)}</div>'
def render_compared_repositories(repositories: list[dict]) -> str:
if not repositories:
return '<p class="muted">No repositories selected.</p>'
rows = "\n".join(
f"""
<tr>
<td><a href="/ui/repos/{repository['id']}">{escape(repository['name'])}</a></td>
<td><span class="pill">{escape(repository['status'])}</span></td>
<td class="source">{escape(repository['branch'])}</td>
</tr>
"""
for repository in repositories
)
return f"""
<table>
<thead><tr><th>Name</th><th>Status</th><th>Branch</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_compared_abilities(abilities: list[dict]) -> str:
if not abilities:
return '<p class="muted">No shared approved abilities.</p>'
rows = []
for ability in abilities:
repositories = "".join(
f"""
<li>
<strong>{escape(repository['repository_name'])}</strong>
<span class="pill">{repository['confidence']:.2f} {escape(repository['confidence_label'])}</span>
{render_compared_capability_pills(repository['capabilities'])}
</li>
"""
for repository in ability["repositories"]
)
rows.append(
f"""
<li>
<strong>{escape(ability['name'])}</strong>
<ul>{repositories}</ul>
</li>
"""
)
return f'<div class="tree"><ul>{"".join(rows)}</ul></div>'
def render_compared_capability_pills(capabilities: list[dict]) -> str:
if not capabilities:
return ""
return " ".join(
f'<span class="pill">{escape(capability["name"])} · {capability["evidence_count"]} evidence</span>'
for capability in capabilities
)
def render_unique_capabilities(capabilities: list[dict]) -> str:
if not capabilities:
return '<p class="muted">No unique approved capabilities.</p>'
rows = "\n".join(
f"""
<tr>
<td><a href="/ui/repos/{capability['repository_id']}">{escape(capability['repository_name'])}</a></td>
<td>{escape(capability['ability_name'])}</td>
<td><strong>{escape(capability['capability_name'])}</strong></td>
</tr>
"""
for capability in capabilities
)
return f"""
<table>
<thead><tr><th>Repository</th><th>Ability</th><th>Capability</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_gap_report(report: dict) -> str:
return f"""
<div class="grid">
<section>
<h3>Matched</h3>
{render_gap_matches(report["matched_capabilities"])}
</section>
<section>
<h3>Missing</h3>
{render_gap_list(report["missing_capabilities"], "No missing capabilities.")}
</section>
<section>
<h3>Weak Evidence</h3>
{render_weak_evidence(report["weakly_evidenced_capabilities"])}
</section>
<section>
<h3>Duplicates</h3>
{render_duplicate_capabilities(report["duplicate_capabilities"])}
</section>
</div>
"""
def render_gap_matches(matches: list[dict]) -> str:
if not matches:
return '<p class="muted">No desired capabilities matched.</p>'
rows = "\n".join(
f"""
<tr>
<td><strong>{escape(match['capability'])}</strong></td>
<td>{escape(', '.join(match['repositories']))}</td>
</tr>
"""
for match in matches
)
return f"<table><tbody>{rows}</tbody></table>"
def render_gap_list(values: list[str], empty: str) -> str:
if not values:
return f'<p class="muted">{escape(empty)}</p>'
return "<ul>" + "".join(f"<li>{escape(value)}</li>" for value in values) + "</ul>"
def render_weak_evidence(items: list[dict]) -> str:
if not items:
return '<p class="muted">No weak evidence flags.</p>'
rows = "\n".join(
f"""
<tr>
<td>{escape(item['capability'])}</td>
<td>{escape(item['repository_name'])}</td>
<td>{escape(str(item['strongest_evidence'] or 'none'))}</td>
<td>{item['confidence']:.2f} <span class="pill">{escape(item['confidence_label'])}</span></td>
</tr>
"""
for item in items
)
return f"""
<table>
<thead><tr><th>Capability</th><th>Repository</th><th>Evidence</th><th>Confidence</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_duplicate_capabilities(items: list[dict]) -> str:
if not items:
return '<p class="muted">No duplicate capabilities.</p>'
rows = "\n".join(
f"""
<tr>
<td>{escape(item['capability'])}</td>
<td>{escape(', '.join(item['repositories']))}</td>
</tr>
"""
for item in items
)
return f"<table><tbody>{rows}</tbody></table>"
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:

View File

@@ -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()

View File

@@ -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"
```