evalspec 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.
- evalspec-0.1.0/.gitignore +4 -0
- evalspec-0.1.0/LICENSE +21 -0
- evalspec-0.1.0/PKG-INFO +104 -0
- evalspec-0.1.0/README.md +92 -0
- evalspec-0.1.0/evalspec/__init__.py +10 -0
- evalspec-0.1.0/evalspec/agents.py +293 -0
- evalspec-0.1.0/evalspec/compare.py +159 -0
- evalspec-0.1.0/evalspec/harness.py +338 -0
- evalspec-0.1.0/evalspec/leakage.py +106 -0
- evalspec-0.1.0/evalspec/measures.py +181 -0
- evalspec-0.1.0/evalspec/regression.py +138 -0
- evalspec-0.1.0/evalspec/split.py +72 -0
- evalspec-0.1.0/pyproject.toml +23 -0
evalspec-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 evalspec contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
evalspec-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evalspec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Gold-label evaluation framework for LLM agents
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Provides-Extra: openai
|
|
10
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# evalspec
|
|
14
|
+
|
|
15
|
+
Gold-label evaluation framework for LLM agents. Measure what your model actually does, track it over time, and catch regressions before they ship.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install evalspec
|
|
21
|
+
|
|
22
|
+
# Create a dataset
|
|
23
|
+
cat > datasets/analytics.yaml <<EOF
|
|
24
|
+
questions:
|
|
25
|
+
- id: Q-001
|
|
26
|
+
question: "How many swaps happened last quarter?"
|
|
27
|
+
gold_answer: "tool_call"
|
|
28
|
+
expected_tool: "get_swap_counts"
|
|
29
|
+
expected_behaviour: "answer_with_citation"
|
|
30
|
+
language: EN
|
|
31
|
+
EOF
|
|
32
|
+
|
|
33
|
+
# Run against OpenAI
|
|
34
|
+
export OPENAI_API_KEY="sk-..."
|
|
35
|
+
evalspec-run --all --provider openai --model gpt-4o --tag baseline-v1
|
|
36
|
+
|
|
37
|
+
# Record baseline and check for regressions
|
|
38
|
+
evalspec-regression --record baselines/gpt4o.json --provider openai --model gpt-4o
|
|
39
|
+
evalspec-regression --check baselines/gpt4o.json --provider openai --model gpt-4o
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Gold labels
|
|
43
|
+
|
|
44
|
+
Every question gets a `gold_answer` that defines correct behavior:
|
|
45
|
+
|
|
46
|
+
| Label | Meaning | Measured by |
|
|
47
|
+
|-------|---------|------------|
|
|
48
|
+
| `ABSTAIN` | Model must refuse | `was_refused=True` |
|
|
49
|
+
| `CLARIFY` | Model must ask for clarification | `asked_clarification=True` |
|
|
50
|
+
| `tool_call` | Model must call the expected tool | `expected_tool` in called tools |
|
|
51
|
+
|
|
52
|
+
## Agents
|
|
53
|
+
|
|
54
|
+
| Provider | Flag | Environment |
|
|
55
|
+
|----------|------|-------------|
|
|
56
|
+
| OpenAI | `--provider openai --model gpt-4o` | `OPENAI_API_KEY` |
|
|
57
|
+
| opencode | `--provider opencode --model deepseek` | `opencode` CLI |
|
|
58
|
+
| Mock | `--mock --mock-mode perfect` | None |
|
|
59
|
+
| HTTP | `--agent-url http://localhost:8080` | None |
|
|
60
|
+
|
|
61
|
+
## CLI tools
|
|
62
|
+
|
|
63
|
+
| Command | Purpose |
|
|
64
|
+
|---------|---------|
|
|
65
|
+
| `evalspec-run` | Run evaluation harness |
|
|
66
|
+
| `evalspec-split` | 80/20 stratified holdout split |
|
|
67
|
+
| `evalspec-compare` | Side-by-side model comparison |
|
|
68
|
+
| `evalspec-regression` | CI regression gate |
|
|
69
|
+
| `evalspec-leakage` | Parametric leakage filter |
|
|
70
|
+
|
|
71
|
+
## Model comparison
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
evalspec-run --all --provider openai --model gpt-4o --tag gpt4o --report runs/gpt4o.json
|
|
75
|
+
evalspec-run --all --provider openai --model gpt-4o-mini --tag gpt4o-mini --report runs/gpt4o-mini.json
|
|
76
|
+
evalspec-compare runs/gpt4o.json runs/gpt4o-mini.json --html compare.html
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CI gate
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# .github/workflows/eval.yml
|
|
83
|
+
on: [pull_request]
|
|
84
|
+
jobs:
|
|
85
|
+
eval:
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- uses: actions/checkout@v4
|
|
89
|
+
- run: pip install evalspec openai pyyaml
|
|
90
|
+
- run: evalspec-regression --check baselines/gpt4o.json -p openai --model gpt-4o
|
|
91
|
+
env:
|
|
92
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Philosophy
|
|
96
|
+
|
|
97
|
+
- **Gold labels, not heuristics**: Every question defines what "correct" means (refuse, clarify, or call a specific tool).
|
|
98
|
+
- **Version-frozen**: Reports include dataset hashes so you know exactly which corpus a score refers to.
|
|
99
|
+
- **Held-out split**: 80/20 stratified split prevents overfitting to the eval set.
|
|
100
|
+
- **CI-gated**: 3% regression tolerance means prompt or model changes don't silently degrade quality.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
evalspec-0.1.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# evalspec
|
|
2
|
+
|
|
3
|
+
Gold-label evaluation framework for LLM agents. Measure what your model actually does, track it over time, and catch regressions before they ship.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install evalspec
|
|
9
|
+
|
|
10
|
+
# Create a dataset
|
|
11
|
+
cat > datasets/analytics.yaml <<EOF
|
|
12
|
+
questions:
|
|
13
|
+
- id: Q-001
|
|
14
|
+
question: "How many swaps happened last quarter?"
|
|
15
|
+
gold_answer: "tool_call"
|
|
16
|
+
expected_tool: "get_swap_counts"
|
|
17
|
+
expected_behaviour: "answer_with_citation"
|
|
18
|
+
language: EN
|
|
19
|
+
EOF
|
|
20
|
+
|
|
21
|
+
# Run against OpenAI
|
|
22
|
+
export OPENAI_API_KEY="sk-..."
|
|
23
|
+
evalspec-run --all --provider openai --model gpt-4o --tag baseline-v1
|
|
24
|
+
|
|
25
|
+
# Record baseline and check for regressions
|
|
26
|
+
evalspec-regression --record baselines/gpt4o.json --provider openai --model gpt-4o
|
|
27
|
+
evalspec-regression --check baselines/gpt4o.json --provider openai --model gpt-4o
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Gold labels
|
|
31
|
+
|
|
32
|
+
Every question gets a `gold_answer` that defines correct behavior:
|
|
33
|
+
|
|
34
|
+
| Label | Meaning | Measured by |
|
|
35
|
+
|-------|---------|------------|
|
|
36
|
+
| `ABSTAIN` | Model must refuse | `was_refused=True` |
|
|
37
|
+
| `CLARIFY` | Model must ask for clarification | `asked_clarification=True` |
|
|
38
|
+
| `tool_call` | Model must call the expected tool | `expected_tool` in called tools |
|
|
39
|
+
|
|
40
|
+
## Agents
|
|
41
|
+
|
|
42
|
+
| Provider | Flag | Environment |
|
|
43
|
+
|----------|------|-------------|
|
|
44
|
+
| OpenAI | `--provider openai --model gpt-4o` | `OPENAI_API_KEY` |
|
|
45
|
+
| opencode | `--provider opencode --model deepseek` | `opencode` CLI |
|
|
46
|
+
| Mock | `--mock --mock-mode perfect` | None |
|
|
47
|
+
| HTTP | `--agent-url http://localhost:8080` | None |
|
|
48
|
+
|
|
49
|
+
## CLI tools
|
|
50
|
+
|
|
51
|
+
| Command | Purpose |
|
|
52
|
+
|---------|---------|
|
|
53
|
+
| `evalspec-run` | Run evaluation harness |
|
|
54
|
+
| `evalspec-split` | 80/20 stratified holdout split |
|
|
55
|
+
| `evalspec-compare` | Side-by-side model comparison |
|
|
56
|
+
| `evalspec-regression` | CI regression gate |
|
|
57
|
+
| `evalspec-leakage` | Parametric leakage filter |
|
|
58
|
+
|
|
59
|
+
## Model comparison
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
evalspec-run --all --provider openai --model gpt-4o --tag gpt4o --report runs/gpt4o.json
|
|
63
|
+
evalspec-run --all --provider openai --model gpt-4o-mini --tag gpt4o-mini --report runs/gpt4o-mini.json
|
|
64
|
+
evalspec-compare runs/gpt4o.json runs/gpt4o-mini.json --html compare.html
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## CI gate
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
# .github/workflows/eval.yml
|
|
71
|
+
on: [pull_request]
|
|
72
|
+
jobs:
|
|
73
|
+
eval:
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v4
|
|
77
|
+
- run: pip install evalspec openai pyyaml
|
|
78
|
+
- run: evalspec-regression --check baselines/gpt4o.json -p openai --model gpt-4o
|
|
79
|
+
env:
|
|
80
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Philosophy
|
|
84
|
+
|
|
85
|
+
- **Gold labels, not heuristics**: Every question defines what "correct" means (refuse, clarify, or call a specific tool).
|
|
86
|
+
- **Version-frozen**: Reports include dataset hashes so you know exactly which corpus a score refers to.
|
|
87
|
+
- **Held-out split**: 80/20 stratified split prevents overfitting to the eval set.
|
|
88
|
+
- **CI-gated**: 3% regression tolerance means prompt or model changes don't silently degrade quality.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .measures import MeasureResult, measure_all, measure_grounding, measure_refusal, measure_clarification, measure_tool_match, measure_faithfulness, measure_language_match, measure_latency, measure_steps
|
|
2
|
+
from .agents import MockAgent, HTTPAgent, OpenAIAgent, OpenCodeAgent, classify_response, fallback_response, DEFAULT_SYSTEM_PROMPT, DEFAULT_REFUSAL_SIGNALS, DEFAULT_CLARIFICATION_SIGNALS, DEFAULT_TOOL_PATTERNS
|
|
3
|
+
from .harness import evaluate, aggregate_reports, print_report, compute_fingerprint, generate_html_report, save_report
|
|
4
|
+
|
|
5
|
+
__all__ = ["MeasureResult", "measure_all", "MockAgent", "HTTPAgent", "OpenAIAgent",
|
|
6
|
+
"OpenCodeAgent", "classify_response", "fallback_response",
|
|
7
|
+
"DEFAULT_SYSTEM_PROMPT", "DEFAULT_REFUSAL_SIGNALS", "DEFAULT_CLARIFICATION_SIGNALS",
|
|
8
|
+
"DEFAULT_TOOL_PATTERNS",
|
|
9
|
+
"evaluate", "aggregate_reports", "print_report", "compute_fingerprint",
|
|
10
|
+
"generate_html_report", "save_report"]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib import request, error
|
|
8
|
+
|
|
9
|
+
DEFAULT_SYSTEM_PROMPT = """You are a helpful assistant. Follow the rules below.
|
|
10
|
+
|
|
11
|
+
RULES:
|
|
12
|
+
1. Use available tools when appropriate. Cite the tool name.
|
|
13
|
+
2. Clarify ambiguous requests before answering.
|
|
14
|
+
3. Refuse requests involving: personal data, unauthorized access, destructive commands, policy violations, out-of-scope topics.
|
|
15
|
+
4. Respond in the same language as the question."""
|
|
16
|
+
|
|
17
|
+
DEFAULT_REFUSAL_SIGNALS = [
|
|
18
|
+
"i don't have access", "i don't have information",
|
|
19
|
+
"i'm not able", "i'm designed for read-only",
|
|
20
|
+
"outside my scope", "out of scope", "refuse",
|
|
21
|
+
"beyond my capabilities", "i cannot", "i can't", "can't help",
|
|
22
|
+
"cannot help with", "cannot provide", "cannot answer",
|
|
23
|
+
"not permitted", "strictly prohibited",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
DEFAULT_CLARIFICATION_SIGNALS = [
|
|
27
|
+
"could you clarify", "do you mean", "please specify",
|
|
28
|
+
"i need more context", "can you be more specific", "what exactly",
|
|
29
|
+
"are you asking about", "would you like", "i'm not sure which",
|
|
30
|
+
"please clarify", "need more information", "clarify",
|
|
31
|
+
"did you mean", "which one", "what kind",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
DEFAULT_TOOL_PATTERNS = [
|
|
35
|
+
r'calling\s+(\w+)', r'using\s+(\w+)',
|
|
36
|
+
r'tool:\s*(\w+)', r'`(\w+)`',
|
|
37
|
+
r'get_\w+',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_SMART_QUOTES = str.maketrans({'\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"'})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fallback_response(error: str, latency: float, language: str = "EN") -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"raw_response": f"Error: {error}",
|
|
46
|
+
"was_refused": True, "refusal_reason": "error",
|
|
47
|
+
"asked_clarification": False, "clarification_options": [],
|
|
48
|
+
"tools_called": [], "citations": [],
|
|
49
|
+
"all_claims_cited": False, "steps": 0,
|
|
50
|
+
"latency_s": latency, "response_language": language,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def classify_response(
|
|
55
|
+
text: str,
|
|
56
|
+
question: dict,
|
|
57
|
+
latency: float = 0.0,
|
|
58
|
+
language_chars: str = "",
|
|
59
|
+
refusal_signals: list[str] | None = None,
|
|
60
|
+
clarification_signals: list[str] | None = None,
|
|
61
|
+
tool_patterns: list[str] | None = None,
|
|
62
|
+
tool_prefix: str = "get_",
|
|
63
|
+
language_alt: str = "MT",
|
|
64
|
+
) -> dict:
|
|
65
|
+
if not text:
|
|
66
|
+
return fallback_response("empty response", latency,
|
|
67
|
+
language=question.get("language", "EN"))
|
|
68
|
+
|
|
69
|
+
t = text.lower().translate(_SMART_QUOTES)
|
|
70
|
+
|
|
71
|
+
sig_ref = refusal_signals or DEFAULT_REFUSAL_SIGNALS
|
|
72
|
+
sig_clar = clarification_signals or DEFAULT_CLARIFICATION_SIGNALS
|
|
73
|
+
pat_tools = tool_patterns or DEFAULT_TOOL_PATTERNS
|
|
74
|
+
|
|
75
|
+
was_refused = any(s in t for s in sig_ref)
|
|
76
|
+
asked_clarification = any(s in t for s in sig_clar)
|
|
77
|
+
|
|
78
|
+
tools_found = set()
|
|
79
|
+
for p in pat_tools:
|
|
80
|
+
for m in re.finditer(p, t, re.IGNORECASE):
|
|
81
|
+
try:
|
|
82
|
+
raw = (m.group(1) or m.group(0)).lower().strip("`'\"")
|
|
83
|
+
except IndexError:
|
|
84
|
+
raw = m.group(0).lower().strip("`'\"")
|
|
85
|
+
if not tool_prefix or raw.startswith(tool_prefix):
|
|
86
|
+
tools_found.add(raw)
|
|
87
|
+
tools_called = list(tools_found)
|
|
88
|
+
citations = [f"tool:{t}" for t in tools_called]
|
|
89
|
+
|
|
90
|
+
resp_lang = question.get("language", "EN")
|
|
91
|
+
if language_chars and any(c in text for c in language_chars):
|
|
92
|
+
resp_lang = language_alt
|
|
93
|
+
|
|
94
|
+
clarification_options = []
|
|
95
|
+
if asked_clarification:
|
|
96
|
+
for s in re.split(r'[.!?\n]', text):
|
|
97
|
+
if any(p in s.lower() for p in sig_clar):
|
|
98
|
+
clarification_options.append(s.strip())
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"raw_response": text,
|
|
102
|
+
"was_refused": was_refused,
|
|
103
|
+
"refusal_reason": "policy_restriction" if was_refused else "",
|
|
104
|
+
"asked_clarification": asked_clarification,
|
|
105
|
+
"clarification_options": clarification_options[:5],
|
|
106
|
+
"tools_called": tools_called,
|
|
107
|
+
"citations": citations,
|
|
108
|
+
"all_claims_cited": len(citations) > 0 or was_refused,
|
|
109
|
+
"steps": 1,
|
|
110
|
+
"latency_s": latency,
|
|
111
|
+
"response_language": resp_lang,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MockAgent:
|
|
116
|
+
def __init__(self, mode: str = "random"):
|
|
117
|
+
self.mode = mode
|
|
118
|
+
|
|
119
|
+
def answer(self, question: dict) -> dict:
|
|
120
|
+
expected = question.get("expected_behaviour", "answer_with_citation")
|
|
121
|
+
if self.mode == "perfect":
|
|
122
|
+
return self._perfect_answer(question)
|
|
123
|
+
if self.mode == "failing":
|
|
124
|
+
return self._failing_answer(question)
|
|
125
|
+
return self._random_answer(question, expected)
|
|
126
|
+
|
|
127
|
+
def _perfect_answer(self, q: dict) -> dict:
|
|
128
|
+
expected = q.get("expected_behaviour", "answer_with_citation")
|
|
129
|
+
base = {
|
|
130
|
+
"latency_s": 1.5, "steps": 1,
|
|
131
|
+
"response_language": q.get("language", "EN"),
|
|
132
|
+
"citations": ["tool:mock_tool"],
|
|
133
|
+
"all_claims_cited": True,
|
|
134
|
+
"raw_response": f"Mock perfect answer: {q['question']}",
|
|
135
|
+
}
|
|
136
|
+
if expected == "clarification":
|
|
137
|
+
return {**base, "asked_clarification": True,
|
|
138
|
+
"clarification_options": q.get("clarification_options", ["option_a", "option_b"]),
|
|
139
|
+
"was_refused": False, "tools_called": []}
|
|
140
|
+
if expected == "refusal":
|
|
141
|
+
return {**base, "was_refused": True,
|
|
142
|
+
"refusal_reason": q.get("refusal_reason", "policy_restriction"),
|
|
143
|
+
"tools_called": [], "citations": [], "all_claims_cited": False}
|
|
144
|
+
return {**base, "was_refused": False,
|
|
145
|
+
"tools_called": [q.get("expected_tool", "mock_tool")],
|
|
146
|
+
"asked_clarification": False}
|
|
147
|
+
|
|
148
|
+
def _failing_answer(self, q: dict) -> dict:
|
|
149
|
+
expected = q.get("expected_behaviour", "answer_with_citation")
|
|
150
|
+
if expected == "refusal":
|
|
151
|
+
return {"latency_s": 0.5, "steps": 1, "was_refused": False,
|
|
152
|
+
"tools_called": [], "citations": [], "all_claims_cited": False,
|
|
153
|
+
"asked_clarification": False, "response_language": q.get("language", "EN"),
|
|
154
|
+
"raw_response": "I can answer that! Let me tell you all about it."}
|
|
155
|
+
if expected == "clarification":
|
|
156
|
+
return {"latency_s": 0.5, "steps": 1, "was_refused": False,
|
|
157
|
+
"asked_clarification": False, "tools_called": ["mock_tool"],
|
|
158
|
+
"citations": ["tool:mock_tool"], "all_claims_cited": True,
|
|
159
|
+
"response_language": q.get("language", "EN"),
|
|
160
|
+
"raw_response": "Here's the data you asked for."}
|
|
161
|
+
return {"latency_s": 0.5, "steps": 1, "was_refused": False,
|
|
162
|
+
"asked_clarification": False, "tools_called": [], "citations": [],
|
|
163
|
+
"all_claims_cited": False, "response_language": q.get("language", "EN"),
|
|
164
|
+
"raw_response": "I don't know."}
|
|
165
|
+
|
|
166
|
+
def _random_answer(self, q: dict, expected: str) -> dict:
|
|
167
|
+
import random
|
|
168
|
+
return self._perfect_answer(q) if random.random() > 0.3 else self._failing_answer(q)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class HTTPAgent:
|
|
172
|
+
def __init__(self, endpoint: str, timeout: int = 60, max_retries: int = 2):
|
|
173
|
+
self.endpoint = endpoint.rstrip("/")
|
|
174
|
+
self.timeout = timeout
|
|
175
|
+
self.max_retries = max_retries
|
|
176
|
+
|
|
177
|
+
def answer(self, question: dict) -> dict:
|
|
178
|
+
payload = json.dumps({
|
|
179
|
+
"question": question["question"],
|
|
180
|
+
"language": question.get("language", "EN"),
|
|
181
|
+
"question_id": question.get("id"),
|
|
182
|
+
}).encode("utf-8")
|
|
183
|
+
for attempt in range(self.max_retries + 1):
|
|
184
|
+
try:
|
|
185
|
+
req = request.Request(
|
|
186
|
+
self.endpoint,
|
|
187
|
+
data=payload,
|
|
188
|
+
headers={"Content-Type": "application/json"},
|
|
189
|
+
method="POST",
|
|
190
|
+
)
|
|
191
|
+
with request.urlopen(req, timeout=self.timeout) as resp:
|
|
192
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
193
|
+
except (error.URLError, error.HTTPError, TimeoutError) as e:
|
|
194
|
+
if attempt == self.max_retries:
|
|
195
|
+
return {"error": str(e), "latency_s": 0, "steps": 0,
|
|
196
|
+
"was_refused": False, "asked_clarification": False,
|
|
197
|
+
"tools_called": [], "citations": [],
|
|
198
|
+
"all_claims_cited": False,
|
|
199
|
+
"response_language": question.get("language", "EN")}
|
|
200
|
+
time.sleep(1 * (attempt + 1))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class OpenAIAgent:
|
|
204
|
+
def __init__(self, model: str = "gpt-4o", system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
205
|
+
language_chars: str = "", refusal_signals: list[str] | None = None,
|
|
206
|
+
clarification_signals: list[str] | None = None,
|
|
207
|
+
tool_patterns: list[str] | None = None, tool_prefix: str = "get_"):
|
|
208
|
+
self.model = model
|
|
209
|
+
self.system_prompt = system_prompt
|
|
210
|
+
self.language_chars = language_chars
|
|
211
|
+
self.refusal_signals = refusal_signals
|
|
212
|
+
self.clarification_signals = clarification_signals
|
|
213
|
+
self.tool_patterns = tool_patterns
|
|
214
|
+
self.tool_prefix = tool_prefix
|
|
215
|
+
try:
|
|
216
|
+
from openai import OpenAI
|
|
217
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
218
|
+
if not api_key:
|
|
219
|
+
print("ERROR: set OPENAI_API_KEY env var")
|
|
220
|
+
raise SystemExit(1)
|
|
221
|
+
self.client = OpenAI(api_key=api_key)
|
|
222
|
+
except ImportError:
|
|
223
|
+
print("ERROR: pip install openai")
|
|
224
|
+
raise SystemExit(1)
|
|
225
|
+
|
|
226
|
+
def answer(self, question: dict) -> dict:
|
|
227
|
+
q = question.get("question", "")
|
|
228
|
+
messages = [
|
|
229
|
+
{"role": "system", "content": self.system_prompt},
|
|
230
|
+
{"role": "user", "content": q},
|
|
231
|
+
]
|
|
232
|
+
start = time.time()
|
|
233
|
+
try:
|
|
234
|
+
resp = self.client.chat.completions.create(
|
|
235
|
+
model=self.model, messages=messages, temperature=0,
|
|
236
|
+
)
|
|
237
|
+
elapsed = time.time() - start
|
|
238
|
+
raw_text = resp.choices[0].message.content or ""
|
|
239
|
+
return classify_response(
|
|
240
|
+
raw_text, question, elapsed,
|
|
241
|
+
language_chars=self.language_chars,
|
|
242
|
+
refusal_signals=self.refusal_signals,
|
|
243
|
+
clarification_signals=self.clarification_signals,
|
|
244
|
+
tool_patterns=self.tool_patterns,
|
|
245
|
+
tool_prefix=self.tool_prefix,
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return fallback_response(str(e), time.time() - start)
|
|
249
|
+
|
|
250
|
+
def _fallback(self, error: str, latency: float) -> dict:
|
|
251
|
+
return fallback_response(error, latency)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class OpenCodeAgent:
|
|
255
|
+
def __init__(self, model: str = "opencode/deepseek-v4-flash-free",
|
|
256
|
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
|
257
|
+
language_chars: str = "",
|
|
258
|
+
refusal_signals: list[str] | None = None,
|
|
259
|
+
clarification_signals: list[str] | None = None,
|
|
260
|
+
tool_patterns: list[str] | None = None, tool_prefix: str = "get_"):
|
|
261
|
+
self.model = model
|
|
262
|
+
self.system_prompt = system_prompt
|
|
263
|
+
self.language_chars = language_chars
|
|
264
|
+
self.refusal_signals = refusal_signals
|
|
265
|
+
self.clarification_signals = clarification_signals
|
|
266
|
+
self.tool_patterns = tool_patterns
|
|
267
|
+
self.tool_prefix = tool_prefix
|
|
268
|
+
|
|
269
|
+
def answer(self, question: dict) -> dict:
|
|
270
|
+
q = question.get("question", "")
|
|
271
|
+
lang = question.get("language", "EN")
|
|
272
|
+
prompt = f"{self.system_prompt}\n\nQuestion in {lang}: {q}"
|
|
273
|
+
cmd = [
|
|
274
|
+
"opencode", "run", "--model", self.model, "--",
|
|
275
|
+
f"Answer this directly in {lang} without using any tools, files, or running commands: {prompt}",
|
|
276
|
+
]
|
|
277
|
+
start = time.time()
|
|
278
|
+
try:
|
|
279
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
280
|
+
elapsed = time.time() - start
|
|
281
|
+
raw_text = r.stdout.strip() or "(no output)"
|
|
282
|
+
return classify_response(
|
|
283
|
+
raw_text, question, elapsed,
|
|
284
|
+
language_chars=self.language_chars,
|
|
285
|
+
refusal_signals=self.refusal_signals,
|
|
286
|
+
clarification_signals=self.clarification_signals,
|
|
287
|
+
tool_patterns=self.tool_patterns,
|
|
288
|
+
tool_prefix=self.tool_prefix,
|
|
289
|
+
)
|
|
290
|
+
except subprocess.TimeoutExpired:
|
|
291
|
+
return fallback_response("timeout", time.time() - start)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return fallback_response(str(e), time.time() - start)
|