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:
|
||||
|
||||
Reference in New Issue
Block a user