feat(points): add the point read/edit port and its Qdrant adapter
Why: - PointStorage is deliberately the two bulk operations ingestion performs. Reads, single-point edits, and keyword search have a different caller, a different failure vocabulary, and a different tenant-filter obligation, so they get their own port rather than accreting onto the ingestion one. Changes: - tenant_id is a required keyword argument on every port method, making a forgotten tenant filter a type error rather than a review question. - Reads go through scroll with a HasIdCondition, not retrieve: retrieve takes no filter and would push the tenant check into Python after Qdrant already answered -- the shape ADR-0002's isolation rule exists to prevent. - Ordered listing paginates by order_id value, not offset. Qdrant returns no page offset under order_by, and an offset cursor skips or repeats rows when a concurrent insert shifts positions underneath the reader. - Point.from_payload takes a Mapping, not a dict: dict is invariant in its value type, so the SDK's concrete vector union is not a dict[str, object]. - Request schemas forbid extra keys and omit server-owned fields, so a client sending tenant_id or version gets 422 rather than having it silently ignored. Impact: - No route uses this yet; the /v1/points surface is Phase 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
145
src/api/schemas/points.py
Normal file
145
src/api/schemas/points.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Public request/response models for `/v1/points` (ADR-0002, ADR-0008).
|
||||
|
||||
The shape callers see, kept separate from `application/points`' domain models
|
||||
(ADR-0015). Two rules are encoded here rather than left to route code:
|
||||
|
||||
- **Vectors are opt-in.** `PointResponse` omits them unless the caller asked,
|
||||
so a listing does not ship megabytes of floats nobody reads (ADR-0008).
|
||||
- **Server-owned fields are not accepted on input.** The request models simply
|
||||
do not declare `tenant_id`, `version`, or `chunk_index`, and forbid extra
|
||||
keys, so a client that sends one gets `422` from Pydantic instead of having
|
||||
it silently ignored — ADR-0002's isolation rule enforced at the boundary.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.application.points.point import Point
|
||||
from src.application.ports.point_repository import PointPage
|
||||
|
||||
|
||||
class PointResponse(BaseModel):
|
||||
point_id: uuid.UUID
|
||||
domain: str
|
||||
file_id: uuid.UUID
|
||||
chunk_id: uuid.UUID
|
||||
|
||||
content: str
|
||||
content_type: str
|
||||
source_filename: str
|
||||
source_type: str
|
||||
|
||||
order_id: float
|
||||
chunk_index: int
|
||||
previous_chunk_id: uuid.UUID | None
|
||||
next_chunk_id: uuid.UUID | None
|
||||
|
||||
is_active: bool
|
||||
deleted_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
updated_by: str
|
||||
|
||||
version: int
|
||||
content_hash: str
|
||||
embedding_model_version: str
|
||||
|
||||
vectors: dict[str, object] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_point(cls, point: Point) -> "PointResponse":
|
||||
# `tenant_id` is present on `Point` and deliberately absent here: the
|
||||
# caller already knows which tenant it authenticated as, and echoing it
|
||||
# back invites clients to start sending it.
|
||||
return cls.model_validate(point.model_dump(exclude={"tenant_id"}))
|
||||
|
||||
|
||||
class PointListResponse(BaseModel):
|
||||
"""A page of points plus the cursor for the next one.
|
||||
|
||||
Cursor-based rather than `limit`/`offset`: an offset cursor silently skips
|
||||
or repeats rows when a concurrent insert shifts positions, which is exactly
|
||||
the pagination defect plan 002 requires a test for.
|
||||
"""
|
||||
|
||||
points: list[PointResponse]
|
||||
next_cursor: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_page(cls, page: PointPage) -> "PointListResponse":
|
||||
return cls(
|
||||
points=[PointResponse.from_point(point) for point in page.points],
|
||||
next_cursor=page.next_cursor,
|
||||
)
|
||||
|
||||
|
||||
class PointCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class PointSearchResponse(PointListResponse):
|
||||
"""Results of a **keyword** match, not of semantic retrieval.
|
||||
|
||||
Named and documented so it cannot be mistaken for ADR-0003's hybrid
|
||||
retrieval: these points matched a full-text filter on `content`, they are
|
||||
not ranked by relevance, and there is no score to report. Anything that
|
||||
wants ranked results wants the agent retrieval path in plan 003.
|
||||
"""
|
||||
|
||||
query: str
|
||||
|
||||
|
||||
class PointCreateRequest(BaseModel):
|
||||
"""Create one point. The server assigns identity, ordering, and provenance.
|
||||
|
||||
`after_point_id` positions the new point rather than a raw `order_id`: the
|
||||
caller says where in the sequence it goes and the server computes the
|
||||
fractional key and relinks neighbours, which a client-supplied `order_id`
|
||||
could not do correctly (ADR-0002). `None` means "at the start of the file".
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
file_id: uuid.UUID
|
||||
content: str = Field(min_length=1)
|
||||
content_type: str = "paragraph"
|
||||
after_point_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class PointReplaceRequest(BaseModel):
|
||||
"""Replace a point's content under a version guard.
|
||||
|
||||
`version` here is the *expected* version, not a value being written — the
|
||||
optimistic-concurrency precondition. A mismatch is `409`.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: str = Field(min_length=1)
|
||||
content_type: str | None = None
|
||||
version: int
|
||||
|
||||
|
||||
class PointPayloadPatchRequest(BaseModel):
|
||||
"""Payload-only update of caller-writable fields, under a version guard."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
payload: dict[str, object]
|
||||
version: int
|
||||
|
||||
|
||||
class PointReorderRequest(BaseModel):
|
||||
"""Move a point to sit immediately after `after_point_id`.
|
||||
|
||||
`None` moves it to the front of the file. Expressed as a neighbour rather
|
||||
than an `order_id` for the same reason as `PointCreateRequest`.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
after_point_id: uuid.UUID | None = None
|
||||
version: int
|
||||
Reference in New Issue
Block a user