Add KB proposal review dashboard
This commit is contained in:
parent
1703232f24
commit
5ec375cd6e
4 changed files with 392 additions and 1 deletions
|
|
@ -26,6 +26,7 @@ from review_queue_routes import register_review_queue_routes
|
|||
from daily_digest_routes import register_daily_digest_routes
|
||||
from response_audit_routes import register_response_audit_routes, RESPONSE_AUDIT_PUBLIC_PATHS
|
||||
from leaderboard_routes import register_leaderboard_routes, LEADERBOARD_PUBLIC_PATHS
|
||||
from kb_proposal_routes import KB_PROPOSAL_PUBLIC_PATHS, register_kb_proposal_routes
|
||||
from lib.search import search as kb_search, embed_query, search_qdrant
|
||||
|
||||
logger = logging.getLogger("argus")
|
||||
|
|
@ -509,7 +510,7 @@ def _load_secret(path: Path) -> str | None:
|
|||
@web.middleware
|
||||
async def auth_middleware(request, handler):
|
||||
"""API key check. Public paths skip auth. Protected paths require X-Api-Key header."""
|
||||
if request.path in _PUBLIC_PATHS or request.path in RESPONSE_AUDIT_PUBLIC_PATHS or request.path in LEADERBOARD_PUBLIC_PATHS or request.path.startswith("/api/response-audit/"):
|
||||
if request.path in _PUBLIC_PATHS or request.path in RESPONSE_AUDIT_PUBLIC_PATHS or request.path in LEADERBOARD_PUBLIC_PATHS or request.path in KB_PROPOSAL_PUBLIC_PATHS or request.path.startswith("/api/response-audit/"):
|
||||
return await handler(request)
|
||||
expected = request.app.get("api_key")
|
||||
if not expected:
|
||||
|
|
@ -2356,6 +2357,7 @@ def create_app() -> web.Application:
|
|||
register_dashboard_routes(app, lambda: _conn_from_app(app))
|
||||
register_review_queue_routes(app)
|
||||
register_daily_digest_routes(app, db_path=str(DB_PATH))
|
||||
register_kb_proposal_routes(app)
|
||||
# Portfolio
|
||||
from dashboard_portfolio import register_portfolio_routes
|
||||
register_portfolio_routes(app, lambda: _conn_from_app(app))
|
||||
|
|
|
|||
265
diagnostics/kb_proposal_routes.py
Normal file
265
diagnostics/kb_proposal_routes.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""Read-only KB proposal review routes for Argus.
|
||||
|
||||
This surface is the operator-visible bridge between Leo's Telegram KB reasoning
|
||||
and the narrow apply worker. It intentionally performs no approve/reject/apply
|
||||
mutation; it only renders the same packets produced by
|
||||
``scripts/kb_proposal_review_packet.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aiohttp import web
|
||||
from shared_ui import render_page
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = ROOT / "scripts"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import kb_proposal_review_packet as proposal_review # noqa: E402
|
||||
|
||||
logger = logging.getLogger("argus.kb_proposals")
|
||||
|
||||
KB_PROPOSAL_PUBLIC_PATHS = frozenset({"/kb-proposals", "/api/kb-proposals"})
|
||||
KB_PROPOSAL_LOADER_KEY = web.AppKey("kb_proposal_loader", object)
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
|
||||
|
||||
def _query_limit(value: str | None) -> int:
|
||||
if not value:
|
||||
return DEFAULT_LIMIT
|
||||
try:
|
||||
limit = int(value)
|
||||
except ValueError:
|
||||
return DEFAULT_LIMIT
|
||||
return max(1, min(limit, MAX_LIMIT))
|
||||
|
||||
|
||||
def _query_filters(request: web.Request) -> dict[str, Any]:
|
||||
status = request.query.get("status", "approved").strip() or "approved"
|
||||
proposal_id = (request.query.get("proposal_id") or "").strip()
|
||||
if proposal_id:
|
||||
status = ""
|
||||
return {
|
||||
"status": status,
|
||||
"proposal_id": proposal_id,
|
||||
"limit": _query_limit(request.query.get("limit")),
|
||||
}
|
||||
|
||||
|
||||
def _db_args(filters: dict[str, Any]) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
proposal_id=filters["proposal_id"] or None,
|
||||
status=filters["status"] or None,
|
||||
limit=filters["limit"],
|
||||
secrets_file=os.environ.get(
|
||||
"KB_PROPOSAL_REVIEW_SECRETS_FILE",
|
||||
os.environ.get("KB_APPLY_SECRETS_FILE", proposal_review.ap.DEFAULT_SECRETS_FILE),
|
||||
),
|
||||
container=os.environ.get("KB_PROPOSAL_REVIEW_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
||||
db=os.environ.get("KB_PROPOSAL_REVIEW_DB", proposal_review.ap.DEFAULT_DB),
|
||||
host=os.environ.get("KB_PROPOSAL_REVIEW_HOST", proposal_review.ap.DEFAULT_HOST),
|
||||
role=os.environ.get("KB_PROPOSAL_REVIEW_ROLE", proposal_review.ap.DEFAULT_ROLE),
|
||||
)
|
||||
|
||||
|
||||
def _load_packets(request: web.Request, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
loader = request.app.get(KB_PROPOSAL_LOADER_KEY)
|
||||
proposals = loader(_db_args(filters)) if loader else proposal_review.load_from_db(_db_args(filters))
|
||||
return [proposal_review.classify_proposal(proposal) for proposal in proposals]
|
||||
|
||||
|
||||
def _counts(packets: list[dict[str, Any]], key: str) -> dict[str, int]:
|
||||
return dict(Counter(str(packet.get(key) or "unknown") for packet in packets))
|
||||
|
||||
|
||||
def build_kb_proposal_response(packets: list[dict[str, Any]], filters: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"filters": filters,
|
||||
"total": len(packets),
|
||||
"status_counts": _counts(packets, "status"),
|
||||
"review_state_counts": _counts(packets, "review_state"),
|
||||
"worker_applyable_count": sum(1 for packet in packets if packet.get("worker_applyable")),
|
||||
"packets": packets,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
|
||||
def _badge_class(value: str) -> str:
|
||||
if value in {"applied", "approved_applyable"}:
|
||||
return "badge-green"
|
||||
if value in {"approved", "approved_needs_apply_payload", "needs_human_review", "pending_review"}:
|
||||
return "badge-yellow"
|
||||
if value in {"unsupported_by_apply_worker", "not_ready"}:
|
||||
return "badge-red"
|
||||
return "badge-blue"
|
||||
|
||||
|
||||
def _badge(value: Any) -> str:
|
||||
text = escape(str(value or "unknown"))
|
||||
return f'<span class="badge {_badge_class(str(value or ""))}">{text}</span>'
|
||||
|
||||
|
||||
def _join_values(values: list[Any]) -> str:
|
||||
if not values:
|
||||
return '<span class="muted">none</span>'
|
||||
return ", ".join(escape(str(value)) for value in values)
|
||||
|
||||
|
||||
def _status_link(label: str, status: str, filters: dict[str, Any]) -> str:
|
||||
query = urlencode({"status": status, "limit": filters["limit"]})
|
||||
return f'<a class="filter-link" href="/kb-proposals?{query}">{escape(label)}</a>'
|
||||
|
||||
|
||||
def _packet_card(packet: dict[str, Any]) -> str:
|
||||
auth = packet.get("identity_and_authorization") or {}
|
||||
summary = packet.get("payload_summary") or {}
|
||||
applyability = packet.get("applyability") or {}
|
||||
proposal_id = escape(str(packet.get("proposal_id") or "unknown"))
|
||||
proposal_kind = escape(str(summary.get("proposal_kind") or "unknown"))
|
||||
next_action = escape(str(packet.get("next_admin_action") or "No next action recorded."))
|
||||
source_ref = escape(str(auth.get("source_ref") or "unknown"))
|
||||
channel = escape(str(auth.get("channel") or "unknown"))
|
||||
reviewed_by = escape(str(auth.get("reviewed_by_handle") or "unreviewed"))
|
||||
|
||||
rows = [
|
||||
("Status", _badge(packet.get("status"))),
|
||||
("Review state", _badge(packet.get("review_state"))),
|
||||
("Worker applyable", "yes" if packet.get("worker_applyable") else "no"),
|
||||
("Has strict apply payload", "yes" if packet.get("has_apply_payload") else "no"),
|
||||
("Proposal kind", proposal_kind),
|
||||
("Reviewed by", reviewed_by),
|
||||
("Channel", channel),
|
||||
("Source ref", source_ref),
|
||||
("Old claim id", escape(str(summary.get("old_claim_id") or "unknown"))),
|
||||
("Claim candidates", str(summary.get("claim_candidate_count", 0))),
|
||||
("Source candidates", str(summary.get("source_candidate_count", 0))),
|
||||
("Supersession edges", str(summary.get("supersession_edge_count", 0))),
|
||||
("Missing contract", _join_values(applyability.get("missing_contract") or [])),
|
||||
]
|
||||
details = "\n".join(
|
||||
f"<tr><th>{escape(label)}</th><td>{value}</td></tr>" for label, value in rows
|
||||
)
|
||||
return f"""
|
||||
<article class="proposal-card">
|
||||
<div class="proposal-card-header">
|
||||
<h2>{proposal_id}</h2>
|
||||
</div>
|
||||
<table class="proposal-detail">
|
||||
<tbody>{details}</tbody>
|
||||
</table>
|
||||
<div class="next-action">
|
||||
<div class="label">Next admin action</div>
|
||||
<p>{next_action}</p>
|
||||
</div>
|
||||
</article>"""
|
||||
|
||||
|
||||
def render_kb_proposals_page(data: dict[str, Any]) -> str:
|
||||
filters = data["filters"]
|
||||
packets = data["packets"]
|
||||
generated_at = escape(str(data["generated_at"]))
|
||||
cards = "\n".join(_packet_card(packet) for packet in packets)
|
||||
if not cards:
|
||||
cards = '<div class="alert-banner alert-info">No proposals matched this filter.</div>'
|
||||
|
||||
filter_html = " ".join(
|
||||
[
|
||||
_status_link("Approved", "approved", filters),
|
||||
_status_link("Pending review", "pending_review", filters),
|
||||
_status_link("Applied", "applied", filters),
|
||||
]
|
||||
)
|
||||
body = f"""
|
||||
<div class="alert-banner alert-info">
|
||||
Read-only review surface. It shows proposal intent, applyability, missing contracts,
|
||||
and the next admin action; it does not approve, reject, or apply proposals.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Matching proposals</div><div class="value">{data["total"]}</div></div>
|
||||
<div class="card"><div class="label">Worker applyable</div><div class="value">{data["worker_applyable_count"]}</div></div>
|
||||
<div class="card"><div class="label">Current status filter</div><div class="value small-value">{escape(filters.get("status") or "by id")}</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Filters</div>
|
||||
<div class="filters">{filter_html}</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">Proposal Review Packets</div>
|
||||
<div class="proposal-list">{cards}</div>
|
||||
</div>"""
|
||||
extra_css = """
|
||||
.small-value { font-size: 16px !important; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.filter-link { color: #58a6ff; background: #161b22; border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 6px 10px; text-decoration: none; font-size: 13px; }
|
||||
.filter-link:hover { background: #21262d; }
|
||||
.proposal-list { display: grid; gap: 16px; }
|
||||
.proposal-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||||
.proposal-card-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
|
||||
.proposal-card h2 { color: #c9d1d9; font-size: 15px; overflow-wrap: anywhere; }
|
||||
.proposal-detail { margin-top: 12px; }
|
||||
.proposal-detail th { width: 210px; vertical-align: top; }
|
||||
.proposal-detail td { overflow-wrap: anywhere; }
|
||||
.next-action { margin-top: 14px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; background: #0d1117; }
|
||||
.next-action .label { color: #8b949e; font-size: 11px; text-transform: uppercase; margin-bottom: 6px; }
|
||||
.next-action p { color: #c9d1d9; line-height: 1.4; }
|
||||
.muted { color: #8b949e; }
|
||||
"""
|
||||
return render_page(
|
||||
"KB Proposals",
|
||||
"Review Leo KB proposal packets before any apply step",
|
||||
"/kb-proposals",
|
||||
body,
|
||||
extra_css=extra_css,
|
||||
timestamp=generated_at,
|
||||
)
|
||||
|
||||
|
||||
async def handle_api_kb_proposals(request: web.Request) -> web.Response:
|
||||
filters = _query_filters(request)
|
||||
try:
|
||||
packets = _load_packets(request, filters)
|
||||
except BaseException as exc:
|
||||
logger.exception("KB proposal packet load failed")
|
||||
return web.json_response({"error": "kb_proposal_load_failed", "detail": str(exc)}, status=500)
|
||||
return web.json_response(build_kb_proposal_response(packets, filters))
|
||||
|
||||
|
||||
async def handle_kb_proposals_page(request: web.Request) -> web.Response:
|
||||
filters = _query_filters(request)
|
||||
try:
|
||||
packets = _load_packets(request, filters)
|
||||
except BaseException as exc:
|
||||
logger.exception("KB proposal packet page failed")
|
||||
return web.Response(
|
||||
text=render_page(
|
||||
"KB Proposals",
|
||||
"Review Leo KB proposal packets before any apply step",
|
||||
"/kb-proposals",
|
||||
f'<div class="alert-banner alert-critical">Failed to load KB proposals: {escape(str(exc))}</div>',
|
||||
),
|
||||
content_type="text/html",
|
||||
status=500,
|
||||
)
|
||||
data = build_kb_proposal_response(packets, filters)
|
||||
return web.Response(text=render_kb_proposals_page(data), content_type="text/html")
|
||||
|
||||
|
||||
def register_kb_proposal_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/kb-proposals", handle_api_kb_proposals)
|
||||
app.router.add_get("/kb-proposals", handle_kb_proposals_page)
|
||||
|
|
@ -9,6 +9,7 @@ PAGES = [
|
|||
{"path": "/prs", "label": "PRs", "icon": "✎"},
|
||||
{"path": "/ops", "label": "Operations", "icon": "⚙"},
|
||||
{"path": "/health", "label": "Knowledge Health", "icon": "♥"},
|
||||
{"path": "/kb-proposals", "label": "KB Proposals", "icon": "⚒"},
|
||||
{"path": "/agents", "label": "Agents", "icon": "★"},
|
||||
{"path": "/epistemic", "label": "Epistemic", "icon": "⚖"},
|
||||
{"path": "/portfolio", "label": "Portfolio", "icon": "★"},
|
||||
|
|
|
|||
123
tests/test_kb_proposal_routes.py
Normal file
123
tests/test_kb_proposal_routes.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for the Argus KB proposal review routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "diagnostics"))
|
||||
|
||||
import kb_proposal_routes as routes # noqa: E402
|
||||
|
||||
|
||||
def _approved_freeform_proposal():
|
||||
return {
|
||||
"id": "14fa5ecc-ac7a-41c1-807d-a2e85b936617",
|
||||
"proposal_type": "attach_evidence",
|
||||
"status": "approved",
|
||||
"proposed_by_handle": "leo",
|
||||
"reviewed_by_handle": "m3ta",
|
||||
"channel": "telegram",
|
||||
"source_ref": "telegram:leo:m3taversal:claim-review",
|
||||
"payload": {
|
||||
"proposal_kind": "supersede_compressed_claim_with_split_claim_cluster",
|
||||
"old_claim": {"id": "d0000000-0000-0000-0000-000000000005"},
|
||||
"claim_candidates": [{"headline": "A"}, {"headline": "B"}],
|
||||
"source_candidates": [{"source_key": "hayek"}],
|
||||
"proposed_supersession_edges": [{"edge_type": "supersedes_component_of"}],
|
||||
"proposed_concept_map_dependency": {"headline": "Distributed market intelligence"},
|
||||
"schema_gap_note": "bridge cannot propose claims directly",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _approved_applyable_proposal():
|
||||
return {
|
||||
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"proposal_type": "add_edge",
|
||||
"status": "approved",
|
||||
"payload": {
|
||||
"apply_payload": {
|
||||
"from_claim": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"to_claim": "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
"edge_type": "supports",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _call_api(loader, path="/api/kb-proposals?status=approved&limit=2"):
|
||||
app = web.Application()
|
||||
app[routes.KB_PROPOSAL_LOADER_KEY] = loader
|
||||
req = make_mocked_request("GET", path, app=app)
|
||||
response = asyncio.run(routes.handle_api_kb_proposals(req))
|
||||
return response.status, json.loads(response.body.decode())
|
||||
|
||||
|
||||
def test_api_kb_proposals_returns_review_packets_and_counts():
|
||||
seen = {}
|
||||
|
||||
def loader(args):
|
||||
seen["status"] = args.status
|
||||
seen["limit"] = args.limit
|
||||
return [_approved_freeform_proposal(), _approved_applyable_proposal()]
|
||||
|
||||
status, data = _call_api(loader)
|
||||
|
||||
assert status == 200
|
||||
assert seen == {"status": "approved", "limit": 2}
|
||||
assert data["read_only"] is True
|
||||
assert data["total"] == 2
|
||||
assert data["worker_applyable_count"] == 1
|
||||
assert data["review_state_counts"]["approved_needs_apply_payload"] == 1
|
||||
assert data["review_state_counts"]["approved_applyable"] == 1
|
||||
assert data["packets"][0]["identity_and_authorization"]["source_ref"] == "telegram:leo:m3taversal:claim-review"
|
||||
|
||||
|
||||
def test_page_renders_applyability_and_next_admin_action():
|
||||
data = routes.build_kb_proposal_response(
|
||||
[routes.proposal_review.classify_proposal(_approved_freeform_proposal())],
|
||||
{"status": "approved", "proposal_id": "", "limit": 20},
|
||||
)
|
||||
|
||||
html = routes.render_kb_proposals_page(data)
|
||||
|
||||
assert "KB Proposals" in html
|
||||
assert "approved_needs_apply_payload" in html
|
||||
assert "strict payload.apply_payload" in html
|
||||
assert "claim-row normalization" in html
|
||||
assert "normalize into one or more strict apply_payload" in html
|
||||
|
||||
|
||||
def test_api_kb_proposals_supports_proposal_id_filter():
|
||||
seen = {}
|
||||
|
||||
def loader(args):
|
||||
seen["status"] = args.status
|
||||
seen["proposal_id"] = args.proposal_id
|
||||
seen["limit"] = args.limit
|
||||
return [_approved_freeform_proposal()]
|
||||
|
||||
status, data = _call_api(
|
||||
loader,
|
||||
"/api/kb-proposals?proposal_id=14fa5ecc-ac7a-41c1-807d-a2e85b936617&status=pending_review&limit=999",
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert seen == {
|
||||
"status": None,
|
||||
"proposal_id": "14fa5ecc-ac7a-41c1-807d-a2e85b936617",
|
||||
"limit": routes.MAX_LIMIT,
|
||||
}
|
||||
assert data["filters"]["status"] == ""
|
||||
assert data["filters"]["proposal_id"] == "14fa5ecc-ac7a-41c1-807d-a2e85b936617"
|
||||
Loading…
Reference in a new issue