Some checks are pending
CI / lint-and-test (push) Waiting to run
* Add protected canonical claims read API * Normalize claim loader exits in worker thread * Allow file-gated claims reader defaults
784 lines
30 KiB
Python
784 lines
30 KiB
Python
"""Read-only canonical KB claim routes for Argus.
|
|
|
|
These routes show the Postgres-backed claim graph that Leo uses through the
|
|
``teleo-kb`` bridge: a protected canonical summary list plus one claim, its
|
|
evidence rows, and its graph edges.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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_review_packet as proposal_review # noqa: E402
|
|
|
|
logger = logging.getLogger("argus.kb_claims")
|
|
|
|
KB_CLAIM_SELF_AUTH_PATHS = frozenset({"/api/kb/claims"})
|
|
KB_CLAIM_GLOBAL_AUTH_PREFIXES = ("/api/kb/claims/", "/kb/claims/")
|
|
KB_CLAIM_LOADER_KEY = web.AppKey("kb_claim_loader", object)
|
|
KB_CLAIM_LIST_LOADER_KEY = web.AppKey("kb_claim_list_loader", object)
|
|
KB_CLAIM_LIST_API_KEY = web.AppKey("kb_claim_list_api_key", str)
|
|
|
|
CLAIM_LIST_SCHEMA = "livingip.canonical-claims.v1"
|
|
CLAIM_LIST_DEFAULT_LIMIT = 25
|
|
CLAIM_LIST_MAX_LIMIT = 100
|
|
CLAIM_LIST_MAX_RESPONSE_BYTES = 500_000
|
|
CLAIM_LIST_MAX_QUERY_LENGTH = 200
|
|
CLAIM_LIST_MAX_FILTER_LENGTH = 64
|
|
CLAIM_LIST_REQUIRED_ROLE = "kb_observatory_read"
|
|
CLAIM_LIST_DEFAULT_API_KEY_FILE = "/opt/teleo-eval/secrets/kb-observatory-api-key"
|
|
CLAIM_LIST_DEFAULT_SECRETS_FILE = "/opt/teleo-eval/secrets/kb-observatory-read-password"
|
|
CLAIM_LIST_HANDLER_TIMEOUT_SECONDS = 10.0
|
|
CLAIM_LIST_PROCESS_TIMEOUT_SECONDS = 8.0
|
|
CLAIM_LIST_CONNECT_TIMEOUT_SECONDS = 3
|
|
CLAIM_LIST_STATEMENT_TIMEOUT_MS = 5_000
|
|
|
|
UUID_RE = re.compile(
|
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
)
|
|
SIMPLE_FILTER_RE = re.compile(r"^[A-Za-z0-9_.:-]+$")
|
|
|
|
|
|
class _ClaimListLoaderExited(RuntimeError):
|
|
"""Keep loader exits inside the worker thread's normal exception channel."""
|
|
|
|
|
|
def load_claim_list_api_key() -> str | None:
|
|
"""Load the Observatory-only API token from its root-managed file."""
|
|
path = Path(
|
|
os.environ.get("KB_CLAIM_BROWSER_API_KEY_FILE", CLAIM_LIST_DEFAULT_API_KEY_FILE)
|
|
)
|
|
try:
|
|
if not path.is_file() or path.stat().st_mode & 0o007:
|
|
return None
|
|
token = path.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
return None
|
|
if (
|
|
len(token) < 24
|
|
or len(token) > 512
|
|
or not token.isascii()
|
|
or any(character.isspace() for character in token)
|
|
):
|
|
return None
|
|
return token
|
|
|
|
|
|
def _load_claim_list_password(secrets_file: str) -> str:
|
|
"""Parse one literal ``PGPASSWORD=...`` assignment from a dedicated file."""
|
|
path = Path(secrets_file)
|
|
try:
|
|
if not path.is_file():
|
|
raise RuntimeError("canonical claim browser secrets file is unavailable")
|
|
if path.stat().st_mode & 0o007:
|
|
raise RuntimeError("canonical claim browser secrets file is world-accessible")
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except OSError as exc:
|
|
raise RuntimeError("canonical claim browser secrets file is unavailable") from exc
|
|
|
|
password: str | None = None
|
|
for raw_line in lines:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
key, separator, value = line.partition("=")
|
|
if not separator or key.strip() != "PGPASSWORD" or password is not None:
|
|
raise RuntimeError("canonical claim browser secrets file has invalid format")
|
|
password = value.strip()
|
|
if len(password) >= 2 and password[0] == password[-1] and password[0] in {"'", '"'}:
|
|
password = password[1:-1]
|
|
|
|
if not password or "\n" in password or "\r" in password:
|
|
raise RuntimeError("canonical claim browser secrets file has invalid format")
|
|
return password
|
|
|
|
|
|
def is_claim_id(value: Any) -> bool:
|
|
return bool(UUID_RE.match(str(value or "")))
|
|
|
|
|
|
def claim_path(claim_id: str) -> str:
|
|
return f"/kb/claims/{claim_id}"
|
|
|
|
|
|
def claim_link_html(claim_id: Any, *, label: str | None = None) -> str:
|
|
value = str(claim_id or "")
|
|
if not is_claim_id(value):
|
|
return f"<code>{escape(value or 'unknown')}</code>"
|
|
text = label or value
|
|
return f'<a class="claim-link" href="{claim_path(value)}"><code>{escape(text)}</code></a>'
|
|
|
|
|
|
def short_claim_link_html(claim_id: Any) -> str:
|
|
value = str(claim_id or "")
|
|
if not is_claim_id(value):
|
|
return f"<code>{escape(value or 'unknown')}</code>"
|
|
return claim_link_html(value, label=value[:8])
|
|
|
|
|
|
def encode_claim_cursor(sort_timestamp: str, claim_id: str) -> str:
|
|
payload = json.dumps(
|
|
{"updated_at": sort_timestamp, "id": claim_id},
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
|
|
|
|
def decode_claim_cursor(value: str) -> tuple[str, str]:
|
|
try:
|
|
padded = value + "=" * (-len(value) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
|
timestamp = str(payload["updated_at"])
|
|
claim_id = str(payload["id"])
|
|
parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
if parsed_timestamp.tzinfo is None:
|
|
raise ValueError("cursor timestamp must be timezone-aware")
|
|
if not is_claim_id(claim_id):
|
|
raise ValueError("invalid claim id")
|
|
return timestamp, claim_id.lower()
|
|
except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError("invalid cursor") from exc
|
|
|
|
|
|
def _parse_claim_list_request(request: web.Request) -> dict[str, Any]:
|
|
raw_limit = request.query.get("limit", str(CLAIM_LIST_DEFAULT_LIMIT))
|
|
try:
|
|
limit = max(1, min(int(raw_limit), CLAIM_LIST_MAX_LIMIT))
|
|
except ValueError as exc:
|
|
raise ValueError("invalid_limit") from exc
|
|
|
|
filters: dict[str, Any] = {
|
|
"q": request.query.get("q", "").strip(),
|
|
"status": request.query.get("status", "open").strip(),
|
|
"type": request.query.get("type", "").strip(),
|
|
"tag": request.query.get("tag", "").strip(),
|
|
"limit": limit,
|
|
"cursor": request.query.get("cursor", "").strip(),
|
|
}
|
|
if len(filters["q"]) > CLAIM_LIST_MAX_QUERY_LENGTH:
|
|
raise ValueError("query_too_long")
|
|
if len(filters["cursor"]) > 512:
|
|
raise ValueError("invalid_cursor")
|
|
for key in ("status", "type", "tag"):
|
|
value = filters[key]
|
|
if len(value) > CLAIM_LIST_MAX_FILTER_LENGTH or (value and not SIMPLE_FILTER_RE.match(value)):
|
|
raise ValueError(f"invalid_{key}")
|
|
if filters["cursor"]:
|
|
filters["cursor_values"] = decode_claim_cursor(filters["cursor"])
|
|
else:
|
|
filters["cursor_values"] = None
|
|
return filters
|
|
|
|
|
|
def _db_args() -> argparse.Namespace:
|
|
return argparse.Namespace(
|
|
secrets_file=os.environ.get(
|
|
"KB_CLAIM_REVIEW_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_CLAIM_REVIEW_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
|
db=os.environ.get("KB_CLAIM_REVIEW_DB", proposal_review.ap.DEFAULT_DB),
|
|
host=os.environ.get("KB_CLAIM_REVIEW_HOST", proposal_review.ap.DEFAULT_HOST),
|
|
role=os.environ.get("KB_CLAIM_REVIEW_ROLE", proposal_review.ap.DEFAULT_ROLE),
|
|
)
|
|
|
|
|
|
def _claim_list_db_args() -> argparse.Namespace:
|
|
role = os.environ.get("KB_CLAIM_BROWSER_ROLE", "").strip()
|
|
secrets_file = os.environ.get("KB_CLAIM_BROWSER_SECRETS_FILE", "").strip()
|
|
if not role and not secrets_file and Path(CLAIM_LIST_DEFAULT_SECRETS_FILE).is_file():
|
|
role = CLAIM_LIST_REQUIRED_ROLE
|
|
secrets_file = CLAIM_LIST_DEFAULT_SECRETS_FILE
|
|
if not role or not secrets_file:
|
|
raise RuntimeError("canonical claim browser read role is not configured")
|
|
if role != CLAIM_LIST_REQUIRED_ROLE:
|
|
raise RuntimeError(f"canonical claim browser requires role {CLAIM_LIST_REQUIRED_ROLE}")
|
|
return argparse.Namespace(
|
|
secrets_file=secrets_file,
|
|
container=os.environ.get("KB_CLAIM_BROWSER_CONTAINER", proposal_review.ap.DEFAULT_CONTAINER),
|
|
db=os.environ.get("KB_CLAIM_BROWSER_DB", proposal_review.ap.DEFAULT_DB),
|
|
host=os.environ.get("KB_CLAIM_BROWSER_HOST", proposal_review.ap.DEFAULT_HOST),
|
|
role=role,
|
|
)
|
|
|
|
|
|
def _run_claim_list_psql(args: argparse.Namespace, sql: str, password: str) -> str:
|
|
"""Run the bounded, read-only browser query without leaking process output."""
|
|
docker_binary = shutil.which("docker") or "docker"
|
|
command = [
|
|
docker_binary,
|
|
"exec",
|
|
"-e",
|
|
"PGPASSWORD",
|
|
"-e",
|
|
f"PGCONNECT_TIMEOUT={CLAIM_LIST_CONNECT_TIMEOUT_SECONDS}",
|
|
"-i",
|
|
args.container,
|
|
"psql",
|
|
"-U",
|
|
args.role,
|
|
"-h",
|
|
args.host,
|
|
"-d",
|
|
args.db,
|
|
"-v",
|
|
"ON_ERROR_STOP=1",
|
|
"-At",
|
|
"-q",
|
|
]
|
|
try:
|
|
result = subprocess.run(
|
|
command,
|
|
input=sql,
|
|
text=True,
|
|
capture_output=True,
|
|
env={"PGPASSWORD": password, "PATH": "/usr/bin:/bin:/usr/local/bin"},
|
|
check=False,
|
|
timeout=CLAIM_LIST_PROCESS_TIMEOUT_SECONDS,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise TimeoutError("canonical claim browser database query timed out") from exc
|
|
if result.returncode != 0:
|
|
raise RuntimeError("canonical claim browser database query failed")
|
|
return result.stdout
|
|
|
|
|
|
def _load_claim_from_db(claim_id: str) -> dict[str, Any] | None:
|
|
password = proposal_review.ap.load_password(_db_args().secrets_file)
|
|
args = _db_args()
|
|
sql = f"""
|
|
with target as (
|
|
select c.id,
|
|
c.type,
|
|
c.text,
|
|
c.status,
|
|
c.confidence,
|
|
c.tags,
|
|
c.superseded_by,
|
|
c.created_at,
|
|
c.updated_at
|
|
from public.claims c
|
|
where c.id = {proposal_review.ap.sql_literal(claim_id)}::uuid
|
|
)
|
|
select jsonb_build_object(
|
|
'claim', jsonb_build_object(
|
|
'id', target.id::text,
|
|
'type', target.type,
|
|
'text', target.text,
|
|
'status', target.status,
|
|
'confidence', target.confidence,
|
|
'tags', coalesce(to_jsonb(target.tags), '[]'::jsonb),
|
|
'superseded_by', target.superseded_by::text,
|
|
'created_at', target.created_at::text,
|
|
'updated_at', target.updated_at::text
|
|
),
|
|
'evidence', coalesce((
|
|
select jsonb_agg(row_data order by rn)
|
|
from (
|
|
select row_number() over (
|
|
order by ce.role::text,
|
|
ce.weight desc nulls last,
|
|
s.storage_path nulls last,
|
|
s.url nulls last
|
|
) as rn,
|
|
jsonb_build_object(
|
|
'role', ce.role::text,
|
|
'weight', ce.weight,
|
|
'source_type', s.source_type,
|
|
'url', s.url,
|
|
'storage_path', s.storage_path,
|
|
'excerpt', left(coalesce(s.excerpt, ''), 1200)
|
|
) as row_data
|
|
from public.claim_evidence ce
|
|
join public.sources s on s.id = ce.source_id
|
|
where ce.claim_id = target.id
|
|
limit 30
|
|
) evidence_rows
|
|
), '[]'::jsonb),
|
|
'edges', coalesce((
|
|
select jsonb_agg(row_data order by rn)
|
|
from (
|
|
select row_number() over (
|
|
order by e.edge_type::text,
|
|
other.text
|
|
) as rn,
|
|
jsonb_build_object(
|
|
'direction', case when e.from_claim = target.id then 'outgoing' else 'incoming' end,
|
|
'edge_type', e.edge_type::text,
|
|
'connected_id', other.id::text,
|
|
'connected_text', other.text,
|
|
'connected_status', other.status,
|
|
'connected_confidence', other.confidence
|
|
) as row_data
|
|
from public.claim_edges e
|
|
join public.claims other
|
|
on other.id = case when e.from_claim = target.id then e.to_claim else e.from_claim end
|
|
where e.from_claim = target.id or e.to_claim = target.id
|
|
limit 40
|
|
) edge_rows
|
|
), '[]'::jsonb)
|
|
)::text
|
|
from target;
|
|
"""
|
|
out = proposal_review.ap.run_psql(args, sql, password).strip()
|
|
return json.loads(out) if out else None
|
|
|
|
|
|
def _load_claim(request: web.Request, claim_id: str) -> dict[str, Any] | None:
|
|
loader = request.app.get(KB_CLAIM_LOADER_KEY)
|
|
return loader(claim_id) if loader else _load_claim_from_db(claim_id)
|
|
|
|
|
|
def _load_claim_list_from_db(filters: dict[str, Any]) -> dict[str, Any]:
|
|
args = _claim_list_db_args()
|
|
password = _load_claim_list_password(args.secrets_file)
|
|
where_clauses: list[str] = []
|
|
|
|
if filters["q"]:
|
|
query_literal = proposal_review.ap.sql_literal(filters["q"].lower())
|
|
where_clauses.append(
|
|
"(strpos(lower(c.text), "
|
|
f"{query_literal}) > 0 or exists ("
|
|
"select 1 from unnest(coalesce(c.tags, '{}'::text[])) claim_tag "
|
|
f"where strpos(lower(claim_tag), {query_literal}) > 0))"
|
|
)
|
|
if filters["status"] and filters["status"].lower() != "all":
|
|
where_clauses.append(f"c.status::text = {proposal_review.ap.sql_literal(filters['status'])}")
|
|
if filters["type"]:
|
|
where_clauses.append(f"c.type::text = {proposal_review.ap.sql_literal(filters['type'])}")
|
|
if filters["tag"]:
|
|
where_clauses.append(
|
|
f"{proposal_review.ap.sql_literal(filters['tag'])} = any(coalesce(c.tags, '{{}}'::text[]))"
|
|
)
|
|
|
|
where_sql = f"where {' and '.join(where_clauses)}" if where_clauses else ""
|
|
cursor_sql = ""
|
|
if filters["cursor_values"]:
|
|
cursor_timestamp, cursor_id = filters["cursor_values"]
|
|
cursor_sql = (
|
|
"where (sort_timestamp, id) < ("
|
|
f"{proposal_review.ap.sql_literal(cursor_timestamp)}::timestamptz, "
|
|
f"{proposal_review.ap.sql_literal(cursor_id)}::uuid)"
|
|
)
|
|
|
|
fetch_limit = filters["limit"] + 1
|
|
sql = f"""
|
|
set statement_timeout = '{CLAIM_LIST_STATEMENT_TIMEOUT_MS}ms';
|
|
set lock_timeout = '1000ms';
|
|
|
|
with filtered as (
|
|
select c.id,
|
|
c.type,
|
|
c.text,
|
|
c.status,
|
|
c.confidence,
|
|
c.tags,
|
|
c.superseded_by,
|
|
c.created_at,
|
|
c.updated_at,
|
|
coalesce(c.updated_at, c.created_at, '1970-01-01'::timestamptz) as sort_timestamp
|
|
from public.claims c
|
|
{where_sql}
|
|
), page_rows as (
|
|
select *
|
|
from filtered
|
|
{cursor_sql}
|
|
order by sort_timestamp desc, id desc
|
|
limit {fetch_limit}
|
|
), enriched_page_rows as (
|
|
select page_rows.*,
|
|
coalesce(evidence_counts.evidence_count, 0) as evidence_count,
|
|
coalesce(edge_counts.edge_count, 0) as edge_count
|
|
from page_rows
|
|
left join lateral (
|
|
select count(*) as evidence_count
|
|
from public.claim_evidence ce
|
|
where ce.claim_id = page_rows.id
|
|
) evidence_counts on true
|
|
left join lateral (
|
|
select count(*) as edge_count
|
|
from public.claim_edges e
|
|
where e.from_claim = page_rows.id or e.to_claim = page_rows.id
|
|
) edge_counts on true
|
|
)
|
|
select jsonb_build_object(
|
|
'session_read_only', current_setting('transaction_read_only'),
|
|
'total', (select count(*) from filtered),
|
|
'rows', coalesce((
|
|
select jsonb_agg(
|
|
jsonb_build_object(
|
|
'id', id::text,
|
|
'type', type::text,
|
|
'text', left(text, 1200),
|
|
'text_truncated', length(text) > 1200,
|
|
'status', status::text,
|
|
'confidence', confidence,
|
|
'tags', coalesce(to_jsonb(tags), '[]'::jsonb),
|
|
'superseded_by', superseded_by::text,
|
|
'created_at', created_at::text,
|
|
'updated_at', updated_at::text,
|
|
'evidence_count', evidence_count,
|
|
'edge_count', edge_count,
|
|
'_cursor_timestamp', sort_timestamp::text
|
|
) order by sort_timestamp desc, id desc
|
|
) from enriched_page_rows
|
|
), '[]'::jsonb)
|
|
)::text;
|
|
"""
|
|
out = _run_claim_list_psql(args, sql, password).strip()
|
|
result = json.loads(out) if out else {"total": 0, "rows": []}
|
|
if result.pop("session_read_only", None) != "on":
|
|
raise RuntimeError("canonical claim browser database session is not read-only")
|
|
return result
|
|
|
|
|
|
def _load_claim_list_sync(request: web.Request, filters: dict[str, Any]) -> dict[str, Any]:
|
|
loader = request.app.get(KB_CLAIM_LIST_LOADER_KEY)
|
|
try:
|
|
return loader(filters) if loader else _load_claim_list_from_db(filters)
|
|
except SystemExit as exc:
|
|
# Python 3.11 can propagate SystemExit from asyncio.to_thread before the
|
|
# awaiting handler gets a chance to sanitize it. Convert only that exit
|
|
# signal here; KeyboardInterrupt and other BaseException types retain
|
|
# their normal process-level semantics.
|
|
raise _ClaimListLoaderExited from exc
|
|
|
|
|
|
async def _load_claim_list(request: web.Request, filters: dict[str, Any]) -> dict[str, Any]:
|
|
return await asyncio.wait_for(
|
|
asyncio.to_thread(_load_claim_list_sync, request, filters),
|
|
timeout=CLAIM_LIST_HANDLER_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
|
|
def _sanitize_claim_summary(row: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
|
claim_id = str(row.get("id") or "").lower()
|
|
if not is_claim_id(claim_id):
|
|
raise ValueError("invalid canonical claim row")
|
|
cursor_timestamp = str(row.get("_cursor_timestamp") or row.get("updated_at") or row.get("created_at") or "")
|
|
parsed_timestamp = datetime.fromisoformat(cursor_timestamp.replace("Z", "+00:00"))
|
|
if parsed_timestamp.tzinfo is None:
|
|
raise ValueError("invalid canonical claim timestamp")
|
|
tags = [str(tag)[:128] for tag in (row.get("tags") or []) if isinstance(tag, (str, int, float))][:32]
|
|
summary = {
|
|
"id": claim_id,
|
|
"type": str(row.get("type") or "unknown")[:64],
|
|
"text": str(row.get("text") or "")[:1200],
|
|
"text_truncated": bool(row.get("text_truncated")),
|
|
"status": str(row.get("status") or "unknown")[:64],
|
|
"confidence": row.get("confidence"),
|
|
"tags": tags,
|
|
"superseded_by": str(row["superseded_by"]) if is_claim_id(row.get("superseded_by")) else None,
|
|
"created_at": str(row.get("created_at") or "") or None,
|
|
"updated_at": str(row.get("updated_at") or "") or None,
|
|
"evidence_count": max(0, int(row.get("evidence_count") or 0)),
|
|
"edge_count": max(0, int(row.get("edge_count") or 0)),
|
|
}
|
|
return summary, cursor_timestamp
|
|
|
|
|
|
def _claim_list_payload(raw: dict[str, Any], filters: dict[str, Any]) -> dict[str, Any]:
|
|
raw_rows = list(raw.get("rows") or [])
|
|
if len(raw_rows) > filters["limit"] + 1:
|
|
raise ValueError("canonical claim page exceeded its requested bound")
|
|
|
|
sanitized_rows: list[tuple[dict[str, Any], str]] = []
|
|
seen_ids: set[str] = set()
|
|
previous_key: tuple[datetime, str] | None = None
|
|
request_cursor_key: tuple[datetime, str] | None = None
|
|
if filters["cursor_values"]:
|
|
request_cursor_timestamp, request_cursor_id = filters["cursor_values"]
|
|
request_cursor_key = (
|
|
datetime.fromisoformat(request_cursor_timestamp.replace("Z", "+00:00")),
|
|
request_cursor_id,
|
|
)
|
|
|
|
for row in raw_rows:
|
|
claim, cursor_timestamp = _sanitize_claim_summary(row)
|
|
if claim["id"] in seen_ids:
|
|
raise ValueError("canonical claim page contains duplicate ids")
|
|
seen_ids.add(claim["id"])
|
|
row_key = (
|
|
datetime.fromisoformat(cursor_timestamp.replace("Z", "+00:00")),
|
|
claim["id"],
|
|
)
|
|
if request_cursor_key is not None and row_key >= request_cursor_key:
|
|
raise ValueError("canonical claim page crossed its request cursor")
|
|
if previous_key is not None and row_key >= previous_key:
|
|
raise ValueError("canonical claim page is not strictly ordered")
|
|
previous_key = row_key
|
|
sanitized_rows.append((claim, cursor_timestamp))
|
|
|
|
visible_rows = sanitized_rows[: filters["limit"]]
|
|
total = max(0, int(raw.get("total") or 0))
|
|
if total < len(visible_rows):
|
|
raise ValueError("canonical claim total is smaller than its page")
|
|
generated_at = datetime.now(timezone.utc).isoformat()
|
|
|
|
def build_payload(page_rows: list[tuple[dict[str, Any], str]]) -> dict[str, Any]:
|
|
claims = [claim for claim, _cursor_timestamp in page_rows]
|
|
has_more = len(page_rows) < len(sanitized_rows)
|
|
last_cursor_timestamp = page_rows[-1][1] if page_rows else ""
|
|
next_cursor = (
|
|
encode_claim_cursor(last_cursor_timestamp, claims[-1]["id"])
|
|
if has_more and claims
|
|
else None
|
|
)
|
|
return {
|
|
"schema": CLAIM_LIST_SCHEMA,
|
|
"generated_at": generated_at,
|
|
"read_only": True,
|
|
"source": {
|
|
"store": "canonical_postgres",
|
|
"relation": "public.claims",
|
|
"receipt": "server-side read-only query",
|
|
},
|
|
"filters": {
|
|
"q": filters["q"] or None,
|
|
"status": filters["status"] or None,
|
|
"type": filters["type"] or None,
|
|
"tag": filters["tag"] or None,
|
|
},
|
|
"page": {
|
|
"limit": filters["limit"],
|
|
"returned": len(claims),
|
|
"total": total,
|
|
"has_more": has_more,
|
|
"next_cursor": next_cursor,
|
|
},
|
|
"claims": claims,
|
|
}
|
|
|
|
payload = build_payload(visible_rows)
|
|
if len(json.dumps(payload).encode("utf-8")) <= CLAIM_LIST_MAX_RESPONSE_BYTES:
|
|
return payload
|
|
|
|
fitted_payload: dict[str, Any] | None = None
|
|
low = 1
|
|
high = len(visible_rows) - 1
|
|
while low <= high:
|
|
midpoint = (low + high) // 2
|
|
candidate = build_payload(visible_rows[:midpoint])
|
|
if len(json.dumps(candidate).encode("utf-8")) <= CLAIM_LIST_MAX_RESPONSE_BYTES:
|
|
fitted_payload = candidate
|
|
low = midpoint + 1
|
|
else:
|
|
high = midpoint - 1
|
|
if fitted_payload is None:
|
|
raise ValueError("canonical claim response exceeded its byte bound")
|
|
return fitted_payload
|
|
|
|
|
|
def _private_json_response(payload: dict[str, Any], *, status: int = 200) -> web.Response:
|
|
response = web.json_response(payload, status=status)
|
|
response.headers["Cache-Control"] = "private, no-store, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Vary"] = "X-Api-Key"
|
|
return response
|
|
|
|
|
|
def _private_html_response(body: str, *, status: int = 200) -> web.Response:
|
|
response = web.Response(text=body, content_type="text/html", status=status)
|
|
response.headers["Cache-Control"] = "private, no-store, max-age=0"
|
|
response.headers["Pragma"] = "no-cache"
|
|
response.headers["Vary"] = "X-Api-Key"
|
|
return response
|
|
|
|
|
|
def _metadata_table(claim: dict[str, Any]) -> str:
|
|
rows = [
|
|
("ID", claim_link_html(claim.get("id"))),
|
|
("Status", f"<code>{escape(str(claim.get('status') or 'unknown'))}</code>"),
|
|
("Type", f"<code>{escape(str(claim.get('type') or 'unknown'))}</code>"),
|
|
("Confidence", f"<code>{escape(str(claim.get('confidence') or 'unknown'))}</code>"),
|
|
("Tags", f"<code>{escape(', '.join(claim.get('tags') or []) or 'none')}</code>"),
|
|
("Created", f"<code>{escape(str(claim.get('created_at') or 'unknown'))}</code>"),
|
|
("Updated", f"<code>{escape(str(claim.get('updated_at') or 'unknown'))}</code>"),
|
|
]
|
|
if claim.get("superseded_by"):
|
|
rows.append(("Superseded by", claim_link_html(claim["superseded_by"])))
|
|
return "\n".join(f"<tr><th>{escape(label)}</th><td>{value}</td></tr>" for label, value in rows)
|
|
|
|
|
|
def _render_evidence(evidence: list[dict[str, Any]]) -> str:
|
|
if not evidence:
|
|
return '<div class="empty-state">No evidence rows found for this claim.</div>'
|
|
rows = []
|
|
for row in evidence:
|
|
source = row.get("url") or row.get("storage_path") or "no source pointer"
|
|
source_html = escape(str(source))
|
|
if row.get("url"):
|
|
source_html = f'<a href="{escape(str(row["url"]))}">{source_html}</a>'
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td><code>{escape(str(row.get('role') or 'unknown'))}</code></td>"
|
|
f"<td><code>{escape(str(row.get('weight') or ''))}</code></td>"
|
|
f"<td><code>{escape(str(row.get('source_type') or 'unknown'))}</code></td>"
|
|
f"<td>{source_html}</td>"
|
|
f"<td>{escape(str(row.get('excerpt') or ''))}</td>"
|
|
"</tr>"
|
|
)
|
|
return (
|
|
"<table class=\"claim-table\"><thead><tr><th>Role</th><th>Weight</th>"
|
|
"<th>Source type</th><th>Source</th><th>Excerpt</th></tr></thead>"
|
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
)
|
|
|
|
|
|
def _render_edges(edges: list[dict[str, Any]]) -> str:
|
|
if not edges:
|
|
return '<div class="empty-state">No graph edges found for this claim.</div>'
|
|
rows = []
|
|
for row in edges:
|
|
rows.append(
|
|
"<tr>"
|
|
f"<td><code>{escape(str(row.get('direction') or 'unknown'))}</code></td>"
|
|
f"<td><code>{escape(str(row.get('edge_type') or 'unknown'))}</code></td>"
|
|
f"<td>{short_claim_link_html(row.get('connected_id'))}</td>"
|
|
f"<td>{escape(str(row.get('connected_text') or ''))}</td>"
|
|
f"<td><code>{escape(str(row.get('connected_status') or ''))}</code></td>"
|
|
"</tr>"
|
|
)
|
|
return (
|
|
"<table class=\"claim-table\"><thead><tr><th>Direction</th><th>Edge</th>"
|
|
"<th>Connected claim</th><th>Text</th><th>Status</th></tr></thead>"
|
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
)
|
|
|
|
|
|
def render_kb_claim_page(data: dict[str, Any]) -> str:
|
|
claim = data["claim"]
|
|
generated_at = datetime.now(timezone.utc).isoformat()
|
|
body = f"""
|
|
<div class="claim-card">
|
|
<div class="label">Canonical claim</div>
|
|
<p class="claim-text">{escape(str(claim.get("text") or ""))}</p>
|
|
<table class="proposal-detail"><tbody>{_metadata_table(claim)}</tbody></table>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Evidence</div>
|
|
{_render_evidence(data.get("evidence") or [])}
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Edges</div>
|
|
{_render_edges(data.get("edges") or [])}
|
|
</div>"""
|
|
extra_css = """
|
|
.claim-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
|
.claim-text { margin-top: 8px; color: #f0f6fc; font-size: 17px; line-height: 1.45; }
|
|
.claim-link { color: #58a6ff; text-decoration: none; }
|
|
.claim-link:hover { text-decoration: underline; }
|
|
.claim-table td { vertical-align: top; line-height: 1.35; }
|
|
.claim-table td:nth-child(4), .claim-table td:nth-child(5) { overflow-wrap: anywhere; }
|
|
.proposal-detail { margin-top: 14px; }
|
|
.proposal-detail th { width: 150px; vertical-align: top; }
|
|
.empty-state { color: #8b949e; background: #161b22; border: 1px solid #30363d;
|
|
border-radius: 8px; padding: 14px; }
|
|
"""
|
|
return render_page(
|
|
"KB Claim",
|
|
"Canonical claim, evidence rows, and graph edges",
|
|
"/kb-proposals",
|
|
body,
|
|
extra_css=extra_css,
|
|
timestamp=generated_at,
|
|
)
|
|
|
|
|
|
async def handle_api_kb_claim(request: web.Request) -> web.Response:
|
|
claim_id = request.match_info["claim_id"]
|
|
if not is_claim_id(claim_id):
|
|
return _private_json_response({"error": "invalid_claim_id"}, status=400)
|
|
try:
|
|
data = _load_claim(request, claim_id)
|
|
except (Exception, SystemExit) as exc:
|
|
logger.error("KB claim API load failed (%s)", type(exc).__name__)
|
|
return _private_json_response({"error": "kb_claim_load_failed"}, status=503)
|
|
if not data:
|
|
return _private_json_response({"error": "claim_not_found"}, status=404)
|
|
return _private_json_response(data)
|
|
|
|
|
|
async def handle_api_kb_claims(request: web.Request) -> web.Response:
|
|
expected_api_key = request.app.get(KB_CLAIM_LIST_API_KEY)
|
|
if not expected_api_key:
|
|
return _private_json_response({"error": "canonical_claims_auth_unconfigured"}, status=503)
|
|
provided_api_key = request.headers.get("X-Api-Key", "")
|
|
try:
|
|
authenticated = hmac.compare_digest(
|
|
provided_api_key.encode("ascii"),
|
|
str(expected_api_key).encode("ascii"),
|
|
)
|
|
except UnicodeEncodeError:
|
|
authenticated = False
|
|
if not authenticated:
|
|
return _private_json_response({"error": "unauthorized"}, status=401)
|
|
try:
|
|
filters = _parse_claim_list_request(request)
|
|
except ValueError as exc:
|
|
return _private_json_response({"error": str(exc)}, status=400)
|
|
try:
|
|
raw = await _load_claim_list(request, filters)
|
|
payload = _claim_list_payload(raw, filters)
|
|
except (Exception, SystemExit) as exc:
|
|
logger.error("Canonical KB claim list load failed (%s)", type(exc).__name__)
|
|
return _private_json_response({"error": "canonical_claims_unavailable"}, status=503)
|
|
return _private_json_response(payload)
|
|
|
|
|
|
async def handle_kb_claim_page(request: web.Request) -> web.Response:
|
|
claim_id = request.match_info["claim_id"]
|
|
if not is_claim_id(claim_id):
|
|
return _private_html_response("Invalid claim id", status=400)
|
|
try:
|
|
data = _load_claim(request, claim_id)
|
|
except (Exception, SystemExit) as exc:
|
|
logger.error("KB claim page load failed (%s)", type(exc).__name__)
|
|
return _private_html_response(
|
|
render_page(
|
|
"KB Claim",
|
|
"Canonical claim, evidence rows, and graph edges",
|
|
"/kb-proposals",
|
|
'<div class="alert-banner alert-critical">Canonical claim is temporarily unavailable.</div>',
|
|
),
|
|
status=500,
|
|
)
|
|
if not data:
|
|
return _private_html_response("Claim not found", status=404)
|
|
return _private_html_response(render_kb_claim_page(data))
|
|
|
|
|
|
def register_kb_claim_routes(app: web.Application) -> None:
|
|
app.router.add_get("/api/kb/claims", handle_api_kb_claims)
|
|
app.router.add_get("/api/kb/claims/{claim_id}", handle_api_kb_claim)
|
|
app.router.add_get("/kb/claims/{claim_id}", handle_kb_claim_page)
|