arborist/aborist/qa/client.py
russell@unturf.com 57cf1b183b
add Q&A layer: v9.8 providence_cache writes with Merkle-bound proofs
aborist/qa/ implements the cache-first answer flow from the providence
whitepaper, scaled up to v9.8's full 8-dim admissibility invariant.

cache_key = SHA-256 of:
  source_root | question_hash | model_profile_hash | conversation_hash
  | governance_policy_hash | schema_version | canonicalization_version
  | chunking_version

Any drift in any dimension yields a distinct cache_key — prior records
cannot serve. Falsification states (failed/stale/quarantined) gate
every cache hit.

- qa/keys.py        — pure hash functions, deterministic & testable
- qa/client.py      — ChatClient Protocol + StubClient + OpenAI-compatible
                      HTTP client (vllm/llama.cpp/uncloseai compatible)
- qa/runner.py      — ask(): lookup -> hit (no LLM call, hit_count++)
                      OR miss (call client, write record, audit event,
                      proof binds answer to source root)
- cli.py            — `aborist ask` and `aborist providence` subcommands
- pyproject.toml    — httpx promoted from extras to core (used by both
                      html and qa); selectolax stays in [html] extras

Smoke (StubClient, no network): cache miss writes record with chunk_0
Merkle proof reconstructing source_root; cache hit returns same record
without calling client; 1085 audit events chained 0 breaks across
ingest/derive/evict/rehydrate/providence_write.
2026-04-27 08:01:23 -04:00

91 lines
2.6 KiB
Python

"""Chat-completion clients.
ChatClient is a Protocol — any object with a `chat_completion` method
plugs in. We ship two concrete clients:
- OpenAICompatibleClient — talks to any OpenAI-compatible /v1/chat/completions
endpoint (vllm, llama.cpp server, ollama, TGI, hosted services).
- StubClient — offline canned responses for tests and dry-runs. No
network. Operation Voyeur safe.
"""
from __future__ import annotations
from typing import Protocol
class ChatClient(Protocol):
def chat_completion(
self,
messages: list[dict],
*,
model: str,
temperature: float = 0.1,
max_tokens: int = 512,
top_p: float = 1.0,
) -> str:
"""Return the assistant's text response."""
...
class StubClient:
"""Offline client for tests / --dry-run.
Pass `answer=callable(messages, **kw) -> str` for dynamic stubbing.
"""
def __init__(self, answer="[STUB] dry-run answer; no LLM was called."):
self._answer = answer
self.calls: list[dict] = []
def chat_completion(self, messages, **kwargs) -> str:
self.calls.append({"messages": messages, "kwargs": kwargs})
if callable(self._answer):
return self._answer(messages, **kwargs)
return self._answer
class OpenAICompatibleClient:
"""OpenAI-compatible chat completion over HTTP.
Default endpoint is configurable via env. Pass api_key only if the
target requires it; uncloseai's free endpoint does not.
"""
def __init__(
self,
base_url: str,
api_key: str | None = None,
timeout: float = 60.0,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def chat_completion(
self,
messages: list[dict],
*,
model: str,
temperature: float = 0.1,
max_tokens: int = 512,
top_p: float = 1.0,
) -> str:
import httpx
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"top_p": top_p,
}
url = f"{self.base_url}/chat/completions"
with httpx.Client(timeout=self.timeout) as client:
resp = client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]