From 5ec375cd6e9e2387658071050fa7016950225043 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Thu, 9 Jul 2026 02:46:14 +0200 Subject: [PATCH] Add KB proposal review dashboard --- diagnostics/app.py | 4 +- diagnostics/kb_proposal_routes.py | 265 ++++++++++++++++++++++++++++++ diagnostics/shared_ui.py | 1 + tests/test_kb_proposal_routes.py | 123 ++++++++++++++ 4 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 diagnostics/kb_proposal_routes.py create mode 100644 tests/test_kb_proposal_routes.py diff --git a/diagnostics/app.py b/diagnostics/app.py index cbfe507..0218cc8 100644 --- a/diagnostics/app.py +++ b/diagnostics/app.py @@ -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)) diff --git a/diagnostics/kb_proposal_routes.py b/diagnostics/kb_proposal_routes.py new file mode 100644 index 0000000..ca3c49e --- /dev/null +++ b/diagnostics/kb_proposal_routes.py @@ -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'{text}' + + +def _join_values(values: list[Any]) -> str: + if not values: + return 'none' + 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'{escape(label)}' + + +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"{escape(label)}{value}" for label, value in rows + ) + return f""" +
+
+

{proposal_id}

+
+ + {details} +
+
+
Next admin action
+

{next_action}

+
+
""" + + +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 = '
No proposals matched this filter.
' + + filter_html = " ".join( + [ + _status_link("Approved", "approved", filters), + _status_link("Pending review", "pending_review", filters), + _status_link("Applied", "applied", filters), + ] + ) + body = f""" +
+ Read-only review surface. It shows proposal intent, applyability, missing contracts, + and the next admin action; it does not approve, reject, or apply proposals. +
+
+
Matching proposals
{data["total"]}
+
Worker applyable
{data["worker_applyable_count"]}
+
Current status filter
{escape(filters.get("status") or "by id")}
+
+
+
Filters
+
{filter_html}
+
+
+
Proposal Review Packets
+
{cards}
+
""" + 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'
Failed to load KB proposals: {escape(str(exc))}
', + ), + 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) diff --git a/diagnostics/shared_ui.py b/diagnostics/shared_ui.py index 81a2da7..c3a76b9 100644 --- a/diagnostics/shared_ui.py +++ b/diagnostics/shared_ui.py @@ -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": "★"}, diff --git a/tests/test_kb_proposal_routes.py b/tests/test_kb_proposal_routes.py new file mode 100644 index 0000000..03e717c --- /dev/null +++ b/tests/test_kb_proposal_routes.py @@ -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"