web UI side of change review

This commit is contained in:
2026-04-26 15:54:37 +02:00
parent 1415ad649a
commit 0970620722
3 changed files with 201 additions and 3 deletions

View File

@@ -281,6 +281,7 @@ def repository_detail(
<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>
"""
@@ -310,8 +311,8 @@ def repository_detail(
</form>
<h2>Analysis Runs</h2>
<table>
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Error</th></tr></thead>
<tbody>{run_rows or '<tr><td colspan="4" class="muted">No runs yet.</td></tr>'}</tbody>
<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="panel">
@@ -646,6 +647,7 @@ def analysis_run_detail(
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">
@@ -676,6 +678,59 @@ def analysis_run_detail(
return page(f"{repository.name} Run {analysis_run_id}", 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,
@@ -690,6 +745,21 @@ def approve_candidate_graph_from_form(
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"
@@ -1002,6 +1072,90 @@ def merge_candidate_evidence_from_form(
)
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_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
abilities = graph.get("abilities", [])
if not abilities:

View File

@@ -841,6 +841,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
)
assert run_response.status_code == 303
run_path = run_response.headers["location"]
first_run_id = int(run_path.rsplit("/", 1)[-1])
run_detail = client.get(run_path)
assert run_detail.status_code == 200
@@ -865,6 +866,49 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "interface:app.py:3" in approved_detail.text
assert "approve_candidate_graph" in approved_detail.text
(source / "app.py").write_text(
"from fastapi import FastAPI\n"
"app = FastAPI()\n"
'@app.get("/status")\n'
"def status():\n"
" return {}\n\n"
'@app.get("/ready")\n'
"def ready():\n"
" return {}\n",
encoding="utf-8",
)
second_run_response = client.post(
f"{repository_path}/analysis-runs",
data={"source_path": ""},
follow_redirects=False,
)
assert second_run_response.status_code == 303
second_run_path = second_run_response.headers["location"]
second_run_detail = client.get(second_run_path)
assert second_run_detail.status_code == 200
assert "Compare to #" in second_run_detail.text
second_run_id = int(second_run_path.rsplit("/", 1)[-1])
diff_response = client.get(
f"{repository_path}/analysis-runs/{first_run_id}/diff/{second_run_id}"
)
assert diff_response.status_code == 200
assert "Change Review" in diff_response.text
assert "Approved Registry Impact" in diff_response.text
assert "Candidate Claims" in diff_response.text
assert "GET /ready" in diff_response.text
change_approval = client.post(
f"{second_run_path}/changes/approve",
data={"notes": "Accept UI change review."},
follow_redirects=False,
)
assert change_approval.status_code == 303
changed_detail = client.get(change_approval.headers["location"])
assert "GET /ready" in changed_detail.text
assert "approve_analysis_run_changes" in changed_detail.text
search_response = client.get("/ui/search", params={"q": "repository"})
assert search_response.status_code == 200
assert "UI Repo" in search_response.text

View File

@@ -24,7 +24,7 @@ are reviewable; approved registry truth is explicit.
```task
id: RREG-WP-0002-T01
status: in_progress
status: done
priority: high
state_hub_task_id: "a27142a6-c160-4453-ab59-50a7db92f9c4"
```