Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).
Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
* Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
* Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
* infra/llm/ollama_provider.py — OllamaProvider with health_check
* infra/docling_agent_reasoning.py — runner adapter, encapsulates the
private _rag_loop call (tracked at docling-project/docling-agent#26),
commits OLLAMA_HOST once at boot (eliminates the per-request env race),
translates upstream IndexError into ReasoningParseError
* api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
consumes app.state.reasoning_runner via the port
* main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
when REASONING_ENABLED=true and deps are importable
* Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
type RAGResult → ReasoningResult, frontend feature flag wiring,
i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
consumers in production)
* 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
serialization, R13 Protocol conformance via isinstance
* E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
* README — Live Reasoning section (env vars, archi, link to issue #26)
Bloc B — Security (audit 08, dev-only context)
* docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
flagged as dev-only with link to OpenSearch security docs
* main.py — boot warning if NEO4J_URI is set with the default 'changeme'
password, so prod operators can't silently inherit it
Bloc C — DRY frontend (audit 05)
* shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
* features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
* api/schemas.py — DOCUMENT_STATUS_UPLOADED constant
Bloc D — Quality (audits 02/06/07/09/10/12)
* domain/ports.py — DocumentConverter.supports_page_batching property
(LSP fix, replaces isinstance(ServeConverter) check)
* domain/ports.py — VectorStore.ping() (encapsulation, replaces
_vector_store._client.info() reach-around)
* api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
aligned with the user-facing terminology (URLs unchanged)
* api/documents.py — Path.read_bytes() + generate_preview() wrapped in
asyncio.to_thread, unblocks the FastAPI event loop on /preview
* infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
* src/__tests__/integration/ — cross-feature integration test relocated
out of features/history/ so feature folders stay self-contained
* Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
exact value comparisons)
Validation
* 446 backend pytest, 202 frontend vitest — all green
* ruff + ruff format + ESLint + Prettier + vue-tsc clean
* Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO
Closes #200
215 lines
6.9 KiB
Python
215 lines
6.9 KiB
Python
"""OpenSearch adapter implementing the VectorStore port.
|
|
|
|
Uses the opensearch-py client for kNN vector search, full-text search,
|
|
and document CRUD against an OpenSearch cluster.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from opensearchpy import AsyncOpenSearch, NotFoundError
|
|
|
|
from domain.vector_schema import (
|
|
ChunkBboxEntry,
|
|
ChunkOrigin,
|
|
DocItemRef,
|
|
IndexedChunk,
|
|
SearchResult,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _hit_to_indexed_chunk(hit: dict[str, Any]) -> IndexedChunk:
|
|
"""Reconstruct an IndexedChunk from an OpenSearch _source document."""
|
|
src = hit["_source"]
|
|
origin_raw = src.get("origin")
|
|
origin = (
|
|
ChunkOrigin(binary_hash=origin_raw["binary_hash"], filename=origin_raw["filename"])
|
|
if origin_raw
|
|
else None
|
|
)
|
|
return IndexedChunk(
|
|
doc_id=src["doc_id"],
|
|
filename=src["filename"],
|
|
content=src["content"],
|
|
embedding=src.get("embedding", []),
|
|
chunk_index=src["chunk_index"],
|
|
chunk_type=src["chunk_type"],
|
|
page_number=src["page_number"],
|
|
bboxes=[
|
|
ChunkBboxEntry(page=b["page"], x=b["x"], y=b["y"], w=b["w"], h=b["h"])
|
|
for b in src.get("bboxes", [])
|
|
],
|
|
headings=src.get("headings", []),
|
|
doc_items=[
|
|
DocItemRef(self_ref=d["self_ref"], label=d["label"]) for d in src.get("doc_items", [])
|
|
],
|
|
origin=origin,
|
|
)
|
|
|
|
|
|
def _hit_to_result(hit: dict[str, Any]) -> SearchResult:
|
|
"""Convert an OpenSearch hit to a SearchResult."""
|
|
return SearchResult(
|
|
chunk=_hit_to_indexed_chunk(hit),
|
|
score=hit.get("_score", 0.0),
|
|
)
|
|
|
|
|
|
class OpenSearchStore:
|
|
"""Concrete VectorStore adapter backed by OpenSearch.
|
|
|
|
Satisfies the ``VectorStore`` Protocol defined in ``domain.ports``.
|
|
|
|
Args:
|
|
url: OpenSearch cluster URL (e.g. ``http://localhost:9200``).
|
|
verify_certs: Whether to verify TLS certificates.
|
|
"""
|
|
|
|
def __init__(self, url: str, *, verify_certs: bool = False, default_limit: int = 1000) -> None:
|
|
self._client = AsyncOpenSearch(
|
|
hosts=[url],
|
|
use_ssl=url.startswith("https"),
|
|
verify_certs=verify_certs,
|
|
ssl_show_warn=False,
|
|
)
|
|
self._default_limit = default_limit
|
|
|
|
# -- lifecycle -------------------------------------------------------------
|
|
|
|
async def close(self) -> None:
|
|
"""Close the underlying HTTP connection pool."""
|
|
await self._client.close()
|
|
|
|
async def ping(self) -> bool:
|
|
"""Reachability probe — calls OpenSearch `/` (cluster info) once."""
|
|
try:
|
|
info = await self._client.info()
|
|
return bool(info)
|
|
except Exception:
|
|
return False
|
|
|
|
# -- VectorStore protocol methods ------------------------------------------
|
|
|
|
async def ensure_index(self, index_name: str, mapping: dict) -> None:
|
|
"""Create the index if it does not exist. No-op if it already exists."""
|
|
exists = await self._client.indices.exists(index=index_name)
|
|
if not exists:
|
|
await self._client.indices.create(index=index_name, body=mapping)
|
|
logger.info("Created OpenSearch index '%s'", index_name)
|
|
else:
|
|
logger.debug("Index '%s' already exists — skipping creation", index_name)
|
|
|
|
async def index_chunks(self, index_name: str, chunks: list[IndexedChunk]) -> int:
|
|
"""Bulk-index a list of chunks. Returns the number successfully indexed."""
|
|
if not chunks:
|
|
return 0
|
|
|
|
body: list[dict[str, Any]] = []
|
|
for chunk in chunks:
|
|
doc_id = f"{chunk.doc_id}_{chunk.chunk_index}"
|
|
body.append({"index": {"_index": index_name, "_id": doc_id}})
|
|
body.append(chunk.to_dict())
|
|
|
|
resp = await self._client.bulk(body=body, refresh="wait_for")
|
|
|
|
errors = sum(1 for item in resp["items"] if item["index"].get("error"))
|
|
indexed = len(chunks) - errors
|
|
if errors:
|
|
logger.warning("Bulk index to '%s': %d/%d failed", index_name, errors, len(chunks))
|
|
return indexed
|
|
|
|
async def search_similar(
|
|
self,
|
|
index_name: str,
|
|
embedding: list[float],
|
|
*,
|
|
k: int = 10,
|
|
doc_id: str | None = None,
|
|
) -> list[SearchResult]:
|
|
"""kNN search for the k nearest chunks by embedding similarity."""
|
|
knn_query: dict[str, Any] = {
|
|
"knn": {
|
|
"embedding": {
|
|
"vector": embedding,
|
|
"k": k,
|
|
},
|
|
},
|
|
}
|
|
if doc_id:
|
|
knn_query["knn"]["embedding"]["filter"] = {
|
|
"term": {"doc_id": doc_id},
|
|
}
|
|
|
|
resp = await self._client.search(
|
|
index=index_name,
|
|
body={"size": k, "query": knn_query},
|
|
_source_excludes=["embedding"],
|
|
)
|
|
return [_hit_to_result(hit) for hit in resp["hits"]["hits"]]
|
|
|
|
async def get_chunks(
|
|
self,
|
|
index_name: str,
|
|
doc_id: str,
|
|
*,
|
|
limit: int | None = None,
|
|
) -> list[SearchResult]:
|
|
"""Retrieve all indexed chunks for a document, ordered by chunk_index."""
|
|
if limit is None:
|
|
limit = self._default_limit
|
|
resp = await self._client.search(
|
|
index=index_name,
|
|
body={
|
|
"size": limit,
|
|
"query": {"term": {"doc_id": doc_id}},
|
|
"sort": [{"chunk_index": {"order": "asc"}}],
|
|
},
|
|
_source_excludes=["embedding"],
|
|
)
|
|
return [_hit_to_result(hit) for hit in resp["hits"]["hits"]]
|
|
|
|
async def delete_document(self, index_name: str, doc_id: str) -> int:
|
|
"""Delete all chunks for a document. Returns the number deleted."""
|
|
try:
|
|
resp = await self._client.delete_by_query(
|
|
index=index_name,
|
|
body={"query": {"term": {"doc_id": doc_id}}},
|
|
refresh=True,
|
|
)
|
|
deleted: int = resp.get("deleted", 0)
|
|
return deleted
|
|
except NotFoundError:
|
|
return 0
|
|
|
|
# -- full-text search (bonus from spec) ------------------------------------
|
|
|
|
async def search_fulltext(
|
|
self,
|
|
index_name: str,
|
|
query_text: str,
|
|
*,
|
|
k: int = 10,
|
|
doc_id: str | None = None,
|
|
) -> list[SearchResult]:
|
|
"""Full-text search on the content field.
|
|
|
|
This method is not part of the VectorStore protocol but is specified
|
|
in the issue acceptance criteria.
|
|
"""
|
|
must: list[dict[str, Any]] = [{"match": {"content": query_text}}]
|
|
if doc_id:
|
|
must.append({"term": {"doc_id": doc_id}})
|
|
|
|
resp = await self._client.search(
|
|
index=index_name,
|
|
body={
|
|
"size": k,
|
|
"query": {"bool": {"must": must}},
|
|
},
|
|
_source_excludes=["embedding"],
|
|
)
|
|
return [_hit_to_result(hit) for hit in resp["hits"]["hits"]]
|