ingester: add source-adapter scaffolding + FS source
This commit is contained in:
parent
2d199037b2
commit
d0a730ef60
9 changed files with 462 additions and 62 deletions
0
haiku_rag_slim/haiku/rag/ingester/__init__.py
Normal file
0
haiku_rag_slim/haiku/rag/ingester/__init__.py
Normal file
19
haiku_rag_slim/haiku/rag/ingester/sources/__init__.py
Normal file
19
haiku_rag_slim/haiku/rag/ingester/sources/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from haiku.rag.ingester.sources.base import (
|
||||||
|
FetchResult,
|
||||||
|
RevisionSnapshot,
|
||||||
|
Source,
|
||||||
|
SourceEvent,
|
||||||
|
SourceEventKind,
|
||||||
|
)
|
||||||
|
from haiku.rag.ingester.sources.filter import FileFilter
|
||||||
|
from haiku.rag.ingester.sources.fs import FSSource
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FetchResult",
|
||||||
|
"FileFilter",
|
||||||
|
"FSSource",
|
||||||
|
"RevisionSnapshot",
|
||||||
|
"Source",
|
||||||
|
"SourceEvent",
|
||||||
|
"SourceEventKind",
|
||||||
|
]
|
||||||
54
haiku_rag_slim/haiku/rag/ingester/sources/base.py
Normal file
54
haiku_rag_slim/haiku/rag/ingester/sources/base.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from collections.abc import AsyncIterator, Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# uri -> revision. Captures what revisions of which URIs we had last seen
|
||||||
|
# for a given source. Passed to discover() so the source can yield only
|
||||||
|
# UPSERT / UNCHANGED / DELETE deltas instead of a full re-scan.
|
||||||
|
RevisionSnapshot = Mapping[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceEventKind(StrEnum):
|
||||||
|
UPSERT = "upsert"
|
||||||
|
DELETE = "delete"
|
||||||
|
# Emitted for resources whose revision matches the snapshot. The poller
|
||||||
|
# uses these to bump last_seen_at without enqueueing work.
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceEvent(BaseModel):
|
||||||
|
source_id: str
|
||||||
|
uri: str
|
||||||
|
kind: SourceEventKind
|
||||||
|
# Backend's own change indicator (mtime for FS, ETag for HTTP/S3, etc.).
|
||||||
|
# Opaque to consumers — only compared, never parsed. None for DELETE.
|
||||||
|
revision: str | None = None
|
||||||
|
discovered_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FetchResult(BaseModel):
|
||||||
|
uri: str
|
||||||
|
body: bytes
|
||||||
|
content_type: str
|
||||||
|
# MD5 of body. Stored in document metadata as the dedup key — lets the
|
||||||
|
# pipeline short-circuit when bytes are identical but the revision differs
|
||||||
|
# (e.g. S3 multipart re-upload landing a new ETag on the same content).
|
||||||
|
content_hash: str
|
||||||
|
revision: str | None = None
|
||||||
|
extra_metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Source(Protocol):
|
||||||
|
source_id: str
|
||||||
|
|
||||||
|
def supports(self, uri: str) -> bool: ...
|
||||||
|
|
||||||
|
async def fetch(self, uri: str) -> FetchResult: ...
|
||||||
|
|
||||||
|
def discover(
|
||||||
|
self, since: RevisionSnapshot | None = None
|
||||||
|
) -> AsyncIterator[SourceEvent]: ...
|
||||||
51
haiku_rag_slim/haiku/rag/ingester/sources/filter.py
Normal file
51
haiku_rag_slim/haiku/rag/ingester/sources/filter.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import pathspec
|
||||||
|
from watchfiles import Change, DefaultFilter
|
||||||
|
|
||||||
|
|
||||||
|
def _default_supported_extensions() -> list[str]:
|
||||||
|
from haiku.rag.converters.docling_local import DoclingLocalConverter
|
||||||
|
from haiku.rag.converters.text_utils import TextFileHandler
|
||||||
|
|
||||||
|
return DoclingLocalConverter.docling_extensions + TextFileHandler.text_extensions
|
||||||
|
|
||||||
|
|
||||||
|
class FileFilter(DefaultFilter):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ignore_patterns: list[str] | None = None,
|
||||||
|
include_patterns: list[str] | None = None,
|
||||||
|
supported_extensions: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if supported_extensions is None:
|
||||||
|
supported_extensions = _default_supported_extensions()
|
||||||
|
|
||||||
|
self.extensions = tuple(supported_extensions)
|
||||||
|
self.ignore_spec = (
|
||||||
|
pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns)
|
||||||
|
if ignore_patterns
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self.include_spec = (
|
||||||
|
pathspec.PathSpec.from_lines("gitwildmatch", include_patterns)
|
||||||
|
if include_patterns
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def __call__(self, change: Change, path: str) -> bool:
|
||||||
|
if not self.include_file(path):
|
||||||
|
return False
|
||||||
|
return super().__call__(change, path)
|
||||||
|
|
||||||
|
def include_file(self, path: str) -> bool:
|
||||||
|
if not path.endswith(self.extensions):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.include_spec and not self.include_spec.match_file(path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.ignore_spec and self.ignore_spec.match_file(path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
120
haiku_rag_slim/haiku/rag/ingester/sources/fs.py
Normal file
120
haiku_rag_slim/haiku/rag/ingester/sources/fs.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
from haiku.rag.ingester.sources.base import (
|
||||||
|
FetchResult,
|
||||||
|
RevisionSnapshot,
|
||||||
|
SourceEvent,
|
||||||
|
SourceEventKind,
|
||||||
|
)
|
||||||
|
from haiku.rag.ingester.sources.filter import (
|
||||||
|
FileFilter,
|
||||||
|
_default_supported_extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _uri_to_path(uri: str) -> Path:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
if parsed.scheme in ("", "file"):
|
||||||
|
path = parsed.path if parsed.scheme == "file" else uri
|
||||||
|
return Path(unquote(path))
|
||||||
|
raise ValueError(f"Unsupported URI scheme for FSSource: {uri}")
|
||||||
|
|
||||||
|
|
||||||
|
class FSSource:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
ignore_patterns: list[str] | None = None,
|
||||||
|
include_patterns: list[str] | None = None,
|
||||||
|
supported_extensions: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Resolve so symlinks and relative paths collapse to one canonical
|
||||||
|
# source_id. The queue uses source_id as a foreign key — two paths
|
||||||
|
# for the same root would mean duplicate sync_state rows.
|
||||||
|
self.root = Path(root).resolve()
|
||||||
|
self.source_id = f"fs:{self.root}"
|
||||||
|
self.supported_extensions = (
|
||||||
|
list(supported_extensions)
|
||||||
|
if supported_extensions is not None
|
||||||
|
else _default_supported_extensions()
|
||||||
|
)
|
||||||
|
self.filter = FileFilter(
|
||||||
|
ignore_patterns=ignore_patterns,
|
||||||
|
include_patterns=include_patterns,
|
||||||
|
supported_extensions=self.supported_extensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
def supports(self, uri: str) -> bool:
|
||||||
|
scheme = urlparse(uri).scheme
|
||||||
|
if scheme not in ("", "file"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_uri_to_path(uri)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fetch(self, uri: str) -> FetchResult:
|
||||||
|
path = _uri_to_path(uri)
|
||||||
|
body = path.read_bytes()
|
||||||
|
content_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
# mtime_ns rather than st_mtime: nanosecond integer avoids float
|
||||||
|
# precision collisions on rapid edits.
|
||||||
|
revision = str(path.stat().st_mtime_ns)
|
||||||
|
return FetchResult(
|
||||||
|
uri=path.as_uri(),
|
||||||
|
body=body,
|
||||||
|
content_type=content_type,
|
||||||
|
content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(),
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def discover(
|
||||||
|
self, since: RevisionSnapshot | None = None
|
||||||
|
) -> AsyncIterator[SourceEvent]:
|
||||||
|
snapshot: dict[str, str] = dict(since) if since else {}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for path in sorted(self.root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if not self.filter.include_file(str(path)):
|
||||||
|
continue
|
||||||
|
uri = path.as_uri()
|
||||||
|
revision = str(path.stat().st_mtime_ns)
|
||||||
|
seen.add(uri)
|
||||||
|
previous = snapshot.get(uri)
|
||||||
|
kind = (
|
||||||
|
SourceEventKind.UNCHANGED
|
||||||
|
if previous == revision
|
||||||
|
else SourceEventKind.UPSERT
|
||||||
|
)
|
||||||
|
yield SourceEvent(
|
||||||
|
source_id=self.source_id,
|
||||||
|
uri=uri,
|
||||||
|
kind=kind,
|
||||||
|
revision=revision,
|
||||||
|
discovered_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Anything in the snapshot we didn't encounter during the walk is
|
||||||
|
# gone from the source. Emit DELETE so the poller can clean up.
|
||||||
|
for uri in snapshot:
|
||||||
|
if uri in seen:
|
||||||
|
continue
|
||||||
|
yield SourceEvent(
|
||||||
|
source_id=self.source_id,
|
||||||
|
uri=uri,
|
||||||
|
kind=SourceEventKind.DELETE,
|
||||||
|
revision=None,
|
||||||
|
discovered_at=now,
|
||||||
|
)
|
||||||
|
|
@ -1,78 +1,19 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pathspec
|
from watchfiles import Change, awatch
|
||||||
from watchfiles import Change, DefaultFilter, awatch
|
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.config import AppConfig, Config, S3MonitorEntry
|
from haiku.rag.config import AppConfig, Config, S3MonitorEntry
|
||||||
|
from haiku.rag.ingester.sources.filter import FileFilter
|
||||||
from haiku.rag.store.models.document import Document
|
from haiku.rag.store.models.document import Document
|
||||||
from haiku.rag.utils import escape_sql_string
|
from haiku.rag.utils import escape_sql_string
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["FileFilter", "FileWatcher", "S3Watcher"]
|
||||||
class FileFilter(DefaultFilter):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
ignore_patterns: list[str] | None = None,
|
|
||||||
include_patterns: list[str] | None = None,
|
|
||||||
supported_extensions: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
if supported_extensions is None:
|
|
||||||
# Default to docling-local extensions if not provided
|
|
||||||
from haiku.rag.converters.docling_local import DoclingLocalConverter
|
|
||||||
from haiku.rag.converters.text_utils import TextFileHandler
|
|
||||||
|
|
||||||
supported_extensions = (
|
|
||||||
DoclingLocalConverter.docling_extensions
|
|
||||||
+ TextFileHandler.text_extensions
|
|
||||||
)
|
|
||||||
|
|
||||||
self.extensions = tuple(supported_extensions)
|
|
||||||
self.ignore_spec = (
|
|
||||||
pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns)
|
|
||||||
if ignore_patterns
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self.include_spec = (
|
|
||||||
pathspec.PathSpec.from_lines("gitwildmatch", include_patterns)
|
|
||||||
if include_patterns
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def __call__(self, change: Change, path: str) -> bool:
|
|
||||||
if not self.include_file(path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Apply default watchfiles filter
|
|
||||||
return super().__call__(change, path)
|
|
||||||
|
|
||||||
def include_file(self, path: str) -> bool:
|
|
||||||
"""Check if a file should be included based on filters."""
|
|
||||||
# Check extension filter
|
|
||||||
if not path.endswith(self.extensions):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Apply include patterns if specified (whitelist mode)
|
|
||||||
if self.include_spec:
|
|
||||||
if not self.include_spec.match_file(path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Apply ignore patterns (blacklist mode)
|
|
||||||
if self.ignore_spec:
|
|
||||||
if self.ignore_spec.match_file(path):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class FileWatcher:
|
class FileWatcher:
|
||||||
|
|
|
||||||
0
tests/ingester/__init__.py
Normal file
0
tests/ingester/__init__.py
Normal file
147
tests/ingester/test_fs_source.py
Normal file
147
tests/ingester/test_fs_source.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from haiku.rag.ingester.sources.base import SourceEventKind
|
||||||
|
from haiku.rag.ingester.sources.fs import FSSource
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs_root(tmp_path: Path) -> Path:
|
||||||
|
(tmp_path / "a.md").write_text("alpha")
|
||||||
|
(tmp_path / "b.txt").write_text("beta")
|
||||||
|
sub = tmp_path / "sub"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "c.md").write_text("gamma")
|
||||||
|
(tmp_path / "skip.log").write_text("noise")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_fs_source_supports_file_uri(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
assert src.supports((fs_root / "a.md").as_uri())
|
||||||
|
assert src.supports(str(fs_root / "a.md"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fs_source_rejects_other_schemes(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
assert not src.supports("http://example.com/a.md")
|
||||||
|
assert not src.supports("s3://bucket/a.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fs_source_source_id_is_canonical(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
assert src.source_id == f"fs:{fs_root.resolve()}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_fetch_returns_bytes_and_md5(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
target = fs_root / "a.md"
|
||||||
|
result = await src.fetch(target.as_uri())
|
||||||
|
assert result.uri == target.as_uri()
|
||||||
|
assert result.body == b"alpha"
|
||||||
|
assert (
|
||||||
|
result.content_hash == hashlib.md5(b"alpha", usedforsecurity=False).hexdigest()
|
||||||
|
)
|
||||||
|
assert result.content_type == "text/markdown"
|
||||||
|
assert result.revision == str(target.stat().st_mtime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_fetch_accepts_bare_path(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
target = fs_root / "a.md"
|
||||||
|
result = await src.fetch(str(target))
|
||||||
|
assert result.uri == target.as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_fetch_missing_file_raises(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
await src.fetch((fs_root / "missing.md").as_uri())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_initial_scan_yields_upsert(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root, supported_extensions=[".md", ".txt"])
|
||||||
|
events = [e async for e in src.discover(since=None)]
|
||||||
|
uris = {e.uri for e in events}
|
||||||
|
assert uris == {
|
||||||
|
(fs_root / "a.md").as_uri(),
|
||||||
|
(fs_root / "b.txt").as_uri(),
|
||||||
|
(fs_root / "sub" / "c.md").as_uri(),
|
||||||
|
}
|
||||||
|
assert all(e.kind is SourceEventKind.UPSERT for e in events)
|
||||||
|
assert all(e.source_id == src.source_id for e in events)
|
||||||
|
assert all(e.revision is not None for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_unchanged_against_snapshot(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root, supported_extensions=[".md", ".txt"])
|
||||||
|
initial = {e.uri: e.revision or "" async for e in src.discover(since=None)}
|
||||||
|
again = [e async for e in src.discover(since=initial)]
|
||||||
|
assert again
|
||||||
|
assert all(e.kind is SourceEventKind.UNCHANGED for e in again)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_changed_yields_upsert(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root, supported_extensions=[".md", ".txt"])
|
||||||
|
initial = {e.uri: e.revision or "" async for e in src.discover(since=None)}
|
||||||
|
stale = {uri: "0" for uri in initial}
|
||||||
|
events = [e async for e in src.discover(since=stale)]
|
||||||
|
assert {e.kind for e in events} == {SourceEventKind.UPSERT}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_emits_delete_for_missing(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root, supported_extensions=[".md", ".txt"])
|
||||||
|
snapshot = {(fs_root / "ghost.md").as_uri(): "999"}
|
||||||
|
events = [e async for e in src.discover(since=snapshot)]
|
||||||
|
deletes = [e for e in events if e.kind is SourceEventKind.DELETE]
|
||||||
|
assert len(deletes) == 1
|
||||||
|
assert deletes[0].uri == (fs_root / "ghost.md").as_uri()
|
||||||
|
assert deletes[0].revision is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_respects_extension_filter(fs_root: Path):
|
||||||
|
src = FSSource(root=fs_root, supported_extensions=[".md"])
|
||||||
|
uris = {e.uri async for e in src.discover(since=None)}
|
||||||
|
assert (fs_root / "a.md").as_uri() in uris
|
||||||
|
assert (fs_root / "b.txt").as_uri() not in uris
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_respects_ignore_patterns(fs_root: Path):
|
||||||
|
src = FSSource(
|
||||||
|
root=fs_root,
|
||||||
|
supported_extensions=[".md", ".txt"],
|
||||||
|
ignore_patterns=["**/sub/**"],
|
||||||
|
)
|
||||||
|
uris = {e.uri async for e in src.discover(since=None)}
|
||||||
|
assert (fs_root / "sub" / "c.md").as_uri() not in uris
|
||||||
|
assert (fs_root / "a.md").as_uri() in uris
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_discover_respects_include_patterns(fs_root: Path):
|
||||||
|
src = FSSource(
|
||||||
|
root=fs_root,
|
||||||
|
supported_extensions=[".md", ".txt"],
|
||||||
|
include_patterns=["**/*.md"],
|
||||||
|
)
|
||||||
|
uris = {e.uri async for e in src.discover(since=None)}
|
||||||
|
assert (fs_root / "b.txt").as_uri() not in uris
|
||||||
|
assert (fs_root / "a.md").as_uri() in uris
|
||||||
|
|
||||||
|
|
||||||
|
def test_filefilter_backward_compatible_reexport():
|
||||||
|
from haiku.rag.ingester.sources.filter import FileFilter as IngesterFileFilter
|
||||||
|
from haiku.rag.monitor import FileFilter as MonitorFileFilter
|
||||||
|
|
||||||
|
assert MonitorFileFilter is IngesterFileFilter
|
||||||
68
tests/ingester/test_sources_base.py
Normal file
68
tests/ingester/test_sources_base.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from haiku.rag.ingester.sources.base import (
|
||||||
|
FetchResult,
|
||||||
|
Source,
|
||||||
|
SourceEvent,
|
||||||
|
SourceEventKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_event_kind_values():
|
||||||
|
assert SourceEventKind.UPSERT.value == "upsert"
|
||||||
|
assert SourceEventKind.DELETE.value == "delete"
|
||||||
|
assert SourceEventKind.UNCHANGED.value == "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_event_round_trip():
|
||||||
|
event = SourceEvent(
|
||||||
|
source_id="fs:/tmp/docs",
|
||||||
|
uri="file:///tmp/docs/a.md",
|
||||||
|
kind=SourceEventKind.UPSERT,
|
||||||
|
revision="123456",
|
||||||
|
discovered_at=datetime(2026, 5, 20, 12, 0, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
raw = event.model_dump_json()
|
||||||
|
again = SourceEvent.model_validate_json(raw)
|
||||||
|
assert again == event
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_result_round_trip():
|
||||||
|
result = FetchResult(
|
||||||
|
uri="file:///tmp/docs/a.md",
|
||||||
|
body=b"hello",
|
||||||
|
content_type="text/markdown",
|
||||||
|
content_hash="abcd1234",
|
||||||
|
revision="123456",
|
||||||
|
extra_metadata={"source": "fs"},
|
||||||
|
)
|
||||||
|
raw = result.model_dump_json()
|
||||||
|
again = FetchResult.model_validate_json(raw)
|
||||||
|
assert again == result
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_result_defaults_extra_metadata_to_empty():
|
||||||
|
result = FetchResult(
|
||||||
|
uri="file:///tmp/docs/a.md",
|
||||||
|
body=b"x",
|
||||||
|
content_type="text/markdown",
|
||||||
|
content_hash="x",
|
||||||
|
revision=None,
|
||||||
|
)
|
||||||
|
assert result.extra_metadata == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_protocol_runtime_checkable():
|
||||||
|
class Dummy:
|
||||||
|
source_id = "dummy"
|
||||||
|
|
||||||
|
def supports(self, uri: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fetch(self, uri: str):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def discover(self, since=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
assert isinstance(Dummy(), Source)
|
||||||
Loading…
Reference in a new issue