feat(qdrant): add tenant-scoped point storage for ingestion
Why: - Ingested chunks need to become searchable Qdrant points before the upload response returns, with tenant/domain isolation and a safe re-ingestion story per ADR-0001/0017. Changes: - src/application/points/: index_chunks() is the sole entry point, owning payload construction, batched/bounded-concurrency upserts (upsert_concurrency semaphore), and a soft-delete sweep for points a shorter re-ingestion leaves behind. The sweep runs only after every upsert in the attempt succeeds, so a failed attempt can leave a stale prefix but never removes content from a working index. - PointStorage port (application/ports/) + QdrantPointStorage adapter (infrastructure/qdrant/points.py), keeping the qdrant_client SDK out of application code per ADR-0015. - FakePointStorage test double for exercising the ordering/idempotency guarantees without a real Qdrant.
This commit is contained in:
45
src/application/ports/point_storage.py
Normal file
45
src/application/ports/point_storage.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""The point-storage port (ADR-0001, ADR-0015).
|
||||
|
||||
`src/infrastructure/qdrant/points.py` is the production adapter; tests use a
|
||||
hand-written fake (ADR-0016). Application code depends on this Protocol, not on
|
||||
the `qdrant_client` SDK.
|
||||
|
||||
Deliberately narrow: exactly the two operations ingestion performs. Reads,
|
||||
single-point edits, reordering, and keyword search are plan 002's `/v1/points`
|
||||
surface and belong on a port of their own rather than accreting here.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from src.application.points.models import ChunkPoint
|
||||
|
||||
|
||||
class PointStorage(Protocol):
|
||||
async def upsert_points(self, points: Sequence[ChunkPoint]) -> None:
|
||||
"""Upsert one batch of points.
|
||||
|
||||
Callers own batching and concurrency bounding (ADR-0017's
|
||||
`upsert_concurrency` semaphore), not this Protocol — the same division
|
||||
`DenseEmbedder.embed_batch` uses.
|
||||
"""
|
||||
...
|
||||
|
||||
async def deactivate_points_from_index(
|
||||
self,
|
||||
*,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
from_chunk_index: int,
|
||||
deleted_at: datetime,
|
||||
updated_by: str,
|
||||
) -> int:
|
||||
"""Soft-delete this file's points at or past `from_chunk_index`.
|
||||
|
||||
Sets `is_active=false`/`deleted_at` rather than removing the points
|
||||
(ADR-0002: delete is soft by default). Tenant-filtered — a `file_id`
|
||||
alone is never sufficient authority. Returns how many points matched.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user