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.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. 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
+ )
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)