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"""
+
+
+
+
+
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"