File size: 7,096 Bytes
901cd4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""Does LLM_REASONING_EFFORT=none reduce answer quality?



Isolates the one variable that matters: given IDENTICAL evidence, does the model

produce a less correct answer with reasoning disabled?



Each case defines the required facts a correct answer must state. A case scores

1.0 if every required fact appears and no forbidden (hallucinated) fact appears,

otherwise a partial score. Run both settings, compare mean accuracy.



Usage:

    python scripts/reasoning_quality_test.py

"""

from __future__ import annotations

import json
import os
import re
import time

import httpx
from dotenv import load_dotenv

URL = "https://openrouter.ai/api/v1/chat/completions"

SYSTEM = (
    "You answer strictly from the provided evidence. Respond with a single JSON "
    'object: {"answer": string, "citations": [string], "claims": '
    '[{"claim": string, "citation_ids": [string]}]}. '
    "Every factual sentence in `answer` must end with an inline marker like [C1]. "
    "If the evidence is insufficient, refuse instead of guessing."
)

# `must` = facts a correct answer contains; `forbidden` = claims NOT supported by
# the evidence (appearing means the model hallucinated).
CASES = [
    {
        "q": "How long do API keys last and what happens at expiry?",
        "evidence": "[C1] API keys are prefixed with amsk_ and shown only once.\n"
        "[C2] Keys expire after 90 days and can be revoked from the Admin Console.",
        "must": ["90 day", "revoke"],
        "forbidden": ["never expire", "365 day", "one year"],
    },
    {
        "q": "What dominates ingestion latency on CPU?",
        "evidence": "[C1] Pipeline stages: parse, clean, chunk, embed, upsert, keyword index.\n"
        "[C2] Embedding took 46,977 ms of a 47.1 s run.",
        "must": ["embed"],
        "forbidden": ["parsing dominates", "chunking dominates", "qdrant dominates"],
    },
    {
        "q": "How is chunk identity preserved across stores?",
        "evidence": "[C1] Chunk IDs are deterministic hashes of the document id and "
        "chunk index, so they match across Postgres, Qdrant and tsvector.",
        "must": ["deterministic", "hash"],
        "forbidden": ["random", "uuid"],
    },
    {
        "q": "What does the safety question gate block?",
        "evidence": "[C1] The deterministic question gate refuses disallowed requests "
        "(explosives, malware, hacking how-tos) and instruction-takeover phrasing, "
        "before any retrieval or model spend.",
        "must": ["malware", "refus"],
        "forbidden": ["calls the llm first", "always allows"],
    },
    {
        "q": "Why does the app refuse instead of answering when evidence is missing?",
        "evidence": "[C1] The answer contract requires every claim to carry a citation "
        "the judge can verify; unsupported claims make the status ungrounded, so the "
        "system refuses rather than guessing.",
        "must": ["citation", "refus"],
        "forbidden": ["always answers", "guesses"],
    },
    {
        "q": "What is the final context budget and why is it bounded?",
        "evidence": "[C1] FINAL_CONTEXT_K selects 5 chunks and MAX_CONTEXT_TOKENS caps "
        "the prompt at 3000 tokens so generation cost and latency stay bounded.",
        "must": ["5 chunk", "3000"],
        "forbidden": ["10 chunk", "unbounded"],
    },
]


def score(answer: str, case: dict) -> float:
    low = answer.lower()
    hits = sum(1 for m in case["must"] if m.lower() in low)
    penalties = sum(1 for f in case["forbidden"] if f.lower() in low)
    base = hits / len(case["must"])
    return max(0.0, base - 0.5 * penalties)


def run(api_key: str, model: str, effort: str, case: dict) -> dict:
    payload = {
        "model": model,
        "messages": [
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": f"Question: {case['q']}\n\nEvidence:\n{case['evidence']}"},
        ],
        "max_tokens": 900,
        "temperature": 0.2,
        "response_format": {"type": "json_object"},
    }
    if effort != "default":
        payload["reasoning"] = {"effort": effort}
    start = time.time()
    r = httpx.post(
        URL,
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        json=payload,
        timeout=180,
    )
    secs = time.time() - start
    if r.status_code != 200:
        return {"error": f"HTTP {r.status_code}", "score": 0.0, "secs": secs}
    d = r.json()
    ch = (d.get("choices") or [{}])[0]
    content = (ch.get("message") or {}).get("content") or ""
    usage = d.get("usage") or {}
    det = usage.get("completion_tokens_details") or {}
    try:
        obj = json.loads(content)
        answer = obj.get("answer") or ""
        s = score(answer, case)
        return {
            "score": s,
            "secs": secs,
            "reasoning": det.get("reasoning_tokens"),
            "completion": usage.get("completion_tokens"),
            "markers": len(re.findall(r"\[C\d\]", answer)),
            "answer": answer[:120],
        }
    except Exception:  # noqa: BLE001
        return {"score": 0.0, "secs": secs, "error": "not json", "answer": content[:80]}


def main() -> int:
    load_dotenv()
    key = os.getenv("OPENROUTER_API_KEY")
    model = os.getenv("OPENROUTER_MODEL", "nex-agi/nex-n2.5-mini:free")
    if not key:
        print("OPENROUTER_API_KEY not set")
        return 1

    print(f"model: {model}\n")
    summary = {}
    for effort in ("high", "none"):
        scores, secs, reas, marks = [], [], [], []
        print(f"=== reasoning effort: {effort} ===")
        for case in CASES:
            r = run(key, model, effort, case)
            scores.append(r.get("score", 0.0))
            secs.append(r.get("secs", 0))
            if r.get("reasoning") is not None:
                reas.append(r["reasoning"])
            marks.append(r.get("markers", 0))
            flag = "OK " if r.get("score", 0) >= 1.0 else "PARTIAL"
            print(f"  [{flag}] score={r.get('score',0):.2f} {r.get('secs',0):5.1f}s "
                  f"reasoning={r.get('reasoning')} markers={r.get('markers')} :: {str(r.get('answer'))[:70]}")
        summary[effort] = {
            "mean": sum(scores) / len(scores),
            "perfect": sum(1 for s in scores if s >= 1.0),
            "avg_s": sum(secs) / len(secs),
            "avg_reasoning": (sum(reas) / len(reas)) if reas else 0,
            "avg_markers": sum(marks) / len(marks),
        }
        print()

    print("=== SUMMARY ===")
    print(
        f"{'effort':8s} {'mean acc':>9s} {'perfect':>8s} {'avg s':>7s} "
        f"{'reasoning tok':>14s} {'markers':>8s}"
    )
    for eff, s in summary.items():
        print(f"{eff:8s} {s['mean']:9.2f} {s['perfect']:>5d}/6 {s['avg_s']:7.1f} "
              f"{s['avg_reasoning']:14.0f} {s['avg_markers']:8.1f}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())