420 lines
14 KiB
Python
420 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Build source-specific canonical claim context from a Postgres dump without a database write."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import codecs
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections import Counter
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Any
|
|
|
|
SCHEMA = "livingip.kbSourceDumpContextReceipt.v1"
|
|
STATUS_SCHEMA = "livingip.kbSourceDumpContextStatus.v1"
|
|
DEFAULT_DOCKER_IMAGE = "postgres:16-alpine"
|
|
DEFAULT_LIMIT = 12
|
|
DEFAULT_COPY_ENCODING = "utf-8"
|
|
MAX_PREVIEW_BYTES = 16_000
|
|
TABLES = ("claims", "claim_evidence", "claim_edges")
|
|
|
|
|
|
class ContextError(RuntimeError):
|
|
"""Raised when a dump cannot safely produce canonical retrieval context."""
|
|
|
|
|
|
def sha256_bytes(value: bytes) -> str:
|
|
return hashlib.sha256(value).hexdigest()
|
|
|
|
|
|
def _read_file(path: Path, label: str) -> bytes:
|
|
try:
|
|
if not path.is_file():
|
|
raise ContextError(f"{label} is not a regular file: {path}")
|
|
return path.read_bytes()
|
|
except OSError as exc:
|
|
raise ContextError(f"could not read {label}: {exc}") from exc
|
|
|
|
|
|
def _load_kb_tool(repo_root: Path) -> ModuleType:
|
|
path = repo_root / "hermes-agent" / "leoclean-bin" / "kb_tool.py"
|
|
spec = importlib.util.spec_from_file_location("teleo_kb_tool_context", path)
|
|
if spec is None or spec.loader is None:
|
|
raise ContextError(f"could not load retrieval semantics from {path}")
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _copy_encoding(value: str) -> str:
|
|
try:
|
|
return codecs.lookup(value).name
|
|
except LookupError as exc:
|
|
raise ContextError("COPY encoding is not supported") from exc
|
|
|
|
|
|
def _decode_copy_field(value: str, *, encoding: str = DEFAULT_COPY_ENCODING) -> str | None:
|
|
if value == r"\N":
|
|
return None
|
|
encoding = _copy_encoding(encoding)
|
|
result = bytearray()
|
|
index = 0
|
|
escapes = {"b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11, "\\": 92}
|
|
|
|
def append_text(text: str) -> None:
|
|
try:
|
|
result.extend(text.encode(encoding, errors="strict"))
|
|
except UnicodeEncodeError as exc:
|
|
raise ContextError(f"COPY field cannot be encoded as {encoding}") from exc
|
|
|
|
while index < len(value):
|
|
char = value[index]
|
|
if char != "\\":
|
|
append_text(char)
|
|
index += 1
|
|
continue
|
|
if index + 1 >= len(value):
|
|
raise ContextError("COPY field contains a dangling backslash escape")
|
|
index += 1
|
|
escaped = value[index]
|
|
if escaped in escapes:
|
|
result.append(escapes[escaped])
|
|
index += 1
|
|
continue
|
|
if escaped == "x":
|
|
match = re.match(r"[0-9A-Fa-f]{1,2}", value[index + 1 :])
|
|
if match is not None:
|
|
result.append(int(match.group(0), 16))
|
|
index += 1 + len(match.group(0))
|
|
continue
|
|
if escaped in "01234567":
|
|
match = re.match(r"[0-7]{1,3}", value[index:])
|
|
if match is None:
|
|
raise ContextError("COPY field contains a malformed octal byte escape")
|
|
byte_value = int(match.group(0), 8)
|
|
if byte_value > 0xFF:
|
|
raise ContextError("COPY field contains an out-of-range octal byte escape")
|
|
result.append(byte_value)
|
|
index += len(match.group(0))
|
|
continue
|
|
append_text(escaped)
|
|
index += 1
|
|
if 0 in result:
|
|
raise ContextError("COPY field contains a zero byte")
|
|
try:
|
|
return result.decode(encoding, errors="strict")
|
|
except UnicodeDecodeError as exc:
|
|
raise ContextError(f"COPY byte escapes do not form valid {encoding} text") from exc
|
|
|
|
|
|
def parse_copy_table(
|
|
sql: str, table: str, *, encoding: str = DEFAULT_COPY_ENCODING
|
|
) -> list[dict[str, str | None]]:
|
|
encoding = _copy_encoding(encoding)
|
|
prefix = f"COPY public.{table} ("
|
|
lines = iter(line[:-1] if line.endswith("\r") else line for line in sql.split("\n"))
|
|
for line in lines:
|
|
if line.startswith(prefix) and line.endswith(") FROM stdin;"):
|
|
columns = [item.strip() for item in line[len(prefix) : -len(") FROM stdin;")].split(",")]
|
|
break
|
|
else:
|
|
raise ContextError(f"pg_restore output has no COPY block for public.{table}")
|
|
rows: list[dict[str, str | None]] = []
|
|
for line in lines:
|
|
if line == r"\.":
|
|
return rows
|
|
fields = line.split("\t")
|
|
if len(fields) != len(columns):
|
|
raise ContextError(f"public.{table} COPY row has {len(fields)} fields; expected {len(columns)}")
|
|
rows.append(
|
|
dict(zip(columns, (_decode_copy_field(field, encoding=encoding) for field in fields), strict=True))
|
|
)
|
|
raise ContextError(f"public.{table} COPY block is unterminated")
|
|
|
|
|
|
def restore_table(
|
|
dump_path: Path, table: str, docker_image: str, *, encoding: str = DEFAULT_COPY_ENCODING
|
|
) -> str:
|
|
if table not in TABLES:
|
|
raise ContextError(f"unsupported canonical table: {table}")
|
|
if shutil.which("docker") is None:
|
|
raise ContextError("docker is not installed or not available on PATH")
|
|
command = [
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--network=none",
|
|
"--mount",
|
|
f"type=bind,src={dump_path.resolve()},dst=/canonical.dump,readonly",
|
|
docker_image,
|
|
"pg_restore",
|
|
"--data-only",
|
|
"-t",
|
|
table,
|
|
"-f",
|
|
"-",
|
|
"/canonical.dump",
|
|
]
|
|
encoding = _copy_encoding(encoding)
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding=encoding,
|
|
errors="strict",
|
|
timeout=180,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired, UnicodeError) as exc:
|
|
raise ContextError(f"pg_restore failed ({type(exc).__name__}); diagnostic detail redacted") from exc
|
|
if completed.returncode != 0:
|
|
raise ContextError(f"pg_restore returned exit {completed.returncode}; diagnostic detail redacted")
|
|
return completed.stdout
|
|
|
|
|
|
def _decimal(value: str | None) -> Decimal:
|
|
if value is None:
|
|
return Decimal(0)
|
|
try:
|
|
return Decimal(value)
|
|
except InvalidOperation as exc:
|
|
raise ContextError("canonical claim confidence is not numeric") from exc
|
|
|
|
|
|
def build_context(
|
|
*,
|
|
dump_path: Path,
|
|
artifact_path: Path,
|
|
title: str,
|
|
limit: int,
|
|
docker_image: str,
|
|
repo_root: Path,
|
|
copy_encoding: str = DEFAULT_COPY_ENCODING,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
if limit < 1 or limit > 20:
|
|
raise ContextError("limit must be between 1 and 20")
|
|
dump_bytes = _read_file(dump_path, "canonical dump")
|
|
artifact_bytes = _read_file(artifact_path, "source artifact")
|
|
try:
|
|
preview = artifact_bytes[:MAX_PREVIEW_BYTES].decode("utf-8", errors="strict")
|
|
except UnicodeDecodeError as exc:
|
|
raise ContextError(f"source artifact preview must be strict UTF-8: {exc}") from exc
|
|
kb_tool = _load_kb_tool(repo_root)
|
|
query = " ".join(kb_tool.query_terms(f"{title} {preview}"))
|
|
terms = kb_tool.query_terms(query)
|
|
min_score = 2 if len(terms) >= 3 else 1
|
|
|
|
copy_encoding = _copy_encoding(copy_encoding)
|
|
tables = {
|
|
table: parse_copy_table(
|
|
restore_table(dump_path, table, docker_image, encoding=copy_encoding),
|
|
table,
|
|
encoding=copy_encoding,
|
|
)
|
|
for table in TABLES
|
|
}
|
|
evidence_counts = Counter(row["claim_id"] for row in tables["claim_evidence"] if row["claim_id"])
|
|
edge_counts: Counter[str] = Counter()
|
|
for row in tables["claim_edges"]:
|
|
if row["from_claim"]:
|
|
edge_counts[row["from_claim"]] += 1
|
|
if row["to_claim"]:
|
|
edge_counts[row["to_claim"]] += 1
|
|
|
|
ranked: list[dict[str, Any]] = []
|
|
for row in tables["claims"]:
|
|
if row["status"] != "open" or not row["id"] or not row["text"]:
|
|
continue
|
|
text = row["text"]
|
|
searchable = f"{text}\n{row['tags'] or ''}".lower()
|
|
score = sum(term in searchable for term in terms)
|
|
if score < min_score:
|
|
continue
|
|
ranked.append(
|
|
{
|
|
"id": row["id"],
|
|
"text": text,
|
|
"score": score,
|
|
"evidence_count": evidence_counts[row["id"]],
|
|
"edge_count": edge_counts[row["id"]],
|
|
"_confidence": _decimal(row["confidence"]),
|
|
}
|
|
)
|
|
ranked.sort(
|
|
key=lambda row: (
|
|
-row["score"],
|
|
-row["evidence_count"],
|
|
-row["edge_count"],
|
|
-row["_confidence"],
|
|
len(row["text"]),
|
|
row["text"],
|
|
row["id"],
|
|
)
|
|
)
|
|
candidates = [{key: value for key, value in row.items() if key != "_confidence"} for row in ranked[:limit]]
|
|
context = {"database_search_query": query, "candidate_claims": candidates}
|
|
receipt = {
|
|
"schema": SCHEMA,
|
|
"status": "prepared",
|
|
"database_write_performed": False,
|
|
"model_call_performed": False,
|
|
"canonical_dump": {
|
|
"path": str(dump_path.resolve()),
|
|
"sha256": sha256_bytes(dump_bytes),
|
|
},
|
|
"source": {
|
|
"path": str(artifact_path.resolve()),
|
|
"sha256": sha256_bytes(artifact_bytes),
|
|
},
|
|
"retrieval": {
|
|
"query_sha256": sha256_bytes(query.encode("utf-8")),
|
|
"term_count": len(terms),
|
|
"minimum_score": min_score,
|
|
"canonical_open_claim_count": sum(row["status"] == "open" for row in tables["claims"]),
|
|
"candidate_count": len(candidates),
|
|
"candidate_ids": [row["id"] for row in candidates],
|
|
},
|
|
"runtime": {
|
|
"docker_image": docker_image,
|
|
"network_disabled": True,
|
|
"pg_restore_output_retained": False,
|
|
"copy_encoding": copy_encoding,
|
|
"copy_decode_errors": "strict",
|
|
},
|
|
}
|
|
return context, receipt
|
|
|
|
|
|
def _stage_private_json(path: Path, value: Any) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chmod(path.parent, 0o700)
|
|
fd, raw_temp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
temp = Path(raw_temp)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(value, handle, indent=2, sort_keys=True, ensure_ascii=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
return temp
|
|
except BaseException:
|
|
if temp.exists():
|
|
temp.unlink()
|
|
raise
|
|
|
|
|
|
def _private_json(path: Path, value: Any) -> None:
|
|
if path.exists():
|
|
raise ContextError(f"refusing to overwrite existing output: {path}")
|
|
temp = _stage_private_json(path, value)
|
|
try:
|
|
os.replace(temp, path)
|
|
os.chmod(path, 0o600)
|
|
finally:
|
|
if temp.exists():
|
|
temp.unlink()
|
|
|
|
|
|
def _publish_private_json_outputs(outputs: tuple[tuple[Path, Any], ...]) -> None:
|
|
for path, _value in outputs:
|
|
if path.exists():
|
|
raise ContextError(f"refusing to overwrite existing output: {path}")
|
|
|
|
staged: list[tuple[Path, Path]] = []
|
|
try:
|
|
for path, value in outputs:
|
|
staged.append((path, _stage_private_json(path, value)))
|
|
for path, temp in staged:
|
|
os.replace(temp, path)
|
|
os.chmod(path, 0o600)
|
|
finally:
|
|
for _path, temp in staged:
|
|
if temp.exists():
|
|
temp.unlink()
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dump", required=True, type=Path)
|
|
parser.add_argument("--artifact", required=True, type=Path)
|
|
parser.add_argument("--title", required=True)
|
|
parser.add_argument("--context-output", required=True, type=Path)
|
|
parser.add_argument("--receipt-output", required=True, type=Path)
|
|
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
|
parser.add_argument("--docker-image", default=DEFAULT_DOCKER_IMAGE)
|
|
parser.add_argument("--copy-encoding", default=DEFAULT_COPY_ENCODING)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(argv)
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
context_existed = args.context_output.is_file()
|
|
receipt_existed = args.receipt_output.is_file()
|
|
try:
|
|
if args.context_output.resolve() == args.receipt_output.resolve():
|
|
raise ContextError("context and receipt outputs must be different files")
|
|
context, receipt = build_context(
|
|
dump_path=args.dump,
|
|
artifact_path=args.artifact,
|
|
title=args.title,
|
|
limit=args.limit,
|
|
docker_image=args.docker_image,
|
|
repo_root=repo_root,
|
|
copy_encoding=args.copy_encoding,
|
|
)
|
|
receipt["outputs"] = {
|
|
"context": str(args.context_output.resolve()),
|
|
"receipt": str(args.receipt_output.resolve()),
|
|
}
|
|
_publish_private_json_outputs(
|
|
((args.context_output, context), (args.receipt_output, receipt))
|
|
)
|
|
except (ContextError, OSError, ValueError):
|
|
context_written = not context_existed and args.context_output.is_file()
|
|
receipt_written = not receipt_existed and args.receipt_output.is_file()
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"schema": STATUS_SCHEMA,
|
|
"status": "rejected",
|
|
"database_write_performed": False,
|
|
"model_call_performed": False,
|
|
"private_context_written": context_written,
|
|
"private_receipt_written": receipt_written,
|
|
"reason": "context_preparation_failed",
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return 2
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"schema": STATUS_SCHEMA,
|
|
"status": "prepared",
|
|
"database_write_performed": False,
|
|
"model_call_performed": False,
|
|
"private_context_written": True,
|
|
"private_receipt_written": True,
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|