ragrefine 0.2.1__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.
ragrefine/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Retriever-agnostic post-retrieval refinement for RAG candidate sets."""
2
+
3
+ from ragrefine.config import RefinerConfig
4
+ from ragrefine.models import Candidate, CandidateSet
5
+ from ragrefine.refiner import Refiner
6
+ from ragrefine.results import RefinementResult
7
+
8
+ __all__ = [
9
+ "Candidate",
10
+ "CandidateSet",
11
+ "RefinerConfig",
12
+ "RefinementResult",
13
+ "Refiner",
14
+ ]
ragrefine/_channels.py ADDED
@@ -0,0 +1,16 @@
1
+ """Private channel execution result used only by :mod:`ragrefine.refiner`."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+
6
+ from ragrefine.models import Candidate
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class _ChannelResult:
11
+ """One successful channel execution over the unchanged input candidate pool."""
12
+
13
+ name: str
14
+ ranking: tuple[Candidate, ...]
15
+ evidence: Mapping[str, Mapping[str, object]]
16
+ metadata: Mapping[str, object] = field(default_factory=dict)
ragrefine/config.py ADDED
@@ -0,0 +1,27 @@
1
+ """Public configuration for deterministic refinement channels."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ChannelConfig:
8
+ """Whether to run one channel, require success, and its fusion weight."""
9
+
10
+ enabled: bool = False
11
+ required: bool = True
12
+ weight: float = 1.0
13
+
14
+ def __post_init__(self) -> None:
15
+ if self.weight < 0:
16
+ raise ValueError("channel weight must be non-negative")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class RefinerConfig:
21
+ """Explicit independent channel configuration for B0 through B3 modes."""
22
+
23
+ original: ChannelConfig = field(default_factory=lambda: ChannelConfig(enabled=True))
24
+ neural: ChannelConfig = field(default_factory=ChannelConfig)
25
+ lexical: ChannelConfig = field(default_factory=ChannelConfig)
26
+ pattern: ChannelConfig = field(default_factory=ChannelConfig)
27
+ fusion_enabled: bool = False
ragrefine/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Typed package exceptions for refinement failures."""
2
+
3
+
4
+ class RagRefineError(Exception):
5
+ """Base exception for explicit ragrefine failures."""
6
+
7
+
8
+ class RerankerError(RagRefineError):
9
+ """Raised when neural reranking cannot complete safely."""
10
+
11
+
12
+ class PatternError(RagRefineError):
13
+ """Raised when configured pattern processing is invalid or unsafe."""
14
+
15
+
16
+ class ChannelExecutionError(RagRefineError):
17
+ """Raised when a required ranking channel cannot complete."""
18
+
19
+
20
+ class MultipleActiveChannelsError(RagRefineError):
21
+ """Raised when independent rankings need fusion but it is disabled."""
@@ -0,0 +1 @@
1
+ """Optional framework integrations for ragrefine."""
@@ -0,0 +1,33 @@
1
+ """Optional Haystack integration for ragrefine.
2
+
3
+ Installed with ``ragrefine[haystack]``. Provides the document adapter,
4
+ ``RagRefineComponent`` with serializable refiner profiles, the prompt builder,
5
+ a frozen retrieval fixture, and a ready-made RAG pipeline builder.
6
+ """
7
+
8
+ from ragrefine.integrations.haystack.adapter import HaystackDocumentAdapter
9
+ from ragrefine.integrations.haystack.component import (
10
+ RagRefineComponent,
11
+ refiner_for_profile,
12
+ register_refiner_factory,
13
+ )
14
+ from ragrefine.integrations.haystack.fixture import FrozenHaystackFixture
15
+ from ragrefine.integrations.haystack.pipeline import (
16
+ ChatPromptAdapter,
17
+ build_rag_pipeline,
18
+ demo_documents,
19
+ )
20
+ from ragrefine.integrations.haystack.prompt import PROMPT_TEMPLATE, prompt_builder
21
+
22
+ __all__ = [
23
+ "ChatPromptAdapter",
24
+ "FrozenHaystackFixture",
25
+ "HaystackDocumentAdapter",
26
+ "PROMPT_TEMPLATE",
27
+ "RagRefineComponent",
28
+ "build_rag_pipeline",
29
+ "demo_documents",
30
+ "prompt_builder",
31
+ "refiner_for_profile",
32
+ "register_refiner_factory",
33
+ ]
@@ -0,0 +1,72 @@
1
+ """Optional Haystack ``Document`` conversion without core-package coupling."""
2
+
3
+ from collections.abc import Iterable, Sequence
4
+
5
+ from haystack import Document
6
+
7
+ from ragrefine.models import Candidate, CandidateSet, RefinedCandidate
8
+
9
+
10
+ class HaystackDocumentAdapter:
11
+ """Adapt one ordered Haystack result set while retaining document identity.
12
+
13
+ The adapter owns no ranking logic. It converts documents to immutable core
14
+ candidates, then maps selected/refined candidates back to the exact original
15
+ ``Document`` objects supplied at construction.
16
+ """
17
+
18
+ def __init__(
19
+ self, documents: Sequence[Document], *, name: str = "haystack"
20
+ ) -> None:
21
+ if not name:
22
+ raise ValueError("candidate-set name must be non-empty")
23
+ self._documents = tuple(documents)
24
+ self._by_id: dict[str, Document] = {}
25
+ for document in self._documents:
26
+ if not isinstance(document, Document):
27
+ raise TypeError("documents must contain Haystack Document instances")
28
+ document_id = str(document.id)
29
+ if not document_id:
30
+ raise ValueError("Haystack Document IDs must be non-empty")
31
+ if document_id in self._by_id:
32
+ raise ValueError(f"duplicate Haystack Document ID: {document_id}")
33
+ self._by_id[document_id] = document
34
+ self._name = name
35
+
36
+ def candidate_set(self) -> CandidateSet:
37
+ """Return the ordered, provenance-preserving core candidate set."""
38
+ return CandidateSet(
39
+ name=self._name,
40
+ candidates=tuple(
41
+ Candidate(
42
+ id=str(document.id),
43
+ text=document.content or "",
44
+ metadata=dict(document.meta),
45
+ retrieval_score=document.score,
46
+ retrieval_rank=index,
47
+ )
48
+ for index, document in enumerate(self._documents, start=1)
49
+ ),
50
+ )
51
+
52
+ def documents_for(
53
+ self, candidates: Iterable[RefinedCandidate | Candidate]
54
+ ) -> tuple[Document, ...]:
55
+ """Return the original documents for refined/selected core candidates."""
56
+ resolved: list[Document] = []
57
+ for item in candidates:
58
+ candidate = item.candidate if isinstance(item, RefinedCandidate) else item
59
+ if not isinstance(candidate, Candidate):
60
+ raise TypeError("candidates must contain Candidate or RefinedCandidate")
61
+ try:
62
+ document = self._by_id[candidate.id]
63
+ except KeyError as error:
64
+ raise ValueError(
65
+ f"candidate {candidate.id!r} was not created by this adapter"
66
+ ) from error
67
+ if document.content != candidate.text:
68
+ raise ValueError(
69
+ f"candidate {candidate.id!r} text does not match source"
70
+ )
71
+ resolved.append(document)
72
+ return tuple(resolved)
@@ -0,0 +1,150 @@
1
+ """Native optional Haystack component for post-retrieval refinement."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import asdict
5
+ from typing import Any
6
+
7
+ from haystack import Document, component, default_to_dict
8
+
9
+ from ragrefine import Refiner, RefinerConfig
10
+ from ragrefine.config import ChannelConfig
11
+ from ragrefine.integrations.haystack.adapter import HaystackDocumentAdapter
12
+ from ragrefine.ranking.lexical import LexicalRanker
13
+
14
+ _REFINER_FACTORIES: dict[str, Callable[[], Refiner]] = {}
15
+
16
+
17
+ def register_refiner_factory(name: str, factory: Callable[[], Refiner]) -> None:
18
+ """Register a named refiner profile for serializable components.
19
+
20
+ Built-in profiles are ``b0`` (original retrieval order) and
21
+ ``b2-l-lexical`` (the retained lightweight lexical profile). A custom
22
+ profile must be registered before a component using it is serialized, so
23
+ Haystack pipeline YAML can rebuild the exact refiner on load.
24
+ """
25
+ if not isinstance(name, str) or not name:
26
+ raise ValueError("refiner profile name must be a non-empty string")
27
+ if not callable(factory):
28
+ raise TypeError("refiner factory must be callable")
29
+ if name in _REFINER_FACTORIES:
30
+ raise ValueError(f"refiner profile already registered: {name!r}")
31
+ _REFINER_FACTORIES[name] = factory
32
+
33
+
34
+ def refiner_for_profile(name: str) -> Refiner:
35
+ """Build the refiner for one registered profile name."""
36
+ try:
37
+ return _REFINER_FACTORIES[name]()
38
+ except KeyError as error:
39
+ raise ValueError(
40
+ f"unknown refiner profile {name!r}; "
41
+ f"registered: {sorted(_REFINER_FACTORIES)}"
42
+ ) from error
43
+
44
+
45
+ def _b0_refiner() -> Refiner:
46
+ """Original-order (B0) profile: refinement leaves the pool order unchanged."""
47
+ return Refiner()
48
+
49
+
50
+ def _b2_l_lexical_refiner() -> Refiner:
51
+ """Retained B2-L profile: stdlib lexical-coverage ranking only."""
52
+ return Refiner(
53
+ lexical_ranker=LexicalRanker(),
54
+ config=RefinerConfig(
55
+ original=ChannelConfig(enabled=False),
56
+ lexical=ChannelConfig(enabled=True),
57
+ ),
58
+ )
59
+
60
+
61
+ register_refiner_factory("b0", _b0_refiner)
62
+ register_refiner_factory("b2-l-lexical", _b2_l_lexical_refiner)
63
+
64
+
65
+ @component
66
+ class RagRefineComponent:
67
+ """Run a configured core refiner over one Haystack document result set.
68
+
69
+ The component intentionally delegates all ranking and selection semantics to
70
+ ``Refiner``. It returns the original document objects in the selected final
71
+ order plus a JSON-compatible trace. Built-in serializable profiles are
72
+ ``b0`` and ``b2-l-lexical``; custom refiners need a registered factory.
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ refiner: Refiner | None = None,
79
+ candidate_set_name: str = "haystack",
80
+ top_k: int = 5,
81
+ max_tokens: int | None = None,
82
+ ) -> None:
83
+ if top_k < 0:
84
+ raise ValueError("top_k must be non-negative")
85
+ if not candidate_set_name:
86
+ raise ValueError("candidate_set_name must be non-empty")
87
+ self._refiner = refiner or Refiner()
88
+ self._candidate_set_name = candidate_set_name
89
+ self._top_k = top_k
90
+ self._max_tokens = max_tokens
91
+
92
+ @component.output_types(documents=list[Document], trace=dict[str, Any])
93
+ def run(self, query: str, documents: list[Document]) -> dict[str, object]:
94
+ """Refine retrieved documents, preserving final core ordering exactly."""
95
+ if not isinstance(query, str):
96
+ raise TypeError("query must be a string")
97
+ adapter = HaystackDocumentAdapter(documents, name=self._candidate_set_name)
98
+ result = self._refiner.refine(
99
+ query,
100
+ adapter.candidate_set(),
101
+ top_k=self._top_k,
102
+ max_tokens=self._max_tokens,
103
+ )
104
+ return {
105
+ "documents": list(adapter.documents_for(result.candidates)),
106
+ "trace": asdict(result.trace),
107
+ }
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """Serialize the component with its named refiner profile."""
111
+ return default_to_dict(
112
+ self,
113
+ candidate_set_name=self._candidate_set_name,
114
+ top_k=self._top_k,
115
+ max_tokens=self._max_tokens,
116
+ refiner_profile=self._profile_name(),
117
+ )
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: dict[str, Any]) -> "RagRefineComponent":
121
+ """Recreate a component from its serialized profile and settings."""
122
+ init_parameters = dict(data.get("init_parameters", {}))
123
+ profile = init_parameters.pop("refiner_profile", "b0")
124
+ return cls(refiner=refiner_for_profile(profile), **init_parameters)
125
+
126
+ def _profile_name(self) -> str:
127
+ for name, factory in _REFINER_FACTORIES.items():
128
+ try:
129
+ candidate = factory()
130
+ except Exception:
131
+ continue
132
+ if self._equivalent(candidate):
133
+ return name
134
+ raise ValueError(
135
+ "serialization requires a registered refiner profile; register one "
136
+ "with register_refiner_factory(...) "
137
+ f"(registered: {sorted(_REFINER_FACTORIES)})"
138
+ )
139
+
140
+ def _equivalent(self, other: "Refiner") -> bool:
141
+ # SLF001 - serialization boundary; compares the exact ranking wiring.
142
+ return (
143
+ self._refiner._config == other._config
144
+ and type(self._refiner._reranker) is type(other._reranker)
145
+ and type(self._refiner._lexical_ranker) is type(other._lexical_ranker)
146
+ and type(self._refiner._pattern_ranker) is type(other._pattern_ranker)
147
+ and type(self._refiner._fusion) is type(other._fusion)
148
+ and type(self._refiner._deduplicator) is type(other._deduplicator)
149
+ and type(self._refiner._selector) is type(other._selector)
150
+ )
@@ -0,0 +1,96 @@
1
+ """Frozen, reproducible Haystack retrieval fixture independent of ragrefine."""
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ from haystack import Document
10
+ from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
11
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
12
+
13
+ FROZEN_CORPUS = (
14
+ {
15
+ "id": "python-311",
16
+ "content": "Python 3.11 adds exception groups.",
17
+ "meta": {"topic": "python"},
18
+ },
19
+ {
20
+ "id": "python-312",
21
+ "content": "Python 3.12 improves the f-string parser.",
22
+ "meta": {"topic": "python"},
23
+ },
24
+ {
25
+ "id": "haystack",
26
+ "content": "Haystack pipelines connect document retrieval components.",
27
+ "meta": {"topic": "rag"},
28
+ },
29
+ )
30
+ FROZEN_QUERIES = {
31
+ "python": "Which Python version improves f-string parsing?",
32
+ "rag": "How do Haystack pipelines retrieve documents?",
33
+ }
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class RetrievalIdentity:
38
+ """Persistable identity for the frozen retrieval corpus and configuration."""
39
+
40
+ corpus_sha256: str
41
+ queries_sha256: str
42
+ retriever: str
43
+ document_store: str
44
+ top_n: int
45
+
46
+
47
+ class FrozenHaystackFixture:
48
+ """Build one real deterministic BM25 document-store retrieval fixture."""
49
+
50
+ def __init__(self, *, top_n: int = 2) -> None:
51
+ if top_n < 1:
52
+ raise ValueError("top_n must be positive")
53
+ self._top_n = top_n
54
+ self._store = InMemoryDocumentStore(bm25_algorithm="BM25L")
55
+ self._store.write_documents(
56
+ [
57
+ Document(
58
+ id=str(row["id"]),
59
+ content=str(row["content"]),
60
+ meta=dict(cast(Mapping[str, Any], row["meta"])),
61
+ )
62
+ for row in FROZEN_CORPUS
63
+ ]
64
+ )
65
+ self._retriever = InMemoryBM25Retriever(document_store=self._store)
66
+
67
+ def identity(self) -> RetrievalIdentity:
68
+ """Return frozen corpus/query/retriever/index identity for persistence."""
69
+ return RetrievalIdentity(
70
+ corpus_sha256=_checksum(FROZEN_CORPUS),
71
+ queries_sha256=_checksum(FROZEN_QUERIES),
72
+ retriever="haystack.InMemoryBM25Retriever/BM25L",
73
+ document_store="haystack.InMemoryDocumentStore",
74
+ top_n=self._top_n,
75
+ )
76
+
77
+ def retrieve(self, query: str) -> tuple[Document, ...]:
78
+ """Return the single frozen pool to reuse for every comparison branch."""
79
+ if not isinstance(query, str) or not query:
80
+ raise ValueError("query must be a non-empty string")
81
+ documents = self._retriever.run(query=query, top_k=self._top_n)["documents"]
82
+ if any(document.score is None for document in documents):
83
+ raise RuntimeError("Haystack retriever returned a document without a score")
84
+ return tuple(documents)
85
+
86
+ def frozen_query(self, name: str) -> str:
87
+ """Return one named immutable fixture query."""
88
+ try:
89
+ return FROZEN_QUERIES[name]
90
+ except KeyError as error:
91
+ raise ValueError(f"unknown frozen query: {name}") from error
92
+
93
+
94
+ def _checksum(value: object) -> str:
95
+ encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
96
+ return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,104 @@
1
+ """Runnable Haystack RAG pipeline wiring for the ragrefine integration.
2
+
3
+ ``build_rag_pipeline`` wires the frozen BM25 retriever, ``RagRefineComponent``
4
+ with a registered refiner profile, the fixed prompt builder, and an optional
5
+ chat generator:
6
+
7
+ ```text
8
+ InMemoryBM25Retriever -> RagRefineComponent -> PromptBuilder -> ChatPromptAdapter
9
+ -> OpenAIChatGenerator
10
+ ```
11
+
12
+ The generator is built for DeepSeek's OpenAI-compatible endpoint from
13
+ ``DEEPSEEK_API_KEY``/``DEEPSEEK_MODEL`` when configured, or omitted entirely
14
+ (prompt-only pipeline). Refinement is post-retrieval only: it never changes
15
+ retrieval, indexing, or generation semantics.
16
+ """
17
+
18
+ import os
19
+ from collections.abc import Mapping
20
+ from typing import Any, cast
21
+
22
+ from haystack import Document, Pipeline, component
23
+ from haystack.components.generators.chat import OpenAIChatGenerator
24
+ from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
25
+ from haystack.core.component.component import Component
26
+ from haystack.dataclasses import ChatMessage
27
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
28
+ from haystack.utils import Secret
29
+
30
+ from ragrefine.integrations.haystack.component import (
31
+ RagRefineComponent,
32
+ refiner_for_profile,
33
+ )
34
+ from ragrefine.integrations.haystack.fixture import FROZEN_CORPUS
35
+ from ragrefine.integrations.haystack.prompt import prompt_builder
36
+
37
+ DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
38
+
39
+
40
+ @component
41
+ class ChatPromptAdapter:
42
+ """Turn a PromptBuilder ``prompt`` string into a chat ``messages`` list."""
43
+
44
+ @component.output_types(messages=list[ChatMessage])
45
+ def run(self, prompt: str) -> dict[str, Any]:
46
+ """Wrap one prompt string as a user chat message."""
47
+ return {"messages": [ChatMessage.from_user(prompt)]}
48
+
49
+
50
+ def demo_documents() -> list[Document]:
51
+ """Return the frozen retrieval corpus as documents for an example store."""
52
+ return [
53
+ Document(
54
+ id=str(row["id"]),
55
+ content=str(row["content"]),
56
+ meta=dict(cast(Mapping[str, Any], row["meta"])),
57
+ )
58
+ for row in FROZEN_CORPUS
59
+ ]
60
+
61
+
62
+ def build_rag_pipeline(
63
+ document_store: InMemoryDocumentStore,
64
+ *,
65
+ top_k: int = 3,
66
+ profile: str = "b2-l-lexical",
67
+ api_key: str | None = None,
68
+ model: str | None = None,
69
+ generator: Component | None = None,
70
+ ) -> Pipeline:
71
+ """Return a retriever -> refine -> prompt -> chat-generator Haystack pipeline.
72
+
73
+ ``profile`` is any registered refiner profile (built-in ``b0`` and
74
+ ``b2-l-lexical``). When ``generator`` is supplied it is used as the final
75
+ chat generator; otherwise one is built for DeepSeek's OpenAI-compatible
76
+ endpoint when ``api_key`` is available, and the pipeline stops after the
77
+ prompt builder when it is not.
78
+ """
79
+ pipeline = Pipeline()
80
+ pipeline.add_component(
81
+ "retriever", InMemoryBM25Retriever(document_store=document_store)
82
+ )
83
+ pipeline.add_component(
84
+ "refine",
85
+ RagRefineComponent(top_k=top_k, refiner=refiner_for_profile(profile)),
86
+ )
87
+ pipeline.add_component("prompt", prompt_builder())
88
+ pipeline.connect("retriever.documents", "refine.documents")
89
+ pipeline.connect("refine.documents", "prompt.documents")
90
+ if generator is None:
91
+ if not api_key:
92
+ return pipeline
93
+ generator = OpenAIChatGenerator(
94
+ api_key=Secret.from_token(api_key),
95
+ model=model or os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
96
+ api_base_url=DEEPSEEK_BASE_URL,
97
+ generation_kwargs={"temperature": 0},
98
+ max_retries=0,
99
+ )
100
+ pipeline.add_component("chat", ChatPromptAdapter())
101
+ pipeline.add_component("generator", generator)
102
+ pipeline.connect("prompt.prompt", "chat.prompt")
103
+ pipeline.connect("chat.messages", "generator.messages")
104
+ return pipeline
@@ -0,0 +1,17 @@
1
+ """Prompt construction helpers for the optional Haystack integration."""
2
+
3
+ from haystack.components.builders import PromptBuilder
4
+
5
+ PROMPT_TEMPLATE = """Question: {{ query }}
6
+ Context:
7
+ {% for document in documents %}
8
+ [{{ document.id }}] {{ document.content }} | {{ document.meta }}
9
+ {% endfor %}"""
10
+
11
+
12
+ def prompt_builder() -> PromptBuilder:
13
+ """Create the fixed prompt builder shared by bypass and refinement paths."""
14
+ return PromptBuilder(
15
+ template=PROMPT_TEMPLATE,
16
+ required_variables=["query", "documents"],
17
+ )
ragrefine/models.py ADDED
@@ -0,0 +1,52 @@
1
+ """Core retriever-agnostic candidate domain models."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Candidate:
10
+ """One immutable evidence unit supplied by an external retriever.
11
+
12
+ The model preserves the retriever's identifier, text, metadata, and optional
13
+ retrieval annotations without depending on any retrieval framework.
14
+ """
15
+
16
+ id: str
17
+ text: str
18
+ metadata: Mapping[str, Any] = field(default_factory=dict)
19
+ retrieval_score: float | None = None
20
+ retrieval_rank: int | None = None
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CandidateSet:
25
+ """An immutable, ordered candidate collection from one named retriever."""
26
+
27
+ name: str
28
+ candidates: tuple[Candidate, ...]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RankingSignal:
33
+ """One optional, traceable ranking observation for a candidate."""
34
+
35
+ rank: int | None = None
36
+ score: float | None = None
37
+ details: Mapping[str, object] = field(default_factory=dict)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class RefinedCandidate:
42
+ """A candidate with its final ranking position and named evidence.
43
+
44
+ The wrapped candidate is always supplied by the caller; refinement never
45
+ creates or rewrites evidence. Retrieval provenance remains on ``candidate``.
46
+ ``rank`` is the position after ranking; a later context-selection position
47
+ is recorded separately in :class:`ragrefine.selection.SelectionRecord`.
48
+ """
49
+
50
+ candidate: Candidate
51
+ rank: int
52
+ signals: Mapping[str, RankingSignal] = field(default_factory=dict)
ragrefine/py.typed ADDED
File without changes
@@ -0,0 +1,18 @@
1
+ """Query-analysis helpers."""
2
+
3
+ from ragrefine.query.normalize import normalize_text, normalized_terms
4
+ from ragrefine.query.patterns import (
5
+ PatternMatch,
6
+ PatternRegistry,
7
+ PatternRule,
8
+ example_pattern_rules,
9
+ )
10
+
11
+ __all__ = [
12
+ "PatternMatch",
13
+ "PatternRegistry",
14
+ "PatternRule",
15
+ "example_pattern_rules",
16
+ "normalize_text",
17
+ "normalized_terms",
18
+ ]
@@ -0,0 +1,34 @@
1
+ """Deterministic, dependency-free text normalization for query analysis."""
2
+
3
+ import re
4
+ import unicodedata
5
+
6
+ _WHITESPACE = re.compile(r"\s+")
7
+ _TOKEN = re.compile(r"[^\W_]+(?:[-_][^\W_]+)*", flags=re.UNICODE)
8
+
9
+
10
+ def normalize_text(text: str) -> str:
11
+ """Return a NFKC-normalized, case-folded, whitespace-normalized string.
12
+
13
+ The returned value is suitable for deterministic comparison. It does not
14
+ stem, lemmatize, remove stop words, rewrite text, or mutate the caller's
15
+ original query/candidate text.
16
+ """
17
+ return _WHITESPACE.sub(" ", unicodedata.normalize("NFKC", text).casefold()).strip()
18
+
19
+
20
+ def normalized_terms(text: str) -> tuple[str, ...]:
21
+ """Extract unique normalized lexical terms in their first-seen order.
22
+
23
+ Hyphenated and underscored technical identifiers remain one term. The
24
+ uniqueness matches lexical coverage's set-based definition while ordering
25
+ makes evidence deterministic and human-readable.
26
+ """
27
+ terms: list[str] = []
28
+ seen: set[str] = set()
29
+ for match in _TOKEN.finditer(normalize_text(text)):
30
+ term = match.group().replace("_", "-")
31
+ if term not in seen:
32
+ terms.append(term)
33
+ seen.add(term)
34
+ return tuple(terms)