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

440 lines
20 KiB
Python

"""Small, dependency-free client shared by Claude skills and the MCP bridge."""
from __future__ import annotations
import argparse
import hashlib
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 = {
'invalid_context':'Kontext predchádzajúcej otázky sa nepodarilo overiť. Zopakuj úplnú otázku s osobou, prácou a prípadným dátumom.',
'stale_context':'Dáta alebo kontext konverzácie sa zmenili. Zopakuj úplnú otázku; starý výber stretnutia nepoužijem.',
'expired_context':'Kontext predchádzajúcej otázky už vypršal. Zopakuj úplnú otázku.',
'proposal_not_available':'Otázka žiada návrh možného prepojenia, nie iba existujúce fakty. Návrhový režim ešte nie je dostupný; výpis podkladov preto nevydávam za hotový návrh.',
"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.",
'verification_unavailable':'Finálne overenie odpovede momentálne nie je dostupné. Skús otázku neskôr; nejde o potvrdenie chýbajúcich podkladov.',
"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 validated_delivery_check(value,query,engine):
check=value.get('delivery_check')
if check is None:return None
digest=hashlib.sha256(json.dumps([engine,query.strip()],ensure_ascii=False,separators=(',',':')).encode()).hexdigest()
if (not isinstance(check,dict) or set(check)!={'layer','code','request_fingerprint'}
or check.get('request_fingerprint')!=digest
or check.get('layer') not in {'delivery','scope','evidence','coverage','answer','verification','fulfilment','context','planning','retrieval','service'}
or check.get('code') not in {'complete','partial','not_applicable','wrong_person','wrong_work_or_meeting',
'wrong_period','mixed_snapshot','altered_evidence','missing_evidence','missing_person','invalid_citation','verification_unavailable',
'request_not_fulfilled','invalid_review','undisclosed_gap','clarification_required','evidence_not_found','unavailable'}):
raise ClientError('invalid_backend_response')
return check
def query_backend(query: str, engine: str, *, mode="answer", limit=5, config=None, context_token=None):
"""Return only approved answer fields, or explicitly marked retrieval context."""
checked_delivery=None
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()
if context_token is not None and (mode!='answer' or not isinstance(context_token,str) or not 1<=len(context_token)<=16000):
raise ClientError('invalid_query')
endpoint = "/answer" if mode == "answer" else "/" + engine
body = {"query": query.strip(), "limit": limit}
if context_token is not None:body['context_token']=context_token
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 mode=='answer':checked_delivery=validated_delivery_check(value,query,engine)
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 = {
'verification_unavailable',
'invalid_context','stale_context','expired_context','proposal_not_available',
"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")
if 'conversation_context' in value:
context=value['conversation_context']
if (not isinstance(context,dict) or not isinstance(context.get('token'),str)
or not 1<=len(context['token'])<=16000
or any(not isinstance(context.get(k),list) for k in ['work_options','meeting_options'])):
raise ClientError('invalid_backend_response')
if 'resolved_query' in value and not isinstance(value['resolved_query'],str):
raise ClientError('invalid_backend_response')
if 'answer_kind' in value and value['answer_kind'] not in {'grounded_facts','recommendation'}:
raise ClientError('invalid_backend_response')
if 'recommendation_status' in value and value['recommendation_status'] not in {'accepted','insufficient_grounding','unavailable'}:
raise ClientError('invalid_backend_response')
fields |= {
"query",
"answer",
"source_urls",
"claims",
"count",
"accepted_for_delivery",
"answer_coverage",
}
fields.add("knowledge_version")
fields |= {'conversation_context','resolved_query','answer_kind','recommendation_status','delivery_check'}
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)),**({'delivery_check':checked_delivery} if checked_delivery else {})}
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"),
**({"capabilities": value["capabilities"]} if isinstance(value.get("capabilities"), dict) else {}),
**({"answer_ready": value["answer_ready"]} if isinstance(value.get("answer_ready"), dict) else {}),
"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 = {
'conversation_context','resolved_query','answer_kind','recommendation_status','delivery_check',
"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")
parser.add_argument('--context-token',help='Opaque conversation token from this chat\'s previous tool result')
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, context_token=args.context_token)
)
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())