dp-zp-agent/packages/claude/client.py

396 lines
16 KiB
Python

"""Small, dependency-free client shared by Claude skills and the MCP bridge."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import ipaddress
import json
import os
import re
from pathlib import Path
import stat
import urllib.error
import urllib.parse
import urllib.request
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
ENGINES = ("rag", "graphrag")
STATUSES = {"ok", "no_answer", "ambiguous", "error"}
class ClientError(Exception):
"""The message is a public reason code, never an exception from the transport."""
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
@dataclass(frozen=True)
class Config:
url: str
key: str
timeout: float = 300
allow_http: bool = False
@classmethod
def load(cls):
values = {}
filename = os.getenv("ZP_AGENT_CONFIG", "").strip()
if filename:
try:
path = Path(filename).expanduser()
if os.name == "posix" and stat.S_IMODE(path.stat().st_mode) & 0o077:
raise ClientError("config_permissions_require_0600")
values = json.loads(path.read_text())
if not isinstance(values, dict):
raise ValueError()
except ClientError:
raise
except (OSError, ValueError):
raise ClientError("invalid_configuration") from None
try:
config = cls(
os.getenv("ZP_AGENT_URL", values.get("backend_url", "")).strip().rstrip("/"),
os.getenv("ZP_AGENT_API_KEY", values.get("api_key", "")).strip(),
float(os.getenv("ZP_AGENT_TIMEOUT", values.get("timeout_seconds", 300))),
os.getenv("ZP_AGENT_ALLOW_HTTP", "").lower() in {"1", "true"},
)
config.validate()
return config
except (ValueError, TypeError, AttributeError):
raise ClientError("invalid_configuration") from None
def validate(self):
try:
url = urllib.parse.urlsplit(self.url)
if (
url.scheme not in {"http", "https"}
or not url.hostname
or url.username is not None
or url.password is not None
or url.query
or url.fragment
or any(c.isspace() for c in self.url)
):
raise ValueError()
url.port
if url.scheme == "http":
local = url.hostname == "localhost"
try:
local = local or ipaddress.ip_address(url.hostname).is_loopback
except ValueError:
pass
if not local and not self.allow_http:
raise ValueError()
if not self.key or "\r" in self.key or "\n" in self.key or not 1 <= self.timeout <= 600:
raise ValueError()
except (ValueError, TypeError):
raise ClientError("invalid_configuration") from None
def error_result(reason):
messages = {
"reference_unavailable": "Zdrojový repozitár sa nepodarilo verejne overiť. Odpoveď založenú iba na tomto odkaze neuvádzam.",
"unauthorized": "Prístup nebol autorizovaný. Skontroluj prihlásenie konektora a jeho konfiguráciu.",
"forbidden": "Pre tento nástroj nemáš potrebné oprávnenie.",
"invalid_query": "Otázku sa nepodarilo spracovať. Zadaj konkrétnu otázku a primeraný počet výsledkov.",
"question_mismatch": "Odpoveď patrí k inej otázke a nezobrazí sa. Zopakuj aktuálnu otázku.",
"engine_mismatch": "Príkaz a zvolený nástroj sa nezhodujú. Použi nástroj podľa /rag alebo /graphrag.",
"model_unavailable": "Jazykový model momentálne nie je dostupný. Skús otázku neskôr.",
"answer_rejected": "Odpoveď sa nepodarilo spoľahlivo overiť podľa podkladov. "
"Neoverené údaje sa nezobrazia.",
"answer_unavailable": "Overenú odpoveď sa momentálne nepodarilo pripraviť. Skús to neskôr.",
"answer_capacity_exceeded": "Agent práve spracúva iné otázky. Skús to o chvíľu.",
"backend_busy": "Agent práve spracúva iné otázky. Skús to o chvíľu.",
"backend_timeout": "Vyhľadávanie neodpovedalo včas. Skús otázku zopakovať neskôr.",
"backend_unavailable": "Spojenie s backendom momentálne nie je dostupné. Skontroluj, či bežia služby a tunel.",
"retrieval_unavailable": "Vyhľadávací index momentálne nie je dostupný. Skús otázku neskôr.",
"content_index_unavailable": "Obsahové vyhľadávanie momentálne nie je pripravené alebo sa jeho výstup "
"nepodarilo overiť. Treba overiť extrakciu pre aktuálnu verziu dát; "
"nejde o potvrdenie, že hľadané údaje neexistujú.",
}
return {
"status": "error",
"reason": reason,
"accepted_for_delivery": False,
"answer": messages.get(reason, "Overenú odpoveď sa momentálne nepodarilo získať."),
"sources": [],
"index_version": None,
"complete": False,
"truncated": False,
"truncation_reasons": [],
}
def request_json(config: Config, endpoint: str, body=None):
config.validate()
request = urllib.request.Request(
config.url + endpoint,
data=json.dumps(body, ensure_ascii=False).encode() if body is not None else None,
headers={
"Authorization": "Bearer " + config.key,
"Accept": "application/json",
**({"Content-Type": "application/json"} if body is not None else {}),
},
)
try:
with urllib.request.build_opener(NoRedirect()).open(
request, timeout=config.timeout
) as response:
raw = response.read(MAX_RESPONSE_BYTES + 1)
except urllib.error.HTTPError as exc:
code = exc.code
exc.close()
reason = {
401: "unauthorized",
403: "forbidden",
404: "backend_endpoint_not_found",
400: "invalid_query",
422: "invalid_request",
429: "backend_busy",
503: "backend_unavailable",
}.get(code, "backend_http_error")
raise ClientError("redirect_not_allowed" if 300 <= code < 400 else reason) from None
except (urllib.error.URLError, TimeoutError, OSError) as exc:
timeout = isinstance(exc, TimeoutError) or isinstance(
getattr(exc, "reason", None), TimeoutError
)
raise ClientError("backend_timeout" if timeout else "backend_unavailable") from None
if len(raw) > MAX_RESPONSE_BYTES:
raise ClientError("response_too_large")
try:
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError()
return value
except (ValueError, UnicodeError):
raise ClientError("invalid_backend_response") from None
def validate_metadata(value):
if (
not {"complete", "truncated", "truncation_reasons", "index_version"} <= value.keys()
or value.get("complete") is not None
and type(value["complete"]) is not bool
or type(value.get("truncated")) is not bool
or value["truncated"]
and value.get("complete") is not False
or not isinstance(value.get("truncation_reasons"), list)
or not all(isinstance(x, str) for x in value["truncation_reasons"])
or value["truncation_reasons"]
and not value["truncated"]
or value.get("index_version") is not None
and not isinstance(value["index_version"], dict)
):
raise ClientError("invalid_backend_response")
def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=None):
"""Return only approved answer fields, or explicitly marked retrieval context."""
try:
if (
engine not in ENGINES
or mode not in {"answer", "retrieve"}
or type(limit) is not int
or not 1 <= limit <= 20
or not isinstance(query, str)
):
raise ClientError("invalid_query")
query = query.strip()
command = re.match(r"^/(rag|graphrag)(?=\s|$)", query, re.I)
if command:
if command[1].lower() != engine:
raise ClientError("engine_mismatch")
query = query[command.end() :].strip()
if not 1 <= len(query) <= 500:
raise ClientError("invalid_query")
config = config or Config.load()
endpoint = "/answer" if mode == "answer" else "/" + engine
body = {"query": query.strip(), "limit": limit}
if mode == "answer" or engine == "rag":
body["include_diagnostics"] = False
if mode == "answer":
body["engine"] = engine
else:
body["published_only"] = True
value = request_json(config, endpoint, body)
if not isinstance(value.get("status"), str) or value["status"] not in STATUSES:
raise ClientError("invalid_backend_response")
if value["status"] == "error":
# Raw model output, diagnostics and rejected text must not escape.
reason = value.get("reason")
safe_reasons = {
"model_unavailable",
"answer_capacity_exceeded",
"answer_rejected",
"answer_unavailable",
"retrieval_unavailable",
"content_index_unavailable",
"reference_unavailable",
}
raise ClientError(
reason
if isinstance(reason, str) and reason in safe_reasons
else "backend_answer_error" if mode == "answer" else "retrieval_unavailable"
)
validate_metadata(value)
fields = {
"status",
"reason",
"index_version",
"complete",
"truncated",
"truncation_reasons",
}
if mode == "answer":
if not isinstance(value.get("query"), str):
raise ClientError("invalid_backend_response")
if value["query"] != body["query"] or value.get("engine") != engine:
raise ClientError("question_mismatch")
if value.get("accepted_for_delivery") is not True:
raise ClientError("answer_rejected")
if (
not isinstance(value.get("answer"), str)
or not value["answer"].strip()
or not isinstance(value.get("source_urls"), list)
or not all(isinstance(x, str) for x in value["source_urls"])
or not isinstance(value.get("claims"), list)
):
raise ClientError("invalid_backend_response")
if "answer_coverage" in value:
coverage = value["answer_coverage"]
if (
not isinstance(coverage, dict)
or coverage.get("scope")
not in ("fact", "overview", "assignment", "list", "count")
or coverage.get("status")
not in ("supported", "partial", "unknown", "not_found", "unavailable")
or any(
not isinstance(coverage.get(key), list)
or any(not isinstance(item, str) for item in coverage[key])
for key in ["warnings", "limitations"]
)
):
raise ClientError("invalid_backend_response")
if "knowledge_version" in value and not isinstance(value["knowledge_version"], str):
raise ClientError("invalid_backend_response")
fields |= {
"query",
"answer",
"source_urls",
"claims",
"count",
"accepted_for_delivery",
"answer_coverage",
}
fields.add("knowledge_version")
else:
if not isinstance(value.get("sources"), list):
raise ClientError("invalid_backend_response")
if engine == "rag" and not isinstance(value.get("context"), str):
raise ClientError("invalid_backend_response")
if engine == "graphrag" and (
not isinstance(value.get("facts"), list) or not isinstance(value.get("paths"), list)
):
raise ClientError("invalid_backend_response")
fields |= {
"sources",
"context",
"facts",
"supporting_facts",
"paths",
"aggregation",
"constraints",
"answer_candidates",
"instructions",
}
result = {key: item for key, item in value.items() if key in fields}
return {
**result,
"engine": engine,
"output_kind": "verified_answer" if mode == "answer" else "retrieval_context",
**({"requires_final_answer_verification": True} if mode == "retrieve" else {}),
}
except ClientError as exc:
return error_result(str(exc))
def backend_health(config=None):
try:
value = request_json(config or Config.load(), "/health")
tools = value.get("tools")
if (
type(value.get("ready")) is not bool
or not isinstance(tools, dict)
or any(
not isinstance(tools.get(engine), dict)
or type(tools[engine].get("ready")) is not bool
for engine in ENGINES
)
):
raise ClientError("invalid_backend_response")
return {
"status": "ok" if value["ready"] else "error",
"ready": value["ready"],
"index_version": value.get("index_version"),
"tools": {
engine: {
k: v
for k, v in tools[engine].items()
if k in {"ready", "reason", "index_version"}
}
for engine in ENGINES
},
}
except ClientError as exc:
return {**error_result(str(exc)), "ready": False}
def answer_for_presentation(result: dict) -> dict:
"""Keep approved prose/caveats intact; retrieval telemetry is not answer prose.
Called after query_backend validates the complete backend contract. Full
metadata remains available there, in /answer and in diagnostic retrieval.
Never change an incomplete result into a claim of completeness.
"""
fields = {
"status",
"reason",
"query",
"engine",
"output_kind",
"accepted_for_delivery",
"answer",
"source_urls",
"index_version",
"knowledge_version",
}
return {key: value for key, value in result.items() if key in fields}
def main(engine=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("query", nargs="?")
if engine is None:
parser.add_argument("--engine", choices=ENGINES, default="rag")
parser.add_argument("--mode", choices=["answer", "retrieve"], default="answer")
parser.add_argument("--limit", type=int, default=5)
parser.add_argument("--health", action="store_true")
args = parser.parse_args()
result = (
backend_health()
if args.health
else query_backend(args.query, engine or args.engine, mode=args.mode, limit=args.limit)
)
if not args.health and args.mode == "answer":
result = answer_for_presentation(result)
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 1 if result.get("status") == "error" else 0
if __name__ == "__main__":
raise SystemExit(main())