ragaudit-cli 0.3.0__py3-none-any.whl
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.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Generates a TestSet by prompting an LLM to write questions per chunk."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from ragaudit.models.document import Chunk, Document
|
|
10
|
+
from ragaudit.models.testset import TestCase, TestSet
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_QUESTIONS_PER_CHUNK = 1
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = (
|
|
17
|
+
"You are generating a test set for a RAG evaluation. Given a single "
|
|
18
|
+
"passage of text, write questions that can be answered ONLY using facts "
|
|
19
|
+
"stated in that passage — not from general knowledge about the topic, "
|
|
20
|
+
"and not answerable by someone who has never seen this passage but "
|
|
21
|
+
"knows the subject well.\n"
|
|
22
|
+
"\n"
|
|
23
|
+
"Phrase every question exactly as a real user would ask it. Never refer "
|
|
24
|
+
"to \"the passage\", \"the document\", \"the text\", \"this section\", "
|
|
25
|
+
"or any other document structure — the reader must not be able to tell "
|
|
26
|
+
"the question was generated from a document. Do not ask meta-questions "
|
|
27
|
+
"about ordering, sections, or headings.\n"
|
|
28
|
+
"\n"
|
|
29
|
+
"If asked for more than one question, each question must target a "
|
|
30
|
+
"different fact from the passage — no two questions may test the same "
|
|
31
|
+
"piece of information.\n"
|
|
32
|
+
"\n"
|
|
33
|
+
"Respond with strict JSON only, no prose, no markdown fences, in the "
|
|
34
|
+
'form: {"questions": [{"question": str, "answer": str}, ...]}'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Completer(Protocol):
|
|
39
|
+
"""The subset of LLMClient this module depends on — easy to stub in tests."""
|
|
40
|
+
|
|
41
|
+
model: str
|
|
42
|
+
|
|
43
|
+
def complete(self, system: str, user: str) -> str: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_user_prompt(chunk_text: str, n: int) -> str:
|
|
47
|
+
return f"Write {n} question(s) answerable only from this passage:\n\n{chunk_text}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_questions_response(raw: str) -> list[dict[str, str]] | None:
|
|
51
|
+
"""Parse the LLM's JSON response into a list of {question, answer} dicts.
|
|
52
|
+
|
|
53
|
+
Returns None on any malformed input (bad JSON, wrong shape, missing
|
|
54
|
+
fields) instead of raising, so callers can skip and log without
|
|
55
|
+
crashing a large run.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(raw)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
return None
|
|
64
|
+
questions = data.get("questions")
|
|
65
|
+
if not isinstance(questions, list):
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
parsed = []
|
|
69
|
+
for item in questions:
|
|
70
|
+
if not isinstance(item, dict):
|
|
71
|
+
continue
|
|
72
|
+
question = item.get("question")
|
|
73
|
+
answer = item.get("answer")
|
|
74
|
+
if isinstance(question, str) and isinstance(answer, str) and question and answer:
|
|
75
|
+
parsed.append({"question": question, "answer": answer})
|
|
76
|
+
return parsed if parsed else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _test_id(chunk_id: str, index: int) -> str:
|
|
80
|
+
payload = f"{chunk_id}:{index}"
|
|
81
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def generate_test_cases_for_chunk(
|
|
85
|
+
client: Completer,
|
|
86
|
+
chunk: Chunk,
|
|
87
|
+
n: int = DEFAULT_QUESTIONS_PER_CHUNK,
|
|
88
|
+
) -> list[TestCase]:
|
|
89
|
+
"""Prompt the LLM for questions on one chunk, returning TestCases.
|
|
90
|
+
|
|
91
|
+
Returns an empty list (logging a warning) if the LLM's response can't
|
|
92
|
+
be parsed as the expected JSON shape — never raises.
|
|
93
|
+
"""
|
|
94
|
+
raw = client.complete(SYSTEM_PROMPT, _build_user_prompt(chunk.text, n))
|
|
95
|
+
parsed = parse_questions_response(raw)
|
|
96
|
+
|
|
97
|
+
if parsed is None:
|
|
98
|
+
logger.warning("Skipping chunk %s: LLM returned unparseable JSON", chunk.chunk_id)
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
now = datetime.now(timezone.utc)
|
|
102
|
+
return [
|
|
103
|
+
TestCase(
|
|
104
|
+
test_id=_test_id(chunk.chunk_id, index),
|
|
105
|
+
question=item["question"],
|
|
106
|
+
expected_answer=item["answer"],
|
|
107
|
+
source_chunk_id=chunk.chunk_id,
|
|
108
|
+
source_doc_id=chunk.doc_id,
|
|
109
|
+
generation_model=client.model,
|
|
110
|
+
created_at=now,
|
|
111
|
+
)
|
|
112
|
+
for index, item in enumerate(parsed)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compute_corpus_hash(documents: list[Document]) -> str:
|
|
117
|
+
"""Hash over all Document.content_hash values, sorted for order-independence."""
|
|
118
|
+
payload = "|".join(sorted(doc.content_hash for doc in documents))
|
|
119
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def generate_test_set(
|
|
123
|
+
client: Completer,
|
|
124
|
+
documents: list[Document],
|
|
125
|
+
chunks: list[Chunk],
|
|
126
|
+
n_per_chunk: int = DEFAULT_QUESTIONS_PER_CHUNK,
|
|
127
|
+
testset_id: str | None = None,
|
|
128
|
+
) -> TestSet:
|
|
129
|
+
"""Generate a full TestSet by prompting the LLM once per chunk.
|
|
130
|
+
|
|
131
|
+
Chunks whose LLM response can't be parsed are skipped and logged; a
|
|
132
|
+
malformed response from one chunk never aborts the rest of the run.
|
|
133
|
+
"""
|
|
134
|
+
test_cases: list[TestCase] = []
|
|
135
|
+
for chunk in chunks:
|
|
136
|
+
test_cases.extend(generate_test_cases_for_chunk(client, chunk, n=n_per_chunk))
|
|
137
|
+
|
|
138
|
+
corpus_hash = compute_corpus_hash(documents)
|
|
139
|
+
created_at = datetime.now(timezone.utc)
|
|
140
|
+
return TestSet(
|
|
141
|
+
testset_id=testset_id or hashlib.sha256(f"{corpus_hash}:{created_at.isoformat()}".encode()).hexdigest(),
|
|
142
|
+
corpus_hash=corpus_hash,
|
|
143
|
+
created_at=created_at,
|
|
144
|
+
generation_model=client.model,
|
|
145
|
+
test_cases=test_cases,
|
|
146
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pure text helpers for v2 generation: tokenizing, lexical overlap, relative-time detection."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
STOPWORDS = frozenset(
|
|
6
|
+
"""
|
|
7
|
+
a about above after again against all also am an and any are as at be because been before being below
|
|
8
|
+
between both but by can could did do does doing down during each few for from further had has have having
|
|
9
|
+
he her here hers herself him himself his how i if in into is it its itself just me more most my myself no
|
|
10
|
+
nor not of off on once only or other our ours ourselves out over own same she should so some such than that
|
|
11
|
+
the their theirs them themselves then there these they this those through to too under until up very was we
|
|
12
|
+
were what when where which while who whom whose why will with would you your yours yourself yourselves
|
|
13
|
+
s t don shall may might must per via vs etc many much
|
|
14
|
+
""".split()
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+(?:[.,][0-9]+)*")
|
|
18
|
+
|
|
19
|
+
RELATIVE_TIME_RE = re.compile(
|
|
20
|
+
r"\b("
|
|
21
|
+
r"yesterday|today|tonight|tomorrow|now|nowadays|currently|current|recent|recently|lately|latest|"
|
|
22
|
+
r"these\s+days|at\s+present|at\s+the\s+moment|right\s+now|so\s+far|to\s+date|up\s+to\s+date|"
|
|
23
|
+
r"(?:this|last|next|past|previous|coming|upcoming)\s+"
|
|
24
|
+
r"(?:few\s+)?(?:day|week|weekend|month|quarter|year|decade|season|spring|summer|autumn|fall|winter)s?|"
|
|
25
|
+
r"(?:a\s+few|several|some|\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+"
|
|
26
|
+
r"(?:day|week|month|year|decade)s?\s+(?:ago|back)|"
|
|
27
|
+
r"ago|most\s+recent(?:ly)?"
|
|
28
|
+
r")\b",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize(token: str) -> str:
|
|
34
|
+
"""A crude plural fold so "calibrations" and "calibration" count as the same word."""
|
|
35
|
+
if len(token) > 3 and token.endswith("s") and not token.endswith("ss"):
|
|
36
|
+
return token[:-1]
|
|
37
|
+
return token
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def tokenize(text: str) -> list[str]:
|
|
41
|
+
return [_normalize(t) for t in _TOKEN_RE.findall(text.lower())]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def content_tokens(text: str) -> set[str]:
|
|
45
|
+
"""Distinct non-stopword tokens (stopwords are checked before the plural fold, so "does" stays out)."""
|
|
46
|
+
return {_normalize(t) for t in _TOKEN_RE.findall(text.lower()) if t not in STOPWORDS}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lexical_overlap(question: str, source_text: str) -> float:
|
|
50
|
+
"""Fraction of the question's distinct content tokens that also appear in the source text.
|
|
51
|
+
|
|
52
|
+
High overlap means the question reuses the chunk's wording, which makes
|
|
53
|
+
keyword retrieval trivially easy and inflates retrieval scores. A
|
|
54
|
+
question with no content tokens at all scores 0.0.
|
|
55
|
+
"""
|
|
56
|
+
question_tokens = content_tokens(question)
|
|
57
|
+
if not question_tokens:
|
|
58
|
+
return 0.0
|
|
59
|
+
return len(question_tokens & content_tokens(source_text)) / len(question_tokens)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_relative_time(text: str) -> str | None:
|
|
63
|
+
"""Return the first relative-time expression in text ("last week", "recently", ...), or None."""
|
|
64
|
+
match = RELATIVE_TIME_RE.search(text)
|
|
65
|
+
return match.group(0) if match else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def has_relative_time(text: str) -> bool:
|
|
69
|
+
return find_relative_time(text) is not None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def jaccard(a: set[str], b: set[str]) -> float:
|
|
73
|
+
if not a or not b:
|
|
74
|
+
return 0.0
|
|
75
|
+
return len(a & b) / len(a | b)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Splits Document content into fixed-size, overlapping Chunk objects."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from ragaudit.models.document import Chunk, Document
|
|
6
|
+
|
|
7
|
+
DEFAULT_CHUNK_SIZE = 800
|
|
8
|
+
DEFAULT_OVERLAP = 100
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _chunk_id(doc_id: str, position: int, text: str) -> str:
|
|
12
|
+
payload = f"{doc_id}:{position}:{text}"
|
|
13
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chunk_document(
|
|
17
|
+
document: Document,
|
|
18
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
19
|
+
overlap: int = DEFAULT_OVERLAP,
|
|
20
|
+
) -> list[Chunk]:
|
|
21
|
+
"""Split a Document's content into fixed-size chunks with overlap.
|
|
22
|
+
|
|
23
|
+
Chunking is deterministic: chunk_id is a hash of (doc_id, position,
|
|
24
|
+
text), so re-chunking the same document with the same parameters always
|
|
25
|
+
produces the same chunk_ids.
|
|
26
|
+
"""
|
|
27
|
+
if chunk_size <= 0:
|
|
28
|
+
raise ValueError("chunk_size must be positive")
|
|
29
|
+
if overlap < 0 or overlap >= chunk_size:
|
|
30
|
+
raise ValueError("overlap must be non-negative and smaller than chunk_size")
|
|
31
|
+
|
|
32
|
+
text = document.content
|
|
33
|
+
if not text:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
step = chunk_size - overlap
|
|
37
|
+
chunks: list[Chunk] = []
|
|
38
|
+
position = 0
|
|
39
|
+
start = 0
|
|
40
|
+
while start < len(text):
|
|
41
|
+
piece = text[start : start + chunk_size]
|
|
42
|
+
chunks.append(
|
|
43
|
+
Chunk(
|
|
44
|
+
chunk_id=_chunk_id(document.doc_id, position, piece),
|
|
45
|
+
doc_id=document.doc_id,
|
|
46
|
+
position=position,
|
|
47
|
+
text=piece,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
position += 1
|
|
51
|
+
start += step
|
|
52
|
+
|
|
53
|
+
return chunks
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Loads a v2 corpus: chunks with stable ids, content hashes and optional capture timestamps.
|
|
2
|
+
|
|
3
|
+
Two input formats:
|
|
4
|
+
|
|
5
|
+
- a `.jsonl` file, one chunk per line:
|
|
6
|
+
`{"chunk_id": str, "text": str, "timestamp"?: ISO-8601, "doc_id"?: str, "position"?: int}`
|
|
7
|
+
- a directory of `.txt`/`.md` files, loaded and chunked with the v1 loader
|
|
8
|
+
and chunker, but with stable ids `relative/path.md#position`.
|
|
9
|
+
|
|
10
|
+
v2 needs chunk ids that survive edits: a *changed* chunk (same id, new
|
|
11
|
+
hash) is handled differently from a new one. v1's chunk ids hash the
|
|
12
|
+
document content, so an edit gives every chunk a fresh id; that's why
|
|
13
|
+
directory corpora get path-based ids here instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ragaudit.ingest import chunker
|
|
22
|
+
from ragaudit.ingest.loader import load_documents
|
|
23
|
+
from ragaudit.models.document import Chunk
|
|
24
|
+
|
|
25
|
+
TIMESTAMP_KEY = "timestamp"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CorpusError(Exception):
|
|
29
|
+
"""Raised when a corpus file is malformed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def chunk_hash(chunk: Chunk) -> str:
|
|
33
|
+
"""Content hash of a chunk's text."""
|
|
34
|
+
return hashlib.sha256(chunk.text.encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_timestamp(value: str) -> datetime:
|
|
38
|
+
"""Parse an ISO-8601 date or datetime; naive values are taken as UTC."""
|
|
39
|
+
parsed = datetime.fromisoformat(value.strip())
|
|
40
|
+
if parsed.tzinfo is None:
|
|
41
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
42
|
+
return parsed.astimezone(timezone.utc)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chunk_timestamp(chunk: Chunk) -> datetime | None:
|
|
46
|
+
value = chunk.metadata.get(TIMESTAMP_KEY)
|
|
47
|
+
return parse_timestamp(value) if value else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_jsonl(path: Path) -> list[Chunk]:
|
|
51
|
+
chunks: list[Chunk] = []
|
|
52
|
+
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
53
|
+
if not line.strip():
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
record = json.loads(line)
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
raise CorpusError(f"{path}:{line_no}: invalid JSON ({exc.msg})") from exc
|
|
59
|
+
if not isinstance(record, dict):
|
|
60
|
+
raise CorpusError(f"{path}:{line_no}: expected a JSON object")
|
|
61
|
+
|
|
62
|
+
chunk_id = record.get("chunk_id")
|
|
63
|
+
text = record.get("text")
|
|
64
|
+
if not isinstance(chunk_id, str) or not chunk_id:
|
|
65
|
+
raise CorpusError(f"{path}:{line_no}: missing string field 'chunk_id'")
|
|
66
|
+
if not isinstance(text, str) or not text.strip():
|
|
67
|
+
raise CorpusError(f"{path}:{line_no}: missing non-empty string field 'text'")
|
|
68
|
+
|
|
69
|
+
metadata: dict[str, str] = {}
|
|
70
|
+
timestamp = record.get(TIMESTAMP_KEY)
|
|
71
|
+
if timestamp:
|
|
72
|
+
try:
|
|
73
|
+
metadata[TIMESTAMP_KEY] = parse_timestamp(str(timestamp)).isoformat()
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise CorpusError(f"{path}:{line_no}: timestamp is not ISO-8601: {timestamp!r}") from exc
|
|
76
|
+
for key, value in record.items():
|
|
77
|
+
if key not in {"chunk_id", "text", "doc_id", "position", TIMESTAMP_KEY} and isinstance(value, str):
|
|
78
|
+
metadata[key] = value
|
|
79
|
+
|
|
80
|
+
chunks.append(
|
|
81
|
+
Chunk(
|
|
82
|
+
chunk_id=chunk_id,
|
|
83
|
+
doc_id=str(record.get("doc_id") or chunk_id),
|
|
84
|
+
position=int(record.get("position", len(chunks))),
|
|
85
|
+
text=text,
|
|
86
|
+
metadata=metadata,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return chunks
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_directory(root: Path, chunk_size: int, overlap: int) -> list[Chunk]:
|
|
93
|
+
chunks: list[Chunk] = []
|
|
94
|
+
for document in load_documents(root):
|
|
95
|
+
relative = Path(document.source_path).relative_to(root).as_posix()
|
|
96
|
+
for chunk in chunker.chunk_document(document, chunk_size=chunk_size, overlap=overlap):
|
|
97
|
+
chunks.append(
|
|
98
|
+
chunk.model_copy(update={"chunk_id": f"{relative}#{chunk.position}", "doc_id": relative})
|
|
99
|
+
)
|
|
100
|
+
return chunks
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_corpus(
|
|
104
|
+
path: str | Path,
|
|
105
|
+
chunk_size: int = chunker.DEFAULT_CHUNK_SIZE,
|
|
106
|
+
overlap: int = chunker.DEFAULT_OVERLAP,
|
|
107
|
+
) -> list[Chunk]:
|
|
108
|
+
"""Load a corpus from a .jsonl file or a directory of .txt/.md files. Chunk ids must be unique."""
|
|
109
|
+
path = Path(path)
|
|
110
|
+
if not path.exists():
|
|
111
|
+
raise CorpusError(f"corpus not found: {path}")
|
|
112
|
+
|
|
113
|
+
chunks = _load_directory(path, chunk_size, overlap) if path.is_dir() else _load_jsonl(path)
|
|
114
|
+
|
|
115
|
+
seen: set[str] = set()
|
|
116
|
+
for chunk in chunks:
|
|
117
|
+
if chunk.chunk_id in seen:
|
|
118
|
+
raise CorpusError(f"duplicate chunk_id in corpus: {chunk.chunk_id}")
|
|
119
|
+
seen.add(chunk.chunk_id)
|
|
120
|
+
return chunks
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Walks a directory and loads .txt/.md files into Document objects."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ragaudit.models.document import Document
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
SUPPORTED_EXTENSIONS = {".txt", ".md"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sha256(text: str) -> str:
|
|
15
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_documents(root: str | Path) -> list[Document]:
|
|
19
|
+
"""Walk `root` and load every supported file into a Document.
|
|
20
|
+
|
|
21
|
+
Only .txt and .md files are read. Files that can't be decoded as UTF-8
|
|
22
|
+
text, or can't be read at all, are skipped with a logged warning rather
|
|
23
|
+
than raising.
|
|
24
|
+
"""
|
|
25
|
+
root_path = Path(root)
|
|
26
|
+
documents: list[Document] = []
|
|
27
|
+
|
|
28
|
+
for file_path in sorted(root_path.rglob("*")):
|
|
29
|
+
if not file_path.is_file() or file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
content = file_path.read_text(encoding="utf-8")
|
|
34
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
35
|
+
logger.warning("Skipping unreadable file %s: %s", file_path, exc)
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
content_hash = _sha256(content)
|
|
39
|
+
doc_id = _sha256(f"{file_path}:{content_hash}")
|
|
40
|
+
documents.append(
|
|
41
|
+
Document(
|
|
42
|
+
doc_id=doc_id,
|
|
43
|
+
source_path=str(file_path),
|
|
44
|
+
content=content,
|
|
45
|
+
content_hash=content_hash,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return documents
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Corpus versions and staleness: what changed since the last generate/evaluate run.
|
|
2
|
+
|
|
3
|
+
`index_version` is a hash of the sorted set of current chunk hashes. Each
|
|
4
|
+
run diffs the current corpus (chunk_id -> chunk_hash) against the last
|
|
5
|
+
recorded one:
|
|
6
|
+
|
|
7
|
+
- new chunk: no questions yet, so `generate` will write some;
|
|
8
|
+
- changed chunk (same id, new hash): its old questions are marked stale,
|
|
9
|
+
and `generate` writes new ones for the new content;
|
|
10
|
+
- deleted chunk: its questions are marked stale.
|
|
11
|
+
|
|
12
|
+
Staleness is decided per question from every (chunk_id, chunk_hash) it
|
|
13
|
+
depends on, so a multi-hop question goes stale if either chunk changes.
|
|
14
|
+
If a corpus edit is reverted, the questions it made stale become active
|
|
15
|
+
again, because their source content is back exactly as it was.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from ragaudit.ingest.corpus import chunk_hash
|
|
23
|
+
from ragaudit.models.document import Chunk
|
|
24
|
+
from ragaudit.storage.store import Store
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CorpusDiff(BaseModel):
|
|
28
|
+
index_version: str
|
|
29
|
+
previous_index_version: str | None = None
|
|
30
|
+
new: list[str] = Field(default_factory=list)
|
|
31
|
+
changed: list[str] = Field(default_factory=list)
|
|
32
|
+
deleted: list[str] = Field(default_factory=list)
|
|
33
|
+
unchanged: int = 0
|
|
34
|
+
marked_stale: int = 0
|
|
35
|
+
revived: int = 0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def index_changed(self) -> bool:
|
|
39
|
+
return self.index_version != self.previous_index_version
|
|
40
|
+
|
|
41
|
+
def counts(self) -> dict[str, int]:
|
|
42
|
+
return {
|
|
43
|
+
"new": len(self.new),
|
|
44
|
+
"changed": len(self.changed),
|
|
45
|
+
"deleted": len(self.deleted),
|
|
46
|
+
"unchanged": self.unchanged,
|
|
47
|
+
"marked_stale": self.marked_stale,
|
|
48
|
+
"revived": self.revived,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chunk_map(chunks: list[Chunk]) -> dict[str, str]:
|
|
53
|
+
return {chunk.chunk_id: chunk_hash(chunk) for chunk in chunks}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compute_index_version(chunk_hashes: list[str] | dict[str, str]) -> str:
|
|
57
|
+
"""Hash of the sorted set of chunk hashes: same content, same version, whatever the order or ids."""
|
|
58
|
+
hashes = chunk_hashes.values() if isinstance(chunk_hashes, dict) else chunk_hashes
|
|
59
|
+
return hashlib.sha256("\n".join(sorted(set(hashes))).encode("utf-8")).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def diff_chunk_maps(previous: dict[str, str], current: dict[str, str]) -> tuple[list[str], list[str], list[str], int]:
|
|
63
|
+
"""(new ids, changed ids, deleted ids, unchanged count), ids sorted."""
|
|
64
|
+
new = sorted(cid for cid in current if cid not in previous)
|
|
65
|
+
changed = sorted(cid for cid in current if cid in previous and previous[cid] != current[cid])
|
|
66
|
+
deleted = sorted(cid for cid in previous if cid not in current)
|
|
67
|
+
unchanged = sum(1 for cid in current if previous.get(cid) == current[cid])
|
|
68
|
+
return new, changed, deleted, unchanged
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sync_corpus(store: Store, chunks: list[Chunk]) -> CorpusDiff:
|
|
72
|
+
"""Diff the corpus against the last recorded version, update question staleness, record the new version."""
|
|
73
|
+
current = chunk_map(chunks)
|
|
74
|
+
index_version = compute_index_version(current)
|
|
75
|
+
last = store.latest_corpus_version()
|
|
76
|
+
previous_version, previous_map = last if last is not None else (None, {})
|
|
77
|
+
|
|
78
|
+
new, changed, deleted, unchanged = diff_chunk_maps(previous_map, current)
|
|
79
|
+
marked_stale, revived = store.refresh_staleness(current)
|
|
80
|
+
if last is None or previous_version != index_version or previous_map != current:
|
|
81
|
+
store.record_corpus_version(index_version, current)
|
|
82
|
+
|
|
83
|
+
return CorpusDiff(
|
|
84
|
+
index_version=index_version,
|
|
85
|
+
previous_index_version=previous_version,
|
|
86
|
+
new=new,
|
|
87
|
+
changed=changed,
|
|
88
|
+
deleted=deleted,
|
|
89
|
+
unchanged=unchanged,
|
|
90
|
+
marked_stale=marked_stale,
|
|
91
|
+
revived=revived,
|
|
92
|
+
)
|
ragaudit/llm/__init__.py
ADDED
|
File without changes
|
ragaudit/llm/client.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Thin OpenAI-compatible chat completions client over httpx."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
DEFAULT_MAX_RETRIES = 3
|
|
9
|
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMClientError(Exception):
|
|
13
|
+
"""Raised when the LLM API call fails after exhausting all retries."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMClient:
|
|
17
|
+
"""Minimal OpenAI-compatible chat completions client.
|
|
18
|
+
|
|
19
|
+
Configuration defaults to environment variables:
|
|
20
|
+
- RAGAUDIT_API_BASE: base URL, e.g. https://api.openai.com/v1
|
|
21
|
+
- RAGAUDIT_API_KEY: bearer token
|
|
22
|
+
- RAGAUDIT_MODEL: model name
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_base: str | None = None,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
model: str | None = None,
|
|
30
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
31
|
+
timeout: float = 60.0,
|
|
32
|
+
client: httpx.Client | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.api_base = (api_base or os.environ["RAGAUDIT_API_BASE"]).rstrip("/")
|
|
35
|
+
self.api_key = api_key or os.environ["RAGAUDIT_API_KEY"]
|
|
36
|
+
self.model = model or os.environ["RAGAUDIT_MODEL"]
|
|
37
|
+
self.max_retries = max_retries
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
40
|
+
|
|
41
|
+
def complete(self, system: str, user: str) -> str:
|
|
42
|
+
"""Send one chat completion request and return the assistant's text.
|
|
43
|
+
|
|
44
|
+
Retries on 429 and 5xx responses (and transport errors) with
|
|
45
|
+
exponential backoff, up to max_retries attempts total.
|
|
46
|
+
"""
|
|
47
|
+
url = f"{self.api_base}/chat/completions"
|
|
48
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
49
|
+
payload = {
|
|
50
|
+
"model": self.model,
|
|
51
|
+
"messages": [
|
|
52
|
+
{"role": "system", "content": system},
|
|
53
|
+
{"role": "user", "content": user},
|
|
54
|
+
],
|
|
55
|
+
"stream": False,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
last_error: Exception | str | None = None
|
|
59
|
+
for attempt in range(self.max_retries):
|
|
60
|
+
is_last_attempt = attempt == self.max_retries - 1
|
|
61
|
+
try:
|
|
62
|
+
response = self._client.post(url, headers=headers, json=payload)
|
|
63
|
+
except httpx.TransportError as exc:
|
|
64
|
+
last_error = exc
|
|
65
|
+
if is_last_attempt:
|
|
66
|
+
break
|
|
67
|
+
self._sleep_backoff(attempt)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
71
|
+
last_error = f"status {response.status_code}: {response.text}"
|
|
72
|
+
if is_last_attempt:
|
|
73
|
+
break
|
|
74
|
+
self._sleep_backoff(attempt)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
raise LLMClientError(f"LLM request failed with status {response.status_code}: {response.text}")
|
|
79
|
+
|
|
80
|
+
data = response.json()
|
|
81
|
+
return data["choices"][0]["message"]["content"]
|
|
82
|
+
|
|
83
|
+
raise LLMClientError(f"LLM request failed after {self.max_retries} attempts: {last_error}")
|
|
84
|
+
|
|
85
|
+
def _sleep_backoff(self, attempt: int) -> None:
|
|
86
|
+
time.sleep(2**attempt)
|