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.
Files changed (36) hide show
  1. nlweb_widget/__init__.py +43 -0
  2. nlweb_widget/cli.py +18 -0
  3. nlweb_widget/content/__init__.py +52 -0
  4. nlweb_widget/content/adapters.py +335 -0
  5. nlweb_widget/content/fingerprint.py +20 -0
  6. nlweb_widget/content/haystack.py +105 -0
  7. nlweb_widget/content/models.py +72 -0
  8. nlweb_widget/content/neo4j.py +236 -0
  9. nlweb_widget/content/qdrant.py +154 -0
  10. nlweb_widget/content/qdrant_edge.py +202 -0
  11. nlweb_widget/content/retrieval.py +31 -0
  12. nlweb_widget/content/scheduler.py +29 -0
  13. nlweb_widget/content/service.py +239 -0
  14. nlweb_widget/content/sqlite.py +404 -0
  15. nlweb_widget/content/state.py +35 -0
  16. nlweb_widget/content/store.py +179 -0
  17. nlweb_widget/embedding_benchmark.py +86 -0
  18. nlweb_widget/embeddings.py +61 -0
  19. nlweb_widget/errors.py +25 -0
  20. nlweb_widget/integrations/__init__.py +13 -0
  21. nlweb_widget/integrations/_bridge.py +10 -0
  22. nlweb_widget/integrations/django_integration.py +46 -0
  23. nlweb_widget/integrations/fastapi_integration.py +143 -0
  24. nlweb_widget/integrations/flask_integration.py +64 -0
  25. nlweb_widget/integrations/starlette_integration.py +45 -0
  26. nlweb_widget/integrations/stdlib_http_integration.py +93 -0
  27. nlweb_widget/models.py +219 -0
  28. nlweb_widget/protocol.py +196 -0
  29. nlweb_widget/retrieval.py +50 -0
  30. nlweb_widget/settings.py +109 -0
  31. nlweb_widget/widget.py +460 -0
  32. nlweb_widget-0.9.12.dist-info/METADATA +212 -0
  33. nlweb_widget-0.9.12.dist-info/RECORD +36 -0
  34. nlweb_widget-0.9.12.dist-info/WHEEL +4 -0
  35. nlweb_widget-0.9.12.dist-info/entry_points.txt +2 -0
  36. nlweb_widget-0.9.12.dist-info/licenses/LICENSE +21 -0
@@ -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)