nlweb-widget 0.9.12__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.
- nlweb_widget/__init__.py +43 -0
- nlweb_widget/cli.py +18 -0
- nlweb_widget/content/__init__.py +52 -0
- nlweb_widget/content/adapters.py +335 -0
- nlweb_widget/content/fingerprint.py +20 -0
- nlweb_widget/content/haystack.py +105 -0
- nlweb_widget/content/models.py +72 -0
- nlweb_widget/content/neo4j.py +236 -0
- nlweb_widget/content/qdrant.py +154 -0
- nlweb_widget/content/qdrant_edge.py +202 -0
- nlweb_widget/content/retrieval.py +31 -0
- nlweb_widget/content/scheduler.py +29 -0
- nlweb_widget/content/service.py +239 -0
- nlweb_widget/content/sqlite.py +404 -0
- nlweb_widget/content/state.py +35 -0
- nlweb_widget/content/store.py +179 -0
- nlweb_widget/embedding_benchmark.py +86 -0
- nlweb_widget/embeddings.py +61 -0
- nlweb_widget/errors.py +25 -0
- nlweb_widget/integrations/__init__.py +13 -0
- nlweb_widget/integrations/_bridge.py +10 -0
- nlweb_widget/integrations/django_integration.py +46 -0
- nlweb_widget/integrations/fastapi_integration.py +143 -0
- nlweb_widget/integrations/flask_integration.py +64 -0
- nlweb_widget/integrations/starlette_integration.py +45 -0
- nlweb_widget/integrations/stdlib_http_integration.py +93 -0
- nlweb_widget/models.py +219 -0
- nlweb_widget/protocol.py +196 -0
- nlweb_widget/retrieval.py +50 -0
- nlweb_widget/settings.py +109 -0
- nlweb_widget/widget.py +460 -0
- nlweb_widget-0.9.12.dist-info/METADATA +212 -0
- nlweb_widget-0.9.12.dist-info/RECORD +36 -0
- nlweb_widget-0.9.12.dist-info/WHEEL +4 -0
- nlweb_widget-0.9.12.dist-info/entry_points.txt +2 -0
- nlweb_widget-0.9.12.dist-info/licenses/LICENSE +21 -0
nlweb_widget/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from .content import (
|
|
2
|
+
ContentSource,
|
|
3
|
+
ContentSourceKind,
|
|
4
|
+
ContentSyncService,
|
|
5
|
+
HaystackInMemoryRetrievalStore,
|
|
6
|
+
Neo4jKnowledgeGraphStore,
|
|
7
|
+
PgVectorStore,
|
|
8
|
+
QdrantEdgeRetrievalStore,
|
|
9
|
+
QdrantRetrievalStore,
|
|
10
|
+
SQLiteVecRetrievalStore,
|
|
11
|
+
)
|
|
12
|
+
from .embeddings import SentenceTransformersEmbeddingProvider
|
|
13
|
+
from .models import (
|
|
14
|
+
ContentDocument,
|
|
15
|
+
DiscoveryDocument,
|
|
16
|
+
MCPRequest,
|
|
17
|
+
NLWebRequest,
|
|
18
|
+
NLWebResponse,
|
|
19
|
+
SearchResult,
|
|
20
|
+
)
|
|
21
|
+
from .widget import NLWebWidget
|
|
22
|
+
|
|
23
|
+
__version__ = "0.9.12.dev1"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ContentDocument",
|
|
27
|
+
"ContentSource",
|
|
28
|
+
"ContentSourceKind",
|
|
29
|
+
"ContentSyncService",
|
|
30
|
+
"DiscoveryDocument",
|
|
31
|
+
"HaystackInMemoryRetrievalStore",
|
|
32
|
+
"MCPRequest",
|
|
33
|
+
"NLWebRequest",
|
|
34
|
+
"NLWebResponse",
|
|
35
|
+
"NLWebWidget",
|
|
36
|
+
"Neo4jKnowledgeGraphStore",
|
|
37
|
+
"PgVectorStore",
|
|
38
|
+
"QdrantEdgeRetrievalStore",
|
|
39
|
+
"QdrantRetrievalStore",
|
|
40
|
+
"SQLiteVecRetrievalStore",
|
|
41
|
+
"SearchResult",
|
|
42
|
+
"SentenceTransformersEmbeddingProvider",
|
|
43
|
+
]
|
nlweb_widget/cli.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Command-line entry point for basic package diagnostics."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
"""Run the package command-line interface."""
|
|
8
|
+
parser = argparse.ArgumentParser(prog="nlweb-widget")
|
|
9
|
+
parser.add_argument(
|
|
10
|
+
"--version", action="store_true", help="show the package version"
|
|
11
|
+
)
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
if args.version:
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
print(__version__)
|
|
17
|
+
else:
|
|
18
|
+
parser.print_help()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .adapters import (
|
|
2
|
+
CallableAdapter,
|
|
3
|
+
ContentAdapter,
|
|
4
|
+
DatabaseAdapter,
|
|
5
|
+
DocumentAdapter,
|
|
6
|
+
RSSAtomAdapter,
|
|
7
|
+
SchemaJSONAdapter,
|
|
8
|
+
SitemapAdapter,
|
|
9
|
+
WebPageSchemaAdapter,
|
|
10
|
+
)
|
|
11
|
+
from .haystack import HaystackInMemoryRetrievalStore
|
|
12
|
+
from .models import (
|
|
13
|
+
ContentSource,
|
|
14
|
+
ContentSourceKind,
|
|
15
|
+
SourceState,
|
|
16
|
+
SyncResult,
|
|
17
|
+
SyncSummary,
|
|
18
|
+
)
|
|
19
|
+
from .neo4j import Neo4jKnowledgeGraphStore
|
|
20
|
+
from .qdrant import QdrantRetrievalStore
|
|
21
|
+
from .qdrant_edge import QdrantEdgeRetrievalStore
|
|
22
|
+
from .retrieval import EmbeddingProvider, RetrievalProvider
|
|
23
|
+
from .service import ContentSyncService
|
|
24
|
+
from .sqlite import SQLiteVecRetrievalStore
|
|
25
|
+
from .state import StateStore
|
|
26
|
+
from .store import PgVectorStore
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"CallableAdapter",
|
|
30
|
+
"ContentAdapter",
|
|
31
|
+
"ContentSource",
|
|
32
|
+
"ContentSourceKind",
|
|
33
|
+
"ContentSyncService",
|
|
34
|
+
"DatabaseAdapter",
|
|
35
|
+
"DocumentAdapter",
|
|
36
|
+
"EmbeddingProvider",
|
|
37
|
+
"HaystackInMemoryRetrievalStore",
|
|
38
|
+
"Neo4jKnowledgeGraphStore",
|
|
39
|
+
"PgVectorStore",
|
|
40
|
+
"QdrantEdgeRetrievalStore",
|
|
41
|
+
"QdrantRetrievalStore",
|
|
42
|
+
"RSSAtomAdapter",
|
|
43
|
+
"RetrievalProvider",
|
|
44
|
+
"SQLiteVecRetrievalStore",
|
|
45
|
+
"SchemaJSONAdapter",
|
|
46
|
+
"SitemapAdapter",
|
|
47
|
+
"SourceState",
|
|
48
|
+
"StateStore",
|
|
49
|
+
"SyncResult",
|
|
50
|
+
"SyncSummary",
|
|
51
|
+
"WebPageSchemaAdapter",
|
|
52
|
+
]
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import urllib.request
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from ..errors import OptionalDependencyError
|
|
11
|
+
from ..models import ContentDocument
|
|
12
|
+
from .models import ContentSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContentAdapter(Protocol):
|
|
16
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SchemaJSONAdapter:
|
|
20
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
21
|
+
raw = await _read_location(source.location)
|
|
22
|
+
payload = json.loads(raw)
|
|
23
|
+
objects = _objects(payload)
|
|
24
|
+
return [
|
|
25
|
+
_schema_document(item, source, index) for index, item in enumerate(objects)
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RSSAtomAdapter:
|
|
30
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
31
|
+
root = ET.fromstring(await _read_location(source.location))
|
|
32
|
+
is_atom = root.tag.lower().endswith("feed")
|
|
33
|
+
entries = root.findall(".//{*}entry") if is_atom else root.findall(".//item")
|
|
34
|
+
documents: list[ContentDocument] = []
|
|
35
|
+
for index, entry in enumerate(entries):
|
|
36
|
+
title = _xml_text(entry, "title") or f"entry-{index}"
|
|
37
|
+
url = _xml_text(entry, "link")
|
|
38
|
+
link = entry.find("{*}link")
|
|
39
|
+
if link is not None:
|
|
40
|
+
url = url or link.attrib.get("href", "")
|
|
41
|
+
url = url or f"{source.location}#{index}"
|
|
42
|
+
text = _xml_text(entry, "description", "summary", "content")
|
|
43
|
+
documents.append(
|
|
44
|
+
ContentDocument(
|
|
45
|
+
id=url,
|
|
46
|
+
url=url,
|
|
47
|
+
title=title,
|
|
48
|
+
content=f"{title}\n{text}".strip(),
|
|
49
|
+
site=source.site,
|
|
50
|
+
metadata={"source": source.name},
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return documents
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SitemapAdapter:
|
|
57
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
58
|
+
root = ET.fromstring(await _read_location(source.location))
|
|
59
|
+
return [
|
|
60
|
+
ContentDocument(
|
|
61
|
+
id=url,
|
|
62
|
+
url=url,
|
|
63
|
+
title=url,
|
|
64
|
+
content=url,
|
|
65
|
+
site=source.site,
|
|
66
|
+
metadata={"source": source.name},
|
|
67
|
+
)
|
|
68
|
+
for node in root.findall(".//{*}loc")
|
|
69
|
+
if (url := (node.text or "").strip())
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class WebPageSchemaAdapter:
|
|
74
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
75
|
+
parser = _SchemaParser()
|
|
76
|
+
parser.feed(await _read_location(source.location))
|
|
77
|
+
parser.close()
|
|
78
|
+
return [
|
|
79
|
+
document.model_copy(update={"site": source.site})
|
|
80
|
+
for document in parser.documents
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CallableAdapter:
|
|
85
|
+
def __init__(self, provider: Callable[[ContentSource], Any]) -> None:
|
|
86
|
+
self._provider = provider
|
|
87
|
+
|
|
88
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
89
|
+
result = self._provider(source)
|
|
90
|
+
result = await result if inspect.isawaitable(result) else result
|
|
91
|
+
return [_coerce_document(item, source) for item in result]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DocumentAdapter:
|
|
95
|
+
"""Optional Docling-backed adapter for common unstructured documents."""
|
|
96
|
+
|
|
97
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
98
|
+
import asyncio
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
import docling.document_converter # noqa: F401
|
|
103
|
+
except ImportError as exc:
|
|
104
|
+
raise OptionalDependencyError(
|
|
105
|
+
"Document ingestion requires the 'documents' extra (Docling)"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
def convert() -> ContentDocument:
|
|
109
|
+
# Keep the optional Docling path deterministic on CPU-only hosts.
|
|
110
|
+
# PDF layout/table/OCR inference can otherwise invoke Torch/RT-DETR
|
|
111
|
+
# even when DOCLING_DEVICE=cpu; the backend text path is sufficient
|
|
112
|
+
# for authoritative text projection and remains overrideable.
|
|
113
|
+
converter = _build_docling_converter()
|
|
114
|
+
result = converter.convert(source.location)
|
|
115
|
+
document = result.document
|
|
116
|
+
markdown = document.export_to_markdown()
|
|
117
|
+
title = Path(source.location).stem or source.name
|
|
118
|
+
return ContentDocument(
|
|
119
|
+
id=f"{source.name}:{source.location}",
|
|
120
|
+
url=source.location,
|
|
121
|
+
title=title,
|
|
122
|
+
content=markdown,
|
|
123
|
+
site=source.site,
|
|
124
|
+
metadata={"source": source.name, "parser": "docling"},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return [await asyncio.to_thread(convert)]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_docling_converter() -> Any:
|
|
131
|
+
"""Build a Docling converter with environment-driven, CPU-safe PDF options."""
|
|
132
|
+
import os
|
|
133
|
+
|
|
134
|
+
from docling.document_converter import DocumentConverter
|
|
135
|
+
|
|
136
|
+
device = os.getenv("DOCLING_DEVICE", "auto")
|
|
137
|
+
threads = max(
|
|
138
|
+
1, int(os.getenv("DOCLING_NUM_THREADS", os.getenv("OMP_NUM_THREADS", "4")))
|
|
139
|
+
)
|
|
140
|
+
safe_pdf = os.getenv("DOCLING_SAFE_PDF", "false").strip().lower() in {
|
|
141
|
+
"1",
|
|
142
|
+
"true",
|
|
143
|
+
"yes",
|
|
144
|
+
"on",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if not safe_pdf:
|
|
148
|
+
return DocumentConverter()
|
|
149
|
+
|
|
150
|
+
from docling.datamodel.base_models import InputFormat
|
|
151
|
+
from docling.datamodel.pipeline_options import (
|
|
152
|
+
AcceleratorOptions,
|
|
153
|
+
PdfPipelineOptions,
|
|
154
|
+
)
|
|
155
|
+
from docling.document_converter import PdfFormatOption
|
|
156
|
+
|
|
157
|
+
pdf_options = PdfPipelineOptions(
|
|
158
|
+
accelerator_options=AcceleratorOptions(device=device, num_threads=threads),
|
|
159
|
+
do_ocr=False,
|
|
160
|
+
do_table_structure=False,
|
|
161
|
+
force_backend_text=True,
|
|
162
|
+
)
|
|
163
|
+
return DocumentConverter(
|
|
164
|
+
format_options={
|
|
165
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_options),
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DatabaseAdapter:
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
provider: Callable[[ContentSource], Awaitable[Iterable[dict[str, Any]]]],
|
|
174
|
+
) -> None:
|
|
175
|
+
self._provider = provider
|
|
176
|
+
|
|
177
|
+
async def read(self, source: ContentSource) -> list[ContentDocument]:
|
|
178
|
+
return [_coerce_document(item, source) for item in await self._provider(source)]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _objects(payload: Any) -> list[dict[str, Any]]:
|
|
182
|
+
if isinstance(payload, list):
|
|
183
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
184
|
+
if isinstance(payload, dict):
|
|
185
|
+
graph = payload.get("@graph")
|
|
186
|
+
if isinstance(graph, list):
|
|
187
|
+
return [item for item in graph if isinstance(item, dict)]
|
|
188
|
+
return [payload]
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _schema_document(
|
|
193
|
+
item: dict[str, Any], source: ContentSource, index: int
|
|
194
|
+
) -> ContentDocument:
|
|
195
|
+
nested_schema = item.get("schema_object")
|
|
196
|
+
schema = nested_schema if isinstance(nested_schema, dict) else item
|
|
197
|
+
url = str(
|
|
198
|
+
item.get("url")
|
|
199
|
+
or schema.get("url")
|
|
200
|
+
or item.get("@id")
|
|
201
|
+
or schema.get("@id")
|
|
202
|
+
or f"{source.location}#{index}"
|
|
203
|
+
)
|
|
204
|
+
title = str(
|
|
205
|
+
item.get("name")
|
|
206
|
+
or item.get("title")
|
|
207
|
+
or schema.get("name")
|
|
208
|
+
or schema.get("headline")
|
|
209
|
+
or url
|
|
210
|
+
)
|
|
211
|
+
content = str(
|
|
212
|
+
item.get("content") or item.get("description") or _flatten_schema(schema)
|
|
213
|
+
)
|
|
214
|
+
return ContentDocument(
|
|
215
|
+
id=str(item.get("id") or url),
|
|
216
|
+
url=url,
|
|
217
|
+
title=title,
|
|
218
|
+
content=content,
|
|
219
|
+
schema_object=schema,
|
|
220
|
+
site=source.site,
|
|
221
|
+
metadata={"source": source.name},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _coerce_document(item: Any, source: ContentSource) -> ContentDocument:
|
|
226
|
+
if isinstance(item, ContentDocument):
|
|
227
|
+
return item.model_copy(update={"site": item.site or source.site})
|
|
228
|
+
if not isinstance(item, dict):
|
|
229
|
+
raise TypeError("Content adapters must return mappings or ContentDocument")
|
|
230
|
+
identifier = str(
|
|
231
|
+
item.get("id") or item.get("url") or f"{source.name}:{hash(str(item))}"
|
|
232
|
+
)
|
|
233
|
+
title = str(item.get("title") or item.get("name") or identifier)
|
|
234
|
+
content = str(
|
|
235
|
+
item.get("content") or item.get("text") or item.get("description") or title
|
|
236
|
+
)
|
|
237
|
+
schema_object = item.get("schema_object")
|
|
238
|
+
return ContentDocument(
|
|
239
|
+
id=identifier,
|
|
240
|
+
url=str(item.get("url") or identifier),
|
|
241
|
+
title=title,
|
|
242
|
+
content=content,
|
|
243
|
+
schema_object=schema_object if isinstance(schema_object, dict) else {},
|
|
244
|
+
site=str(item.get("site") or source.site),
|
|
245
|
+
metadata={
|
|
246
|
+
key: value
|
|
247
|
+
for key, value in item.items()
|
|
248
|
+
if key
|
|
249
|
+
not in {
|
|
250
|
+
"id",
|
|
251
|
+
"url",
|
|
252
|
+
"title",
|
|
253
|
+
"name",
|
|
254
|
+
"content",
|
|
255
|
+
"text",
|
|
256
|
+
"description",
|
|
257
|
+
"schema_object",
|
|
258
|
+
"site",
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _flatten_schema(value: Any) -> str:
|
|
265
|
+
if isinstance(value, dict):
|
|
266
|
+
return " ".join(
|
|
267
|
+
f"{key}: {_flatten_schema(item)}"
|
|
268
|
+
for key, item in value.items()
|
|
269
|
+
if key != "@context"
|
|
270
|
+
)
|
|
271
|
+
if isinstance(value, list):
|
|
272
|
+
return " ".join(_flatten_schema(item) for item in value)
|
|
273
|
+
return str(value)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _xml_text(element: ET.Element, *names: str) -> str:
|
|
277
|
+
for name in names:
|
|
278
|
+
node = element.find(name) or element.find(f"{{*}}{name}")
|
|
279
|
+
if node is not None and node.text:
|
|
280
|
+
return node.text.strip()
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _read_location(location: str) -> str:
|
|
285
|
+
import asyncio
|
|
286
|
+
|
|
287
|
+
if re.match(r"^https?://", location, re.IGNORECASE):
|
|
288
|
+
return await asyncio.to_thread(_read_url, location)
|
|
289
|
+
return await asyncio.to_thread(_read_file, location)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _read_file(path: str) -> str:
|
|
293
|
+
with open(path, encoding="utf-8") as stream:
|
|
294
|
+
return stream.read()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _read_url(url: str) -> str:
|
|
298
|
+
with urllib.request.urlopen(url, timeout=20) as response:
|
|
299
|
+
return response.read().decode("utf-8", errors="replace")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _SchemaParser(HTMLParser):
|
|
303
|
+
def __init__(self) -> None:
|
|
304
|
+
super().__init__()
|
|
305
|
+
self.documents: list[ContentDocument] = []
|
|
306
|
+
self._jsonld: list[str] = []
|
|
307
|
+
self._in_jsonld = False
|
|
308
|
+
|
|
309
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
310
|
+
if tag.lower() != "script":
|
|
311
|
+
return
|
|
312
|
+
attributes = dict(attrs)
|
|
313
|
+
self._in_jsonld = attributes.get("type", "").lower() == "application/ld+json"
|
|
314
|
+
|
|
315
|
+
def handle_data(self, data: str) -> None:
|
|
316
|
+
if self._in_jsonld:
|
|
317
|
+
self._jsonld.append(data)
|
|
318
|
+
|
|
319
|
+
def handle_endtag(self, tag: str) -> None:
|
|
320
|
+
if tag.lower() == "script":
|
|
321
|
+
self._in_jsonld = False
|
|
322
|
+
|
|
323
|
+
def close(self) -> None:
|
|
324
|
+
super().close()
|
|
325
|
+
for raw in self._jsonld:
|
|
326
|
+
try:
|
|
327
|
+
payload = json.loads(raw)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
continue
|
|
330
|
+
for index, item in enumerate(_objects(payload)):
|
|
331
|
+
if item.get("url") or item.get("@id"):
|
|
332
|
+
source = ContentSource(
|
|
333
|
+
name="webpage", kind="webpage_schema", location="webpage"
|
|
334
|
+
)
|
|
335
|
+
self.documents.append(_schema_document(item, source, index))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import urllib.request
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .models import ContentSource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def fingerprint_source(source: ContentSource, *, timeout: float = 15.0) -> str:
|
|
10
|
+
value = await asyncio.wait_for(
|
|
11
|
+
asyncio.to_thread(_read_source, source.location, timeout), timeout=timeout
|
|
12
|
+
)
|
|
13
|
+
return hashlib.sha256(value).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_source(location: str, timeout: float) -> bytes:
|
|
17
|
+
if location.startswith(("http://", "https://")):
|
|
18
|
+
with urllib.request.urlopen(location, timeout=timeout) as response:
|
|
19
|
+
return response.read()
|
|
20
|
+
return Path(location).read_bytes()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from ..models import ContentDocument
|
|
5
|
+
from .retrieval import EmbeddingProvider, embed_query
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HaystackInMemoryRetrievalStore:
|
|
9
|
+
"""Optional Haystack-backed in-memory semantic retrieval provider.
|
|
10
|
+
|
|
11
|
+
Haystack remains behind this package's RetrievalProvider boundary so callers
|
|
12
|
+
do not depend on Haystack internals. The injected embedding provider owns
|
|
13
|
+
model selection and vector generation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, embedding_provider: EmbeddingProvider) -> None:
|
|
17
|
+
try:
|
|
18
|
+
from haystack import Document
|
|
19
|
+
from haystack.components.retrievers.in_memory import (
|
|
20
|
+
InMemoryEmbeddingRetriever,
|
|
21
|
+
)
|
|
22
|
+
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"Haystack support requires the 'haystack' extra"
|
|
26
|
+
) from exc
|
|
27
|
+
self._Document = Document
|
|
28
|
+
self._store = InMemoryDocumentStore(embedding_similarity_function="cosine")
|
|
29
|
+
self._retriever = InMemoryEmbeddingRetriever(self._store)
|
|
30
|
+
self._embedder = embedding_provider
|
|
31
|
+
|
|
32
|
+
async def initialize(self) -> None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
async def upsert(
|
|
36
|
+
self, documents: Sequence[ContentDocument] | ContentDocument
|
|
37
|
+
) -> int:
|
|
38
|
+
items = (
|
|
39
|
+
[documents] if isinstance(documents, ContentDocument) else list(documents)
|
|
40
|
+
)
|
|
41
|
+
vectors = await self._embedder.embed([document.content for document in items])
|
|
42
|
+
haystack_documents = [
|
|
43
|
+
self._Document(
|
|
44
|
+
id=document.id,
|
|
45
|
+
content=document.content,
|
|
46
|
+
embedding=vector,
|
|
47
|
+
meta={
|
|
48
|
+
"url": str(document.url),
|
|
49
|
+
"title": document.title,
|
|
50
|
+
"site": document.site or "default",
|
|
51
|
+
"schema_object": document.schema_object,
|
|
52
|
+
"metadata": document.metadata,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
for document, vector in zip(items, vectors, strict=True)
|
|
56
|
+
]
|
|
57
|
+
self._store.write_documents(haystack_documents)
|
|
58
|
+
return len(items)
|
|
59
|
+
|
|
60
|
+
async def delete(self, ids: Sequence[str]) -> int:
|
|
61
|
+
existing = [
|
|
62
|
+
item.id
|
|
63
|
+
for item in self._store.filter_documents(
|
|
64
|
+
{"field": "id", "operator": "in", "value": list(ids)}
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
if existing:
|
|
68
|
+
self._store.delete_documents(existing)
|
|
69
|
+
return len(existing)
|
|
70
|
+
|
|
71
|
+
async def search(
|
|
72
|
+
self,
|
|
73
|
+
query: str,
|
|
74
|
+
*,
|
|
75
|
+
limit: int = 10,
|
|
76
|
+
site: str | None = None,
|
|
77
|
+
) -> list[tuple[ContentDocument, float]]:
|
|
78
|
+
vector = await embed_query(self._embedder, query)
|
|
79
|
+
filters: dict[str, Any] | None = None
|
|
80
|
+
if site:
|
|
81
|
+
filters = {"field": "meta.site", "operator": "==", "value": site}
|
|
82
|
+
result = self._retriever.run(
|
|
83
|
+
query_embedding=vector, top_k=limit, filters=filters
|
|
84
|
+
)
|
|
85
|
+
return [
|
|
86
|
+
(
|
|
87
|
+
ContentDocument(
|
|
88
|
+
id=document.id,
|
|
89
|
+
url=document.meta.get("url", document.id),
|
|
90
|
+
title=document.meta.get("title", document.id),
|
|
91
|
+
content=document.content or "",
|
|
92
|
+
schema_object=document.meta.get("schema_object", {}),
|
|
93
|
+
site=document.meta.get("site"),
|
|
94
|
+
metadata=document.meta.get("metadata", {}),
|
|
95
|
+
),
|
|
96
|
+
float(document.score or 0.0),
|
|
97
|
+
)
|
|
98
|
+
for document in result["documents"]
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
async def health(self) -> dict[str, object]:
|
|
102
|
+
return {"backend": "haystack-inmemory", "status": "ok"}
|
|
103
|
+
|
|
104
|
+
async def close(self) -> None:
|
|
105
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ContentSourceKind(str, Enum):
|
|
9
|
+
SCHEMA_JSON = "schema_json"
|
|
10
|
+
WEBPAGE_SCHEMA = "webpage_schema"
|
|
11
|
+
RSS = "rss"
|
|
12
|
+
ATOM = "atom"
|
|
13
|
+
SITEMAP = "sitemap"
|
|
14
|
+
DATABASE = "database"
|
|
15
|
+
CUSTOM = "custom"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContentSource(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="allow")
|
|
20
|
+
|
|
21
|
+
name: str = Field(min_length=1, max_length=200)
|
|
22
|
+
kind: ContentSourceKind
|
|
23
|
+
location: str = Field(min_length=1)
|
|
24
|
+
site: str = "default"
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
replace_site: bool = False
|
|
27
|
+
batch_size: int = Field(default=100, ge=1, le=10_000)
|
|
28
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SourceState(BaseModel):
|
|
32
|
+
source: str
|
|
33
|
+
fingerprint: str | None = None
|
|
34
|
+
item_keys: set[str] = Field(default_factory=set)
|
|
35
|
+
last_success: datetime | None = None
|
|
36
|
+
last_attempt: datetime | None = None
|
|
37
|
+
last_status: str | None = None
|
|
38
|
+
last_error: str | None = None
|
|
39
|
+
version: int = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SyncResult(BaseModel):
|
|
43
|
+
source: str
|
|
44
|
+
site: str
|
|
45
|
+
kind: ContentSourceKind
|
|
46
|
+
status: str
|
|
47
|
+
changed: bool = False
|
|
48
|
+
skipped: bool = False
|
|
49
|
+
total_loaded: int = 0
|
|
50
|
+
deleted_count: int = 0
|
|
51
|
+
message: str | None = None
|
|
52
|
+
attempts: int = 1
|
|
53
|
+
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
54
|
+
finished_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SyncSummary(BaseModel):
|
|
58
|
+
started_at: datetime
|
|
59
|
+
finished_at: datetime
|
|
60
|
+
results: list[SyncResult] = Field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def succeeded(self) -> int:
|
|
64
|
+
return sum(result.status == "ok" for result in self.results)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def failed(self) -> int:
|
|
68
|
+
return sum(result.status == "error" for result in self.results)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def skipped(self) -> int:
|
|
72
|
+
return sum(result.skipped for result in self.results)
|