ragdoc 0.1.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.
- ragdoc/__init__.py +33 -0
- ragdoc/artifacts.py +256 -0
- ragdoc/attribution/__init__.py +40 -0
- ragdoc/attribution/checks.py +420 -0
- ragdoc/attribution/engine.py +269 -0
- ragdoc/attribution/verdict.py +338 -0
- ragdoc/batch/__init__.py +245 -0
- ragdoc/chunker/__init__.py +248 -0
- ragdoc/cli.py +397 -0
- ragdoc/compare/__init__.py +140 -0
- ragdoc/config.py +54 -0
- ragdoc/indexer/__init__.py +130 -0
- ragdoc/indexer/local_embeddings.py +199 -0
- ragdoc/loader/__init__.py +95 -0
- ragdoc/loader/pdfplumber_loader.py +70 -0
- ragdoc/loader/pymupdf_loader.py +84 -0
- ragdoc/loader/pypdf_loader.py +66 -0
- ragdoc/loader/registry.py +73 -0
- ragdoc/report/__init__.py +186 -0
- ragdoc/report/_table.py +45 -0
- ragdoc/report/batch.py +170 -0
- ragdoc/report/compare.py +111 -0
- ragdoc/report/retrieved.py +101 -0
- ragdoc/report/sweep.py +155 -0
- ragdoc/report/symbols.py +76 -0
- ragdoc/retriever/__init__.py +145 -0
- ragdoc/sweep/__init__.py +151 -0
- ragdoc-0.1.0.dist-info/METADATA +191 -0
- ragdoc-0.1.0.dist-info/RECORD +33 -0
- ragdoc-0.1.0.dist-info/WHEEL +5 -0
- ragdoc-0.1.0.dist-info/entry_points.txt +2 -0
- ragdoc-0.1.0.dist-info/licenses/LICENSE +21 -0
- ragdoc-0.1.0.dist-info/top_level.txt +1 -0
ragdoc/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ragdoc — headless attribution engine for PDF RAG pipelines.
|
|
2
|
+
|
|
3
|
+
This top level re-exports the run configuration and the artifact/result types
|
|
4
|
+
that form the shared vocabulary of the pipeline and the attribution engine, so
|
|
5
|
+
callers can ``from ragdoc import RunConfig, ExtractedDoc, Chunk, Ranking,
|
|
6
|
+
CheckResult`` without reaching into submodules.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ragdoc.artifacts import (
|
|
12
|
+
CharSpan,
|
|
13
|
+
Chunk,
|
|
14
|
+
CheckResult,
|
|
15
|
+
CheckStatus,
|
|
16
|
+
ExtractedDoc,
|
|
17
|
+
PageSpan,
|
|
18
|
+
Ranking,
|
|
19
|
+
RankedChunk,
|
|
20
|
+
)
|
|
21
|
+
from ragdoc.config import RunConfig
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"RunConfig",
|
|
25
|
+
"CharSpan",
|
|
26
|
+
"PageSpan",
|
|
27
|
+
"ExtractedDoc",
|
|
28
|
+
"Chunk",
|
|
29
|
+
"RankedChunk",
|
|
30
|
+
"Ranking",
|
|
31
|
+
"CheckStatus",
|
|
32
|
+
"CheckResult",
|
|
33
|
+
]
|
ragdoc/artifacts.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The artifacts every pipeline stage and attribution check pass to one another.
|
|
2
|
+
|
|
3
|
+
These are the stable vocabulary the forward pipeline (load → chunk → index →
|
|
4
|
+
retrieve) emits and the attribution engine reads. They are deliberately *data
|
|
5
|
+
only*: no stage logic lives here, so the engine can depend on these shapes
|
|
6
|
+
without depending on any stage implementation or on the interface layer
|
|
7
|
+
(``.ai/constitution.md`` — stage boundaries are real module boundaries; the
|
|
8
|
+
engine never depends on the UI).
|
|
9
|
+
|
|
10
|
+
Artifacts live in memory and are rebuilt each run. There is no persistence,
|
|
11
|
+
caching, or on-disk form of these types by design (spec Exclusions;
|
|
12
|
+
``.ai/architecture/system.md``).
|
|
13
|
+
|
|
14
|
+
Two things carried here are what make diagnosis possible at all:
|
|
15
|
+
|
|
16
|
+
* A ``Chunk`` records the *character span* of the source text it came from, not
|
|
17
|
+
just its text. The chunker may alter chunk text (prepending a section path),
|
|
18
|
+
so the span — not the text — is the stable link back to the document, and the
|
|
19
|
+
thing ``survive`` maps a located answer onto.
|
|
20
|
+
* A ``Ranking`` covers *all* chunks, not just the ``top_k`` slice, so a verdict
|
|
21
|
+
can state "ranked 23 of 400" even when the target chunk is far outside the
|
|
22
|
+
cutoff.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CharSpan",
|
|
33
|
+
"PageSpan",
|
|
34
|
+
"ExtractedDoc",
|
|
35
|
+
"Chunk",
|
|
36
|
+
"RankedChunk",
|
|
37
|
+
"Ranking",
|
|
38
|
+
"CheckStatus",
|
|
39
|
+
"CheckResult",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CharSpan:
|
|
45
|
+
"""A half-open ``[start, end)`` character span into an extracted text.
|
|
46
|
+
|
|
47
|
+
Offsets index :attr:`ExtractedDoc.text`. Used both for a ``Chunk``'s source
|
|
48
|
+
span and for the span ``locate`` reports for a found answer, so the two can
|
|
49
|
+
be compared directly by ``survive``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
start: int
|
|
53
|
+
end: int
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.start < 0 or self.end < self.start:
|
|
57
|
+
raise ValueError(f"invalid span: start={self.start}, end={self.end}")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def length(self) -> int:
|
|
61
|
+
return self.end - self.start
|
|
62
|
+
|
|
63
|
+
def contains(self, other: "CharSpan") -> bool:
|
|
64
|
+
"""True if ``other`` lies wholly within this span.
|
|
65
|
+
|
|
66
|
+
This is the test ``survive`` makes: an answer survives chunking if its
|
|
67
|
+
located span is wholly contained in one chunk's source span.
|
|
68
|
+
"""
|
|
69
|
+
return self.start <= other.start and other.end <= self.end
|
|
70
|
+
|
|
71
|
+
def overlaps(self, other: "CharSpan") -> bool:
|
|
72
|
+
"""True if the two spans share any character (half-open)."""
|
|
73
|
+
return self.start < other.end and other.start < self.end
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class PageSpan:
|
|
78
|
+
"""The character span one page occupies in the extracted text.
|
|
79
|
+
|
|
80
|
+
``page`` is 1-based, matching how page numbers are reported to the user.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
page: int
|
|
84
|
+
span: CharSpan
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ExtractedDoc:
|
|
89
|
+
"""Loader output: the full extracted text plus a page map.
|
|
90
|
+
|
|
91
|
+
The extracted text is authoritative for position; page numbers in a verdict
|
|
92
|
+
come from this page map, never from re-reading the PDF later
|
|
93
|
+
(``.ai/architecture/data-flow.md``).
|
|
94
|
+
|
|
95
|
+
The page map is expressed as an ordered, non-overlapping list of
|
|
96
|
+
:class:`PageSpan`, covering the text start to finish. :meth:`page_at`
|
|
97
|
+
resolves any character offset to its page number.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
text: str
|
|
101
|
+
page_map: Sequence[PageSpan] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def empty_pages(self) -> tuple[int, ...]:
|
|
105
|
+
"""1-based numbers of pages that yielded no text, in page-map order.
|
|
106
|
+
|
|
107
|
+
A page with a zero-length span produced nothing extractable (e.g. a
|
|
108
|
+
scanned image page). Derived from :attr:`page_map`; extracts nothing new.
|
|
109
|
+
"""
|
|
110
|
+
return tuple(p.page for p in self.page_map if p.span.length == 0)
|
|
111
|
+
|
|
112
|
+
def page_at(self, offset: int) -> Optional[int]:
|
|
113
|
+
"""Return the 1-based page number containing ``offset``, or ``None``.
|
|
114
|
+
|
|
115
|
+
``None`` means the offset falls outside every recorded page span (an
|
|
116
|
+
empty or incomplete map, or an out-of-range offset).
|
|
117
|
+
"""
|
|
118
|
+
for page_span in self.page_map:
|
|
119
|
+
s = page_span.span
|
|
120
|
+
if s.start <= offset < s.end:
|
|
121
|
+
return page_span.page
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Chunk:
|
|
127
|
+
"""A unit the chunker produced from the extracted text.
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
id: Stable identifier for this chunk within a run. :class:`Ranking`
|
|
131
|
+
refers to chunks by this id.
|
|
132
|
+
text: The chunk's text as it will be embedded. May differ from the
|
|
133
|
+
source slice when ``prepend_section_context`` is on.
|
|
134
|
+
span: The source character span in :attr:`ExtractedDoc.text` this chunk
|
|
135
|
+
came from. Authoritative for survival; unaffected by section-context
|
|
136
|
+
prepending.
|
|
137
|
+
page: 1-based page the chunk's source span starts on.
|
|
138
|
+
section_path: The heading/section path the chunk sits under, outermost
|
|
139
|
+
first (e.g. ``("2. Methods", "2.1 Sampling")``). Empty when no
|
|
140
|
+
structure was detected.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
id: str
|
|
144
|
+
text: str
|
|
145
|
+
span: CharSpan
|
|
146
|
+
page: int
|
|
147
|
+
section_path: tuple[str, ...] = ()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class RankedChunk:
|
|
152
|
+
"""One entry in a :class:`Ranking`: a chunk id and its question score."""
|
|
153
|
+
|
|
154
|
+
chunk_id: str
|
|
155
|
+
score: float
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Ranking:
|
|
160
|
+
"""A full ordering of every chunk by score against the question.
|
|
161
|
+
|
|
162
|
+
``entries`` is ordered best-first and must cover *all* chunks, not just the
|
|
163
|
+
``top_k`` slice — the retriever's contract is that the target chunk's exact
|
|
164
|
+
position is knowable even when it is far outside ``top_k``
|
|
165
|
+
(``.ai/architecture/components.md``).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
entries: Sequence[RankedChunk] = field(default_factory=list)
|
|
169
|
+
|
|
170
|
+
def __len__(self) -> int:
|
|
171
|
+
return len(self.entries)
|
|
172
|
+
|
|
173
|
+
def position_of(self, chunk_id: str) -> Optional[int]:
|
|
174
|
+
"""Return the 1-based rank of ``chunk_id``, or ``None`` if absent.
|
|
175
|
+
|
|
176
|
+
A return of ``1`` is the top-ranked chunk. Combined with :meth:`__len__`
|
|
177
|
+
this supports a verdict phrased as "ranked 23 of 400".
|
|
178
|
+
"""
|
|
179
|
+
for index, entry in enumerate(self.entries, start=1):
|
|
180
|
+
if entry.chunk_id == chunk_id:
|
|
181
|
+
return index
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CheckStatus(Enum):
|
|
186
|
+
"""The four distinct outcomes an attribution check can have.
|
|
187
|
+
|
|
188
|
+
``FAIL`` and ``UNEVALUATED`` are separate values on purpose: a stage that
|
|
189
|
+
*failed* is the verdict, while a stage that *could not be evaluated* must
|
|
190
|
+
withhold the verdict rather than fall through to the next plausible stage
|
|
191
|
+
(``.ai/constitution.md`` — "A verdict is never produced without evidence
|
|
192
|
+
from every stage it rules out"; spec AC#3).
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
PASS = "pass"
|
|
196
|
+
FAIL = "fail"
|
|
197
|
+
NOT_REACHED = "not_reached"
|
|
198
|
+
UNEVALUATED = "unevaluated"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class CheckResult:
|
|
203
|
+
"""The outcome of one attribution check.
|
|
204
|
+
|
|
205
|
+
Construct via the classmethods rather than the raw initializer so the
|
|
206
|
+
``reason``/``evidence`` invariants are respected:
|
|
207
|
+
|
|
208
|
+
* :meth:`passed` — the check passed. May carry supporting ``evidence``.
|
|
209
|
+
* :meth:`failed` — the check failed; this is a verdict. Carries a ``reason``
|
|
210
|
+
(which a fix suggestion is later attached to) and ``evidence``.
|
|
211
|
+
* :meth:`not_reached` — an earlier check already failed, so this one was
|
|
212
|
+
never run.
|
|
213
|
+
* :meth:`unevaluated` — this check could not be run to a conclusion (e.g. a
|
|
214
|
+
missing input); the verdict is withheld. Distinct from ``failed``.
|
|
215
|
+
|
|
216
|
+
``evidence`` is a free-form mapping (spans, pages, ranks, snippets) that the
|
|
217
|
+
reporter renders; its exact keys are each check's concern, not this type's.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
status: CheckStatus
|
|
221
|
+
reason: Optional[str] = None
|
|
222
|
+
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def passed(cls, evidence: Optional[Mapping[str, Any]] = None) -> "CheckResult":
|
|
226
|
+
return cls(CheckStatus.PASS, evidence=dict(evidence or {}))
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def failed(
|
|
230
|
+
cls, reason: str, evidence: Optional[Mapping[str, Any]] = None
|
|
231
|
+
) -> "CheckResult":
|
|
232
|
+
return cls(CheckStatus.FAIL, reason=reason, evidence=dict(evidence or {}))
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def not_reached(cls) -> "CheckResult":
|
|
236
|
+
return cls(CheckStatus.NOT_REACHED)
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def unevaluated(cls, reason: Optional[str] = None) -> "CheckResult":
|
|
240
|
+
return cls(CheckStatus.UNEVALUATED, reason=reason)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def is_pass(self) -> bool:
|
|
244
|
+
return self.status is CheckStatus.PASS
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def is_fail(self) -> bool:
|
|
248
|
+
return self.status is CheckStatus.FAIL
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def is_not_reached(self) -> bool:
|
|
252
|
+
return self.status is CheckStatus.NOT_REACHED
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def is_unevaluated(self) -> bool:
|
|
256
|
+
return self.status is CheckStatus.UNEVALUATED
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The attribution engine: decide which pipeline stage lost the answer.
|
|
2
|
+
|
|
3
|
+
This package is the diagnostic half of ``ragdoc``. It reads the artefacts the
|
|
4
|
+
forward pipeline produced (an :class:`~ragdoc.artifacts.ExtractedDoc`, a list of
|
|
5
|
+
:class:`~ragdoc.artifacts.Chunk`, and later a :class:`~ragdoc.artifacts.Ranking`)
|
|
6
|
+
and runs the backward checks in order, stopping at the first failure and
|
|
7
|
+
withholding the verdict when a check cannot be evaluated.
|
|
8
|
+
|
|
9
|
+
It depends only on the artefact shapes and on nothing in the interface layer
|
|
10
|
+
(``.ai/constitution.md`` — the headless engine never depends on the UI). Import
|
|
11
|
+
the engine and the offline checks from here:
|
|
12
|
+
|
|
13
|
+
from ragdoc.attribution import AttributionEngine, locate, survive
|
|
14
|
+
|
|
15
|
+
T-004 ships ``locate`` and ``survive`` and the runner; ``rank`` and the final
|
|
16
|
+
verdict composition are T-006, which extends the runner's check sequence.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ragdoc.attribution.checks import locate, survive
|
|
22
|
+
from ragdoc.attribution.engine import (
|
|
23
|
+
DEFAULT_CHECKS,
|
|
24
|
+
AttributionEngine,
|
|
25
|
+
Check,
|
|
26
|
+
CheckOutcome,
|
|
27
|
+
EngineResult,
|
|
28
|
+
RunState,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"locate",
|
|
33
|
+
"survive",
|
|
34
|
+
"AttributionEngine",
|
|
35
|
+
"EngineResult",
|
|
36
|
+
"CheckOutcome",
|
|
37
|
+
"Check",
|
|
38
|
+
"RunState",
|
|
39
|
+
"DEFAULT_CHECKS",
|
|
40
|
+
]
|