haiku.rag/haiku_rag_slim/haiku/rag/client/documents.py
Yiorgis Gozadinos da6cdfbc51
Resolve file:// URIs to paths through url2pathname
urlparse().path keeps the leading slash in front of a Windows drive, so
file:///C:/docs/a.pdf read as \C:\docs\a.pdf and the ingester reported
"File does not exist" for every file it discovered. url2pathname is the
stdlib conversion that strips it, per platform.

Four sites each decided both "is this local" and "what path is this":
FSSource._uri_to_path and supports, resolve_adhoc_fetcher,
create_document_from_source and check_source_accessible, and convert.
is_local_uri and uri_to_path in haiku.rag.uri own those two decisions now,
which closes two more cases of the same root cause. A bare C:\docs\a.pdf
parses with scheme "c", so add-src raised "No source adapter for URI scheme
'c'" and convert silently treated the path as raw text. And convert and
check_source_accessible never percent-decoded at all, so a file named
a[b] c.md read as missing on Linux and macOS too.

A file URI's host is reattached after conversion rather than passed to
url2pathname, which as of 3.14 rejects a non-local authority off Windows.
file:////server/share is the empty-authority spelling of a UNC path, its
host being the first path segment, so that host is normalised into the
authority before conversion. Output is identical on 3.12, 3.13 and 3.14.

The ad-hoc FS fetcher roots at the path's own anchor rather than "/", which
on Windows is only the current drive.

test_uri.py runs on ubuntu, macos and windows across 3.13 and 3.14 without
the project installed: --noconftest because the repo conftest imports
dependencies that job does not need, and -o addopts= to drop the
repository's -n auto. The Windows legs are what cover the drive conversion.

Fixes #574.
2026-08-21 10:22:26 +03:00

899 lines
33 KiB
Python

import asyncio
import hashlib
import json
import logging
import mimetypes
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import quote, urlparse
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.client.processing import (
_write_fetch_body,
ensure_chunks_embedded,
get_extension_from_content_type_or_url,
)
from haiku.rag.client.titles import resolve_title
from haiku.rag.converters import get_converter
from haiku.rag.store.models.chunk import Chunk
from haiku.rag.store.models.document import Document
from haiku.rag.store.models.document_item import DocumentItem, extract_items
from haiku.rag.telemetry import logfire
from haiku.rag.uri import is_local_uri, uri_to_path
if TYPE_CHECKING:
from docling_core.types.doc.document import DoclingDocument
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.metadata import MetadataProvider
from haiku.rag.sources.base import FetchResult, Source
logger = logging.getLogger(__name__)
@dataclass
class DocumentImport:
"""A prepared document for batch import via ``import_documents``.
Carries the same inputs as ``import_document``: a converted
``DoclingDocument``, its chunks (embeddings filled in if missing), and
optional uri/title/metadata.
"""
docling_document: "DoclingDocument"
chunks: list[Chunk]
uri: str | None = None
title: str | None = None
metadata: dict | None = field(default=None)
# Maximum length of an attachment chain rooted at a top-level ingest. With
# value 3, a PDF whose attachments contain PDFs which themselves contain
# PDFs is fully ingested (3 levels); a fourth nested level logs a warning
# and is skipped.
MAX_ATTACHMENT_DEPTH = 3
# Keys the source pipeline owns (content_type/md5/source_revision, which drive
# sync_state). A provider must not set them, or the metadata-only refresh path
# would let provider values overwrite the real source-derived ones. Stripped
# before provider metadata is merged into the document.
_RESERVED_METADATA_KEYS = frozenset({"content_type", "md5", "source_revision"})
def _prepare_document_from_docling_sync(
document: Document, docling_document: "DoclingDocument"
) -> str:
"""Populate content/docling blobs from a DoclingDocument.
This performs size-proportional serialization, JSON splitting, and
compression via ``Document.set_docling``. Async ingestion paths should call
it through ``_prepare_document_from_docling`` so large image-bearing
documents do not block the event loop.
"""
content = docling_document.export_to_markdown()
document.content = content
document.set_docling(docling_document)
return content
async def _prepare_document_from_docling(
document: Document, docling_document: "DoclingDocument"
) -> str:
return await asyncio.to_thread(
_prepare_document_from_docling_sync, document, docling_document
)
async def _prepare_and_title(
client: "HaikuRAG", document: Document, docling_document: "DoclingDocument"
) -> None:
"""Fill the document from its converted form and title it if it has none.
A caller-supplied title always wins: set it on the document before calling.
Update paths that must keep an existing empty title call
``_prepare_document_from_docling`` directly instead.
"""
stored_content = await _prepare_document_from_docling(document, docling_document)
if document.title is None:
document.title = await resolve_title(
client._config, docling_document, stored_content
)
def parent_uri_filter(parent_uri: str) -> str:
"""SQL `WHERE` clause matching documents whose ``metadata.parent_uri``
equals ``parent_uri``. ``metadata`` is stored as a JSON string produced by
the standard library's ``json.dumps`` (which inserts ``": "`` between key
and value), so the match is a substring search over that serialized form —
escape JSON-meaningful chars in the URI, then SQL-escape single quotes."""
json_fragment = json.dumps(parent_uri)[1:-1].replace("'", "''")
return f'metadata LIKE \'%"parent_uri": "{json_fragment}"%\''
async def _store_document_with_chunks(
client: "HaikuRAG",
document: Document,
chunks: list[Chunk],
docling_document: "DoclingDocument",
) -> Document:
"""Store a document with chunks, embedding any that lack embeddings.
Handles versioning/rollback on failure.
"""
chunks = await ensure_chunks_embedded(client._config, chunks, client.embedder)
items = await asyncio.to_thread(extract_items, "", docling_document)
async with client.store.write_transaction():
# A concurrent ingestion of the same URI may have created the document
# while this one was converting/embedding outside the lock. LanceDB has
# no unique constraint on `uri`, so re-check under the lock and update in
# place rather than inserting a duplicate.
existing = (
await client.get_document_by_uri(document.uri)
if document.uri is not None
else None
)
if existing is not None:
document.id = existing.id
document.created_at = existing.created_at
stored_doc = await client.document_repository.update(document)
else:
stored_doc = await client.document_repository.create(document)
assert stored_doc.id is not None, "Document ID should not be None after storing"
for order, chunk in enumerate(chunks):
chunk.document_id = stored_doc.id
chunk.order = order
for item in items:
item.document_id = stored_doc.id
if existing is not None:
await client.chunk_repository.replace_for_document(stored_doc.id, chunks)
await client.document_item_repository.replace_for_document(
stored_doc.id, items
)
else:
await client.chunk_repository.create(chunks)
await client.document_item_repository.create_items(stored_doc.id, items)
if client._config.storage.auto_vacuum:
client._schedule_vacuum()
return stored_doc
async def _update_document_with_chunks(
client: "HaikuRAG",
document: Document,
chunks: list[Chunk],
docling_document: "DoclingDocument | None" = None,
) -> Document:
"""Update a document and replace its chunks, embedding any that lack embeddings.
Handles versioning/rollback on failure. When `docling_document` is None,
existing items are preserved.
"""
assert document.id is not None, "Document ID is required for update"
# Snapshot existing picture bytes before deleting items so the post-delete
# extract_items can merge them back when the live docling has had its
# picture URIs stripped (rebuild / re-extract via the stored blob).
existing_picture_data: dict[str, bytes] | None = None
if docling_document is not None:
existing_picture_data = (
await client.document_item_repository.get_all_picture_data(document.id)
)
chunks = await ensure_chunks_embedded(client._config, chunks, client.embedder)
items: list[DocumentItem] | None = None
if docling_document is not None:
items = await asyncio.to_thread(
extract_items, document.id, docling_document, existing_picture_data
)
async with client.store.write_transaction():
updated_doc = await client.document_repository.update(document)
assert updated_doc.id is not None
for order, chunk in enumerate(chunks):
chunk.document_id = updated_doc.id
chunk.order = order
await client.chunk_repository.replace_for_document(updated_doc.id, chunks)
if items is not None:
await client.document_item_repository.replace_for_document(
updated_doc.id, items
)
if client._config.storage.auto_vacuum:
client._schedule_vacuum()
return updated_doc
async def create_document(
client: "HaikuRAG",
content: str,
uri: str | None = None,
title: str | None = None,
metadata: dict | None = None,
format: str = "md",
) -> Document:
"""Create a new document from text content.
Converts the content, chunks it, and generates embeddings.
"""
converter = get_converter(client._config)
docling_document = await converter.convert_text(content, format=format)
chunks = await client.chunk(docling_document)
document = Document(
content="",
uri=uri,
title=title,
metadata=metadata or {},
)
await _prepare_and_title(client, document, docling_document)
return await _store_document_with_chunks(client, document, chunks, docling_document)
async def import_document(
client: "HaikuRAG",
docling_document: "DoclingDocument",
chunks: list[Chunk],
uri: str | None = None,
title: str | None = None,
metadata: dict | None = None,
) -> Document:
"""Import a pre-processed document with chunks.
Use this when conversion, chunking, and embedding were done externally.
Chunks without embeddings will be automatically embedded.
"""
document = Document(
content="",
uri=uri,
title=title,
metadata=metadata or {},
)
await _prepare_and_title(client, document, docling_document)
return await _store_document_with_chunks(client, document, chunks, docling_document)
async def _store_documents_with_chunks(
client: "HaikuRAG",
prepared: list[tuple[Document, list[Chunk], "DoclingDocument"]],
) -> list[Document]:
"""Store many documents with their chunks in a single table version each.
Embeds any chunks that lack embeddings, then writes the documents, chunks,
and document_items tables once apiece. Restores all tables on any failure.
"""
flat = await ensure_chunks_embedded(
client._config,
[chunk for _, chunks, _ in prepared for chunk in chunks],
client.embedder,
)
embedded: list[list[Chunk]] = []
position = 0
for _, chunks, _ in prepared:
embedded.append(flat[position : position + len(chunks)])
position += len(chunks)
def _extract_all_items():
return [extract_items("", d) for _, _, d in prepared]
all_item_lists = await asyncio.to_thread(_extract_all_items)
async with client.store.write_transaction():
created = await client.document_repository.create(
[doc for doc, _, _ in prepared]
)
all_chunks: list[Chunk] = []
all_items = []
for doc, doc_chunks, item_list in zip(created, embedded, all_item_lists):
assert doc.id is not None
for order, chunk in enumerate(doc_chunks):
chunk.document_id = doc.id
chunk.order = order
all_chunks.extend(doc_chunks)
for item in item_list:
item.document_id = doc.id
all_items.extend(item_list)
await client.chunk_repository.create(all_chunks)
await client.document_item_repository.create_all(all_items)
if client._config.storage.auto_vacuum:
client._schedule_vacuum()
return created
async def import_documents(
client: "HaikuRAG",
imports: list[DocumentImport],
) -> list[Document]:
"""Batch-import pre-processed documents with their chunks.
The batch analog of ``import_document``: writes the documents, chunks, and
document_items tables once each regardless of how many documents are
imported. Chunks without embeddings are embedded automatically.
"""
if not imports:
return []
prepared: list[tuple[Document, list[Chunk], DoclingDocument]] = []
for item in imports:
document = Document(
content="",
uri=item.uri,
title=item.title,
metadata=item.metadata or {},
)
await _prepare_and_title(client, document, item.docling_document)
prepared.append((document, item.chunks, item.docling_document))
return await _store_documents_with_chunks(client, prepared)
async def _refresh_doc_metadata(
client: "HaikuRAG",
doc: Document,
*,
title: str | None,
user_metadata: dict,
source_metadata: dict | None,
) -> Document:
"""Update a document's title + metadata without re-chunking. Used by the
cheap revision and MD5 short-circuits in create_document_from_source."""
updated = False
if title is not None and title != doc.title:
doc.title = title
updated = True
merged = {**(doc.metadata or {}), **user_metadata}
if source_metadata:
merged.update(source_metadata)
if merged != doc.metadata:
doc.metadata = merged
updated = True
if updated:
async with client.store._write_lock:
result = await client.document_repository.update_meta(doc)
# Reclaim the document_meta churn from rolling source_revision sweeps.
# The vacuum is debounced, and document_meta is tiny, so this is cheap.
if client._config.storage.auto_vacuum:
client._schedule_vacuum()
return result
return doc
async def _provider_metadata(
provider: "MetadataProvider | None",
source_id: str,
uri: str,
result: "FetchResult",
) -> dict:
if provider is None:
return {}
# Hand the provider an isolated copy: mutating the live FetchResult
# (e.g. result.content_hash or result.extra_metadata) would feed the
# MD5 short-circuit and source_meta, bypassing the reserved-key filter
# that only guards the returned dict.
provider_result = result.model_copy(deep=True)
return {
k: v
for k, v in (await provider(source_id, uri, provider_result)).items()
if k not in _RESERVED_METADATA_KEYS
}
async def _ingest_fetch_result(
client: "HaikuRAG",
result: "FetchResult",
*,
title: str | None,
user_metadata: dict,
stored_uri: str,
existing_doc: Document | None,
depth: int = 0,
filename: str | None = None,
) -> Document:
"""Convert / chunk / embed / store a fetched document. Replaces an
existing document if one is supplied. ``depth`` tracks position in an
attachment chain so the reconciliation step can bound recursion.
``filename``, when given, makes its suffix authoritative for the file
extension (and thus the docling format), overriding the URI/content-type
fallback. Callers pass it when ``result.uri`` cannot yield the right
extension, e.g. embedded attachments whose name lives in a URI fragment."""
converter = get_converter(client._config)
if filename is not None:
file_extension = Path(filename).suffix.lower()
else:
file_extension = get_extension_from_content_type_or_url(
result.uri, result.content_type
)
if file_extension not in converter.supported_extensions:
raise UnsupportedSourceError(
f"Unsupported content type/extension: {result.content_type}/{file_extension}"
)
source_metadata: dict = {
"content_type": result.content_type,
"md5": result.content_hash,
**result.extra_metadata,
}
if result.revision is not None:
source_metadata["source_revision"] = result.revision
if result.disk_path is not None:
target_path = result.disk_path
cleanup_path: Path | None = None
else:
target_path = await _write_fetch_body(result.body, file_extension)
cleanup_path = target_path
try:
with logfire.span("document.convert", uri=result.uri):
docling_document = await client.convert(target_path, source_uri=result.uri)
with logfire.span("document.chunk", uri=result.uri) as chunk_span:
chunks = await client.chunk(docling_document)
chunk_span.set_attribute("chunks_created", len(chunks))
finally:
if cleanup_path is not None:
cleanup_path.unlink(missing_ok=True)
final_metadata = {**user_metadata, **source_metadata}
if existing_doc:
existing_doc.metadata = final_metadata
if title is not None:
existing_doc.title = title
await _prepare_and_title(client, existing_doc, docling_document)
with logfire.span("document.store", uri=result.uri, op="update") as store_span:
updated = await _update_document_with_chunks(
client, existing_doc, chunks, docling_document
)
store_span.set_attribute("document_id", updated.id)
await _reconcile_pdf_attachments(client, updated, result.body, depth=depth)
return updated
document = Document(
content="",
uri=stored_uri,
title=title,
metadata=final_metadata,
)
await _prepare_and_title(client, document, docling_document)
with logfire.span("document.store", uri=result.uri, op="create") as store_span:
created = await _store_document_with_chunks(
client, document, chunks, docling_document
)
store_span.set_attribute("document_id", created.id)
await _reconcile_pdf_attachments(client, created, result.body, depth=depth)
return created
def _extract_pdf_attachments(
parent_body: bytes, parent_uri: str, *, depth: int
) -> dict[str, tuple[str, bytes, str, str]] | None:
"""Open the parent PDF and return its embedded attachments keyed by child
URI. Returns ``None`` when the PDF can't be opened or the recursion depth
cap is reached — in both cases the caller skips reconciliation entirely.
Every pdfium call is held under ``PDFIUM_LOCK`` (shared with page slicing)
because libpdfium's global C state is not thread-safe; concurrent access
from another worker corrupts it and then fails valid PDFs with "Data format
error" until the process restarts.
"""
import pypdfium2 as pdfium
from haiku.rag.converters.pdf_split import PDFIUM_LOCK
with PDFIUM_LOCK:
try:
pdf = pdfium.PdfDocument(parent_body)
except pdfium.PdfiumError as exc:
logger.warning(
"Cannot scan %s for embedded attachments: %s", parent_uri, exc
)
return None
try:
attachment_count = pdf.count_attachments()
if depth + 1 >= MAX_ATTACHMENT_DEPTH:
if attachment_count > 0:
logger.warning(
"Attachment depth cap (%d) reached at %s; skipping %d nested "
"attachment(s).",
MAX_ATTACHMENT_DEPTH,
parent_uri,
attachment_count,
)
return None
new_attachments: dict[str, tuple[str, bytes, str, str]] = {}
for i in range(attachment_count):
att = pdf.get_attachment(i)
name = att.get_name()
# A malformed PDF can carry an attachment with an empty /F, so
# this is real validation on untrusted input — it just needs a
# hand-crafted file to reach, which no fixture here produces.
if not name: # pragma: no cover - needs a malformed PDF
continue
data = bytes(att.get_data())
child_uri = f"{parent_uri}#attachment={quote(name, safe='')}"
content_type = (
mimetypes.guess_type(name)[0] or "application/octet-stream"
)
content_hash = hashlib.md5(data, usedforsecurity=False).hexdigest()
new_attachments[child_uri] = (name, data, content_type, content_hash)
return new_attachments
finally:
pdf.close()
async def _reconcile_pdf_attachments(
client: "HaikuRAG",
parent_doc: Document,
parent_body: bytes,
*,
depth: int,
) -> None:
"""Diff the parent PDF's ``/EmbeddedFiles`` table against any children
already linked via ``metadata.parent_uri`` and bring the child set in line:
ingest additions, update changed bytes, cascade-delete removed names.
Re-uses ``_ingest_fetch_result`` for each child so the standard conversion
path runs uniformly — child PDFs recurse into this helper one level deeper,
bounded by ``MAX_ATTACHMENT_DEPTH``.
"""
if not client._config.processing.extract_pdf_attachments:
return
if not parent_doc.uri:
return
if (parent_doc.metadata or {}).get("content_type") != "application/pdf":
return
new_attachments = await asyncio.to_thread(
_extract_pdf_attachments, parent_body, parent_doc.uri, depth=depth
)
if new_attachments is None:
return
existing = await client.list_documents(filter=parent_uri_filter(parent_doc.uri))
existing_by_uri: dict[str, Document] = {d.uri: d for d in existing if d.uri}
for child_uri, (name, data, content_type, content_hash) in new_attachments.items():
existing_child = existing_by_uri.get(child_uri)
if (
existing_child
and (existing_child.metadata or {}).get("md5") == content_hash
):
continue
from haiku.rag.sources.base import FetchResult
child_fr = FetchResult(
uri=child_uri,
body=data,
content_type=content_type,
content_hash=content_hash,
extra_metadata={"parent_uri": parent_doc.uri},
)
try:
await _ingest_fetch_result(
client,
child_fr,
title=None,
user_metadata={},
stored_uri=child_uri,
existing_doc=existing_child,
depth=depth + 1,
filename=name,
)
except UnsupportedSourceError:
logger.warning(
"Skipping attachment %r in %s: unsupported extension %r "
"(content type %r)",
name,
parent_doc.uri,
Path(name).suffix.lower(),
content_type,
)
for child_uri, child in existing_by_uri.items():
if child_uri not in new_attachments and child.id:
await client.delete_document(child.id)
async def create_document_from_source(
client: "HaikuRAG",
source: str | Path,
title: str | None = None,
metadata: dict | None = None,
uri: str | None = None,
storage_options: dict[str, str] | None = None,
sources: "list[Source] | None" = None,
source_id: str | None = None,
metadata_provider: "MetadataProvider | None" = None,
force: bool = False,
) -> Document | list[Document]:
"""Create or update document(s) from a file path, directory, or URL.
Checks if a document with the same URI already exists:
- If MD5 is unchanged, returns existing document
- If MD5 changed, updates the document
- If no document exists, creates a new one
``force`` skips both freshness checks so an unchanged source is re-converted,
re-chunked and re-embedded into the existing document. Internal: rebuild uses
it to refresh a document in place instead of deleting and recreating it.
If ``uri`` is provided, it overrides the URI auto-derived from the source
(which is normally ``file://`` for local files or the URL for remote
sources). Not supported for directory sources, which produce one document
per file.
Returns a single Document for files/URLs, a list for directories.
"""
metadata = metadata or {}
source_str = str(source)
parsed_url = urlparse(source_str)
# Directory case: recurse with the existing FS filter and produce one
# document per file. Remote schemes (http/s3) never hit this branch.
if is_local_uri(source_str):
local_path = uri_to_path(source_str) if isinstance(source, str) else source
if local_path.is_dir():
if uri is not None:
raise UnsupportedSourceError(
"uri override is not supported for directory sources; each file "
"produces its own document with its own auto-derived URI."
)
from haiku.rag.sources.filter import FileFilter
from haiku.rag.sources.fs import walk_files
# One-shot CLI directory ingest uses the converter's supported
# extensions but no include/ignore patterns. For pattern-based
# filtering use `haiku-ingester serve` with an FS source.
documents: list[Document] = []
filter = FileFilter()
for child in walk_files(local_path):
if child.is_file() and filter.include_file(str(child)):
doc = await create_document_from_source(
client,
child,
title=None,
metadata=metadata,
sources=sources,
source_id=source_id,
metadata_provider=metadata_provider,
force=force,
)
assert isinstance(doc, Document)
documents.append(doc)
return documents
if not local_path.exists():
raise UnsupportedSourceError(f"File does not exist: {local_path}")
# Match the old _create_document_from_file behaviour: fail fast on
# unsupported extension before reading any bytes.
converter = get_converter(client._config)
if local_path.suffix.lower() not in converter.supported_extensions:
raise UnsupportedSourceError(
f"Unsupported file extension: {local_path.suffix}"
)
# Worker jobs carry source_id from the poller; strict lookup so a
# renamed/removed source surfaces as a DLQ instead of silently dropping
# credentials. Ad-hoc CLI calls (no source_id) fall back to scheme-based
# adapters when no configured source matches.
from haiku.rag.sources import (
resolve_adhoc_fetcher,
resolve_configured_source,
)
if source_id is not None:
fetcher = resolve_configured_source(source_str, source_id, sources)
else:
fetcher = resolve_adhoc_fetcher(
source_str, sources=sources, storage_options=storage_options
)
# A fetcher built for this call holds its own httpx pool (HTTP, WebDAV) and
# has to be closed here. One handed in through `sources` belongs to the
# caller: the ingester keeps its sources open across jobs.
owns_fetcher = all(fetcher is not configured for configured in sources or ())
try:
# The stored URI is what we look up + persist by. For an explicit uri
# override, use it as-is. For a file:// input the source string is
# already canonical (URL-encoded); round-tripping via Path.as_uri()
# would double-encode any escapes like %5B. For bare paths, canonicalize.
if uri is not None:
stored_uri = uri
elif parsed_url.scheme == "file":
stored_uri = source_str
elif is_local_uri(source_str):
stored_uri = Path(source_str).absolute().as_uri()
else:
stored_uri = source_str
existing_doc = await client.get_document_by_uri(stored_uri)
# Cheap revision-based short-circuit: only worth a HEAD when we have a
# stored revision to compare against. All sources persist their native
# revision (mtime_ns for FS, ETag for S3, ETag/Last-Modified for HTTP)
# under the canonical "source_revision" metadata key.
stored_revision = (
(existing_doc.metadata or {}).get("source_revision")
if existing_doc
else None
)
if existing_doc and stored_revision and not force:
current_revision = await fetcher.head(source_str)
if current_revision == stored_revision:
return await _refresh_doc_metadata(
client,
existing_doc,
title=title,
user_metadata=metadata,
source_metadata=None,
)
with logfire.span("document.fetch", uri=source_str) as fetch_span:
result = await fetcher.fetch(source_str)
fetch_span.set_attribute("bytes", len(result.body))
fetch_span.set_attribute("content_hash", result.content_hash)
provider_metadata = await _provider_metadata(
metadata_provider, source_id or fetcher.source_id, source_str, result
)
user_metadata = {**metadata, **provider_metadata}
# MD5 short-circuit: the bytes are unchanged even if the revision wasn't.
# Refresh the source-derived metadata (revision may have rolled) but skip
# convert/embed/store entirely.
if (
existing_doc
and not force
and existing_doc.metadata.get("md5") == result.content_hash
):
source_meta: dict = {
"content_type": result.content_type,
"md5": result.content_hash,
**result.extra_metadata,
}
if result.revision is not None:
source_meta["source_revision"] = result.revision
return await _refresh_doc_metadata(
client,
existing_doc,
title=title,
user_metadata=user_metadata,
source_metadata=source_meta,
)
return await _ingest_fetch_result(
client,
result,
title=title,
user_metadata=user_metadata,
stored_uri=stored_uri,
existing_doc=existing_doc,
)
finally:
if owns_fetcher:
await fetcher.aclose()
async def update_document(
client: "HaikuRAG",
document_id: str,
content: str | None = None,
metadata: dict | None = None,
chunks: list[Chunk] | None = None,
title: str | None = None,
docling_document: "DoclingDocument | None" = None,
uri: str | None = None,
) -> Document:
"""Update a document by ID.
Updates specified fields. When content or docling_document is provided, the
document is rechunked and re-embedded. Updates to only metadata or title
skip rechunking for efficiency.
Raises:
ValueError: If document not found, or if both content and
docling_document are provided.
"""
if content is not None and docling_document is not None:
raise ValueError(
"content and docling_document are mutually exclusive. "
"Provide one or the other, not both."
)
# Caller-supplied chunks without a docling document replace neither blob,
# and the row is written back whole, so they have to make the round trip.
existing_doc = await client.document_repository.get_by_id(
document_id, include_blobs=chunks is not None and docling_document is None
)
if existing_doc is None:
raise ValueError(f"Document with ID {document_id} not found")
if title is not None:
existing_doc.title = title
if metadata is not None:
existing_doc.metadata = metadata
if uri is not None:
existing_doc.uri = uri
if content is None and chunks is None and docling_document is None:
async with client.store._write_lock:
updated = await client.document_repository.update_meta(existing_doc)
if client._config.storage.auto_vacuum:
client._schedule_vacuum()
return updated
if chunks is not None:
if docling_document is not None:
await _prepare_document_from_docling(existing_doc, docling_document)
elif content is not None:
existing_doc.content = content
return await _update_document_with_chunks(
client, existing_doc, chunks, docling_document
)
if docling_document is not None:
await _prepare_document_from_docling(existing_doc, docling_document)
new_chunks = await client.chunk(docling_document)
return await _update_document_with_chunks(
client, existing_doc, new_chunks, docling_document
)
assert content is not None
existing_doc.content = content
converter = get_converter(client._config)
converted_docling = await converter.convert_text(existing_doc.content, format="md")
await _prepare_document_from_docling(existing_doc, converted_docling)
new_chunks = await client.chunk(converted_docling)
return await _update_document_with_chunks(
client, existing_doc, new_chunks, converted_docling
)
def check_source_accessible(uri: str) -> bool:
"""Check if a document's source URI is accessible.
Anything the URI itself makes unanswerable counts as inaccessible rather
than aborting the caller's sweep: ``urlparse`` rejects malformed IPv6
hosts, and ``Path.exists`` re-raises errno values outside its ignored set
(an unreadable parent directory, an over-long name).
"""
try:
parsed_url = urlparse(uri)
if parsed_url.scheme == "file":
return uri_to_path(uri).exists()
elif parsed_url.scheme in ("http", "https", "s3"):
return True
return False
except (ValueError, OSError):
return False