generated from coulomb/repo-seed
3059 lines
110 KiB
Python
3059 lines
110 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from html import escape
|
|
from urllib.parse import quote_plus
|
|
|
|
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
|
|
from repo_registry.web_api.app import get_service
|
|
|
|
|
|
router = APIRouter(include_in_schema=False)
|
|
|
|
|
|
def page(title: str, body: str) -> HTMLResponse:
|
|
return HTMLResponse(
|
|
f"""
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{escape(title)} · Repository Ability Registry</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: light;
|
|
--bg: #f7f8fa;
|
|
--panel: #ffffff;
|
|
--text: #1f2933;
|
|
--muted: #637381;
|
|
--line: #d9dee7;
|
|
--accent: #0f766e;
|
|
--accent-dark: #115e59;
|
|
--warn: #9a3412;
|
|
--danger: #b42318;
|
|
--danger-bg: #fff4f2;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}}
|
|
header {{
|
|
background: #17212b;
|
|
color: #fff;
|
|
padding: 14px 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}}
|
|
header a {{ color: #e6fffb; text-decoration: none; }}
|
|
main {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
|
|
h1 {{ font-size: 24px; margin: 0 0 18px; }}
|
|
h2 {{ font-size: 18px; margin: 28px 0 10px; }}
|
|
h3 {{ font-size: 15px; margin: 16px 0 6px; }}
|
|
p {{ margin: 0 0 10px; }}
|
|
a {{ color: var(--accent-dark); }}
|
|
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }}
|
|
.panel {{
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}}
|
|
.notice {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 12px;
|
|
}}
|
|
.notice.error {{
|
|
border-color: #f3b8ae;
|
|
background: var(--danger-bg);
|
|
color: var(--danger);
|
|
}}
|
|
.stack {{ display: grid; gap: 12px; }}
|
|
.muted {{ color: var(--muted); }}
|
|
.pill {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 24px;
|
|
padding: 2px 8px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
color: var(--muted);
|
|
background: #fbfcfd;
|
|
font-size: 12px;
|
|
}}
|
|
table {{ width: 100%; border-collapse: collapse; background: var(--panel); }}
|
|
th, td {{ text-align: left; border-bottom: 1px solid var(--line); padding: 10px 8px; vertical-align: top; }}
|
|
th {{ color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }}
|
|
input, textarea {{
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 9px 10px;
|
|
font: inherit;
|
|
}}
|
|
select {{
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
padding: 9px 10px;
|
|
background: white;
|
|
font: inherit;
|
|
}}
|
|
label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 600; }}
|
|
label.checkbox {{
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}}
|
|
label.checkbox input {{ width: auto; }}
|
|
button, .button {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 34px;
|
|
padding: 7px 12px;
|
|
border: 1px solid var(--accent);
|
|
border-radius: 6px;
|
|
background: var(--accent);
|
|
color: white;
|
|
font: inherit;
|
|
font-weight: 650;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}}
|
|
.button.secondary, button.secondary {{
|
|
color: var(--accent-dark);
|
|
background: white;
|
|
}}
|
|
.tree ul {{ margin: 8px 0 0 20px; padding: 0; }}
|
|
.tree li {{ margin: 6px 0; }}
|
|
.source {{ color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }}
|
|
.actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
|
|
[data-pending] {{ display: none; color: var(--muted); }}
|
|
form.is-submitting [data-pending] {{ display: inline; }}
|
|
form.is-submitting button[type="submit"] {{ opacity: .7; cursor: wait; }}
|
|
@media (max-width: 780px) {{
|
|
header {{ padding: 12px 16px; }}
|
|
main {{ padding: 16px; }}
|
|
.grid {{ grid-template-columns: 1fr; }}
|
|
table, tbody, tr, td {{ display: block; width: 100%; }}
|
|
thead {{ display: none; }}
|
|
td {{ border-bottom: 0; padding: 6px 0; }}
|
|
tr {{ border-bottom: 1px solid var(--line); padding: 10px 0; display: block; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<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>
|
|
<main>{body}</main>
|
|
<script>
|
|
document.addEventListener("submit", (event) => {{
|
|
const form = event.target;
|
|
if (form instanceof HTMLFormElement) {{
|
|
form.classList.add("is-submitting");
|
|
const button = form.querySelector('button[type="submit"]');
|
|
if (button) button.setAttribute("disabled", "disabled");
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
)
|
|
|
|
|
|
def render_repository_index(
|
|
service: RegistryService,
|
|
*,
|
|
error_message: str | None = None,
|
|
status_code: int = 200,
|
|
) -> HTMLResponse:
|
|
repositories = service.list_repositories()
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><a href="/ui/repos/{repo.id}">{escape(repo.name)}</a></td>
|
|
<td><span class="pill">{escape(repo.status)}</span></td>
|
|
<td class="source">{escape(repo.branch)}</td>
|
|
<td class="source">{escape(repo.url)}</td>
|
|
</tr>
|
|
"""
|
|
for repo in repositories
|
|
)
|
|
error = (
|
|
f"""
|
|
<div class="notice error" role="alert">
|
|
<strong>Registration failed.</strong>
|
|
<p>{escape(error_message)}</p>
|
|
</div>
|
|
"""
|
|
if error_message
|
|
else ""
|
|
)
|
|
body = f"""
|
|
<h1>Repositories</h1>
|
|
{error}
|
|
<div class="grid">
|
|
<section class="panel">
|
|
<h2>Register Repository</h2>
|
|
<form class="stack" method="post" action="/ui/repos">
|
|
<label>Git URL or local path <input name="url" required></label>
|
|
<label>Branch <input name="branch" value="main"></label>
|
|
<label>Username <input name="access_username" autocomplete="username" placeholder="Optional for private HTTP(S) repos"></label>
|
|
<label>Password or access token <input name="access_password" type="password" autocomplete="current-password" placeholder="Used for this Git operation only"></label>
|
|
<label class="checkbox"><input type="checkbox" name="explore_after_registration" value="1" checked> Explore after registration</label>
|
|
<label class="checkbox"><input type="checkbox" name="use_llm_assistance" value="1" checked> Use LLM assistance if configured</label>
|
|
<label class="checkbox"><input type="checkbox" name="trusted_auto_approve" value="1"> Trusted auto-populate after analysis</label>
|
|
<div class="actions">
|
|
<button type="submit">Register</button>
|
|
<span data-pending>Registering repository...</span>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
<section class="panel">
|
|
<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>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
"""
|
|
response = page("Repositories", body)
|
|
response.status_code = status_code
|
|
return response
|
|
|
|
|
|
@router.get("/ui")
|
|
def repository_index(service: RegistryService = Depends(get_service)) -> HTMLResponse:
|
|
return render_repository_index(service)
|
|
|
|
|
|
@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 = "",
|
|
status: str = "",
|
|
language: str = "",
|
|
framework: str = "",
|
|
ability: str = "",
|
|
capability: str = "",
|
|
service: RegistryService = Depends(get_service),
|
|
) -> HTMLResponse:
|
|
results = (
|
|
service.search(
|
|
q,
|
|
status=status or None,
|
|
language=language or None,
|
|
framework=framework or None,
|
|
ability=ability or None,
|
|
capability=capability or None,
|
|
)
|
|
if q.strip()
|
|
else []
|
|
)
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><a href="{search_result_href(asdict(result))}">{escape(result.repository_name)}</a></td>
|
|
<td><span class="pill">{escape(result.match_type)}</span></td>
|
|
<td>
|
|
<strong>{escape(result.match_name)}</strong>
|
|
{render_search_context(asdict(result))}
|
|
</td>
|
|
<td>{escape(result.matched_field)}</td>
|
|
<td>{result.confidence:.2f} <span class="pill">{escape(result.confidence_label)}</span></td>
|
|
</tr>
|
|
"""
|
|
for result in results
|
|
)
|
|
empty = (
|
|
'<tr><td colspan="4" class="muted">No matches.</td></tr>'
|
|
if q.strip()
|
|
else '<tr><td colspan="4" class="muted">Enter a need, capability, or repository name.</td></tr>'
|
|
)
|
|
body = f"""
|
|
<div class="actions">
|
|
<h1 style="margin-right:auto">Search</h1>
|
|
<a class="button secondary" href="/ui">Repositories</a>
|
|
</div>
|
|
<section class="panel">
|
|
<form class="stack" method="get" action="/ui/search">
|
|
<input name="q" value="{escape(q)}" placeholder="Search approved registry entries">
|
|
<div class="grid">
|
|
<label>Status <input name="status" value="{escape(status)}" placeholder="indexed"></label>
|
|
<label>Language <input name="language" value="{escape(language)}" placeholder="Python"></label>
|
|
<label>Framework <input name="framework" value="{escape(framework)}" placeholder="FastAPI"></label>
|
|
<label>Ability <input name="ability" value="{escape(ability)}" placeholder="Email Routing"></label>
|
|
<label>Capability <input name="capability" value="{escape(capability)}" placeholder="Classification"></label>
|
|
</div>
|
|
<div class="actions">
|
|
<button type="submit">Search</button>
|
|
<a class="button secondary" href="/ui/search">Clear</a>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
<section class="panel" style="margin-top:18px">
|
|
<table>
|
|
<thead><tr><th>Repository</th><th>Match</th><th>Name</th><th>Field</th><th>Confidence</th></tr></thead>
|
|
<tbody>{rows or empty}</tbody>
|
|
</table>
|
|
</section>
|
|
"""
|
|
return page("Search", body)
|
|
|
|
|
|
@router.post("/ui/repos")
|
|
def create_repository_from_form(
|
|
url: str = Form(...),
|
|
branch: str = Form("main"),
|
|
access_username: str = Form(""),
|
|
access_password: str = Form(""),
|
|
explore_after_registration: str | None = Form(None),
|
|
use_llm_assistance: str | None = Form(None),
|
|
trusted_auto_approve: str | None = Form(None),
|
|
service: RegistryService = Depends(get_service),
|
|
):
|
|
try:
|
|
repository = service.register_repository(
|
|
url=url,
|
|
branch=branch or "main",
|
|
access_username=access_username or None,
|
|
access_password=access_password or None,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
return render_repository_index(
|
|
service,
|
|
error_message=str(exc),
|
|
status_code=400,
|
|
)
|
|
if explore_after_registration:
|
|
summary = service.analyze_repository(
|
|
repository.id,
|
|
use_llm_assistance=bool(use_llm_assistance),
|
|
trusted_auto_approve=bool(trusted_auto_approve),
|
|
access_username=access_username or None,
|
|
access_password=access_password or None,
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository.id}/analysis-runs/{summary.analysis_run.id}",
|
|
status_code=303,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository.id}", status_code=303)
|
|
|
|
|
|
@router.get("/ui/repos/{repository_id}")
|
|
def repository_detail(
|
|
repository_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> HTMLResponse:
|
|
try:
|
|
repository = service.get_repository(repository_id)
|
|
except NotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
runs = service.list_analysis_runs(repository_id)
|
|
ability_map = service.ability_map(repository_id)
|
|
latest_candidate = latest_completed_candidate_graph(
|
|
service,
|
|
repository_id,
|
|
runs,
|
|
)
|
|
decisions = service.list_review_decisions(repository_id)
|
|
facts = service.list_observed_facts(repository_id)
|
|
languages = sorted({fact.name for fact in facts if fact.kind == "language"})
|
|
frameworks = sorted({fact.name for fact in facts if fact.kind == "framework"})
|
|
run_rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><a href="/ui/repos/{repository_id}/analysis-runs/{run.id}">#{run.id}</a></td>
|
|
<td><span class="pill">{escape(run.status)}</span></td>
|
|
<td class="source">{escape(run.started_at)}</td>
|
|
<td>{render_run_compare_link(repository_id, run.id, runs)}</td>
|
|
<td>{escape(run.error_message or '')}</td>
|
|
</tr>
|
|
"""
|
|
for run in runs
|
|
)
|
|
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>
|
|
<p><span class="pill">{escape(repository.status)}</span> <span class="source">{escape(repository.url)}</span></p>
|
|
{render_repository_facts(languages, frameworks)}
|
|
<div class="grid">
|
|
<section class="panel">
|
|
<h2>Repository Metadata</h2>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/edit">
|
|
<label>Name <input name="name" value="{escape(repository.name)}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(repository.description or '')}</textarea></label>
|
|
<label>Branch <input name="branch" value="{escape(repository.branch)}" required></label>
|
|
<button class="secondary" type="submit">Save Repository</button>
|
|
</form>
|
|
<h2>Run Analysis</h2>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/analysis-runs">
|
|
<label>Override source path <input name="source_path" placeholder="Optional local path"></label>
|
|
<label class="checkbox"><input type="checkbox" name="use_cached_checkout" value="1"> Analyze cached checkout without fetching upstream</label>
|
|
<label class="checkbox"><input type="checkbox" name="use_llm_assistance" value="1" checked> Use LLM assistance if configured</label>
|
|
<label class="checkbox"><input type="checkbox" name="trusted_auto_approve" value="1"> Trusted auto-populate after analysis</label>
|
|
<label>Username <input name="access_username" autocomplete="username" placeholder="Optional for private HTTP(S) repos"></label>
|
|
<label>Password or access token <input name="access_password" type="password" autocomplete="current-password" placeholder="Used for this Git operation only"></label>
|
|
<div class="actions">
|
|
<button type="submit">Analyze</button>
|
|
<span data-pending>Running analysis...</span>
|
|
</div>
|
|
</form>
|
|
<h2>Analysis Runs</h2>
|
|
<table>
|
|
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Compare</th><th>Error</th></tr></thead>
|
|
<tbody>{run_rows or '<tr><td colspan="5" class="muted">No runs yet.</td></tr>'}</tbody>
|
|
</table>
|
|
</section>
|
|
<section class="stack">
|
|
<div class="panel">
|
|
<h2>Approved Characteristics</h2>
|
|
{render_graph_counts(
|
|
asdict(ability_map),
|
|
facts_count=None,
|
|
base_href=(
|
|
f"/ui/repos/{repository_id}/elements?scope=all"
|
|
f"&entry_filter=approved"
|
|
),
|
|
)}
|
|
{render_approved_registry_actions(repository_id, asdict(ability_map))}
|
|
</div>
|
|
<div class="panel">
|
|
<h2>Latest Candidate Graph</h2>
|
|
{render_latest_candidate_counts(repository_id, latest_candidate, service)}
|
|
</div>
|
|
<div class="panel">
|
|
<h2>Approved Characteristic Tree</h2>
|
|
{render_ability_map(asdict(ability_map), repository_id)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<section class="panel" style="margin-top:18px">
|
|
<h2>Manual Characteristic Tuning</h2>
|
|
<div class="grid">
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/abilities">
|
|
<h3>Add Ability</h3>
|
|
<label>Name <input name="name" required></label>
|
|
<label>Description <textarea name="description" rows="2"></textarea></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
|
|
<button type="submit">Add Ability</button>
|
|
</form>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/capabilities">
|
|
<h3>Add Capability</h3>
|
|
<label>Ability ID <input name="ability_id" type="number" min="1" required></label>
|
|
<label>Name <input name="name" required></label>
|
|
<label>Description <textarea name="description" rows="2"></textarea></label>
|
|
<label>Inputs <input name="inputs" placeholder="Comma-separated"></label>
|
|
<label>Outputs <input name="outputs" placeholder="Comma-separated"></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
|
|
<button type="submit">Add Capability</button>
|
|
</form>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/features">
|
|
<h3>Add Feature</h3>
|
|
<label>Capability ID <input name="capability_id" type="number" min="1" required></label>
|
|
<label>Name <input name="name" required></label>
|
|
<label>Type <input name="type" required></label>
|
|
<label>Location <input name="location"></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="1.0" required></label>
|
|
<button type="submit">Add Feature</button>
|
|
</form>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/evidence">
|
|
<h3>Add Capability Support</h3>
|
|
<label>Supported capability ID <input name="capability_id" type="number" min="1" required></label>
|
|
<label>Supported characteristic kind <input name="target_kind" value="capability" required></label>
|
|
<label>Supported characteristic ID <input name="target_id" type="number" min="1" placeholder="Defaults to supported capability ID"></label>
|
|
<label>Support type <input name="type" placeholder="fact, documentation, test, example, feature" required></label>
|
|
<label>Reference <input name="reference" placeholder="Observed fact, file, or lower-level characteristic" required></label>
|
|
<label>Reference kind <input name="reference_kind" value="source" placeholder="source, fact, feature, capability"></label>
|
|
<label>Reference ID <input name="reference_id" type="number" min="1" placeholder="Optional fact or characteristic ID"></label>
|
|
<label>Strength <input name="strength" value="medium" required></label>
|
|
<button type="submit">Add Support</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
<section class="panel" style="margin-top:18px">
|
|
<h2>Review Decisions</h2>
|
|
{render_review_decisions(decisions)}
|
|
</section>
|
|
<section class="panel" style="margin-top:18px">
|
|
<h2>Delete Repository</h2>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/delete">
|
|
<label>Confirm repository name <input name="confirm_name" placeholder="{escape(repository.name)}" required></label>
|
|
<button class="secondary" type="submit">Delete Repository</button>
|
|
</form>
|
|
</section>
|
|
"""
|
|
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,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
branch: str = Form("main"),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_repository(
|
|
repository_id,
|
|
name=name,
|
|
description=description,
|
|
branch=branch or "main",
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/scope/edit")
|
|
def edit_scope_from_form(
|
|
repository_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_scope(
|
|
repository_id,
|
|
name=name,
|
|
description=description,
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/delete")
|
|
def delete_repository_from_form(
|
|
repository_id: int,
|
|
confirm_name: str = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
repository = service.get_repository(repository_id)
|
|
if confirm_name != repository.name:
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
service.delete_repository(repository_id)
|
|
return RedirectResponse("/ui", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/abilities")
|
|
def create_ability_from_form(
|
|
repository_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.add_ability(
|
|
repository_id,
|
|
name=name,
|
|
description=description,
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/capabilities")
|
|
def create_capability_from_form(
|
|
repository_id: int,
|
|
ability_id: int = Form(...),
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
inputs: str = Form(""),
|
|
outputs: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.add_capability(
|
|
repository_id,
|
|
ability_id,
|
|
name=name,
|
|
description=description,
|
|
inputs=split_csv(inputs),
|
|
outputs=split_csv(outputs),
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/features")
|
|
def create_feature_from_form(
|
|
repository_id: int,
|
|
capability_id: int = Form(...),
|
|
name: str = Form(...),
|
|
type: str = Form(...),
|
|
location: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.add_feature(
|
|
repository_id,
|
|
capability_id,
|
|
name=name,
|
|
type=type,
|
|
location=location,
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/evidence")
|
|
def create_evidence_from_form(
|
|
repository_id: int,
|
|
capability_id: int = Form(...),
|
|
type: str = Form(...),
|
|
reference: str = Form(...),
|
|
strength: str = Form("medium"),
|
|
target_kind: str = Form("capability"),
|
|
target_id: int | None = Form(default=None),
|
|
reference_kind: str = Form("source"),
|
|
reference_id: int | None = Form(default=None),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.add_evidence(
|
|
repository_id,
|
|
capability_id,
|
|
type=type,
|
|
reference=reference,
|
|
strength=strength,
|
|
target_kind=target_kind,
|
|
target_id=target_id,
|
|
reference_kind=reference_kind,
|
|
reference_id=reference_id,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/abilities/{ability_id}/edit")
|
|
def edit_ability_from_form(
|
|
repository_id: int,
|
|
ability_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_ability(
|
|
repository_id,
|
|
ability_id,
|
|
name=name,
|
|
description=description,
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/abilities/{ability_id}/delete")
|
|
def delete_ability_from_form(
|
|
repository_id: int,
|
|
ability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.delete_ability(repository_id, ability_id)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/capabilities/{capability_id}/edit")
|
|
def edit_capability_from_form(
|
|
repository_id: int,
|
|
capability_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
inputs: str = Form(""),
|
|
outputs: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_capability(
|
|
repository_id,
|
|
capability_id,
|
|
name=name,
|
|
description=description,
|
|
inputs=split_csv(inputs),
|
|
outputs=split_csv(outputs),
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/capabilities/{capability_id}/delete")
|
|
def delete_capability_from_form(
|
|
repository_id: int,
|
|
capability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.delete_capability(repository_id, capability_id)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/features/{feature_id}/edit")
|
|
def edit_feature_from_form(
|
|
repository_id: int,
|
|
feature_id: int,
|
|
name: str = Form(...),
|
|
type: str = Form(...),
|
|
location: str = Form(""),
|
|
confidence: float = Form(1.0),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_feature(
|
|
repository_id,
|
|
feature_id,
|
|
name=name,
|
|
type=type,
|
|
location=location,
|
|
confidence=confidence,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/features/{feature_id}/delete")
|
|
def delete_feature_from_form(
|
|
repository_id: int,
|
|
feature_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.delete_feature(repository_id, feature_id)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/evidence/{evidence_id}/edit")
|
|
def edit_evidence_from_form(
|
|
repository_id: int,
|
|
evidence_id: int,
|
|
type: str = Form(...),
|
|
reference: str = Form(...),
|
|
strength: str = Form("medium"),
|
|
target_kind: str = Form("capability"),
|
|
target_id: int | None = Form(default=None),
|
|
reference_kind: str = Form("source"),
|
|
reference_id: int | None = Form(default=None),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.update_evidence(
|
|
repository_id,
|
|
evidence_id,
|
|
type=type,
|
|
reference=reference,
|
|
strength=strength,
|
|
target_kind=target_kind,
|
|
target_id=target_id,
|
|
reference_kind=reference_kind,
|
|
reference_id=reference_id,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/evidence/{evidence_id}/delete")
|
|
def delete_evidence_from_form(
|
|
repository_id: int,
|
|
evidence_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.delete_evidence(repository_id, evidence_id)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/analysis-runs")
|
|
def create_analysis_run_from_form(
|
|
repository_id: int,
|
|
source_path: str = Form(""),
|
|
use_cached_checkout: str | None = Form(None),
|
|
use_llm_assistance: str | None = Form(None),
|
|
trusted_auto_approve: str | None = Form(None),
|
|
access_username: str = Form(""),
|
|
access_password: str = Form(""),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
summary = service.analyze_repository(
|
|
repository_id,
|
|
source_path=source_path or None,
|
|
use_cached_checkout=bool(use_cached_checkout),
|
|
use_llm_assistance=bool(use_llm_assistance),
|
|
trusted_auto_approve=bool(trusted_auto_approve),
|
|
access_username=access_username or None,
|
|
access_password=access_password or None,
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{summary.analysis_run.id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.get("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}")
|
|
def analysis_run_detail(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> HTMLResponse:
|
|
repository = service.get_repository(repository_id)
|
|
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
|
|
facts = service.list_observed_facts(repository_id, analysis_run_id)
|
|
chunks = service.list_content_chunks(repository_id, analysis_run_id)
|
|
decisions = service.list_review_decisions(repository_id, analysis_run_id)
|
|
expectation_gaps = service.list_expectation_gaps(repository_id, analysis_run_id)
|
|
fact_rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td>{escape(fact.kind)}</td>
|
|
<td>{escape(fact.name)}</td>
|
|
<td class="source">{escape(fact.path)}</td>
|
|
<td class="source">{escape(fact.value)}</td>
|
|
</tr>
|
|
"""
|
|
for fact in facts
|
|
)
|
|
body = f"""
|
|
<div class="actions">
|
|
<h1 style="margin-right:auto">{escape(repository.name)} · Run #{analysis_run_id}</h1>
|
|
{render_run_detail_compare_link(repository_id, analysis_run_id, service.list_analysis_runs(repository_id))}
|
|
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
|
</div>
|
|
<div class="grid">
|
|
<section class="panel">
|
|
<div class="actions">
|
|
<h2 style="margin-right:auto">Candidate Graph</h2>
|
|
{render_graph_counts(
|
|
asdict(candidate_graph),
|
|
facts_count=len(facts),
|
|
base_href=(
|
|
f"/ui/repos/{repository_id}/elements?scope=all"
|
|
f"&entry_filter=candidate"
|
|
f"&analysis_run_id={analysis_run_id}"
|
|
),
|
|
facts_href=(
|
|
f"/ui/repos/{repository_id}/elements?scope=facts"
|
|
f"&analysis_run_id={analysis_run_id}&type=facts"
|
|
),
|
|
)}
|
|
<form method="post" action="/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve">
|
|
<button type="submit">Approve</button>
|
|
</form>
|
|
</div>
|
|
{render_candidate_graph(asdict(candidate_graph), repository_id, analysis_run_id)}
|
|
</section>
|
|
<section class="panel">
|
|
<div class="actions">
|
|
<h2 style="margin-right:auto">Observed Facts</h2>
|
|
{render_count_pills(
|
|
hrefs={
|
|
"facts": (
|
|
f"/ui/repos/{repository_id}/elements?scope=facts"
|
|
f"&analysis_run_id={analysis_run_id}&type=facts"
|
|
)
|
|
},
|
|
facts=len(facts),
|
|
)}
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Kind</th><th>Name</th><th>Path</th><th>Value</th></tr></thead>
|
|
<tbody>{fact_rows or '<tr><td colspan="4" class="muted">No observed facts.</td></tr>'}</tbody>
|
|
</table>
|
|
<h2>Review Decisions</h2>
|
|
{render_review_decisions(decisions)}
|
|
</section>
|
|
</div>
|
|
<section class="panel" style="margin-top:18px">
|
|
<h2>Content Chunks</h2>
|
|
{render_content_chunks(chunks)}
|
|
</section>
|
|
<section class="panel" style="margin-top:18px">
|
|
<h2>Expectation Gaps</h2>
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/expectation-gaps">
|
|
<div class="grid">
|
|
<label>Expected type <input name="expected_type" placeholder="capability, feature, fact, classification" required></label>
|
|
<label>Expected name <input name="expected_name" placeholder="Use OpenRouter Models" required></label>
|
|
<label>Source <input name="source" value="human" required></label>
|
|
<label>Notes <input name="notes" placeholder="What made you expect this?"></label>
|
|
</div>
|
|
<button type="submit">Record Gap</button>
|
|
</form>
|
|
{render_expectation_gaps(expectation_gaps)}
|
|
</section>
|
|
"""
|
|
return page(f"{repository.name} Run {analysis_run_id}", body)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/expectation-gaps")
|
|
def create_expectation_gap_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
expected_type: str = Form(...),
|
|
expected_name: str = Form(...),
|
|
source: str = Form("human"),
|
|
notes: str = Form(""),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.record_expectation_gap(
|
|
repository_id,
|
|
analysis_run_id=analysis_run_id,
|
|
expected_type=expected_type,
|
|
expected_name=expected_name,
|
|
source=source,
|
|
notes=notes,
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.get("/ui/repos/{repository_id}/elements")
|
|
def repository_element_listing(
|
|
repository_id: int,
|
|
scope: str = Query("all"),
|
|
type: str = Query("abilities"),
|
|
q: str = Query(""),
|
|
class_filter: str = Query(""),
|
|
entry_filter: str = Query(""),
|
|
candidate_status_filter: str = Query("active"),
|
|
analysis_run_id: int | None = Query(default=None),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> HTMLResponse:
|
|
repository = service.get_repository(repository_id)
|
|
if scope in {"approved", "candidate"} and not entry_filter:
|
|
entry_filter = scope
|
|
title = element_listing_title(repository.name, scope, type)
|
|
if scope in {"all", "approved", "candidate"}:
|
|
elements = graph_element_rows(
|
|
asdict(service.ability_map(repository_id)),
|
|
type,
|
|
entry_state="approved",
|
|
)
|
|
candidate_elements: list[dict] = []
|
|
if analysis_run_id is None:
|
|
runs = service.list_analysis_runs(repository_id)
|
|
latest = latest_completed_candidate_graph(service, repository_id, runs)
|
|
if latest is not None:
|
|
analysis_run_id, candidate_graph = latest
|
|
candidate_elements = graph_element_rows(
|
|
asdict(candidate_graph),
|
|
type,
|
|
entry_state="candidate",
|
|
)
|
|
else:
|
|
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
|
|
candidate_elements = graph_element_rows(
|
|
asdict(candidate_graph),
|
|
type,
|
|
entry_state="candidate",
|
|
)
|
|
elements.extend(candidate_elements)
|
|
elif scope == "facts":
|
|
facts = service.list_observed_facts(repository_id, analysis_run_id)
|
|
elements = fact_element_rows(facts)
|
|
else:
|
|
elements = []
|
|
entry_scoped_elements = filter_element_rows(
|
|
elements,
|
|
"",
|
|
"",
|
|
entry_filter,
|
|
candidate_status_filter,
|
|
)
|
|
filtered = filter_element_rows(
|
|
entry_scoped_elements,
|
|
q,
|
|
class_filter,
|
|
candidate_status_filter="all",
|
|
)
|
|
rows = render_element_rows(
|
|
filtered,
|
|
repository_id=repository_id,
|
|
analysis_run_id=analysis_run_id,
|
|
)
|
|
filter_action = f"/ui/repos/{repository_id}/elements"
|
|
listing_scope = "facts" if scope == "facts" else "all"
|
|
|
|
body = f"""
|
|
<div class="actions">
|
|
<h1 style="margin-right:auto">{escape(title)}</h1>
|
|
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
|
</div>
|
|
<section class="panel" style="margin-bottom:18px">
|
|
<form class="stack" method="get" action="{filter_action}">
|
|
<input type="hidden" name="scope" value="{escape(listing_scope)}">
|
|
<input type="hidden" name="type" value="{escape(type)}">
|
|
{render_optional_hidden("analysis_run_id", analysis_run_id)}
|
|
<div class="grid">
|
|
<label>Search <input name="q" value="{escape(q)}" placeholder="Name, parent, source, or class"></label>
|
|
<label>Class <input name="class_filter" value="{escape(class_filter)}" list="element-classes" placeholder="Any class"></label>
|
|
{render_entry_filter(entry_filter) if scope != "facts" else ""}
|
|
{render_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""}
|
|
</div>
|
|
{render_class_datalist(entry_scoped_elements)}
|
|
<div class="actions">
|
|
<button type="submit">Filter</button>
|
|
<a class="button secondary" href="{filter_action}?scope={escape(listing_scope)}&type={escape(type)}{render_analysis_run_query_suffix(analysis_run_id)}">Clear</a>
|
|
<span class="muted">{len(filtered)} of {len(entry_scoped_elements)} shown</span>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
<section class="panel">
|
|
<table>
|
|
<thead><tr><th>Entry</th><th>Class</th><th>Name</th><th>Parent</th><th>Source</th><th>Actions</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
</section>
|
|
"""
|
|
return page(title, body)
|
|
|
|
|
|
@router.get("/ui/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}")
|
|
def analysis_run_diff_detail(
|
|
repository_id: int,
|
|
base_analysis_run_id: int,
|
|
target_analysis_run_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> HTMLResponse:
|
|
try:
|
|
diff = service.diff_analysis_runs(
|
|
repository_id,
|
|
base_analysis_run_id,
|
|
target_analysis_run_id,
|
|
)
|
|
except NotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
body = f"""
|
|
<div class="actions">
|
|
<h1 style="margin-right:auto">{escape(diff.repository.name)} · Change Review</h1>
|
|
<a class="button secondary" href="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}">Target Run</a>
|
|
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
|
</div>
|
|
<p class="muted">Comparing run #{base_analysis_run_id} to run #{target_analysis_run_id}.</p>
|
|
<section class="panel">
|
|
<div class="actions">
|
|
<h2 style="margin-right:auto">Approve Target Changes</h2>
|
|
<form class="actions" method="post" action="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}/changes/approve">
|
|
<input name="notes" value="Approved target run changes from web UI" aria-label="Approval notes">
|
|
<button type="submit">Approve Changes</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
<div class="grid" style="margin-top:18px">
|
|
<section class="panel">
|
|
<h2>Approved Registry Impact</h2>
|
|
{render_diff_section(asdict(diff.approved_entries))}
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Candidate Claims</h2>
|
|
{render_diff_section(asdict(diff.candidates))}
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Observed Facts</h2>
|
|
{render_diff_section(asdict(diff.facts))}
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Content Chunks</h2>
|
|
{render_diff_section(asdict(diff.chunks))}
|
|
</section>
|
|
</div>
|
|
"""
|
|
return page(f"{diff.repository.name} Change Review", body)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve")
|
|
def approve_candidate_graph_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.approve_candidate_graph(
|
|
repository_id,
|
|
analysis_run_id,
|
|
notes="Approved from web UI",
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve")
|
|
def approve_analysis_run_changes_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
notes: str = Form("Approved target run changes from web UI"),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.approve_analysis_run_changes(
|
|
repository_id,
|
|
analysis_run_id,
|
|
notes=notes,
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-abilities/{candidate_ability_id}/reject"
|
|
)
|
|
def reject_candidate_ability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_ability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.reject_candidate_ability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_ability_id,
|
|
notes="Rejected from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-abilities/{candidate_ability_id}/accept"
|
|
)
|
|
def accept_candidate_ability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_ability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.accept_candidate_ability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_ability_id,
|
|
notes="Accepted from web UI",
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-capabilities/{candidate_capability_id}/reject"
|
|
)
|
|
def reject_candidate_capability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_capability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.reject_candidate_capability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_capability_id,
|
|
notes="Rejected from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-capabilities/{candidate_capability_id}/accept"
|
|
)
|
|
def accept_candidate_capability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_capability_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.accept_candidate_capability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_capability_id,
|
|
notes="Accepted from web UI",
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-features/{candidate_feature_id}/reject"
|
|
)
|
|
def reject_candidate_feature_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_feature_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.reject_candidate_feature(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_feature_id,
|
|
notes="Rejected from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-features/{candidate_feature_id}/accept"
|
|
)
|
|
def accept_candidate_feature_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_feature_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.accept_candidate_feature(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_feature_id,
|
|
notes="Accepted from web UI",
|
|
)
|
|
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-evidence/{candidate_evidence_id}/reject"
|
|
)
|
|
def reject_candidate_evidence_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_evidence_id: int,
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.reject_candidate_evidence(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_evidence_id,
|
|
notes="Rejected from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-abilities/{candidate_ability_id}/edit"
|
|
)
|
|
def edit_candidate_ability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_ability_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
confidence: float = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.edit_candidate_ability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_ability_id,
|
|
name=name,
|
|
description=description,
|
|
confidence=confidence,
|
|
notes="Edited from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-capabilities/{candidate_capability_id}/edit"
|
|
)
|
|
def edit_candidate_capability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_capability_id: int,
|
|
name: str = Form(...),
|
|
description: str = Form(""),
|
|
confidence: float = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.edit_candidate_capability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_capability_id,
|
|
name=name,
|
|
description=description,
|
|
confidence=confidence,
|
|
notes="Edited from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-capabilities/{candidate_capability_id}/relink"
|
|
)
|
|
def relink_candidate_capability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_capability_id: int,
|
|
target_ability_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.relink_candidate_capability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_capability_id,
|
|
target_ability_id=target_ability_id,
|
|
notes="Relinked from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-features/{candidate_feature_id}/relink"
|
|
)
|
|
def relink_candidate_feature_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_feature_id: int,
|
|
target_capability_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.relink_candidate_feature(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_feature_id,
|
|
target_capability_id=target_capability_id,
|
|
notes="Relinked from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-evidence/{candidate_evidence_id}/relink"
|
|
)
|
|
def relink_candidate_evidence_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
candidate_evidence_id: int,
|
|
target_capability_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.relink_candidate_evidence(
|
|
repository_id,
|
|
analysis_run_id,
|
|
candidate_evidence_id,
|
|
target_capability_id=target_capability_id,
|
|
notes="Relinked from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-abilities/{source_ability_id}/merge"
|
|
)
|
|
def merge_candidate_ability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
source_ability_id: int,
|
|
target_ability_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.merge_candidate_ability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
source_ability_id,
|
|
target_ability_id=target_ability_id,
|
|
notes="Merged from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-capabilities/{source_capability_id}/merge"
|
|
)
|
|
def merge_candidate_capability_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
source_capability_id: int,
|
|
target_capability_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.merge_candidate_capability(
|
|
repository_id,
|
|
analysis_run_id,
|
|
source_capability_id,
|
|
target_capability_id=target_capability_id,
|
|
notes="Merged from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-features/{source_feature_id}/merge"
|
|
)
|
|
def merge_candidate_feature_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
source_feature_id: int,
|
|
target_feature_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.merge_candidate_feature(
|
|
repository_id,
|
|
analysis_run_id,
|
|
source_feature_id,
|
|
target_feature_id=target_feature_id,
|
|
notes="Merged from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
"/candidate-evidence/{source_evidence_id}/merge"
|
|
)
|
|
def merge_candidate_evidence_from_form(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
source_evidence_id: int,
|
|
target_evidence_id: int = Form(...),
|
|
service: RegistryService = Depends(get_service),
|
|
) -> RedirectResponse:
|
|
service.merge_candidate_evidence(
|
|
repository_id,
|
|
analysis_run_id,
|
|
source_evidence_id,
|
|
target_evidence_id=target_evidence_id,
|
|
notes="Merged from web UI",
|
|
)
|
|
return RedirectResponse(
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}",
|
|
status_code=303,
|
|
)
|
|
|
|
|
|
def render_run_compare_link(repository_id: int, run_id: int, runs: list) -> str:
|
|
base_run = next((run for run in runs if run.id < run_id), None)
|
|
if base_run is None:
|
|
return '<span class="muted">Baseline</span>'
|
|
href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{run_id}"
|
|
return f'<a href="{href}">Compare to #{base_run.id}</a>'
|
|
|
|
|
|
def render_run_detail_compare_link(
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
runs: list,
|
|
) -> str:
|
|
base_run = next((run for run in runs if run.id < analysis_run_id), None)
|
|
if base_run is None:
|
|
return ""
|
|
href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{analysis_run_id}"
|
|
return f'<a class="button secondary" href="{href}">Compare to #{base_run.id}</a>'
|
|
|
|
|
|
def render_diff_section(section: dict) -> str:
|
|
groups = [
|
|
("added", "Added"),
|
|
("removed", "Removed"),
|
|
("changed", "Changed"),
|
|
("weakened", "Weakened"),
|
|
]
|
|
rendered = "".join(
|
|
render_diff_group(section.get(key, []), title)
|
|
for key, title in groups
|
|
)
|
|
return rendered or '<p class="muted">No differences.</p>'
|
|
|
|
|
|
def render_diff_group(items: list[dict], title: str) -> str:
|
|
if not items:
|
|
return ""
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><span class="pill">{escape(item['item_type'])}</span></td>
|
|
<td class="source">{escape(item['key'])}</td>
|
|
<td>{render_diff_payload(item.get('base'))}</td>
|
|
<td>{render_diff_payload(item.get('target'))}</td>
|
|
</tr>
|
|
"""
|
|
for item in items
|
|
)
|
|
return f"""
|
|
<h3>{escape(title)}</h3>
|
|
<table>
|
|
<thead><tr><th>Type</th><th>Key</th><th>Base</th><th>Target</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def render_diff_payload(payload: dict | None) -> str:
|
|
if payload is None:
|
|
return '<span class="muted">None</span>'
|
|
preferred = [
|
|
"name",
|
|
"description",
|
|
"confidence",
|
|
"strength",
|
|
"path",
|
|
"location",
|
|
"reference",
|
|
"value",
|
|
"text",
|
|
]
|
|
parts = []
|
|
for key in preferred:
|
|
if key not in payload or payload[key] in (None, "", []):
|
|
continue
|
|
value = str(payload[key])
|
|
if key == "text" and len(value) > 180:
|
|
value = value[:180] + "..."
|
|
parts.append(
|
|
f'<div><span class="muted">{escape(key)}</span>: {escape(value)}</div>'
|
|
)
|
|
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 latest_completed_candidate_graph(
|
|
service: RegistryService,
|
|
repository_id: int,
|
|
runs: list,
|
|
):
|
|
for run in sorted(runs, key=lambda item: item.id, reverse=True):
|
|
if run.status != "completed":
|
|
continue
|
|
try:
|
|
return run.id, service.candidate_graph(repository_id, run.id)
|
|
except NotFoundError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def render_latest_candidate_counts(
|
|
repository_id: int,
|
|
latest_candidate,
|
|
service: RegistryService,
|
|
) -> str:
|
|
if latest_candidate is None:
|
|
return '<p class="muted">No completed candidate graph yet.</p>'
|
|
analysis_run_id, candidate_graph = latest_candidate
|
|
facts_count = len(service.list_observed_facts(repository_id, analysis_run_id))
|
|
return render_graph_counts(
|
|
active_candidate_graph(asdict(candidate_graph)),
|
|
facts_count=facts_count,
|
|
label_prefix="candidate",
|
|
base_href=(
|
|
f"/ui/repos/{repository_id}/elements?scope=all"
|
|
f"&entry_filter=candidate"
|
|
f"&analysis_run_id={analysis_run_id}"
|
|
),
|
|
facts_href=(
|
|
f"/ui/repos/{repository_id}/elements?scope=facts"
|
|
f"&analysis_run_id={analysis_run_id}&type=facts"
|
|
),
|
|
)
|
|
|
|
|
|
def active_candidate_graph(graph: dict) -> dict:
|
|
active_abilities = []
|
|
for ability in graph.get("abilities", []):
|
|
active_capabilities = []
|
|
for capability in ability.get("capabilities", []):
|
|
active_features = [
|
|
feature
|
|
for feature in capability.get("features", [])
|
|
if feature.get("status", "candidate") != "rejected"
|
|
]
|
|
active_evidence = [
|
|
evidence
|
|
for evidence in capability.get("evidence", [])
|
|
if evidence.get("status", "candidate") != "rejected"
|
|
]
|
|
capability_active = (
|
|
capability.get("status", "candidate") != "rejected"
|
|
and (active_features or active_evidence)
|
|
)
|
|
if capability_active:
|
|
active_capability = {**capability}
|
|
active_capability["features"] = active_features
|
|
active_capability["evidence"] = active_evidence
|
|
active_capabilities.append(active_capability)
|
|
if ability.get("status", "candidate") != "rejected" and active_capabilities:
|
|
active_ability = {**ability}
|
|
active_ability["capabilities"] = active_capabilities
|
|
active_abilities.append(active_ability)
|
|
active_graph = {**graph}
|
|
active_graph["abilities"] = active_abilities
|
|
return active_graph
|
|
|
|
|
|
def render_graph_counts(
|
|
graph: dict,
|
|
facts_count: int | None = None,
|
|
label_prefix: str = "",
|
|
base_href: str | None = None,
|
|
facts_href: str | None = None,
|
|
) -> str:
|
|
abilities = graph.get("abilities", [])
|
|
capabilities = [
|
|
capability
|
|
for ability in abilities
|
|
for capability in ability.get("capabilities", [])
|
|
]
|
|
features = [
|
|
feature
|
|
for capability in capabilities
|
|
for feature in capability.get("features", [])
|
|
]
|
|
supports = [
|
|
evidence
|
|
for capability in capabilities
|
|
for evidence in capability.get("evidence", [])
|
|
]
|
|
counts: dict[str, int] = {
|
|
"scopes": 1 if graph.get("scope") else 0,
|
|
"abilities": len(abilities),
|
|
"capabilities": len(capabilities),
|
|
"features": len(features),
|
|
"supports": len(supports),
|
|
}
|
|
if facts_count is not None:
|
|
counts["facts"] = facts_count
|
|
hrefs = (
|
|
{name: f"{base_href}&type={name}" for name in counts if name != "facts"}
|
|
if base_href
|
|
else {}
|
|
)
|
|
if facts_href:
|
|
hrefs["facts"] = facts_href
|
|
return render_count_pills(label_prefix=label_prefix, hrefs=hrefs, **counts)
|
|
|
|
|
|
def render_count_pills(
|
|
label_prefix: str = "",
|
|
hrefs: dict[str, str] | None = None,
|
|
**counts: int,
|
|
) -> str:
|
|
labels = {
|
|
"scopes": "scope",
|
|
"abilities": "abilities",
|
|
"capabilities": "capabilities",
|
|
"features": "features",
|
|
"supports": "supports",
|
|
"facts": "facts",
|
|
}
|
|
prefix = f"{label_prefix} " if label_prefix else ""
|
|
hrefs = hrefs or {}
|
|
items = []
|
|
for name, count in counts.items():
|
|
label = f"{count} {prefix}{labels[name]}"
|
|
if name in hrefs:
|
|
items.append(f'<a class="pill" href="{escape(hrefs[name])}">{label}</a>')
|
|
else:
|
|
items.append(f'<span class="pill">{label}</span>')
|
|
return "".join(items)
|
|
|
|
|
|
def render_approved_registry_actions(repository_id: int, ability_map: dict) -> str:
|
|
abilities = ability_map.get("abilities", [])
|
|
if not abilities:
|
|
return ""
|
|
search_query = abilities[0]["name"]
|
|
return f"""
|
|
<div class="notice" style="margin-top:12px">
|
|
<h3>Use Approved Registry</h3>
|
|
<div class="actions">
|
|
<a class="button secondary" href="/ui/search?q={quote_plus(search_query)}">Search Profile</a>
|
|
<a class="button secondary" href="/ui/discovery">Discovery</a>
|
|
<a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a>
|
|
<a class="button secondary" href="/ui/repos/{repository_id}/elements?scope=all&entry_filter=approved&type=abilities">Elements</a>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def graph_element_rows(
|
|
graph: dict,
|
|
item_type: str,
|
|
*,
|
|
entry_state: str = "",
|
|
) -> list[dict]:
|
|
rows: list[dict] = []
|
|
scope = graph.get("scope")
|
|
if item_type in {"scopes", "scope"} and scope and entry_state == "approved":
|
|
rows.append(
|
|
element_row(
|
|
"scope",
|
|
scope["name"],
|
|
"",
|
|
[],
|
|
item_id=scope.get("id"),
|
|
item_kind="scope",
|
|
description=scope.get("description", ""),
|
|
confidence=scope.get("confidence", 1.0),
|
|
entry_state=entry_state,
|
|
)
|
|
)
|
|
for ability in graph.get("abilities", []):
|
|
if item_type == "abilities":
|
|
rows.append(
|
|
element_row(
|
|
ability.get("primary_class", "ability"),
|
|
ability["name"],
|
|
"",
|
|
ability.get("source_refs", []),
|
|
item_id=ability.get("id"),
|
|
item_kind="abilities",
|
|
description=ability.get("description", ""),
|
|
confidence=ability.get("confidence", 1.0),
|
|
status=ability.get("status", ""),
|
|
entry_state=entry_state,
|
|
)
|
|
)
|
|
for capability in ability.get("capabilities", []):
|
|
if item_type == "capabilities":
|
|
rows.append(
|
|
element_row(
|
|
capability.get("primary_class", "capability"),
|
|
capability["name"],
|
|
ability["name"],
|
|
capability.get("source_refs", []),
|
|
item_id=capability.get("id"),
|
|
item_kind="capabilities",
|
|
description=capability.get("description", ""),
|
|
confidence=capability.get("confidence", 1.0),
|
|
inputs=capability.get("inputs", []),
|
|
outputs=capability.get("outputs", []),
|
|
status=capability.get("status", ""),
|
|
entry_state=entry_state,
|
|
)
|
|
)
|
|
for feature in capability.get("features", []):
|
|
if item_type == "features":
|
|
rows.append(
|
|
element_row(
|
|
feature.get("type", "feature"),
|
|
feature["name"],
|
|
capability["name"],
|
|
feature.get("source_refs", []),
|
|
item_id=feature.get("id"),
|
|
item_kind="features",
|
|
confidence=feature.get("confidence", 1.0),
|
|
location=feature.get("location", ""),
|
|
status=feature.get("status", ""),
|
|
entry_state=entry_state,
|
|
)
|
|
)
|
|
for evidence in capability.get("evidence", []):
|
|
if item_type in {"supports", "evidence"}:
|
|
rows.append(
|
|
element_row(
|
|
evidence.get("strength", "medium"),
|
|
f"{evidence.get('type', 'support')}: {evidence.get('reference', '')}",
|
|
capability["name"],
|
|
evidence.get("source_refs", []),
|
|
item_id=evidence.get("id"),
|
|
item_kind="evidence",
|
|
support_type=evidence.get("type", ""),
|
|
reference=evidence.get("reference", ""),
|
|
strength=evidence.get("strength", "medium"),
|
|
status=evidence.get("status", ""),
|
|
target_kind=evidence.get("target_kind", "capability"),
|
|
target_id=evidence.get("target_id"),
|
|
reference_kind=evidence.get("reference_kind", "source"),
|
|
reference_id=evidence.get("reference_id"),
|
|
entry_state=entry_state,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def element_listing_title(repository_name: str, scope: str, item_type: str) -> str:
|
|
type_labels = {
|
|
"abilities": "Abilities",
|
|
"capabilities": "Capabilities",
|
|
"features": "Features",
|
|
"scopes": "Scopes",
|
|
"supports": "Supports",
|
|
"facts": "Facts",
|
|
}
|
|
if scope == "facts":
|
|
return f"{repository_name} · Observed Facts"
|
|
scope_labels = {
|
|
"all": "Registry",
|
|
"approved": "Approved",
|
|
"candidate": "Candidate",
|
|
}
|
|
scope_label = scope_labels.get(scope, scope.title())
|
|
type_label = type_labels.get(item_type, item_type.title())
|
|
return f"{repository_name} · {scope_label} {type_label}"
|
|
|
|
|
|
def fact_element_rows(facts: list) -> list[dict]:
|
|
return [
|
|
element_row(
|
|
fact.kind,
|
|
fact.name,
|
|
fact.path,
|
|
[
|
|
{
|
|
"path": fact.path,
|
|
"kind": fact.kind,
|
|
"name": fact.name,
|
|
"line": fact.metadata.get("line"),
|
|
}
|
|
],
|
|
)
|
|
for fact in facts
|
|
]
|
|
|
|
|
|
def element_row(
|
|
primary_class: str,
|
|
name: str,
|
|
parent: str,
|
|
source_refs: list[dict],
|
|
**metadata,
|
|
) -> dict:
|
|
return {
|
|
"primary_class": primary_class,
|
|
"name": name,
|
|
"parent": parent,
|
|
"source_refs": source_refs,
|
|
**metadata,
|
|
}
|
|
|
|
|
|
def filter_element_rows(
|
|
rows: list[dict],
|
|
query: str,
|
|
class_filter: str,
|
|
entry_filter: str = "",
|
|
candidate_status_filter: str = "",
|
|
) -> list[dict]:
|
|
query = query.strip().lower()
|
|
class_filter = class_filter.strip().lower()
|
|
entry_filter = entry_filter.strip().lower()
|
|
candidate_status_filter = candidate_status_filter.strip().lower()
|
|
filtered = []
|
|
for row in rows:
|
|
if entry_filter and row.get("entry_state", "").lower() != entry_filter:
|
|
continue
|
|
if not candidate_status_matches(row, candidate_status_filter):
|
|
continue
|
|
row_class = str(row["primary_class"]).lower()
|
|
if class_filter and class_filter not in row_class:
|
|
continue
|
|
haystack = " ".join(
|
|
[
|
|
str(row["primary_class"]),
|
|
str(row["name"]),
|
|
str(row["parent"]),
|
|
str(row.get("entry_state", "")),
|
|
str(row.get("status", "")),
|
|
str(row.get("support_type", "")),
|
|
str(row.get("reference", "")),
|
|
str(row.get("strength", "")),
|
|
str(row.get("target_kind", "")),
|
|
str(row.get("target_id", "")),
|
|
str(row.get("reference_kind", "")),
|
|
str(row.get("reference_id", "")),
|
|
source_refs_text(row["source_refs"]),
|
|
]
|
|
).lower()
|
|
if query and query not in haystack:
|
|
continue
|
|
filtered.append(row)
|
|
return filtered
|
|
|
|
|
|
def candidate_status_matches(row: dict, candidate_status_filter: str) -> bool:
|
|
if row.get("entry_state") != "candidate":
|
|
return True
|
|
status = str(row.get("status") or "candidate").lower()
|
|
if candidate_status_filter == "all":
|
|
return True
|
|
if candidate_status_filter in {"", "active"}:
|
|
return status != "rejected"
|
|
return status == candidate_status_filter
|
|
|
|
|
|
def render_element_rows(
|
|
rows: list[dict],
|
|
repository_id: int,
|
|
analysis_run_id: int | None,
|
|
) -> str:
|
|
if not rows:
|
|
return '<tr><td colspan="6" class="muted">No matching elements.</td></tr>'
|
|
return "\n".join(
|
|
render_element_row(row, repository_id, analysis_run_id)
|
|
for row in rows
|
|
)
|
|
|
|
|
|
def render_element_row(
|
|
row: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int | None,
|
|
) -> str:
|
|
return f"""
|
|
<tr>
|
|
<td>{render_entry_badge(row)}</td>
|
|
<td><span class="pill">{escape(str(row["primary_class"]))}</span></td>
|
|
<td>{escape(str(row["name"]))}</td>
|
|
<td>{escape(str(row["parent"]))}</td>
|
|
<td>{render_element_source_detail(row)}</td>
|
|
<td>{render_element_actions(row, repository_id, analysis_run_id)}</td>
|
|
</tr>
|
|
"""
|
|
|
|
|
|
def render_element_source_detail(row: dict) -> str:
|
|
if row.get("item_kind") == "evidence":
|
|
target = escape(str(row.get("target_kind") or "capability"))
|
|
target_id = row.get("target_id")
|
|
reference_kind = escape(str(row.get("reference_kind") or "source"))
|
|
reference_id = row.get("reference_id")
|
|
return (
|
|
f'<p><span class="pill">supports {target}{f" #{target_id}" if target_id else ""}</span>'
|
|
f' <span class="pill">references {reference_kind}{f" #{reference_id}" if reference_id else ""}</span></p>'
|
|
f'{render_sources(row["source_refs"])}'
|
|
)
|
|
return render_sources(row["source_refs"])
|
|
|
|
|
|
def render_element_actions(
|
|
row: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int | None,
|
|
) -> str:
|
|
item_id = row.get("item_id")
|
|
item_kind = row.get("item_kind")
|
|
if not item_id or not item_kind:
|
|
return ""
|
|
if row.get("entry_state") == "approved":
|
|
return render_approved_element_actions(row, repository_id)
|
|
if (
|
|
row.get("entry_state") == "candidate"
|
|
and row.get("item_kind") == "evidence"
|
|
and analysis_run_id is not None
|
|
and row.get("status", "candidate") == "candidate"
|
|
):
|
|
return render_candidate_support_element_actions(
|
|
row,
|
|
repository_id,
|
|
analysis_run_id,
|
|
)
|
|
if (
|
|
row.get("entry_state") == "candidate"
|
|
and analysis_run_id is not None
|
|
and row.get("status", "candidate") in {"candidate", "rejected"}
|
|
):
|
|
return render_candidate_element_actions(row, repository_id, analysis_run_id)
|
|
if row.get("entry_state") == "candidate" and row.get("status") == "approved":
|
|
return '<span class="muted">Accepted</span>'
|
|
return ""
|
|
|
|
|
|
def render_entry_badge(row: dict) -> str:
|
|
entry_state = row.get("entry_state")
|
|
if not entry_state:
|
|
return '<span class="pill">fact</span>'
|
|
status = row.get("status")
|
|
label = str(entry_state)
|
|
if entry_state == "candidate" and status and status != "candidate":
|
|
label = f"candidate: {status}"
|
|
return f'<span class="pill">{escape(label)}</span>'
|
|
|
|
|
|
def render_entry_filter(entry_filter: str) -> str:
|
|
options = [
|
|
("", "Approved and candidate"),
|
|
("approved", "Approved only"),
|
|
("candidate", "Candidate only"),
|
|
]
|
|
rendered_options = "".join(
|
|
f'<option value="{escape(value)}"{" selected" if entry_filter == value else ""}>{escape(label)}</option>'
|
|
for value, label in options
|
|
)
|
|
return f"""
|
|
<label>Entry
|
|
<select name="entry_filter">
|
|
{rendered_options}
|
|
</select>
|
|
</label>
|
|
"""
|
|
|
|
|
|
def render_approved_element_actions(row: dict, repository_id: int) -> str:
|
|
item_id = row["item_id"]
|
|
item_kind = row["item_kind"]
|
|
if item_kind == "scope":
|
|
return f"""
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/scope/edit">
|
|
{render_element_edit_fields(row)}
|
|
<button class="secondary" type="submit">Save</button>
|
|
</form>
|
|
"""
|
|
edit_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/edit"
|
|
delete_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/delete"
|
|
hidden_fields = render_element_hidden_fields(row)
|
|
edit_fields = render_element_edit_fields(row)
|
|
return f"""
|
|
<form class="stack" method="post" action="{edit_action}">
|
|
{hidden_fields}
|
|
{edit_fields}
|
|
<button class="secondary" type="submit">Save</button>
|
|
</form>
|
|
<form method="post" action="{delete_action}">
|
|
<button class="secondary" type="submit">Delete</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_support_element_actions(
|
|
row: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
item_id = row["item_id"]
|
|
reject_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/candidate-evidence/{item_id}/reject"
|
|
)
|
|
relink_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/candidate-evidence/{item_id}/relink"
|
|
)
|
|
merge_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/candidate-evidence/{item_id}/merge"
|
|
)
|
|
return f"""
|
|
<form method="post" action="{reject_action}">
|
|
<button class="secondary" type="submit">Remove</button>
|
|
</form>
|
|
<form style="display:inline-grid; grid-template-columns: 120px auto; gap: 6px; align-items: end;" method="post" action="{relink_action}">
|
|
<label>Target capability ID<input name="target_capability_id" type="number" min="1" required></label>
|
|
<button class="secondary" type="submit">Relink</button>
|
|
</form>
|
|
<form style="display:inline-grid; grid-template-columns: 140px auto; gap: 6px; align-items: end;" method="post" action="{merge_action}">
|
|
<label>Merge into support ID<input name="target_evidence_id" type="number" min="1" required></label>
|
|
<button class="secondary" type="submit">Merge</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_element_actions(
|
|
row: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
item_id = row["item_id"]
|
|
item_kind = row["item_kind"]
|
|
collection = f"candidate-{item_kind}"
|
|
reject_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{item_id}/reject"
|
|
)
|
|
accept_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{item_id}/accept"
|
|
)
|
|
status = row.get("status", "candidate")
|
|
edit = ""
|
|
if item_kind in {"abilities", "capabilities"}:
|
|
edit_action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{item_id}/edit"
|
|
)
|
|
edit = f"""
|
|
<form class="stack" method="post" action="{edit_action}">
|
|
{render_candidate_hidden_fields(row)}
|
|
{render_candidate_edit_fields(row)}
|
|
<button class="secondary" type="submit">Save</button>
|
|
</form>
|
|
"""
|
|
remove = (
|
|
f"""
|
|
<form method="post" action="{reject_action}">
|
|
<button class="secondary" type="submit">Remove</button>
|
|
</form>
|
|
"""
|
|
if status == "candidate"
|
|
else ""
|
|
)
|
|
return f"""
|
|
{edit}
|
|
<form method="post" action="{accept_action}">
|
|
<button type="submit">Accept</button>
|
|
</form>
|
|
{remove}
|
|
"""
|
|
|
|
|
|
def render_candidate_status_filter(candidate_status_filter: str) -> str:
|
|
options = [
|
|
("active", "Hide rejected"),
|
|
("candidate", "Pending only"),
|
|
("approved", "Accepted only"),
|
|
("rejected", "Rejected only"),
|
|
("all", "Include rejected"),
|
|
]
|
|
rendered_options = "".join(
|
|
f'<option value="{escape(value)}"{" selected" if candidate_status_filter == value else ""}>{escape(label)}</option>'
|
|
for value, label in options
|
|
)
|
|
return f"""
|
|
<label>Candidate status
|
|
<select name="candidate_status_filter">
|
|
{rendered_options}
|
|
</select>
|
|
</label>
|
|
"""
|
|
|
|
|
|
def render_element_edit_fields(row: dict) -> str:
|
|
item_kind = row["item_kind"]
|
|
name = escape(str(row["name"]))
|
|
if item_kind == "scope":
|
|
return f"""
|
|
<label>Name <input name="name" value="{name}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(str(row.get("description", "")))}</textarea></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{float(row.get("confidence", 1.0)):.2f}" required></label>
|
|
"""
|
|
if item_kind == "features":
|
|
feature_type = escape(str(row["primary_class"]))
|
|
location = escape(str(row.get("location", "")))
|
|
return f"""
|
|
<label>Name <input name="name" value="{name}" required></label>
|
|
<label>Type <input name="type" value="{feature_type}" required></label>
|
|
<label>Location <input name="location" value="{location}"></label>
|
|
"""
|
|
if item_kind == "evidence":
|
|
return f"""
|
|
<label>Supported characteristic kind <input name="target_kind" value="{escape(str(row.get("target_kind", "capability")))}" required></label>
|
|
<label>Supported characteristic ID <input name="target_id" type="number" min="1" value="{row.get('target_id') or ''}"></label>
|
|
<label>Support type <input name="type" value="{escape(str(row.get("support_type", "")))}" required></label>
|
|
<label>Reference <input name="reference" value="{escape(str(row.get("reference", "")))}" required></label>
|
|
<label>Reference kind <input name="reference_kind" value="{escape(str(row.get("reference_kind", "source")))}" required></label>
|
|
<label>Reference ID <input name="reference_id" type="number" min="1" value="{row.get('reference_id') or ''}"></label>
|
|
<label>Strength <input name="strength" value="{escape(str(row.get("strength", "medium")))}" required></label>
|
|
"""
|
|
return f'<label>Name <input name="name" value="{name}" required></label>'
|
|
|
|
|
|
def render_element_hidden_fields(row: dict) -> str:
|
|
item_kind = row["item_kind"]
|
|
confidence = float(row.get("confidence", 1.0))
|
|
fields = [f'<input type="hidden" name="confidence" value="{confidence:.2f}">']
|
|
if item_kind in {"abilities", "capabilities"}:
|
|
fields.append(
|
|
f'<input type="hidden" name="description" value="{escape(str(row.get("description", "")))}">'
|
|
)
|
|
if item_kind == "scope":
|
|
fields.append(
|
|
f'<input type="hidden" name="description" value="{escape(str(row.get("description", "")))}">'
|
|
)
|
|
if item_kind == "capabilities":
|
|
inputs = escape(", ".join(row.get("inputs", [])))
|
|
outputs = escape(", ".join(row.get("outputs", [])))
|
|
fields.append(
|
|
f'<input type="hidden" name="inputs" value="{inputs}">'
|
|
)
|
|
fields.append(
|
|
f'<input type="hidden" name="outputs" value="{outputs}">'
|
|
)
|
|
return "".join(fields)
|
|
|
|
|
|
def render_candidate_edit_fields(row: dict) -> str:
|
|
return f'<label>Name <input name="name" value="{escape(str(row["name"]))}" required></label>'
|
|
|
|
|
|
def render_candidate_hidden_fields(row: dict) -> str:
|
|
return (
|
|
f'<input type="hidden" name="description" value="{escape(str(row.get("description", "")))}">'
|
|
f'<input type="hidden" name="confidence" value="{float(row.get("confidence", 1.0)):.2f}">'
|
|
)
|
|
|
|
|
|
def render_class_datalist(rows: list[dict]) -> str:
|
|
classes = sorted({str(row["primary_class"]) for row in rows if row["primary_class"]})
|
|
options = "".join(
|
|
f'<option value="{escape(item)}"></option>'
|
|
for item in classes
|
|
)
|
|
return f'<datalist id="element-classes">{options}</datalist>'
|
|
|
|
|
|
def render_optional_hidden(name: str, value: int | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
return f'<input type="hidden" name="{escape(name)}" value="{value}">'
|
|
|
|
|
|
def render_analysis_run_query_suffix(analysis_run_id: int | None) -> str:
|
|
if analysis_run_id is None:
|
|
return ""
|
|
return f"&analysis_run_id={analysis_run_id}"
|
|
|
|
|
|
def source_refs_text(source_refs: list[dict]) -> str:
|
|
return " ".join(
|
|
" ".join(
|
|
str(ref.get(key, ""))
|
|
for key in ("kind", "name", "path", "line")
|
|
)
|
|
for ref in source_refs
|
|
)
|
|
|
|
|
|
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
|
|
abilities = graph.get("abilities", [])
|
|
if not abilities:
|
|
return '<p class="muted">No candidates generated.</p>'
|
|
items = []
|
|
for ability in abilities:
|
|
capabilities = "".join(
|
|
render_candidate_capability(capability, repository_id, analysis_run_id)
|
|
for capability in ability["capabilities"]
|
|
)
|
|
items.append(
|
|
f"""
|
|
<li>
|
|
<strong>{escape(ability['name'])}</strong>
|
|
<span class="pill">ID {ability['id']}</span>
|
|
<span class="pill">{escape(ability['status'])}</span>
|
|
<span class="pill">{ability['confidence']:.2f} {escape(ability['confidence_label'])}</span>
|
|
{render_candidate_ability_actions(ability, repository_id, analysis_run_id)}
|
|
<p class="muted">{escape(ability['description'])}</p>
|
|
{render_candidate_edit_form('candidate-abilities', ability, repository_id, analysis_run_id)}
|
|
{render_candidate_merge_form('candidate-abilities', ability, repository_id, analysis_run_id, 'target_ability_id', 'Merge into ability ID')}
|
|
{render_sources(ability['source_refs'])}
|
|
<ul>{capabilities}</ul>
|
|
</li>
|
|
"""
|
|
)
|
|
return f'<div class="tree"><ul>{"".join(items)}</ul></div>'
|
|
|
|
|
|
def render_repository_facts(languages: list[str], frameworks: list[str]) -> str:
|
|
if not languages and not frameworks:
|
|
return ""
|
|
language_pills = "".join(
|
|
f'<span class="pill">Language: {escape(language)}</span>'
|
|
for language in languages
|
|
)
|
|
framework_pills = "".join(
|
|
f'<span class="pill">Framework: {escape(framework)}</span>'
|
|
for framework in frameworks
|
|
)
|
|
return f'<p class="actions">{language_pills}{framework_pills}</p>'
|
|
|
|
|
|
def split_csv(value: str) -> list[str]:
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
def render_review_decisions(decisions: list) -> str:
|
|
if not decisions:
|
|
return '<p class="muted">No review decisions yet.</p>'
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td>{escape(decision.action)}</td>
|
|
<td class="source">{escape(decision.created_at)}</td>
|
|
<td>{escape(decision.notes)}</td>
|
|
</tr>
|
|
"""
|
|
for decision in decisions
|
|
)
|
|
return f"""
|
|
<table>
|
|
<thead><tr><th>Action</th><th>Created</th><th>Notes</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def render_expectation_gaps(gaps: list) -> str:
|
|
if not gaps:
|
|
return '<p class="muted">No expectation gaps recorded for this run.</p>'
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><span class="pill">{escape(gap.expected_type)}</span></td>
|
|
<td>{escape(gap.expected_name)}</td>
|
|
<td>{escape(gap.source)}</td>
|
|
<td>{escape(gap.notes)}</td>
|
|
<td>{escape(gap.status)}</td>
|
|
</tr>
|
|
"""
|
|
for gap in gaps
|
|
)
|
|
return f"""
|
|
<table>
|
|
<thead><tr><th>Type</th><th>Name</th><th>Source</th><th>Notes</th><th>Status</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def render_content_chunks(chunks: list) -> str:
|
|
if not chunks:
|
|
return '<p class="muted">No content chunks extracted.</p>'
|
|
rows = "\n".join(
|
|
f"""
|
|
<tr>
|
|
<td><span class="pill">{escape(chunk.kind)}</span></td>
|
|
<td class="source">{escape(chunk.path)}:{chunk.start_line}-{chunk.end_line}</td>
|
|
<td><pre>{escape(chunk.text[:500])}</pre></td>
|
|
</tr>
|
|
"""
|
|
for chunk in chunks
|
|
)
|
|
return f"""
|
|
<table>
|
|
<thead><tr><th>Kind</th><th>Source</th><th>Text</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def render_candidate_ability_actions(
|
|
ability: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
if ability["status"] != "candidate":
|
|
return ""
|
|
action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/candidate-abilities/{ability['id']}/reject"
|
|
)
|
|
return f"""
|
|
<form style="display:inline" method="post" action="{action}">
|
|
<button class="secondary" type="submit">Reject</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_edit_form(
|
|
collection: str,
|
|
candidate: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
if candidate["status"] != "candidate":
|
|
return ""
|
|
action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{candidate['id']}/edit"
|
|
)
|
|
confidence = f"{candidate['confidence']:.2f}"
|
|
return f"""
|
|
<form class="stack" method="post" action="{action}">
|
|
<label>Name <input name="name" value="{escape(candidate['name'])}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(candidate['description'])}</textarea></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{confidence}" required></label>
|
|
<button class="secondary" type="submit">Save Edit</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_capability(
|
|
capability: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
features = "".join(
|
|
render_candidate_feature(feature, repository_id, analysis_run_id)
|
|
for feature in capability["features"]
|
|
)
|
|
evidence = "".join(
|
|
render_candidate_evidence(item, repository_id, analysis_run_id)
|
|
for item in capability["evidence"]
|
|
)
|
|
return f"""
|
|
<li>
|
|
<strong>{escape(capability['name'])}</strong>
|
|
<span class="pill">ID {capability['id']}</span>
|
|
<span class="pill">{escape(capability['status'])}</span>
|
|
<span class="pill">{capability['confidence']:.2f} {escape(capability['confidence_label'])}</span>
|
|
{render_candidate_reject_form('candidate-capabilities', capability, repository_id, analysis_run_id)}
|
|
<p class="muted">{escape(capability['description'])}</p>
|
|
{render_candidate_edit_form('candidate-capabilities', capability, repository_id, analysis_run_id)}
|
|
{render_candidate_relink_form('candidate-capabilities', capability, repository_id, analysis_run_id, 'target_ability_id', 'Target ability ID')}
|
|
{render_candidate_merge_form('candidate-capabilities', capability, repository_id, analysis_run_id, 'target_capability_id', 'Merge into capability ID')}
|
|
{render_sources(capability['source_refs'])}
|
|
<h3>Features</h3>
|
|
<ul>{features or '<li class="muted">No feature candidates.</li>'}</ul>
|
|
<h3>Evidence</h3>
|
|
<ul>{evidence or '<li class="muted">No evidence candidates.</li>'}</ul>
|
|
</li>
|
|
"""
|
|
|
|
|
|
def render_candidate_feature(
|
|
feature: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
return f"""
|
|
<li>
|
|
{escape(feature["name"])}
|
|
<span class="pill">ID {feature["id"]}</span>
|
|
<span class="pill">{escape(feature["status"])}</span>
|
|
<span class="pill">{escape(feature["type"])}</span>
|
|
<span class="source">{escape(feature["location"])}</span>
|
|
{render_candidate_reject_form('candidate-features', feature, repository_id, analysis_run_id)}
|
|
{render_candidate_relink_form('candidate-features', feature, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')}
|
|
{render_candidate_merge_form('candidate-features', feature, repository_id, analysis_run_id, 'target_feature_id', 'Merge into feature ID')}
|
|
</li>
|
|
"""
|
|
|
|
|
|
def render_candidate_evidence(
|
|
evidence: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
return f"""
|
|
<li>
|
|
{escape(evidence["type"])}
|
|
<span class="pill">ID {evidence["id"]}</span>
|
|
<span class="pill">{escape(evidence["status"])}</span>
|
|
<span class="pill">{escape(evidence["strength"])}</span>
|
|
<span class="source">{escape(evidence["reference"])}</span>
|
|
{render_candidate_reject_form('candidate-evidence', evidence, repository_id, analysis_run_id)}
|
|
{render_candidate_relink_form('candidate-evidence', evidence, repository_id, analysis_run_id, 'target_capability_id', 'Target capability ID')}
|
|
{render_candidate_merge_form('candidate-evidence', evidence, repository_id, analysis_run_id, 'target_evidence_id', 'Merge into evidence ID')}
|
|
</li>
|
|
"""
|
|
|
|
|
|
def render_candidate_reject_form(
|
|
collection: str,
|
|
candidate: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
) -> str:
|
|
if candidate["status"] != "candidate":
|
|
return ""
|
|
action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{candidate['id']}/reject"
|
|
)
|
|
return f"""
|
|
<form style="display:inline" method="post" action="{action}">
|
|
<button class="secondary" type="submit">Reject</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_relink_form(
|
|
collection: str,
|
|
candidate: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
field_name: str,
|
|
label: str,
|
|
) -> str:
|
|
if candidate["status"] != "candidate":
|
|
return ""
|
|
action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{candidate['id']}/relink"
|
|
)
|
|
return f"""
|
|
<form style="display:inline-grid; grid-template-columns: 120px auto; gap: 6px; align-items: end;" method="post" action="{action}">
|
|
<label>{label}<input name="{field_name}" type="number" min="1" required></label>
|
|
<button class="secondary" type="submit">Relink</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_candidate_merge_form(
|
|
collection: str,
|
|
candidate: dict,
|
|
repository_id: int,
|
|
analysis_run_id: int,
|
|
field_name: str,
|
|
label: str,
|
|
) -> str:
|
|
if candidate["status"] != "candidate":
|
|
return ""
|
|
action = (
|
|
f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
|
f"/{collection}/{candidate['id']}/merge"
|
|
)
|
|
return f"""
|
|
<form style="display:inline-grid; grid-template-columns: 140px auto; gap: 6px; align-items: end;" method="post" action="{action}">
|
|
<label>{label}<input name="{field_name}" type="number" min="1" required></label>
|
|
<button class="secondary" type="submit">Merge</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_ability_map(ability_map: dict, repository_id: int) -> str:
|
|
abilities = ability_map.get("abilities", [])
|
|
scope = ability_map["scope"]
|
|
scope_description = scope.get("description") or (
|
|
f"Scope root for the approved characteristics of {scope['name']}."
|
|
)
|
|
items = []
|
|
for ability in abilities:
|
|
capabilities = []
|
|
for capability in ability["capabilities"]:
|
|
features = "".join(
|
|
render_approved_feature(feature, repository_id)
|
|
for feature in capability["features"]
|
|
)
|
|
evidence = "".join(
|
|
render_approved_evidence(item, repository_id)
|
|
for item in capability["evidence"]
|
|
)
|
|
capabilities.append(
|
|
f"""
|
|
<li id="capability-{capability['id']}">
|
|
<strong>{escape(capability['name'])}</strong>
|
|
<span class="pill">ID {capability['id']}</span>
|
|
<span class="pill">{capability['confidence']:.2f} {escape(capability['confidence_label'])}</span>
|
|
<p class="muted">{escape(capability['description'])}</p>
|
|
{render_approved_capability_forms(capability, repository_id)}
|
|
<h3>Features</h3>
|
|
<ul>{features or '<li class="muted">No approved features.</li>'}</ul>
|
|
<h3>Evidence supporting this capability</h3>
|
|
<ul>{evidence or '<li class="muted">No support evidence yet.</li>'}</ul>
|
|
</li>
|
|
"""
|
|
)
|
|
items.append(
|
|
f"""
|
|
<li id="ability-{ability['id']}">
|
|
<strong>{escape(ability['name'])}</strong>
|
|
<span class="pill">ID {ability['id']}</span>
|
|
<span class="pill">{ability['confidence']:.2f} {escape(ability['confidence_label'])}</span>
|
|
<p class="muted">{escape(ability['description'])}</p>
|
|
{render_approved_ability_forms(ability, repository_id)}
|
|
<ul>{''.join(capabilities)}</ul>
|
|
</li>
|
|
"""
|
|
)
|
|
return f"""
|
|
<div class="tree">
|
|
<ul>
|
|
<li id="scope-{scope['id']}">
|
|
<strong>{escape(scope['name'])}</strong>
|
|
<span class="pill">scope</span>
|
|
<span class="pill">{scope['confidence']:.2f} {escape(scope['confidence_label'])}</span>
|
|
<p class="muted">{escape(scope_description)}</p>
|
|
{render_approved_scope_form(scope, repository_id)}
|
|
<ul>{"".join(items) or '<li class="muted">No approved characteristics yet.</li>'}</ul>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
"""
|
|
|
|
|
|
def render_approved_scope_form(scope: dict, repository_id: int) -> str:
|
|
return f"""
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/scope/edit">
|
|
<label>Name <input name="name" value="{escape(scope['name'])}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(scope['description'])}</textarea></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{scope['confidence']:.2f}" required></label>
|
|
<div class="actions">
|
|
<button class="secondary" type="submit">Save Scope</button>
|
|
</div>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_approved_ability_forms(ability: dict, repository_id: int) -> str:
|
|
return f"""
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/abilities/{ability['id']}/edit">
|
|
<label>Name <input name="name" value="{escape(ability['name'])}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(ability['description'])}</textarea></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{ability['confidence']:.2f}" required></label>
|
|
<div class="actions">
|
|
<button class="secondary" type="submit">Save Ability</button>
|
|
</div>
|
|
</form>
|
|
<form method="post" action="/ui/repos/{repository_id}/abilities/{ability['id']}/delete">
|
|
<button class="secondary" type="submit">Delete Ability</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_approved_capability_forms(capability: dict, repository_id: int) -> str:
|
|
inputs = ", ".join(capability["inputs"])
|
|
outputs = ", ".join(capability["outputs"])
|
|
return f"""
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/capabilities/{capability['id']}/edit">
|
|
<label>Name <input name="name" value="{escape(capability['name'])}" required></label>
|
|
<label>Description <textarea name="description" rows="2">{escape(capability['description'])}</textarea></label>
|
|
<label>Inputs <input name="inputs" value="{escape(inputs)}"></label>
|
|
<label>Outputs <input name="outputs" value="{escape(outputs)}"></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{capability['confidence']:.2f}" required></label>
|
|
<div class="actions">
|
|
<button class="secondary" type="submit">Save Capability</button>
|
|
</div>
|
|
</form>
|
|
<form method="post" action="/ui/repos/{repository_id}/capabilities/{capability['id']}/delete">
|
|
<button class="secondary" type="submit">Delete Capability</button>
|
|
</form>
|
|
"""
|
|
|
|
|
|
def render_approved_feature(feature: dict, repository_id: int) -> str:
|
|
return f"""
|
|
<li>
|
|
{escape(feature["name"])}
|
|
<span class="pill">{escape(feature["type"])}</span>
|
|
<span class="pill">{feature["confidence"]:.2f} {escape(feature["confidence_label"])}</span>
|
|
<span class="source">{escape(feature["location"])}</span>
|
|
{render_sources(feature.get("source_refs", []))}
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/features/{feature['id']}/edit">
|
|
<label>Name <input name="name" value="{escape(feature['name'])}" required></label>
|
|
<label>Type <input name="type" value="{escape(feature['type'])}" required></label>
|
|
<label>Location <input name="location" value="{escape(feature['location'])}"></label>
|
|
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{feature['confidence']:.2f}" required></label>
|
|
<button class="secondary" type="submit">Save Feature</button>
|
|
</form>
|
|
<form method="post" action="/ui/repos/{repository_id}/features/{feature['id']}/delete">
|
|
<button class="secondary" type="submit">Delete Feature</button>
|
|
</form>
|
|
</li>
|
|
"""
|
|
|
|
|
|
def render_approved_evidence(evidence: dict, repository_id: int) -> str:
|
|
target_kind = escape(str(evidence.get("target_kind") or "capability"))
|
|
target_id = evidence.get("target_id")
|
|
reference_kind = escape(str(evidence.get("reference_kind") or "source"))
|
|
reference_id = evidence.get("reference_id")
|
|
return f"""
|
|
<li>
|
|
<strong>{escape(evidence["type"])}</strong>
|
|
<span class="pill">{escape(evidence["strength"])}</span>
|
|
<span class="pill">supports {target_kind}{f' #{target_id}' if target_id else ''}</span>
|
|
<span class="pill">references {reference_kind}{f' #{reference_id}' if reference_id else ''}</span>
|
|
<span class="source">{escape(evidence["reference"])}</span>
|
|
{render_sources(evidence.get("source_refs", []))}
|
|
<form class="stack" method="post" action="/ui/repos/{repository_id}/evidence/{evidence['id']}/edit">
|
|
<label>Supported characteristic kind <input name="target_kind" value="{target_kind}" required></label>
|
|
<label>Supported characteristic ID <input name="target_id" type="number" min="1" value="{target_id or ''}"></label>
|
|
<label>Support type <input name="type" value="{escape(evidence['type'])}" required></label>
|
|
<label>Reference <input name="reference" value="{escape(evidence['reference'])}" required></label>
|
|
<label>Reference kind <input name="reference_kind" value="{reference_kind}" required></label>
|
|
<label>Reference ID <input name="reference_id" type="number" min="1" value="{reference_id or ''}"></label>
|
|
<label>Strength <input name="strength" value="{escape(evidence['strength'])}" required></label>
|
|
<button class="secondary" type="submit">Save Support</button>
|
|
</form>
|
|
<form method="post" action="/ui/repos/{repository_id}/evidence/{evidence['id']}/delete">
|
|
<button class="secondary" type="submit">Delete Support</button>
|
|
</form>
|
|
</li>
|
|
"""
|
|
|
|
|
|
def search_result_href(result: dict) -> str:
|
|
href = f"/ui/repos/{result['repository_id']}"
|
|
if result.get("capability_id"):
|
|
return f"{href}#capability-{result['capability_id']}"
|
|
if result.get("ability_id"):
|
|
return f"{href}#ability-{result['ability_id']}"
|
|
return href
|
|
|
|
|
|
def render_sources(source_refs: list[dict]) -> str:
|
|
if not source_refs:
|
|
return ""
|
|
sources = ", ".join(
|
|
f'<span class="source">{escape(ref["kind"])}:{escape(source_ref_label(ref))}</span>'
|
|
for ref in source_refs[:5]
|
|
)
|
|
if len(source_refs) > 5:
|
|
sources += f' <span class="muted">+{len(source_refs) - 5} more</span>'
|
|
return f"<p>{sources}</p>"
|
|
|
|
|
|
def source_ref_label(ref: dict) -> str:
|
|
label = ref["path"] or ref["name"]
|
|
if ref.get("line"):
|
|
label = f"{label}:{ref['line']}"
|
|
return label
|
|
|
|
|
|
def render_search_context(result: dict) -> str:
|
|
details = []
|
|
if result.get("ability_name"):
|
|
details.append(f"Ability: {escape(result['ability_name'])}")
|
|
if result.get("capability_name"):
|
|
details.append(f"Capability: {escape(result['capability_name'])}")
|
|
if result.get("evidence_level"):
|
|
details.append(f"Evidence: {escape(result['evidence_level'])}")
|
|
if result.get("source_reference"):
|
|
details.append(f"Source: {escape(result['source_reference'])}")
|
|
if result.get("match_description"):
|
|
details.append(escape(result["match_description"]))
|
|
if not details:
|
|
return ""
|
|
return f'<p class="muted">{" · ".join(details)}</p>'
|