535 lines
21 KiB
Python
535 lines
21 KiB
Python
"""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
|
|
SCRIPT_DIR_CANDIDATES = [
|
|
ROOT / "scripts",
|
|
Path(os.environ.get("TELEO_INFRA_REPO_DIR", "/opt/teleo-eval/workspaces/deploy-infra")) / "scripts",
|
|
]
|
|
for scripts_dir in SCRIPT_DIR_CANDIDATES:
|
|
if str(scripts_dir) not in sys.path:
|
|
sys.path.insert(0, str(scripts_dir))
|
|
|
|
import kb_proposal_normalize as proposal_normalize # noqa: E402
|
|
import kb_proposal_review_packet as proposal_review # noqa: E402
|
|
from kb_claim_routes import claim_link_html # 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))
|
|
packets = []
|
|
for proposal in proposals:
|
|
packet = proposal_review.classify_proposal(proposal)
|
|
packet["normalization_preview"] = proposal_normalize.normalize_proposal(proposal)
|
|
packet["apply_preview"] = build_apply_preview(proposal, packet)
|
|
packets.append(packet)
|
|
return packets
|
|
|
|
|
|
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 _code(value: Any) -> str:
|
|
return f"<code>{escape(str(value))}</code>"
|
|
|
|
|
|
def _payload_dict(value: Any) -> dict[str, Any]:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _payload_list(value: Any) -> list[Any]:
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
def _apply_preview_row(
|
|
*,
|
|
action: str,
|
|
table: str,
|
|
target: str,
|
|
claim_ids: list[str] | None = None,
|
|
details: dict[str, Any] | None = None,
|
|
source: str = "proposal",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"action": action,
|
|
"table": table,
|
|
"target": target,
|
|
"claim_ids": claim_ids or [],
|
|
"details": details or {},
|
|
"source": source,
|
|
}
|
|
|
|
|
|
def _strict_apply_preview_rows(
|
|
proposal_type: str | None,
|
|
apply_payload: dict[str, Any],
|
|
*,
|
|
source: str = "proposal.apply_payload",
|
|
) -> list[dict[str, Any]]:
|
|
if proposal_type == "add_edge":
|
|
from_claim = str(apply_payload.get("from_claim") or "")
|
|
to_claim = str(apply_payload.get("to_claim") or "")
|
|
edge_type = str(apply_payload.get("edge_type") or "")
|
|
return [
|
|
_apply_preview_row(
|
|
action="insert_if_missing",
|
|
table="public.claim_edges",
|
|
target=f"{from_claim} -> {to_claim} ({edge_type or 'edge_type missing'})",
|
|
claim_ids=[claim_id for claim_id in [from_claim, to_claim] if claim_id],
|
|
details={
|
|
"from_claim": from_claim,
|
|
"to_claim": to_claim,
|
|
"edge_type": edge_type,
|
|
"weight": apply_payload.get("weight"),
|
|
},
|
|
source=source,
|
|
)
|
|
]
|
|
|
|
if proposal_type == "attach_evidence":
|
|
rows = []
|
|
for evidence in _payload_list(apply_payload.get("evidence")):
|
|
ev = _payload_dict(evidence)
|
|
claim_id = str(ev.get("claim_id") or "")
|
|
source_id = str(ev.get("source_id") or "")
|
|
role = str(ev.get("role") or "grounds")
|
|
rows.append(
|
|
_apply_preview_row(
|
|
action="insert_if_missing",
|
|
table="public.claim_evidence",
|
|
target=f"{claim_id} <= {source_id} ({role})",
|
|
claim_ids=[claim_id] if claim_id else [],
|
|
details={
|
|
"claim_id": claim_id,
|
|
"source_id": source_id,
|
|
"role": role,
|
|
"weight": ev.get("weight"),
|
|
},
|
|
source=source,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
if proposal_type == "revise_strategy":
|
|
agent_id = str(apply_payload.get("agent_id") or "")
|
|
strategy_nodes = _payload_list(apply_payload.get("strategy_nodes"))
|
|
return [
|
|
_apply_preview_row(
|
|
action="update_then_insert",
|
|
table="public.strategies",
|
|
target=f"new active strategy for agent {agent_id or 'unknown'}",
|
|
details={
|
|
"agent_id": agent_id,
|
|
"strategy_keys": sorted(_payload_dict(apply_payload.get("strategy")).keys()),
|
|
},
|
|
source=source,
|
|
),
|
|
_apply_preview_row(
|
|
action="retire_then_insert",
|
|
table="public.strategy_nodes",
|
|
target=f"{len(strategy_nodes)} replacement strategy node(s)",
|
|
details={"agent_id": agent_id, "strategy_node_count": len(strategy_nodes)},
|
|
source=source,
|
|
),
|
|
]
|
|
|
|
return [
|
|
_apply_preview_row(
|
|
action="blocked",
|
|
table="none",
|
|
target=f"unsupported proposal_type {proposal_type or 'unknown'}",
|
|
details={"proposal_type": proposal_type},
|
|
source=source,
|
|
)
|
|
]
|
|
|
|
|
|
def build_apply_preview(proposal: dict[str, Any], packet: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build a read-only, row-level preview of what an apply step would touch."""
|
|
payload = _payload_dict(proposal.get("payload"))
|
|
apply_payload = _payload_dict(payload.get("apply_payload"))
|
|
proposal_type = proposal.get("proposal_type")
|
|
normalization = _payload_dict(packet.get("normalization_preview"))
|
|
|
|
if proposal.get("status") == "applied":
|
|
return {
|
|
"state": "applied",
|
|
"rows": [],
|
|
"blocked_fragments": [],
|
|
"note": "Proposal is already applied; use the canonical claim/edge/evidence readback.",
|
|
}
|
|
|
|
if apply_payload and proposal_type in proposal_review.ap.APPLYABLE_TYPES:
|
|
return {
|
|
"state": "strict_apply_payload_ready",
|
|
"rows": _strict_apply_preview_rows(proposal_type, apply_payload),
|
|
"blocked_fragments": [],
|
|
"note": "Preview only; the page does not execute canonical writes.",
|
|
}
|
|
|
|
child_rows = []
|
|
for index, child in enumerate(_payload_list(normalization.get("strict_child_proposals"))):
|
|
child_payload = _payload_dict(_payload_dict(child).get("payload"))
|
|
child_apply_payload = _payload_dict(child_payload.get("apply_payload"))
|
|
child_type = str(_payload_dict(child).get("proposal_type") or "")
|
|
child_rows.extend(
|
|
_strict_apply_preview_rows(
|
|
child_type,
|
|
child_apply_payload,
|
|
source=f"normalization.strict_child_proposals[{index}]",
|
|
)
|
|
)
|
|
|
|
blocked = _payload_list(normalization.get("blocked_fragments"))
|
|
if child_rows and not blocked:
|
|
state = "strict_child_proposals_ready"
|
|
note = "Preview of strict child proposals; reviewer still needs to stage those children before apply."
|
|
elif child_rows:
|
|
state = "partial_preview_blocked_fragments"
|
|
note = "Some strict child rows can be previewed; blocked fragments still need canonical IDs or schema decisions."
|
|
else:
|
|
state = "not_applyable_yet"
|
|
note = "No canonical write preview is safe until the proposal has a strict apply_payload or strict child proposals."
|
|
|
|
return {
|
|
"state": state,
|
|
"rows": child_rows,
|
|
"blocked_fragments": blocked,
|
|
"note": note,
|
|
}
|
|
|
|
|
|
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 _apply_preview_html(preview: dict[str, Any]) -> str:
|
|
state = _badge(preview.get("state"))
|
|
rows = _payload_list(preview.get("rows"))
|
|
blocked = _payload_list(preview.get("blocked_fragments"))
|
|
note = escape(str(preview.get("note") or ""))
|
|
row_html = ""
|
|
if rows:
|
|
rendered_rows = []
|
|
for row in rows:
|
|
details = _payload_dict(_payload_dict(row).get("details"))
|
|
detail_text = ", ".join(
|
|
f"{escape(str(key))}={escape(str(value))}"
|
|
for key, value in details.items()
|
|
if value not in (None, "")
|
|
)
|
|
claim_links = " ".join(
|
|
claim_link_html(claim_id, label=str(claim_id)[:8])
|
|
for claim_id in _payload_list(_payload_dict(row).get("claim_ids"))
|
|
) or '<span class="muted">none</span>'
|
|
detail_html = escape(detail_text) if detail_text else '<span class="muted">none</span>'
|
|
rendered_rows.append(
|
|
"<tr>"
|
|
f"<td>{_code(_payload_dict(row).get('action') or 'unknown')}</td>"
|
|
f"<td>{_code(_payload_dict(row).get('table') or 'unknown')}</td>"
|
|
f"<td>{escape(str(_payload_dict(row).get('target') or ''))}</td>"
|
|
f"<td>{claim_links}</td>"
|
|
f"<td>{detail_html}</td>"
|
|
"</tr>"
|
|
)
|
|
row_html = f"""
|
|
<table class="proposal-detail apply-preview-table">
|
|
<thead><tr><th>Action</th><th>Table</th><th>Target</th><th>Claims</th><th>Details</th></tr></thead>
|
|
<tbody>{''.join(rendered_rows)}</tbody>
|
|
</table>"""
|
|
else:
|
|
row_html = '<p class="muted">No canonical row preview is executable yet.</p>'
|
|
|
|
blocked_html = ""
|
|
if blocked:
|
|
blocked_rows = []
|
|
for fragment in blocked:
|
|
frag = _payload_dict(fragment)
|
|
missing = ", ".join(escape(str(value)) for value in _payload_list(frag.get("missing"))) or "unknown"
|
|
blocked_rows.append(
|
|
"<tr>"
|
|
f"<td>{_code(frag.get('kind') or 'fragment')}</td>"
|
|
f"<td>{escape(str(frag.get('reason') or 'blocked'))}</td>"
|
|
f"<td>{missing}</td>"
|
|
"</tr>"
|
|
)
|
|
blocked_html = f"""
|
|
<div class="label preview-label">Blocked fragments</div>
|
|
<table class="proposal-detail apply-preview-table">
|
|
<thead><tr><th>Kind</th><th>Reason</th><th>Missing</th></tr></thead>
|
|
<tbody>{''.join(blocked_rows)}</tbody>
|
|
</table>"""
|
|
|
|
return f"""
|
|
<div class="next-action apply-preview">
|
|
<div class="label">Apply preview</div>
|
|
<p>{state} <span class="muted">{note}</span></p>
|
|
{row_html}
|
|
{blocked_html}
|
|
</div>"""
|
|
|
|
|
|
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_raw = str(packet.get("proposal_id") or "unknown")
|
|
proposal_id = _code(proposal_id_raw)
|
|
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", claim_link_html(summary.get("old_claim_id") or "unknown")),
|
|
("Claim candidates", _code(summary.get("claim_candidate_count", 0))),
|
|
("Source candidates", _code(summary.get("source_candidate_count", 0))),
|
|
("Supersession edges", _code(summary.get("supersession_edge_count", 0))),
|
|
("Missing contract", _join_values(applyability.get("missing_contract") or [])),
|
|
]
|
|
normalization = packet.get("normalization_preview") or {}
|
|
if normalization:
|
|
rows.extend(
|
|
[
|
|
("Normalization", _badge(normalization.get("normalization_state"))),
|
|
("Strict child proposals", _code(normalization.get("strict_child_count", 0))),
|
|
("Blocked fragments", _code(normalization.get("blocked_count", 0))),
|
|
]
|
|
)
|
|
details = "\n".join(
|
|
f"<tr><th>{escape(label)}</th><td>{value}</td></tr>" for label, value in rows
|
|
)
|
|
normalization_html = ""
|
|
if normalization:
|
|
normalization_html = f"""
|
|
<div class="next-action">
|
|
<div class="label">Normalization action</div>
|
|
<p>{escape(str(normalization.get("next_normalization_action") or "No normalization action recorded."))}</p>
|
|
</div>"""
|
|
apply_preview_html = _apply_preview_html(packet.get("apply_preview") or {})
|
|
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>
|
|
{normalization_html}
|
|
{apply_preview_html}
|
|
</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; }
|
|
.claim-link { color: #58a6ff; text-decoration: none; }
|
|
.claim-link:hover { text-decoration: underline; }
|
|
.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)
|