generated from coulomb/repo-seed
Added Discovery UI
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user