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 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
+ ]