groundedness 0.1.2__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.
- groundedness-0.1.2/LICENSE +9 -0
- groundedness-0.1.2/PKG-INFO +104 -0
- groundedness-0.1.2/README.md +88 -0
- groundedness-0.1.2/groundedness/__init__.py +130 -0
- groundedness-0.1.2/groundedness/__main__.py +21 -0
- groundedness-0.1.2/groundedness.egg-info/PKG-INFO +104 -0
- groundedness-0.1.2/groundedness.egg-info/SOURCES.txt +11 -0
- groundedness-0.1.2/groundedness.egg-info/dependency_links.txt +1 -0
- groundedness-0.1.2/groundedness.egg-info/entry_points.txt +2 -0
- groundedness-0.1.2/groundedness.egg-info/top_level.txt +1 -0
- groundedness-0.1.2/pyproject.toml +24 -0
- groundedness-0.1.2/setup.cfg +4 -0
- groundedness-0.1.2/tests/test_groundedness.py +44 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Salim Mustafazada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: groundedness
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Did the model make this up? Claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language.
|
|
5
|
+
Author: Salim Mustafazada
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aghasalim/groundedness
|
|
8
|
+
Keywords: llm,hallucination,groundedness,rag,evaluation,multilingual
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# groundedness
|
|
18
|
+
|
|
19
|
+
**Did the model make this up?** A claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from groundedness import check
|
|
23
|
+
|
|
24
|
+
facts = "Diş klinikası, Nizami küç. 12. B.e–Şənbə 9:00–19:00. Konsultasiya 30 AZN."
|
|
25
|
+
answer = "Salam! Konsultasiya 25 AZN-dir, bazar günü də işləyirik. Ünvan Nizami küçəsi 12."
|
|
26
|
+
|
|
27
|
+
r = check(answer, sources=[facts], model="qwen/qwen3.8-27b")
|
|
28
|
+
r.unsupported # ['Konsultasiya 25 AZN-dir', 'bazar günü də işləyirik']
|
|
29
|
+
r.fixed # 'Salam! Konsultasiya haqqı və bazar günü işləyib-işləmədiyimiz haqqında məlumatım yoxdur. Ünvan Nizami küçəsi 12-dir.'
|
|
30
|
+
r.score # 0.47
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That is Azerbaijani. It works the same in Russian, Turkish, Arabic or English, because the judge reads the answer in its own language. Every trained hallucination detector published so far — Vectara HHEM, LettuceDetect, Patronus Lynx — is English-only.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
RAG apps and support bots answer from documents. The failure that hurts is not a looping decode or a made-up historical date; it is *"a consultation is 25 AZN"* when the document says 30. `groundedness` finds that sentence, names it, and gives you the answer without it.
|
|
38
|
+
|
|
39
|
+
- **Any model.** Groq, OpenAI, Ollama, vLLM, OpenRouter — anything that speaks `/chat/completions`. No model to download, no GPU.
|
|
40
|
+
- **Any language.** No training data, no language list. If the model can read it, the judge can check it.
|
|
41
|
+
- **Claim-level output.** Not a score you cannot act on: the exact unsupported claims, and a rewrite. Highlight them, hand off to a human, or log the rate.
|
|
42
|
+
- **Zero dependencies.** `urllib` and `json`. Python 3.9+.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install groundedness
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `OPENAI_BASE_URL` and `OPENAI_API_KEY`, or just `GROQ_API_KEY` (Groq's endpoint is the default when it is set). Or pass `base_url=` / `api_key=` to `check()`.
|
|
51
|
+
|
|
52
|
+
## CLI
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
echo "the answer" | groundedness --model qwen/qwen3.8-27b --sources facts.txt policy.md
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Prints JSON: `grounded`, `score`, `unsupported`, `fixed`.
|
|
59
|
+
|
|
60
|
+
## What it is not
|
|
61
|
+
|
|
62
|
+
It checks an answer against **the sources you give it**, not against the world. A true claim that is not in your documents is reported as unsupported — which is what you want from a bot that must only say what the owner wrote. It is a judge call, so it costs one short completion per answer (about 350 tokens on the example above) and it is as good as the model judging; the tests use `qwen/qwen3.8-27b` on Groq, which caught every planted error in az/ru/en.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
GROQ_API_KEY=... python -m pytest -q
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Three languages, one planted wrong price and one invented opening day each; plus a grounded answer that must come back unchanged.
|
|
71
|
+
|
|
72
|
+
## Benchmark: which model judges best, in eleven languages
|
|
73
|
+
|
|
74
|
+
The same case in en, az, ru, tr, uk, kk, ar, fa, hi, id, vi: a clinic's facts, a grounded answer that must pass, and two answers with one planted error each (a wrong price, an invented opening day). Every chat model on Groq's free tier, 19 September 2026. Reproduce with `python benchmark/run.py`; full per-language grid in [`benchmark/RESULTS.md`](benchmark/RESULTS.md).
|
|
75
|
+
|
|
76
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
77
|
+
|---|---:|---:|---:|
|
|
78
|
+
| `openai/gpt-oss-120b` | 22/22 | 0/11 | 0.58 s |
|
|
79
|
+
| `openai/gpt-oss-20b` | 22/22 | 0/11 | 0.48 s |
|
|
80
|
+
| `qwen/qwen3.8-27b` | 22/22 | 0/11 | 0.27 s |
|
|
81
|
+
| `allam-2-7b` | 15/22 | 11/11 | 0.25 s |
|
|
82
|
+
|
|
83
|
+
And Google's models over their OpenAI-compatible endpoint (`python benchmark/run.py --gemini`, results in [`benchmark/gemini.md`](benchmark/gemini.md)):
|
|
84
|
+
|
|
85
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
86
|
+
|---|---:|---:|---:|
|
|
87
|
+
| `gemini-3.8-flash` | 22/22 | 0/11 | 2.7 s |
|
|
88
|
+
| `gemini-3.5-flash` | 22/22 | 0/11 | 4.4 s |
|
|
89
|
+
| `gemini-2.5-flash` | 22/22 | 0/11 | 4.3 s |
|
|
90
|
+
| `gemini-2.5-pro` | 21/21 | 0/11 | 10.6 s |
|
|
91
|
+
|
|
92
|
+
Seven models are perfect across all eleven languages, including Kazakh, Persian and Vietnamese, with no false alarms; `qwen/qwen3.8-27b` on Groq is the fastest by ten times. `allam-2-7b` flags every grounded answer and misses a third of the errors: a 7B Arabic-centred model is not a judge. One `gemini-2.5-pro` call (Azerbaijani, wrong price) hit a transient request error and is left out rather than guessed.
|
|
93
|
+
|
|
94
|
+
A lesson from the first run, kept here so nobody repeats it: thinking models spend their token budget before the first visible character. With `max_tokens: 1200` Gemini 2.5 Pro returned empty replies and an early version of this package scored an empty reply as "grounded" — 0/22 caught, 0 false alarms, a perfect-looking failure. The package now returns `judged=False` for an empty or non-JSON reply, and the benchmark counts it as a miss. The set is small on purpose (33 answers per model, one domain) — it is a smoke test that a model can read the language and follow the instruction, not a measure of fine judgement. Adding a language is one JSON entry in `benchmark/cases.json`; adding a model is one endpoint that lists it.
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- More domains per language (a return policy, a timetable, a contract clause) and harder errors: a right number in the wrong place, a plausible synonym.
|
|
99
|
+
- Models beyond Groq: run the same file against OpenRouter, Ollama, vLLM.
|
|
100
|
+
- A DeepEval / RAGAS metric that wraps this.
|
|
101
|
+
|
|
102
|
+
## Origin
|
|
103
|
+
|
|
104
|
+
Extracted from the groundedness judge that runs on every channel of [SIBA](https://siba.az)'s assistant. MIT.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# groundedness
|
|
2
|
+
|
|
3
|
+
**Did the model make this up?** A claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from groundedness import check
|
|
7
|
+
|
|
8
|
+
facts = "Diş klinikası, Nizami küç. 12. B.e–Şənbə 9:00–19:00. Konsultasiya 30 AZN."
|
|
9
|
+
answer = "Salam! Konsultasiya 25 AZN-dir, bazar günü də işləyirik. Ünvan Nizami küçəsi 12."
|
|
10
|
+
|
|
11
|
+
r = check(answer, sources=[facts], model="qwen/qwen3.8-27b")
|
|
12
|
+
r.unsupported # ['Konsultasiya 25 AZN-dir', 'bazar günü də işləyirik']
|
|
13
|
+
r.fixed # 'Salam! Konsultasiya haqqı və bazar günü işləyib-işləmədiyimiz haqqında məlumatım yoxdur. Ünvan Nizami küçəsi 12-dir.'
|
|
14
|
+
r.score # 0.47
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is Azerbaijani. It works the same in Russian, Turkish, Arabic or English, because the judge reads the answer in its own language. Every trained hallucination detector published so far — Vectara HHEM, LettuceDetect, Patronus Lynx — is English-only.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
RAG apps and support bots answer from documents. The failure that hurts is not a looping decode or a made-up historical date; it is *"a consultation is 25 AZN"* when the document says 30. `groundedness` finds that sentence, names it, and gives you the answer without it.
|
|
22
|
+
|
|
23
|
+
- **Any model.** Groq, OpenAI, Ollama, vLLM, OpenRouter — anything that speaks `/chat/completions`. No model to download, no GPU.
|
|
24
|
+
- **Any language.** No training data, no language list. If the model can read it, the judge can check it.
|
|
25
|
+
- **Claim-level output.** Not a score you cannot act on: the exact unsupported claims, and a rewrite. Highlight them, hand off to a human, or log the rate.
|
|
26
|
+
- **Zero dependencies.** `urllib` and `json`. Python 3.9+.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install groundedness
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set `OPENAI_BASE_URL` and `OPENAI_API_KEY`, or just `GROQ_API_KEY` (Groq's endpoint is the default when it is set). Or pass `base_url=` / `api_key=` to `check()`.
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
echo "the answer" | groundedness --model qwen/qwen3.8-27b --sources facts.txt policy.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Prints JSON: `grounded`, `score`, `unsupported`, `fixed`.
|
|
43
|
+
|
|
44
|
+
## What it is not
|
|
45
|
+
|
|
46
|
+
It checks an answer against **the sources you give it**, not against the world. A true claim that is not in your documents is reported as unsupported — which is what you want from a bot that must only say what the owner wrote. It is a judge call, so it costs one short completion per answer (about 350 tokens on the example above) and it is as good as the model judging; the tests use `qwen/qwen3.8-27b` on Groq, which caught every planted error in az/ru/en.
|
|
47
|
+
|
|
48
|
+
## Tests
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
GROQ_API_KEY=... python -m pytest -q
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Three languages, one planted wrong price and one invented opening day each; plus a grounded answer that must come back unchanged.
|
|
55
|
+
|
|
56
|
+
## Benchmark: which model judges best, in eleven languages
|
|
57
|
+
|
|
58
|
+
The same case in en, az, ru, tr, uk, kk, ar, fa, hi, id, vi: a clinic's facts, a grounded answer that must pass, and two answers with one planted error each (a wrong price, an invented opening day). Every chat model on Groq's free tier, 19 September 2026. Reproduce with `python benchmark/run.py`; full per-language grid in [`benchmark/RESULTS.md`](benchmark/RESULTS.md).
|
|
59
|
+
|
|
60
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
61
|
+
|---|---:|---:|---:|
|
|
62
|
+
| `openai/gpt-oss-120b` | 22/22 | 0/11 | 0.58 s |
|
|
63
|
+
| `openai/gpt-oss-20b` | 22/22 | 0/11 | 0.48 s |
|
|
64
|
+
| `qwen/qwen3.8-27b` | 22/22 | 0/11 | 0.27 s |
|
|
65
|
+
| `allam-2-7b` | 15/22 | 11/11 | 0.25 s |
|
|
66
|
+
|
|
67
|
+
And Google's models over their OpenAI-compatible endpoint (`python benchmark/run.py --gemini`, results in [`benchmark/gemini.md`](benchmark/gemini.md)):
|
|
68
|
+
|
|
69
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
70
|
+
|---|---:|---:|---:|
|
|
71
|
+
| `gemini-3.8-flash` | 22/22 | 0/11 | 2.7 s |
|
|
72
|
+
| `gemini-3.5-flash` | 22/22 | 0/11 | 4.4 s |
|
|
73
|
+
| `gemini-2.5-flash` | 22/22 | 0/11 | 4.3 s |
|
|
74
|
+
| `gemini-2.5-pro` | 21/21 | 0/11 | 10.6 s |
|
|
75
|
+
|
|
76
|
+
Seven models are perfect across all eleven languages, including Kazakh, Persian and Vietnamese, with no false alarms; `qwen/qwen3.8-27b` on Groq is the fastest by ten times. `allam-2-7b` flags every grounded answer and misses a third of the errors: a 7B Arabic-centred model is not a judge. One `gemini-2.5-pro` call (Azerbaijani, wrong price) hit a transient request error and is left out rather than guessed.
|
|
77
|
+
|
|
78
|
+
A lesson from the first run, kept here so nobody repeats it: thinking models spend their token budget before the first visible character. With `max_tokens: 1200` Gemini 2.5 Pro returned empty replies and an early version of this package scored an empty reply as "grounded" — 0/22 caught, 0 false alarms, a perfect-looking failure. The package now returns `judged=False` for an empty or non-JSON reply, and the benchmark counts it as a miss. The set is small on purpose (33 answers per model, one domain) — it is a smoke test that a model can read the language and follow the instruction, not a measure of fine judgement. Adding a language is one JSON entry in `benchmark/cases.json`; adding a model is one endpoint that lists it.
|
|
79
|
+
|
|
80
|
+
## Roadmap
|
|
81
|
+
|
|
82
|
+
- More domains per language (a return policy, a timetable, a contract clause) and harder errors: a right number in the wrong place, a plausible synonym.
|
|
83
|
+
- Models beyond Groq: run the same file against OpenRouter, Ollama, vLLM.
|
|
84
|
+
- A DeepEval / RAGAS metric that wraps this.
|
|
85
|
+
|
|
86
|
+
## Origin
|
|
87
|
+
|
|
88
|
+
Extracted from the groundedness judge that runs on every channel of [SIBA](https://siba.az)'s assistant. MIT.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""groundedness — did the model make this up?
|
|
2
|
+
|
|
3
|
+
One call, any OpenAI-compatible model, any language. The judge reads the
|
|
4
|
+
answer in its own language, which is what makes it work where every trained
|
|
5
|
+
detector (HHEM, LettuceDetect, Lynx) is English-only.
|
|
6
|
+
|
|
7
|
+
from groundedness import check
|
|
8
|
+
r = check(answer, sources=[doc], model="qwen/qwen3.8-27b")
|
|
9
|
+
r.unsupported # claims the sources do not support
|
|
10
|
+
r.fixed # the answer with those claims removed, same language
|
|
11
|
+
r.score # 1.0 = fully grounded
|
|
12
|
+
|
|
13
|
+
No dependencies. Uses OPENAI_BASE_URL / OPENAI_API_KEY (or GROQ_API_KEY).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.2"
|
|
25
|
+
__all__ = ["check", "Result", "PROMPT"]
|
|
26
|
+
|
|
27
|
+
PROMPT = (
|
|
28
|
+
"You check an assistant's answer against the only facts it was allowed to use. Facts:\n{facts}\n\n"
|
|
29
|
+
"List every factual claim in the answer (a price, a date, a time, an address, a phone, a name, "
|
|
30
|
+
"an availability, a rule, a number, a quote) that the facts above do not support. Greetings, offers "
|
|
31
|
+
"to help, and promises to follow up are not claims. Then rewrite the answer in the same language and "
|
|
32
|
+
"tone with the unsupported claims removed, saying plainly that you do not have that information. "
|
|
33
|
+
"If no claim is unsupported, return the answer unchanged. Reply with JSON only: "
|
|
34
|
+
'{{"unsupported": ["..."], "answer": "..."}}'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Result:
|
|
40
|
+
unsupported: list[str] = field(default_factory=list)
|
|
41
|
+
fixed: str = ""
|
|
42
|
+
original: str = ""
|
|
43
|
+
model: str = ""
|
|
44
|
+
raw: str = ""
|
|
45
|
+
judged: bool = True
|
|
46
|
+
"""False when the model returned nothing usable: treat as unknown, never as grounded."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def grounded(self) -> bool:
|
|
50
|
+
return self.judged and not self.unsupported
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def score(self) -> float:
|
|
54
|
+
"""1.0 when nothing was unsupported; otherwise 1 minus the share of the answer's text the unsupported claims make up."""
|
|
55
|
+
if not self.unsupported:
|
|
56
|
+
return 1.0
|
|
57
|
+
if not self.original:
|
|
58
|
+
return 0.0
|
|
59
|
+
bad = sum(len(u) for u in self.unsupported)
|
|
60
|
+
return max(0.0, 1.0 - min(1.0, bad / len(self.original)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _endpoint() -> tuple[str, str]:
|
|
64
|
+
base = os.environ.get("OPENAI_BASE_URL") or ("https://api.groq.com/openai/v1" if os.environ.get("GROQ_API_KEY") else "https://api.openai.com/v1")
|
|
65
|
+
key = os.environ.get("OPENAI_API_KEY") or os.environ.get("GROQ_API_KEY") or ""
|
|
66
|
+
return base.rstrip("/"), key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _facts(sources) -> str:
|
|
70
|
+
if isinstance(sources, str):
|
|
71
|
+
sources = [sources]
|
|
72
|
+
return "\n\n".join(f"[{i + 1}] {s}" for i, s in enumerate(sources) if s and s.strip())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check(answer: str, sources, model: str, *, base_url: str | None = None, api_key: str | None = None, timeout: float = 30.0, temperature: float = 0.0) -> Result:
|
|
76
|
+
"""Judge `answer` against `sources` with `model` over an OpenAI-compatible chat endpoint.
|
|
77
|
+
|
|
78
|
+
Never raises on a bad model reply: a judge that cannot be parsed returns the
|
|
79
|
+
answer unchanged with `unsupported=[]` and `raw` set, so a caller can log it.
|
|
80
|
+
Network and auth errors do raise.
|
|
81
|
+
"""
|
|
82
|
+
facts = _facts(sources)
|
|
83
|
+
if not facts or not answer or not answer.strip():
|
|
84
|
+
return Result(fixed=answer, original=answer, model=model)
|
|
85
|
+
base, key = _endpoint()
|
|
86
|
+
base = (base_url or base).rstrip("/")
|
|
87
|
+
key = api_key or key
|
|
88
|
+
# Thinking models spend tokens before the first visible character; the
|
|
89
|
+
# budget has to cover that or the reply comes back empty.
|
|
90
|
+
body = {"model": model, "temperature": temperature, "max_tokens": 8000, "messages": [{"role": "system", "content": PROMPT.format(facts=facts)}, {"role": "user", "content": answer}]}
|
|
91
|
+
if "qwen3" in model:
|
|
92
|
+
body["reasoning_format"] = "hidden"
|
|
93
|
+
if "gpt-oss" in model:
|
|
94
|
+
body["reasoning_effort"] = "low"
|
|
95
|
+
headers = {"content-type": "application/json", "authorization": f"Bearer {key}", "user-agent": f"groundedness/{__version__} (+https://github.com/aghasalim/groundedness)"}
|
|
96
|
+
# Small models cap max_tokens below the thinking-model budget; on that
|
|
97
|
+
# specific 400, retry once with the cap the server names (or 2000).
|
|
98
|
+
data = None
|
|
99
|
+
for _attempt in (1, 2):
|
|
100
|
+
req = urllib.request.Request(f"{base}/chat/completions", data=json.dumps(body).encode(), headers=headers)
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
103
|
+
data = json.load(r)
|
|
104
|
+
break
|
|
105
|
+
except urllib.error.HTTPError as e:
|
|
106
|
+
msg = e.read().decode("utf-8", "replace") if e.code == 400 else ""
|
|
107
|
+
m = re.search(r"max_tokens.*?`?(\d{3,6})`?", msg)
|
|
108
|
+
if _attempt == 1 and e.code == 400 and "max_tokens" in msg:
|
|
109
|
+
body["max_tokens"] = min(int(m.group(1)) if m else 2000, body["max_tokens"] - 1)
|
|
110
|
+
continue
|
|
111
|
+
raise
|
|
112
|
+
if data is None:
|
|
113
|
+
return Result(fixed=answer, original=answer, model=model, judged=False)
|
|
114
|
+
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content", "") or ""
|
|
115
|
+
if not raw.strip():
|
|
116
|
+
return Result(fixed=answer, original=answer, model=model, raw=raw, judged=False)
|
|
117
|
+
text = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.M).strip()
|
|
118
|
+
try:
|
|
119
|
+
j = json.loads(text)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
m = re.search(r"\{[\s\S]*\}", text)
|
|
122
|
+
try:
|
|
123
|
+
j = json.loads(m.group(0)) if m else {}
|
|
124
|
+
except json.JSONDecodeError:
|
|
125
|
+
j = {}
|
|
126
|
+
if not isinstance(j, dict) or "unsupported" not in j:
|
|
127
|
+
return Result(fixed=answer, original=answer, model=model, raw=raw, judged=False)
|
|
128
|
+
unsupported = [str(x).strip() for x in j.get("unsupported", []) if str(x).strip()]
|
|
129
|
+
fixed = j.get("answer") if isinstance(j.get("answer"), str) else None
|
|
130
|
+
return Result(unsupported=unsupported, fixed=(fixed or answer).strip(), original=answer, model=model, raw=raw)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""`python -m groundedness --model M --sources facts.txt < answer.txt`"""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import check
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
ap = argparse.ArgumentParser(prog="groundedness", description="Did the model make this up? Answer on stdin, sources as files.")
|
|
11
|
+
ap.add_argument("--model", required=True)
|
|
12
|
+
ap.add_argument("--sources", nargs="+", required=True, help="text files the answer must be grounded in")
|
|
13
|
+
ap.add_argument("--base-url")
|
|
14
|
+
a = ap.parse_args()
|
|
15
|
+
sources = [open(p, encoding="utf-8").read() for p in a.sources]
|
|
16
|
+
r = check(sys.stdin.read(), sources, a.model, base_url=a.base_url)
|
|
17
|
+
print(json.dumps({"grounded": r.grounded, "score": round(r.score, 3), "unsupported": r.unsupported, "fixed": r.fixed}, ensure_ascii=False, indent=2))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: groundedness
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Did the model make this up? Claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language.
|
|
5
|
+
Author: Salim Mustafazada
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/aghasalim/groundedness
|
|
8
|
+
Keywords: llm,hallucination,groundedness,rag,evaluation,multilingual
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# groundedness
|
|
18
|
+
|
|
19
|
+
**Did the model make this up?** A claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from groundedness import check
|
|
23
|
+
|
|
24
|
+
facts = "Diş klinikası, Nizami küç. 12. B.e–Şənbə 9:00–19:00. Konsultasiya 30 AZN."
|
|
25
|
+
answer = "Salam! Konsultasiya 25 AZN-dir, bazar günü də işləyirik. Ünvan Nizami küçəsi 12."
|
|
26
|
+
|
|
27
|
+
r = check(answer, sources=[facts], model="qwen/qwen3.8-27b")
|
|
28
|
+
r.unsupported # ['Konsultasiya 25 AZN-dir', 'bazar günü də işləyirik']
|
|
29
|
+
r.fixed # 'Salam! Konsultasiya haqqı və bazar günü işləyib-işləmədiyimiz haqqında məlumatım yoxdur. Ünvan Nizami küçəsi 12-dir.'
|
|
30
|
+
r.score # 0.47
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That is Azerbaijani. It works the same in Russian, Turkish, Arabic or English, because the judge reads the answer in its own language. Every trained hallucination detector published so far — Vectara HHEM, LettuceDetect, Patronus Lynx — is English-only.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
RAG apps and support bots answer from documents. The failure that hurts is not a looping decode or a made-up historical date; it is *"a consultation is 25 AZN"* when the document says 30. `groundedness` finds that sentence, names it, and gives you the answer without it.
|
|
38
|
+
|
|
39
|
+
- **Any model.** Groq, OpenAI, Ollama, vLLM, OpenRouter — anything that speaks `/chat/completions`. No model to download, no GPU.
|
|
40
|
+
- **Any language.** No training data, no language list. If the model can read it, the judge can check it.
|
|
41
|
+
- **Claim-level output.** Not a score you cannot act on: the exact unsupported claims, and a rewrite. Highlight them, hand off to a human, or log the rate.
|
|
42
|
+
- **Zero dependencies.** `urllib` and `json`. Python 3.9+.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install groundedness
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `OPENAI_BASE_URL` and `OPENAI_API_KEY`, or just `GROQ_API_KEY` (Groq's endpoint is the default when it is set). Or pass `base_url=` / `api_key=` to `check()`.
|
|
51
|
+
|
|
52
|
+
## CLI
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
echo "the answer" | groundedness --model qwen/qwen3.8-27b --sources facts.txt policy.md
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Prints JSON: `grounded`, `score`, `unsupported`, `fixed`.
|
|
59
|
+
|
|
60
|
+
## What it is not
|
|
61
|
+
|
|
62
|
+
It checks an answer against **the sources you give it**, not against the world. A true claim that is not in your documents is reported as unsupported — which is what you want from a bot that must only say what the owner wrote. It is a judge call, so it costs one short completion per answer (about 350 tokens on the example above) and it is as good as the model judging; the tests use `qwen/qwen3.8-27b` on Groq, which caught every planted error in az/ru/en.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
GROQ_API_KEY=... python -m pytest -q
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Three languages, one planted wrong price and one invented opening day each; plus a grounded answer that must come back unchanged.
|
|
71
|
+
|
|
72
|
+
## Benchmark: which model judges best, in eleven languages
|
|
73
|
+
|
|
74
|
+
The same case in en, az, ru, tr, uk, kk, ar, fa, hi, id, vi: a clinic's facts, a grounded answer that must pass, and two answers with one planted error each (a wrong price, an invented opening day). Every chat model on Groq's free tier, 19 September 2026. Reproduce with `python benchmark/run.py`; full per-language grid in [`benchmark/RESULTS.md`](benchmark/RESULTS.md).
|
|
75
|
+
|
|
76
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
77
|
+
|---|---:|---:|---:|
|
|
78
|
+
| `openai/gpt-oss-120b` | 22/22 | 0/11 | 0.58 s |
|
|
79
|
+
| `openai/gpt-oss-20b` | 22/22 | 0/11 | 0.48 s |
|
|
80
|
+
| `qwen/qwen3.8-27b` | 22/22 | 0/11 | 0.27 s |
|
|
81
|
+
| `allam-2-7b` | 15/22 | 11/11 | 0.25 s |
|
|
82
|
+
|
|
83
|
+
And Google's models over their OpenAI-compatible endpoint (`python benchmark/run.py --gemini`, results in [`benchmark/gemini.md`](benchmark/gemini.md)):
|
|
84
|
+
|
|
85
|
+
| Model | Planted errors caught | Grounded answers wrongly flagged | Median latency |
|
|
86
|
+
|---|---:|---:|---:|
|
|
87
|
+
| `gemini-3.8-flash` | 22/22 | 0/11 | 2.7 s |
|
|
88
|
+
| `gemini-3.5-flash` | 22/22 | 0/11 | 4.4 s |
|
|
89
|
+
| `gemini-2.5-flash` | 22/22 | 0/11 | 4.3 s |
|
|
90
|
+
| `gemini-2.5-pro` | 21/21 | 0/11 | 10.6 s |
|
|
91
|
+
|
|
92
|
+
Seven models are perfect across all eleven languages, including Kazakh, Persian and Vietnamese, with no false alarms; `qwen/qwen3.8-27b` on Groq is the fastest by ten times. `allam-2-7b` flags every grounded answer and misses a third of the errors: a 7B Arabic-centred model is not a judge. One `gemini-2.5-pro` call (Azerbaijani, wrong price) hit a transient request error and is left out rather than guessed.
|
|
93
|
+
|
|
94
|
+
A lesson from the first run, kept here so nobody repeats it: thinking models spend their token budget before the first visible character. With `max_tokens: 1200` Gemini 2.5 Pro returned empty replies and an early version of this package scored an empty reply as "grounded" — 0/22 caught, 0 false alarms, a perfect-looking failure. The package now returns `judged=False` for an empty or non-JSON reply, and the benchmark counts it as a miss. The set is small on purpose (33 answers per model, one domain) — it is a smoke test that a model can read the language and follow the instruction, not a measure of fine judgement. Adding a language is one JSON entry in `benchmark/cases.json`; adding a model is one endpoint that lists it.
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- More domains per language (a return policy, a timetable, a contract clause) and harder errors: a right number in the wrong place, a plausible synonym.
|
|
99
|
+
- Models beyond Groq: run the same file against OpenRouter, Ollama, vLLM.
|
|
100
|
+
- A DeepEval / RAGAS metric that wraps this.
|
|
101
|
+
|
|
102
|
+
## Origin
|
|
103
|
+
|
|
104
|
+
Extracted from the groundedness judge that runs on every channel of [SIBA](https://siba.az)'s assistant. MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
groundedness/__init__.py
|
|
5
|
+
groundedness/__main__.py
|
|
6
|
+
groundedness.egg-info/PKG-INFO
|
|
7
|
+
groundedness.egg-info/SOURCES.txt
|
|
8
|
+
groundedness.egg-info/dependency_links.txt
|
|
9
|
+
groundedness.egg-info/entry_points.txt
|
|
10
|
+
groundedness.egg-info/top_level.txt
|
|
11
|
+
tests/test_groundedness.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
groundedness
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "groundedness"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Did the model make this up? Claim-level groundedness check for LLM answers: one call, any OpenAI-compatible model, any language."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Salim Mustafazada" }]
|
|
13
|
+
keywords = ["llm", "hallucination", "groundedness", "rag", "evaluation", "multilingual"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent", "Topic :: Scientific/Engineering :: Artificial Intelligence"]
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/aghasalim/groundedness"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
groundedness = "groundedness.__main__:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["groundedness*"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""One live check per language. Needs GROQ_API_KEY (or OPENAI_*); skipped without it.
|
|
2
|
+
|
|
3
|
+
GROQ_API_KEY=... python -m pytest -q
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from groundedness import check
|
|
10
|
+
|
|
11
|
+
MODEL = os.environ.get("GROUNDEDNESS_MODEL", "qwen/qwen3.8-27b")
|
|
12
|
+
KEY = os.environ.get("GROQ_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
13
|
+
|
|
14
|
+
CASES = {
|
|
15
|
+
"az": ("Diş klinikası, Nizami küç. 12. B.e–Şənbə 9:00–19:00. Konsultasiya 30 AZN.",
|
|
16
|
+
"Salam! Konsultasiya 25 AZN-dir, bazar günü də işləyirik. Ünvan Nizami küçəsi 12.", "25"),
|
|
17
|
+
"ru": ("Стоматология, ул. Низами 12. Пн–Сб 9:00–19:00. Консультация 30 AZN.",
|
|
18
|
+
"Здравствуйте! Консультация стоит 25 AZN, работаем и по воскресеньям. Адрес: ул. Низами 12.", "25"),
|
|
19
|
+
"en": ("Dental clinic, 12 Nizami St. Mon–Sat 9:00–19:00. A consultation is 30 AZN.",
|
|
20
|
+
"Hello! A consultation is 25 AZN and we are open on Sundays too. We are at 12 Nizami St.", "25"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.skipif(not KEY, reason="no API key")
|
|
25
|
+
@pytest.mark.parametrize("lang", list(CASES))
|
|
26
|
+
def test_catches_wrong_price(lang):
|
|
27
|
+
facts, answer, bad = CASES[lang]
|
|
28
|
+
r = check(answer, [facts], MODEL)
|
|
29
|
+
assert not r.grounded
|
|
30
|
+
assert any(bad in u for u in r.unsupported), r.unsupported
|
|
31
|
+
assert bad not in r.fixed or "30" in r.fixed, r.fixed
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.skipif(not KEY, reason="no API key")
|
|
35
|
+
def test_grounded_answer_unchanged():
|
|
36
|
+
facts, _, _ = CASES["en"]
|
|
37
|
+
ok = "Hello! A consultation is 30 AZN. We are at 12 Nizami St, open Monday to Saturday 9 to 19."
|
|
38
|
+
r = check(ok, [facts], MODEL)
|
|
39
|
+
assert r.grounded and r.score == 1.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_sources_is_a_noop():
|
|
43
|
+
r = check("anything", [], "any-model")
|
|
44
|
+
assert r.grounded and r.fixed == "anything"
|