refindery 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.
Files changed (118) hide show
  1. refindery/__init__.py +3 -0
  2. refindery/__main__.py +6 -0
  3. refindery/adapters/__init__.py +1 -0
  4. refindery/adapters/chunking/__init__.py +1 -0
  5. refindery/adapters/chunking/chonkie_chunker.py +73 -0
  6. refindery/adapters/clock.py +11 -0
  7. refindery/adapters/cluster/__init__.py +1 -0
  8. refindery/adapters/cluster/engine.py +53 -0
  9. refindery/adapters/cluster/worker.py +208 -0
  10. refindery/adapters/embedding/__init__.py +1 -0
  11. refindery/adapters/embedding/catsu_embedder.py +84 -0
  12. refindery/adapters/embedding/surface_forms.py +53 -0
  13. refindery/adapters/extraction/__init__.py +1 -0
  14. refindery/adapters/extraction/http_fetcher.py +56 -0
  15. refindery/adapters/extraction/pdf_pypdf.py +32 -0
  16. refindery/adapters/extraction/pulpie_html.py +82 -0
  17. refindery/adapters/extractors/__init__.py +1 -0
  18. refindery/adapters/extractors/chain.py +48 -0
  19. refindery/adapters/extractors/gazetteer.py +66 -0
  20. refindery/adapters/extractors/gliner_spacy.py +97 -0
  21. refindery/adapters/extractors/llm.py +86 -0
  22. refindery/adapters/extractors/spacy_ner.py +69 -0
  23. refindery/adapters/llm/__init__.py +1 -0
  24. refindery/adapters/llm/openai_compat.py +70 -0
  25. refindery/adapters/metadata/__init__.py +1 -0
  26. refindery/adapters/metadata/migrations/0001_initial.sql +75 -0
  27. refindery/adapters/metadata/migrations/0002_forget.sql +12 -0
  28. refindery/adapters/metadata/migrations/0003_entities_clusters.sql +94 -0
  29. refindery/adapters/metadata/migrations/0004_backfills.sql +13 -0
  30. refindery/adapters/metadata/migrations/0005_jobs_page_id.sql +7 -0
  31. refindery/adapters/metadata/migrations/__init__.py +1 -0
  32. refindery/adapters/metadata/migrator.py +77 -0
  33. refindery/adapters/metadata/sqlite_store.py +1531 -0
  34. refindery/adapters/observability/__init__.py +1 -0
  35. refindery/adapters/observability/duckdb_sink.py +137 -0
  36. refindery/adapters/observability/logging.py +64 -0
  37. refindery/adapters/observability/metrics.py +76 -0
  38. refindery/adapters/observability/otel.py +58 -0
  39. refindery/adapters/observability/query_log.py +109 -0
  40. refindery/adapters/observability/query_log_reader.py +102 -0
  41. refindery/adapters/queue/__init__.py +1 -0
  42. refindery/adapters/queue/huey_queue.py +243 -0
  43. refindery/adapters/reranking/__init__.py +1 -0
  44. refindery/adapters/reranking/api.py +55 -0
  45. refindery/adapters/vector/__init__.py +1 -0
  46. refindery/adapters/vector/hybrid.py +44 -0
  47. refindery/adapters/vector/lancedb_store.py +326 -0
  48. refindery/adapters/vector/names.py +16 -0
  49. refindery/adapters/vector/qdrant_store.py +325 -0
  50. refindery/adapters/vector/sparse.py +45 -0
  51. refindery/api/__init__.py +1 -0
  52. refindery/api/app.py +65 -0
  53. refindery/api/auth.py +88 -0
  54. refindery/api/deps.py +10 -0
  55. refindery/api/mcp.py +59 -0
  56. refindery/api/routes/__init__.py +1 -0
  57. refindery/api/routes/clusters.py +110 -0
  58. refindery/api/routes/compare.py +79 -0
  59. refindery/api/routes/entities.py +99 -0
  60. refindery/api/routes/forget.py +89 -0
  61. refindery/api/routes/health.py +42 -0
  62. refindery/api/routes/jobs.py +74 -0
  63. refindery/api/routes/models.py +166 -0
  64. refindery/api/routes/pages.py +185 -0
  65. refindery/api/routes/search.py +205 -0
  66. refindery/api/schemas.py +478 -0
  67. refindery/application/__init__.py +1 -0
  68. refindery/application/container.py +503 -0
  69. refindery/application/ports/__init__.py +1 -0
  70. refindery/application/ports/chunker.py +19 -0
  71. refindery/application/ports/clock.py +12 -0
  72. refindery/application/ports/cluster_engine.py +42 -0
  73. refindery/application/ports/content_extractor.py +60 -0
  74. refindery/application/ports/embedder.py +37 -0
  75. refindery/application/ports/entity_extractor.py +17 -0
  76. refindery/application/ports/job_queue.py +43 -0
  77. refindery/application/ports/metadata_store.py +466 -0
  78. refindery/application/ports/query_log.py +72 -0
  79. refindery/application/ports/query_log_reader.py +66 -0
  80. refindery/application/ports/reranker.py +41 -0
  81. refindery/application/ports/surface_embedder.py +23 -0
  82. refindery/application/ports/vector_store.py +147 -0
  83. refindery/application/services/__init__.py +1 -0
  84. refindery/application/services/backfill.py +210 -0
  85. refindery/application/services/canonicalization.py +205 -0
  86. refindery/application/services/cluster_triggers.py +54 -0
  87. refindery/application/services/clustering_run.py +400 -0
  88. refindery/application/services/compare_service.py +299 -0
  89. refindery/application/services/entity_ingest.py +74 -0
  90. refindery/application/services/eval_service.py +360 -0
  91. refindery/application/services/extraction_router.py +38 -0
  92. refindery/application/services/feedback_service.py +28 -0
  93. refindery/application/services/forget_service.py +166 -0
  94. refindery/application/services/indexed_pages.py +27 -0
  95. refindery/application/services/indexing.py +245 -0
  96. refindery/application/services/ingest.py +150 -0
  97. refindery/application/services/model_registry.py +113 -0
  98. refindery/application/services/search_service.py +497 -0
  99. refindery/application/services/similarity_service.py +196 -0
  100. refindery/application/timing.py +31 -0
  101. refindery/cli.py +256 -0
  102. refindery/config.py +302 -0
  103. refindery/domain/__init__.py +5 -0
  104. refindery/domain/canonical_url.py +100 -0
  105. refindery/domain/clustering.py +220 -0
  106. refindery/domain/content_hash.py +8 -0
  107. refindery/domain/ctfidf.py +59 -0
  108. refindery/domain/entities.py +84 -0
  109. refindery/domain/errors.py +119 -0
  110. refindery/domain/ids.py +58 -0
  111. refindery/domain/models.py +250 -0
  112. refindery/domain/ranking_metrics.py +149 -0
  113. refindery/domain/retrieval.py +147 -0
  114. refindery/domain/rollup.py +43 -0
  115. refindery-0.1.0.dist-info/METADATA +233 -0
  116. refindery-0.1.0.dist-info/RECORD +118 -0
  117. refindery-0.1.0.dist-info/WHEEL +4 -0
  118. refindery-0.1.0.dist-info/entry_points.txt +3 -0
refindery/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Refindery: a local, single-machine retrieval engine over the web pages you read."""
2
+
3
+ __version__ = "0.1.0"
refindery/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point: ``python -m refindery`` dispatches to the CLI (default: serve)."""
2
+
3
+ from refindery.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1 @@
1
+ """Adapters: concrete implementations of the application ports."""
@@ -0,0 +1 @@
1
+ """Canonical chunking adapter (chonkie)."""
@@ -0,0 +1,73 @@
1
+ """Sentence-aware chunking via chonkie with a hard token ceiling.
2
+
3
+ Chunking is canonical and model-independent: one chunking, every embedding
4
+ model embeds the same spans. The cl100k tokenizer is the canonical counter.
5
+ chonkie targets ``chunk_size`` but can exceed it on pathological input, so a
6
+ post-pass re-splits anything above the hard max (which model registration
7
+ guarantees is within every registered model's budget).
8
+ """
9
+
10
+ from chonkie import SentenceChunker, TokenChunker
11
+
12
+ from refindery.domain.ids import PageId, new_chunk_id
13
+ from refindery.domain.models import Chunk
14
+
15
+ _TOKENIZER = "cl100k_base"
16
+
17
+
18
+ class ChonkieChunker:
19
+ """Chunker port implementation over chonkie's SentenceChunker."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ target_tokens: int = 448,
25
+ overlap_tokens: int = 64,
26
+ hard_max_tokens: int = 512,
27
+ ) -> None:
28
+ self._hard_max = hard_max_tokens
29
+ self._sentence = SentenceChunker(
30
+ tokenizer=_TOKENIZER,
31
+ chunk_size=target_tokens,
32
+ chunk_overlap=overlap_tokens,
33
+ )
34
+ self._splitter = TokenChunker(
35
+ tokenizer=_TOKENIZER,
36
+ chunk_size=hard_max_tokens,
37
+ chunk_overlap=0,
38
+ )
39
+
40
+ def chunk(self, *, page_id: PageId, text: str) -> list[Chunk]:
41
+ """Split ``text`` into ordered chunks with char offsets and token counts."""
42
+ if not text.strip():
43
+ return []
44
+ spans: list[tuple[str, int, int, int]] = []
45
+ for piece in self._sentence.chunk(text):
46
+ if piece.token_count <= self._hard_max:
47
+ spans.append(
48
+ (piece.text, piece.start_index, piece.end_index, piece.token_count)
49
+ )
50
+ continue
51
+ spans.extend(
52
+ (
53
+ sub.text,
54
+ piece.start_index + sub.start_index,
55
+ piece.start_index + sub.end_index,
56
+ sub.token_count,
57
+ )
58
+ for sub in self._splitter.chunk(piece.text)
59
+ )
60
+ return [
61
+ Chunk(
62
+ id=new_chunk_id(),
63
+ page_id=page_id,
64
+ ordinal=ordinal,
65
+ text=chunk_text,
66
+ token_count=token_count,
67
+ char_start=char_start,
68
+ char_end=char_end,
69
+ )
70
+ for ordinal, (chunk_text, char_start, char_end, token_count) in enumerate(
71
+ spans
72
+ )
73
+ ]
@@ -0,0 +1,11 @@
1
+ """System clock adapter."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+
6
+ class SystemClock:
7
+ """Wall clock returning tz-aware UTC datetimes."""
8
+
9
+ def now(self) -> datetime:
10
+ """Return the current UTC time."""
11
+ return datetime.now(tz=UTC)
@@ -0,0 +1 @@
1
+ """Clustering engine adapters (UMAP/PCA + HDBSCAN/KMeans in a process pool)."""
@@ -0,0 +1,53 @@
1
+ """ClusterEngine adapter: runs the worker in a spawn ProcessPoolExecutor."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import ProcessPoolExecutor
5
+ from functools import partial
6
+ from multiprocessing import get_context
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+
11
+ from refindery.adapters.cluster.worker import reduce_and_cluster
12
+ from refindery.application.ports.cluster_engine import ClusterFitResult, ClusterParams
13
+
14
+
15
+ class ProcessPoolClusterEngine:
16
+ """CPU-heavy clustering off the event loop."""
17
+
18
+ def __init__(self, *, max_workers: int = 1) -> None:
19
+ self._executor = ProcessPoolExecutor(
20
+ max_workers=max_workers, mp_context=get_context("spawn")
21
+ )
22
+
23
+ async def fit(
24
+ self, *, vectors: npt.NDArray[np.float32], params: ClusterParams
25
+ ) -> ClusterFitResult:
26
+ """Fit in the pool; only arrays and primitives cross the boundary."""
27
+ loop = asyncio.get_running_loop()
28
+ labels, probabilities, reduce_ms, cluster_ms = await loop.run_in_executor(
29
+ self._executor,
30
+ partial(
31
+ reduce_and_cluster,
32
+ vectors,
33
+ algorithm=params.algorithm,
34
+ reducer=params.reducer,
35
+ n_components=params.n_components,
36
+ n_neighbors=params.n_neighbors,
37
+ min_dist=params.min_dist,
38
+ min_cluster_size=params.min_cluster_size,
39
+ min_samples=params.min_samples,
40
+ leiden_resolution=params.leiden_resolution,
41
+ random_state=params.random_state,
42
+ ),
43
+ )
44
+ return ClusterFitResult(
45
+ labels=labels,
46
+ probabilities=probabilities,
47
+ reduce_ms=reduce_ms,
48
+ cluster_ms=cluster_ms,
49
+ )
50
+
51
+ def close(self) -> None:
52
+ """Shut the pool down."""
53
+ self._executor.shutdown(wait=False, cancel_futures=True)
@@ -0,0 +1,208 @@
1
+ """Process-pool worker: everything CPU-heavy about clustering.
2
+
3
+ Module-level pure function so the spawn context can pickle the reference.
4
+ Only primitives and numpy arrays cross the boundary; umap/sklearn import
5
+ INSIDE the function so the parent never pays the numba JIT cost. Stage
6
+ timings are returned (spans cannot cross a process boundary).
7
+ """
8
+
9
+ import time
10
+ from collections.abc import Sequence
11
+ from importlib import import_module
12
+ from typing import Protocol, cast
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ _LEIDEN_EXTRA_HINT = "Leiden clustering requires `uv sync --extra leiden`"
18
+
19
+
20
+ class _GraphFactory(Protocol):
21
+ def __call__(
22
+ self, *, n: int, edges: list[tuple[int, int]], directed: bool
23
+ ) -> object:
24
+ """Build an igraph graph."""
25
+ ...
26
+
27
+
28
+ class _IgraphModule(Protocol):
29
+ Graph: _GraphFactory
30
+
31
+
32
+ class _FindPartition(Protocol):
33
+ def __call__(
34
+ self,
35
+ graph: object,
36
+ partition_type: object,
37
+ *,
38
+ weights: list[float],
39
+ resolution_parameter: float,
40
+ seed: int,
41
+ ) -> Sequence[Sequence[int]]:
42
+ """Run Leiden partitioning."""
43
+ ...
44
+
45
+
46
+ class _LeidenModule(Protocol):
47
+ RBConfigurationVertexPartition: object
48
+ find_partition: _FindPartition
49
+
50
+
51
+ def _optional_leiden_modules() -> tuple[_IgraphModule, _LeidenModule]:
52
+ try:
53
+ igraph = import_module("igraph")
54
+ leidenalg = import_module("leidenalg")
55
+ except ImportError as exc:
56
+ raise RuntimeError(_LEIDEN_EXTRA_HINT) from exc
57
+ return cast("_IgraphModule", igraph), cast("_LeidenModule", leidenalg)
58
+
59
+
60
+ def _knn_graph_edges(
61
+ matrix: npt.NDArray[np.float32], *, n_neighbors: int
62
+ ) -> tuple[list[tuple[int, int]], list[float]]:
63
+ from sklearn.neighbors import NearestNeighbors # noqa: PLC0415 — worker only
64
+
65
+ k = min(max(n_neighbors, 1), len(matrix) - 1)
66
+ model = NearestNeighbors(n_neighbors=k + 1, metric="cosine")
67
+ distances, indices = model.fit(matrix).kneighbors(matrix)
68
+
69
+ edge_weights: dict[tuple[int, int], float] = {}
70
+ for source, (row_distances, row_indices) in enumerate(
71
+ zip(distances, indices, strict=True)
72
+ ):
73
+ for distance, target in zip(row_distances, row_indices, strict=True):
74
+ target_int = int(target)
75
+ if source == target_int:
76
+ continue
77
+ edge = (min(source, target_int), max(source, target_int))
78
+ distance_value = float(distance)
79
+ if not np.isfinite(distance_value):
80
+ continue
81
+ weight = 1.0 / (1.0 + max(distance_value, 0.0))
82
+ edge_weights[edge] = max(edge_weights.get(edge, 0.0), weight)
83
+ edges = sorted(edge_weights)
84
+ return edges, [edge_weights[edge] for edge in edges]
85
+
86
+
87
+ def _leiden_cluster(
88
+ matrix: npt.NDArray[np.float32],
89
+ *,
90
+ n_neighbors: int,
91
+ resolution: float,
92
+ random_state: int,
93
+ ) -> npt.NDArray[np.int64]:
94
+ if len(matrix) <= 1:
95
+ return np.zeros(len(matrix), dtype=np.int64)
96
+ igraph, leidenalg = _optional_leiden_modules()
97
+ edges, weights = _knn_graph_edges(matrix, n_neighbors=n_neighbors)
98
+ if not edges:
99
+ return np.arange(len(matrix), dtype=np.int64)
100
+
101
+ graph = igraph.Graph(n=len(matrix), edges=edges, directed=False)
102
+ partition = leidenalg.find_partition(
103
+ graph,
104
+ leidenalg.RBConfigurationVertexPartition,
105
+ weights=weights,
106
+ resolution_parameter=resolution,
107
+ seed=random_state,
108
+ )
109
+
110
+ labels = np.full(len(matrix), -1, dtype=np.int64)
111
+ for label, members in enumerate(partition):
112
+ labels[members] = label
113
+ unassigned_mask = labels < 0
114
+ n_unassigned = int(np.count_nonzero(unassigned_mask))
115
+ if n_unassigned > 0:
116
+ labels[unassigned_mask] = np.arange(
117
+ len(partition), len(partition) + n_unassigned, dtype=np.int64
118
+ )
119
+ return labels
120
+
121
+
122
+ def reduce_and_cluster(
123
+ vectors: npt.NDArray[np.float32],
124
+ *,
125
+ algorithm: str,
126
+ reducer: str,
127
+ n_components: int,
128
+ n_neighbors: int,
129
+ min_dist: float,
130
+ min_cluster_size: int,
131
+ min_samples: int,
132
+ random_state: int,
133
+ leiden_resolution: float = 1.0,
134
+ ) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float32], float, float]:
135
+ """Reduce then cluster; returns (labels, probabilities, reduce_ms, cluster_ms)."""
136
+ if len(vectors) == 0:
137
+ return (
138
+ np.array([], dtype=np.int64),
139
+ np.array([], dtype=np.float32),
140
+ 0.0,
141
+ 0.0,
142
+ )
143
+ started = time.perf_counter()
144
+ reduced = np.ascontiguousarray(vectors, dtype=np.float32)
145
+ match reducer:
146
+ case "umap" if len(vectors) > n_components + 2:
147
+ from umap import UMAP # noqa: PLC0415 — heavy import, worker only
148
+
149
+ reduced = UMAP(
150
+ n_components=n_components,
151
+ n_neighbors=min(n_neighbors, max(len(vectors) - 1, 2)),
152
+ min_dist=min_dist,
153
+ metric="cosine",
154
+ random_state=random_state,
155
+ ).fit_transform(vectors)
156
+ case "umap":
157
+ pass
158
+ case "pca" if len(vectors) > n_components:
159
+ from sklearn.decomposition import PCA # noqa: PLC0415 — worker only
160
+
161
+ reduced = PCA(
162
+ n_components=n_components, random_state=random_state
163
+ ).fit_transform(vectors)
164
+ case "pca" | "none":
165
+ pass
166
+ case _:
167
+ msg = f"unknown reducer {reducer!r}"
168
+ raise ValueError(msg)
169
+ reduce_ms = (time.perf_counter() - started) * 1_000.0
170
+
171
+ started = time.perf_counter()
172
+ match algorithm:
173
+ case "kmeans":
174
+ from sklearn.cluster import KMeans # noqa: PLC0415 — worker only
175
+
176
+ k = min(len(vectors), max(1, int(np.sqrt(len(vectors) / 2))))
177
+ model = KMeans(n_clusters=k, random_state=random_state, n_init="auto")
178
+ labels = model.fit_predict(reduced).astype(np.int64)
179
+ probabilities = np.ones(len(vectors), dtype=np.float32)
180
+ case "hdbscan":
181
+ from sklearn.cluster import ( # noqa: PLC0415 — worker only
182
+ HDBSCAN, # pyrefly: ignore[missing-module-attribute]
183
+ )
184
+
185
+ model = HDBSCAN(
186
+ min_cluster_size=min_cluster_size,
187
+ min_samples=min_samples,
188
+ metric="euclidean",
189
+ cluster_selection_method="eom",
190
+ )
191
+ labels = model.fit_predict(reduced).astype(np.int64)
192
+ probabilities = np.asarray(
193
+ getattr(model, "probabilities_", np.ones(len(vectors))),
194
+ dtype=np.float32,
195
+ )
196
+ case "leiden":
197
+ labels = _leiden_cluster(
198
+ reduced,
199
+ n_neighbors=n_neighbors,
200
+ resolution=leiden_resolution,
201
+ random_state=random_state,
202
+ )
203
+ probabilities = np.ones(len(vectors), dtype=np.float32)
204
+ case _:
205
+ msg = f"unknown clustering algorithm {algorithm!r}"
206
+ raise ValueError(msg)
207
+ cluster_ms = (time.perf_counter() - started) * 1_000.0
208
+ return labels, probabilities, reduce_ms, cluster_ms
@@ -0,0 +1 @@
1
+ """Embedding adapters (catsu multi-provider client)."""
@@ -0,0 +1,84 @@
1
+ """Embedder adapter over catsu (Voyage/OpenAI/Cohere/... behind one client).
2
+
3
+ ``dim``/``max_input_tokens`` come from the registry/settings — catsu does not
4
+ guarantee exposing them per model — and the returned vector length is checked
5
+ against ``dim`` on every call.
6
+ """
7
+
8
+ import numpy as np
9
+ from catsu import Client
10
+
11
+ from refindery.domain.rollup import Vector
12
+
13
+ _PROVIDER_ALIASES = {"voyage": "voyageai"}
14
+
15
+
16
+ class EmbeddingDimensionMismatchError(RuntimeError):
17
+ """The provider returned vectors of an unexpected dimension."""
18
+
19
+ def __init__(self, *, model_id: str, expected: int, got: int) -> None:
20
+ super().__init__(
21
+ f"model {model_id!r} returned {got}-d vectors, expected {expected}; "
22
+ f"fix the registered dim"
23
+ )
24
+
25
+
26
+ class CatsuEmbedder:
27
+ """Embedder port implementation for one registered model."""
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ model_id: str,
33
+ provider: str,
34
+ model_name: str,
35
+ dim: int,
36
+ max_input_tokens: int,
37
+ ) -> None:
38
+ self._model_id = model_id
39
+ self._provider = _PROVIDER_ALIASES.get(provider, provider)
40
+ self._model_name = model_name
41
+ self._dim = dim
42
+ self._max_input_tokens = max_input_tokens
43
+ self._client = Client()
44
+
45
+ @property
46
+ def model_id(self) -> str:
47
+ """Registry id of the model this embedder serves."""
48
+ return self._model_id
49
+
50
+ @property
51
+ def dim(self) -> int:
52
+ """Dimensionality of produced vectors."""
53
+ return self._dim
54
+
55
+ @property
56
+ def max_input_tokens(self) -> int:
57
+ """Maximum tokens the model accepts per input."""
58
+ return self._max_input_tokens
59
+
60
+ async def embed_documents(self, texts: list[str]) -> list[Vector]:
61
+ """Embed document chunks (storage side)."""
62
+ return await self._embed(texts, input_type="document")
63
+
64
+ async def embed_query(self, text: str) -> Vector:
65
+ """Embed a query (query side)."""
66
+ vectors = await self._embed([text], input_type="query")
67
+ return vectors[0]
68
+
69
+ async def _embed(self, texts: list[str], *, input_type: str) -> list[Vector]:
70
+ response = await self._client.aembed(
71
+ model=self._model_name,
72
+ input=texts,
73
+ provider=self._provider,
74
+ input_type=input_type,
75
+ )
76
+ vectors = [np.asarray(row, dtype=np.float32) for row in response.embeddings]
77
+ for vector in vectors:
78
+ if vector.shape != (self._dim,):
79
+ raise EmbeddingDimensionMismatchError(
80
+ model_id=self._model_id,
81
+ expected=self._dim,
82
+ got=int(vector.shape[0]),
83
+ )
84
+ return vectors
@@ -0,0 +1,53 @@
1
+ """Surface-form embedders: model2vec static model (default)."""
2
+
3
+ from threading import Lock
4
+ from typing import Protocol, cast
5
+
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ from refindery.domain.rollup import Vector, l2_normalize
10
+
11
+ _DEFAULT_MODEL = "minishlab/potion-base-8M"
12
+
13
+
14
+ class _StaticModelLike(Protocol):
15
+ def encode(self, sentences: list[str]) -> npt.NDArray[np.float32]:
16
+ """Encode surface forms."""
17
+ ...
18
+
19
+
20
+ class Model2VecSurfaceEmbedder:
21
+ """Static-embedding surface-form encoder (no torch, microseconds/call)."""
22
+
23
+ def __init__(self, model_name: str = _DEFAULT_MODEL) -> None:
24
+ self._model_name = model_name
25
+ self._model: _StaticModelLike | None = None
26
+ self._lock = Lock()
27
+ self._id = f"model2vec:{model_name}"
28
+
29
+ @property
30
+ def embedder_id(self) -> str:
31
+ """Cache key."""
32
+ return self._id
33
+
34
+ def embed(self, forms: list[str]) -> list[Vector]:
35
+ """Encode and L2-normalize."""
36
+ matrix = self._load_model().encode(forms)
37
+ return [l2_normalize(np.asarray(row, dtype=np.float32)) for row in matrix]
38
+
39
+ def _load_model(self) -> _StaticModelLike:
40
+ if self._model is None:
41
+ with self._lock:
42
+ if self._model is None:
43
+ from model2vec import StaticModel # noqa: PLC0415
44
+
45
+ self._model = cast(
46
+ "_StaticModelLike",
47
+ StaticModel.from_pretrained(self._model_name),
48
+ )
49
+ model = self._model
50
+ if model is None:
51
+ msg = "surface-form model failed to load"
52
+ raise RuntimeError(msg)
53
+ return model
@@ -0,0 +1 @@
1
+ """Content fetching and extraction adapters."""
@@ -0,0 +1,56 @@
1
+ """httpx-based fetcher for the fetch_and_index path.
2
+
3
+ The raw response is an external input: it is validated into the pydantic
4
+ ``FetchResult`` model (size cap, content-type normalization) before anything
5
+ downstream touches it.
6
+ """
7
+
8
+ import httpx
9
+ from pydantic import ValidationError
10
+
11
+ from refindery.application.ports.content_extractor import FetchResult
12
+ from refindery.domain.errors import FetchFailedError
13
+
14
+
15
+ class HttpFetcher:
16
+ """Fetcher port implementation."""
17
+
18
+ def __init__(self, *, timeout_s: float = 10.0, max_bytes: int = 10_000_000) -> None:
19
+ self._timeout_s = timeout_s
20
+ self._max_bytes = max_bytes
21
+
22
+ async def fetch(self, url: str) -> FetchResult:
23
+ """Fetch ``url``; raise FetchFailedError on any failure."""
24
+ try:
25
+ async with (
26
+ httpx.AsyncClient(
27
+ follow_redirects=True,
28
+ timeout=self._timeout_s,
29
+ headers={"User-Agent": "refindery/0.1"},
30
+ ) as client,
31
+ client.stream("GET", url) as response,
32
+ ):
33
+ response.raise_for_status()
34
+ body = bytearray()
35
+ async for chunk in response.aiter_bytes():
36
+ body.extend(chunk)
37
+ if len(body) > self._max_bytes:
38
+ raise FetchFailedError(
39
+ url=url,
40
+ detail=f"body exceeds {self._max_bytes} bytes",
41
+ )
42
+ result = FetchResult(
43
+ url=url,
44
+ final_url=str(response.url),
45
+ status_code=response.status_code,
46
+ content_type=response.headers.get(
47
+ "content-type", "application/octet-stream"
48
+ ),
49
+ charset=response.charset_encoding,
50
+ body=bytes(body),
51
+ )
52
+ except httpx.HTTPError as exc:
53
+ raise FetchFailedError(url=url, detail=repr(exc)) from exc
54
+ except ValidationError as exc:
55
+ raise FetchFailedError(url=url, detail=str(exc)) from exc
56
+ return result
@@ -0,0 +1,32 @@
1
+ """PDF text extraction via pypdf (pure Python, BSD)."""
2
+
3
+ import asyncio
4
+ import io
5
+
6
+ from pypdf import PdfReader
7
+
8
+ from refindery.domain.models import ExtractedContent
9
+
10
+
11
+ class PypdfExtractor:
12
+ """ContentExtractor for application/pdf."""
13
+
14
+ @property
15
+ def content_types(self) -> frozenset[str]:
16
+ """Handled content types."""
17
+ return frozenset({"application/pdf"})
18
+
19
+ async def extract(self, *, raw: bytes, charset: str | None) -> ExtractedContent: # noqa: ARG002 — port signature; PDFs carry their own encoding
20
+ """Extract text from all pages; title from PDF metadata when present."""
21
+
22
+ def _extract() -> ExtractedContent:
23
+ reader = PdfReader(io.BytesIO(raw))
24
+ pages = [page.extract_text() or "" for page in reader.pages]
25
+ title = None
26
+ if reader.metadata is not None and reader.metadata.title:
27
+ title = str(reader.metadata.title)
28
+ return ExtractedContent(
29
+ body_text="\n\n".join(p for p in pages if p.strip()), title=title
30
+ )
31
+
32
+ return await asyncio.to_thread(_extract)
@@ -0,0 +1,82 @@
1
+ """HTML -> markdown extraction via the pulpie model (transformers).
2
+
3
+ Requires the ``html`` extra (torch, ~2 GB): without it this extractor
4
+ reports itself unavailable and ingest of ``body_html`` / fetched HTML fails
5
+ with a clear install hint. Inference runs in a ProcessPoolExecutor with the
6
+ model loaded once per worker (spawn context; the model object never crosses
7
+ the process boundary).
8
+ """
9
+
10
+ import asyncio
11
+ from collections.abc import Callable
12
+ from concurrent.futures import ProcessPoolExecutor
13
+ from importlib import util as importlib_util
14
+ from multiprocessing import get_context
15
+
16
+ from refindery.domain.errors import ExtractionUnavailableError
17
+ from refindery.domain.models import ExtractedContent
18
+
19
+ MODEL_NAME = "feyninc/pulpie-orange-small"
20
+ _MAX_HTML_CHARS = 500_000
21
+
22
+ _pipeline: Callable[[str], object] | None = None
23
+
24
+
25
+ def _load_pipeline() -> Callable[[str], object]:
26
+ """Load the pulpie pipeline once per worker process."""
27
+ global _pipeline # noqa: PLW0603 — per-process model cache
28
+ if _pipeline is None:
29
+ from transformers import pipeline # noqa: PLC0415 — worker-only import
30
+
31
+ _pipeline = pipeline(model=MODEL_NAME) # type: ignore[call-overload] # ty: ignore[no-matching-overload] # pyrefly: ignore
32
+ return _pipeline
33
+
34
+
35
+ def _extract_markdown(html: str) -> str:
36
+ """Worker-side: run the model over raw HTML, return markdown."""
37
+ pipe = _load_pipeline()
38
+ result = pipe(html[:_MAX_HTML_CHARS])
39
+ if isinstance(result, str):
40
+ return result
41
+ if isinstance(result, list) and result and isinstance(result[0], dict):
42
+ first = dict(result[0])
43
+ for key in ("generated_text", "summary_text"):
44
+ if isinstance(value := first.get(key), str):
45
+ return value
46
+ msg = f"unexpected pulpie pipeline output shape: {type(result)!r}"
47
+ raise RuntimeError(msg)
48
+
49
+
50
+ def torch_available() -> bool:
51
+ """Whether the html extra (torch) is installed."""
52
+ return importlib_util.find_spec("torch") is not None
53
+
54
+
55
+ class PulpieHtmlExtractor:
56
+ """ContentExtractor for text/html via pulpie."""
57
+
58
+ def __init__(self, *, max_workers: int = 1) -> None:
59
+ if not torch_available():
60
+ raise ExtractionUnavailableError(content_type="text/html", extra="html")
61
+ self._executor = ProcessPoolExecutor(
62
+ max_workers=max_workers, mp_context=get_context("spawn")
63
+ )
64
+
65
+ @property
66
+ def content_types(self) -> frozenset[str]:
67
+ """Handled content types."""
68
+ return frozenset({"text/html", "application/xhtml+xml"})
69
+
70
+ async def extract(self, *, raw: bytes, charset: str | None) -> ExtractedContent:
71
+ """Decode and extract markdown body text from HTML."""
72
+ try:
73
+ html = raw.decode(charset or "utf-8", errors="replace")
74
+ except LookupError:
75
+ html = raw.decode("utf-8", errors="replace")
76
+ loop = asyncio.get_running_loop()
77
+ markdown = await loop.run_in_executor(self._executor, _extract_markdown, html)
78
+ return ExtractedContent(body_text=markdown)
79
+
80
+ def close(self) -> None:
81
+ """Shut the worker pool down."""
82
+ self._executor.shutdown(wait=False, cancel_futures=True)