ragfaith-proxy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ragfaith_proxy-0.1.0/PKG-INFO +10 -0
- ragfaith_proxy-0.1.0/README.md +166 -0
- ragfaith_proxy-0.1.0/pyproject.toml +20 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy/__init__.py +3 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy/decompose.py +56 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy/judge.py +160 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy/proxy.py +792 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/PKG-INFO +10 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/SOURCES.txt +13 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/dependency_links.txt +1 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/entry_points.txt +2 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/requires.txt +7 -0
- ragfaith_proxy-0.1.0/ragfaith_proxy.egg-info/top_level.txt +1 -0
- ragfaith_proxy-0.1.0/setup.cfg +4 -0
- ragfaith_proxy-0.1.0/tests/test_proxy.py +750 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ragfaith-proxy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal OpenAI-compatible sidecar proxy with a live ragfaith faithfulness cascade
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: spacy
|
|
7
|
+
Requires-Dist: spacy>=3.7; extra == "spacy"
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
10
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# ragfaith-proxy
|
|
2
|
+
|
|
3
|
+
Universal OpenAI-compatible sidecar proxy with a **live ragfaith faithfulness
|
|
4
|
+
cascade**. Any host tool without a plugin system — chat GUIs, agent harnesses,
|
|
5
|
+
scripts — just points its OpenAI base URL at this proxy. Streaming replies pass
|
|
6
|
+
through byte-for-byte (**first token never delayed**) while claims in the reply
|
|
7
|
+
are judged against the sources actually pulled in the conversation
|
|
8
|
+
(`role: "tool"` results). Flagged claims are corrected with a nudge, in-band
|
|
9
|
+
or on the next turn.
|
|
10
|
+
|
|
11
|
+
Pure Python stdlib. No third-party runtime dependencies.
|
|
12
|
+
|
|
13
|
+
## Install & run
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install .
|
|
17
|
+
ragfaith-proxy # listens on 127.0.0.1:8787
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Tests (no network): `pip install .[dev] && pytest -q`
|
|
21
|
+
|
|
22
|
+
## Authentication: client-auth-only
|
|
23
|
+
|
|
24
|
+
The proxy holds **no API key of its own**. Every client request must carry its
|
|
25
|
+
own `Authorization: Bearer <provider key>` header, which the proxy forwards to
|
|
26
|
+
the upstream verbatim. The client's token also authenticates the judge calls
|
|
27
|
+
that request's cascade triggers. Requests without an `Authorization` header
|
|
28
|
+
get 401. Consequence: the proxy can be hosted openly — anyone who connects
|
|
29
|
+
needs and spends only their own provider key.
|
|
30
|
+
|
|
31
|
+
## FlowDown (reference example)
|
|
32
|
+
|
|
33
|
+
FlowDown is an Apple AI chat client that accepts a custom OpenAI-compatible
|
|
34
|
+
endpoint. To use it with the cascade:
|
|
35
|
+
|
|
36
|
+
1. Start the proxy: `ragfaith-proxy` (default `http://127.0.0.1:8787`).
|
|
37
|
+
2. In FlowDown, add a custom OpenAI-compatible provider with:
|
|
38
|
+
- Base URL: `http://127.0.0.1:8787/v1`
|
|
39
|
+
- API key: your real provider key (e.g. a synthetic key) — it is forwarded
|
|
40
|
+
upstream and reused for that session's judge calls.
|
|
41
|
+
3. Chat as usual. When a reply misstates the retrieved sources, the next turn
|
|
42
|
+
automatically carries a corrective nudge — or, in chain mode, the correction
|
|
43
|
+
streams right after the flagged reply.
|
|
44
|
+
|
|
45
|
+
Any other client works the same way: set its base URL to
|
|
46
|
+
`http://127.0.0.1:8787/v1`.
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
client -> proxy -> upstream LLM
|
|
52
|
+
|-> stream back immediately, buffer a copy
|
|
53
|
+
|-> on finish: decompose claims (spaCy/regex sentences)
|
|
54
|
+
|-> judge claims vs pulled tool-result premises
|
|
55
|
+
|-> flagged? nudge
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Model detection**: the request's `model` field is the active model.
|
|
59
|
+
- **Judge selection** (never self-judge): the active model is compared against
|
|
60
|
+
the configured GLM judge model (provider prefixes and `:` variants ignored).
|
|
61
|
+
A match is judged by the configured DeepSeek model; anything else is judged
|
|
62
|
+
by the configured GLM model. Both models are configurable for any
|
|
63
|
+
OpenAI-compatible endpoint via `RFE_JUDGE_GLM_MODEL` /
|
|
64
|
+
`RFE_JUDGE_DEEPSEEK_MODEL` — no `hf:`-style id shape required.
|
|
65
|
+
- **Premises**: content the conversation actually pulled — `role: "tool"`
|
|
66
|
+
messages and `tool_result` content blocks, capped to the most recent
|
|
67
|
+
24k chars (`RFE_PREMISE_CAP`). A small per-conversation store covers clients
|
|
68
|
+
that do not resend full history (LRU-capped at 1000 conversations);
|
|
69
|
+
conversations are keyed by `X-Conversation-Id`, else a hash of the first
|
|
70
|
+
user message (full message list if none), else Host header.
|
|
71
|
+
|
|
72
|
+
## Nudge modes (`RFE_NUDGE_MODE`)
|
|
73
|
+
|
|
74
|
+
- **`chain` (default)**: after a streaming reply with flagged claims finishes,
|
|
75
|
+
the proxy withholds the stream close, issues ONE internal continuation call
|
|
76
|
+
(original messages + assistant reply + aggregated nudge), streams it down the
|
|
77
|
+
same SSE stream, then terminates with a single `[DONE]`. Non-streaming
|
|
78
|
+
requests can't be chained, so they fall back to `next` behavior (logged).
|
|
79
|
+
**Spend caveat**: every flagged reply costs one extra upstream completion —
|
|
80
|
+
chain mode roughly doubles the upstream cost of flagged replies.
|
|
81
|
+
- **`next`**: the nudge is stashed per conversation and injected as a user
|
|
82
|
+
message right after the flagged assistant message in the next request's
|
|
83
|
+
history.
|
|
84
|
+
|
|
85
|
+
All-faithful replies are fully silent: no extra call, no injection.
|
|
86
|
+
|
|
87
|
+
Default nudge template:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
ragfaith judge (<model>): N claim(s) in your previous reply were flagged
|
|
91
|
+
<verdict>: <claims>. Re-check against the sources actually pulled in this
|
|
92
|
+
conversation and reconcile; do not invent corrections.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Environment variables
|
|
96
|
+
|
|
97
|
+
| Variable | Default | Purpose |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `RFE_PROXY_HOST` | `127.0.0.1` | Listen address |
|
|
100
|
+
| `RFE_PROXY_PORT` | `8787` | Listen port |
|
|
101
|
+
| `RFE_UPSTREAM_BASE` | `https://api.synthetic.new/v1` | Upstream OpenAI-compatible base |
|
|
102
|
+
| `RFE_JUDGE_PROVIDER` | `synthetic` | Preset: `synthetic` or `openrouter` (free-form with generic vars) |
|
|
103
|
+
| `RFE_JUDGE_BASE` | preset | Generic judge base URL; overrides preset |
|
|
104
|
+
| `RFE_JUDGE_GLM_MODEL` | preset | Generic primary judge model; overrides preset |
|
|
105
|
+
| `RFE_JUDGE_DEEPSEEK_MODEL` | preset | Generic GLM-active judge model; overrides preset |
|
|
106
|
+
| `RFE_SYNTHETIC_BASE` | upstream base | Judge base URL (synthetic preset) |
|
|
107
|
+
| `RFE_SYNTHETIC_GLM_MODEL` | `hf:zai-org/GLM-5.3-Flash` | Primary judge model (synthetic preset) |
|
|
108
|
+
| `RFE_SYNTHETIC_DEEPSEEK_MODEL` | `hf:deepseek-ai/DeepSeek-V4.1-Flash` | Judge when active model is GLM (synthetic preset) |
|
|
109
|
+
| `RFE_OPENROUTER_BASE` | `https://openrouter.ai/api/v1` | Judge base URL (openrouter preset) |
|
|
110
|
+
| `RFE_OPENROUTER_GLM_MODEL` | `z-ai/glm-5.3-flash` | OpenRouter primary judge |
|
|
111
|
+
| `RFE_OPENROUTER_DEEPSEEK_MODEL` | `deepseek/deepseek-v4.1-flash` | OpenRouter GLM-active judge |
|
|
112
|
+
| `RFE_NUDGE_MODE` | `chain` | `chain` or `next` |
|
|
113
|
+
| `RFE_PREMISE_CAP` | `24000` | Max chars of premises per verdict |
|
|
114
|
+
| `RFE_HOST_DECORATORS` | — | JSON map of per-host overrides |
|
|
115
|
+
| `RFE_HOST_CONFIG` | — | Path to same JSON map in a file |
|
|
116
|
+
| `RFE_CACHE_DIR` | — | Append-only JSONL verdict cache dir |
|
|
117
|
+
| `RFE_JUDGE_LOG` | stderr | File for session token log lines |
|
|
118
|
+
| `RFE_MAX_BODY` | `10485760` | Max request body bytes (larger gets 413) |
|
|
119
|
+
|
|
120
|
+
Precedence: generic `RFE_JUDGE_*` vars → preset vars (`RFE_SYNTHETIC_*` /
|
|
121
|
+
`RFE_OPENROUTER_*`, selected by `RFE_JUDGE_PROVIDER`) → built-in defaults.
|
|
122
|
+
Example — judge with any OpenAI-compatible endpoint:
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
export RFE_JUDGE_PROVIDER=my-endpoint # preset name is free-form
|
|
126
|
+
export RFE_JUDGE_BASE=http://llm.internal/v1
|
|
127
|
+
export RFE_JUDGE_GLM_MODEL=openai/gpt-oss-120b
|
|
128
|
+
export RFE_JUDGE_DEEPSEEK_MODEL=mistral/magistral-small
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Per-host decorators are matched by the `X-RFE-Host` header (exact key), else
|
|
132
|
+
by User-Agent substring, else `default`:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{"flowdown": {"template": "...", "nudge_mode": "next"}, "default": {}}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Logging & cache
|
|
139
|
+
|
|
140
|
+
Token log lines go to stderr (or `RFE_JUDGE_LOG` file), one JSON per judge
|
|
141
|
+
call — tokens only, no cost math:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{"ts": 1757680000.0, "kind": "judge", "model": "...", "prompt_tokens": 0, "completion_tokens": 0, "conversation": "..."}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
With `RFE_CACHE_DIR` set, verdicts persist to
|
|
148
|
+
`<dir>/proxy-cache-<judgeModelSanitized>.jsonl` (`{"key","verdict"}` rows,
|
|
149
|
+
sha256 keys over model/context/claim). Unset: in-memory dict only.
|
|
150
|
+
|
|
151
|
+
## spaCy (optional)
|
|
152
|
+
|
|
153
|
+
Claim decomposition uses `split_claims`, a vendored copy of
|
|
154
|
+
`rag_faithfulness_eval.decompose` (copied, not imported). With
|
|
155
|
+
`pip install ragfaith-proxy[spacy]` you get the benchmarked spaCy sentencizer
|
|
156
|
+
path. Without it the proxy falls back to a regex sentence splitter and logs a
|
|
157
|
+
prominent warning — claim boundaries may differ from benchmark results in
|
|
158
|
+
edge cases.
|
|
159
|
+
|
|
160
|
+
## Failure behavior
|
|
161
|
+
|
|
162
|
+
The proxy never blocks or corrupts the stream: any upstream/judge/cascade
|
|
163
|
+
failure is logged and the response passes through untouched. Client
|
|
164
|
+
disconnects mid-stream abort cleanly. Responses are requested
|
|
165
|
+
`Accept-Encoding: identity` from upstream so the buffered copy stays
|
|
166
|
+
parseable.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ragfaith-proxy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Universal OpenAI-compatible sidecar proxy with a live ragfaith faithfulness cascade"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
spacy = ["spacy>=3.7"]
|
|
14
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
ragfaith-proxy = "ragfaith_proxy.proxy:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
include = ["ragfaith_proxy*"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Claim decomposition, copied from rag_faithfulness_eval/decompose.py.
|
|
2
|
+
|
|
3
|
+
spaCy sentencizer is the benchmarked path (pip install ragfaith-proxy[spacy]).
|
|
4
|
+
Without spaCy we degrade to a regex sentence splitter and warn loudly; claim
|
|
5
|
+
offsets stay verbatim char-offsets into the text either way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_nlp = None
|
|
15
|
+
|
|
16
|
+
_SENT_RE = re.compile(r"[^.!?\n]+(?:[.!?]+|\n+|$)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_nlp(): # lazy: spacy is an optional extra, the proxy must not need it
|
|
20
|
+
global _nlp
|
|
21
|
+
if _nlp is not None:
|
|
22
|
+
return _nlp
|
|
23
|
+
try:
|
|
24
|
+
import spacy
|
|
25
|
+
|
|
26
|
+
nlp = spacy.blank("en")
|
|
27
|
+
nlp.add_pipe("sentencizer")
|
|
28
|
+
_nlp = nlp
|
|
29
|
+
except ImportError:
|
|
30
|
+
# spaCy parity is the benchmarked path; regex is the no-dep fallback
|
|
31
|
+
logger.warning(
|
|
32
|
+
"spaCy not installed: using regex sentence splitter (degraded claim "
|
|
33
|
+
"boundaries). Install ragfaith-proxy[spacy] for benchmark parity."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def regex_nlp(text: str):
|
|
37
|
+
sents = []
|
|
38
|
+
for m in _SENT_RE.finditer(text):
|
|
39
|
+
seg = m.group()
|
|
40
|
+
stripped = seg.strip()
|
|
41
|
+
if not stripped:
|
|
42
|
+
continue
|
|
43
|
+
start = m.start() + len(seg) - len(seg.lstrip())
|
|
44
|
+
sents.append(
|
|
45
|
+
SimpleNamespace(start_char=start, end_char=start + len(stripped), text=stripped)
|
|
46
|
+
)
|
|
47
|
+
return SimpleNamespace(sents=sents)
|
|
48
|
+
|
|
49
|
+
_nlp = regex_nlp
|
|
50
|
+
return _nlp
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def split_claims(text: str) -> list[tuple[int, int, str]]:
|
|
54
|
+
"""Return [(start, end, sentence)] with char offsets into text."""
|
|
55
|
+
doc = _get_nlp()(text)
|
|
56
|
+
return [(s.start_char, s.end_char, s.text) for s in doc.sents if s.text.strip()]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""LLM judge for ragfaith-proxy (OpenAI-compatible chat API, stdlib only).
|
|
2
|
+
|
|
3
|
+
Adapted from rag_faithfulness_eval/llm_judge.py: provider-pluggable base URL
|
|
4
|
+
and key supplied by the caller (env-only, no .env loading), token-only log
|
|
5
|
+
lines instead of USD accounting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from collections import OrderedDict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
VERDICTS = ("faithful", "unfaithful", "unverifiable")
|
|
23
|
+
VCACHE_MAX = 10000
|
|
24
|
+
RETRIES = 3 # streaming handler must not stall minutes on an upstream outage
|
|
25
|
+
|
|
26
|
+
SYSTEM_PROMPT = (
|
|
27
|
+
"You are a RAG faithfulness judge. / Du bist ein RAG-Treuerichter.\n"
|
|
28
|
+
"Decide if the CLAIM is fully supported by the CONTEXT alone (never use "
|
|
29
|
+
"outside knowledge). Answer with ONLY one JSON object, no other text:\n"
|
|
30
|
+
'{"verdict": "faithful"} - every fact in the claim is supported by the context\n'
|
|
31
|
+
'{"verdict": "unfaithful"} - at least one fact contradicts or is unsupported '
|
|
32
|
+
"by the context (wrong entity, number, date, or fabricated detail)\n"
|
|
33
|
+
'{"verdict": "unverifiable"} - the context does not address the claim at all'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _verdict_key(model: str, context: str, claim: str) -> str:
|
|
38
|
+
return hashlib.sha256(f"{model}\x00{context}\x00{claim}".encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_verdict(text: str) -> str:
|
|
42
|
+
m = re.search(r'"verdict"\s*:\s*"(\w+)"', text)
|
|
43
|
+
verdict = m.group(1) if m else ""
|
|
44
|
+
if verdict not in VERDICTS:
|
|
45
|
+
raise ValueError(f"unparseable verdict: {text[:200]!r}")
|
|
46
|
+
return verdict
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Judge:
|
|
50
|
+
"""Chat-model judge against any OpenAI-compatible base URL."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
model: str,
|
|
55
|
+
base_url: str,
|
|
56
|
+
api_key: str,
|
|
57
|
+
*,
|
|
58
|
+
max_tokens: int = 256,
|
|
59
|
+
cache_path: Path | None = None,
|
|
60
|
+
log=None,
|
|
61
|
+
vcache_max: int = VCACHE_MAX,
|
|
62
|
+
):
|
|
63
|
+
self.model = model
|
|
64
|
+
self.base_url = base_url.rstrip("/")
|
|
65
|
+
self.api_key = api_key
|
|
66
|
+
self.max_tokens = max_tokens
|
|
67
|
+
self.cache_path = cache_path
|
|
68
|
+
self._log = log or (lambda r: print(json.dumps(r), file=sys.stderr, flush=True))
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
self._vcache_max = vcache_max
|
|
71
|
+
self._vcache: OrderedDict[str, str] = OrderedDict()
|
|
72
|
+
if cache_path and cache_path.exists():
|
|
73
|
+
for line in cache_path.read_text().splitlines():
|
|
74
|
+
if line.strip():
|
|
75
|
+
row = json.loads(line)
|
|
76
|
+
self._vcache[row["key"]] = row["verdict"]
|
|
77
|
+
|
|
78
|
+
def _call(self, user_msg: str, retries: int = RETRIES, max_tokens: int | None = None) -> dict:
|
|
79
|
+
body = json.dumps(
|
|
80
|
+
{
|
|
81
|
+
"model": self.model,
|
|
82
|
+
"temperature": 0,
|
|
83
|
+
"max_tokens": max_tokens or self.max_tokens,
|
|
84
|
+
"reasoning": {"exclude": True}, # hide reasoning; still billed ~100-250 tok
|
|
85
|
+
"messages": [
|
|
86
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
87
|
+
{"role": "user", "content": user_msg},
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
).encode()
|
|
91
|
+
url = f"{self.base_url}/chat/completions"
|
|
92
|
+
for attempt in range(retries):
|
|
93
|
+
req = urllib.request.Request(
|
|
94
|
+
url,
|
|
95
|
+
data=body,
|
|
96
|
+
headers={
|
|
97
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
103
|
+
return json.loads(resp.read())
|
|
104
|
+
except urllib.error.HTTPError as e:
|
|
105
|
+
if e.code in (429, 500, 502, 503) and attempt < retries - 1:
|
|
106
|
+
time.sleep(2**attempt)
|
|
107
|
+
continue
|
|
108
|
+
raise
|
|
109
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
110
|
+
# network drop: bounded retry (<=15 s total sleep at 3 attempts)
|
|
111
|
+
if attempt < retries - 1:
|
|
112
|
+
logger.warning("network error (%s); retry %d/%d", e, attempt + 1, retries)
|
|
113
|
+
time.sleep(min(60, 5 * 2**attempt))
|
|
114
|
+
continue
|
|
115
|
+
raise
|
|
116
|
+
raise RuntimeError("unreachable: retry loop exhausted")
|
|
117
|
+
|
|
118
|
+
def _account(self, resp: dict, conversation: str) -> None:
|
|
119
|
+
usage = resp.get("usage") or {}
|
|
120
|
+
self._log(
|
|
121
|
+
{
|
|
122
|
+
"kind": "judge",
|
|
123
|
+
"model": self.model,
|
|
124
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
125
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
126
|
+
"conversation": conversation,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def verdict(self, context: str, claim: str, conversation: str = "") -> str:
|
|
131
|
+
key = _verdict_key(self.model, context, claim)
|
|
132
|
+
with self._lock:
|
|
133
|
+
if key in self._vcache:
|
|
134
|
+
self._vcache.move_to_end(key)
|
|
135
|
+
return self._vcache[key]
|
|
136
|
+
msg = f"CONTEXT:\n{context}\n\nCLAIM:\n{claim}"
|
|
137
|
+
for max_tokens in (self.max_tokens, self.max_tokens * 2):
|
|
138
|
+
resp = self._call(msg, max_tokens=max_tokens)
|
|
139
|
+
self._account(resp, conversation)
|
|
140
|
+
content = resp["choices"][0]["message"].get("content")
|
|
141
|
+
try:
|
|
142
|
+
verdict = _parse_verdict(content or "")
|
|
143
|
+
self._store(key, verdict)
|
|
144
|
+
return verdict
|
|
145
|
+
except ValueError:
|
|
146
|
+
continue
|
|
147
|
+
verdict = "unverifiable" # parse failure after 256 -> 2x tokens: conservative
|
|
148
|
+
self._store(key, verdict)
|
|
149
|
+
return verdict
|
|
150
|
+
|
|
151
|
+
def _store(self, key: str, verdict: str) -> None:
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._vcache[key] = verdict
|
|
154
|
+
self._vcache.move_to_end(key)
|
|
155
|
+
while len(self._vcache) > self._vcache_max:
|
|
156
|
+
self._vcache.popitem(last=False)
|
|
157
|
+
if self.cache_path:
|
|
158
|
+
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
with self.cache_path.open("a") as f:
|
|
160
|
+
f.write(json.dumps({"key": key, "verdict": verdict}) + "\n")
|