Files
repo-scoping/src/repo_registry/web_ui/views.py
2026-04-29 17:20:06 +02:00

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>'