441 lines
20 KiB
Python
441 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 = 90
|
|
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", 90))),
|
|
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','evidence_selection'}
|
|
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',
|
|
'invalid_model_selection','model_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())
|