From e68c6fdd2ed64d9a5df31ce67464665aa1497c3b Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 00:15:32 +0000 Subject: [PATCH 01/23] =?UTF-8?q?docs(spec):=20record=20Phase=204=20(annot?= =?UTF-8?q?ation=20v2)=20design=20decisions=20(=C2=A717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-27-schema-centric-data-model-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md index 1d209c82a..dd07d20cc 100644 --- a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md +++ b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md @@ -425,3 +425,186 @@ outcomes and the deviations added during implementation/review (roborev jobs 80 no test asserts the deferred-optimize call count; `ge`/`le` filters with a `None` value produce an always-false `>= NULL` (no 422 guard on ordered ops); `lancedb_uri: str | None` is always `str` at runtime and treats `""` as unset. + +## 17. Resolved during Phase 4 design (2026-07-08) — annotation v2 + +Phase 4 builds the annotation layer: the human-in-the-loop review surface over LLM +extractions. This section **supersedes §5's annotation rows and §3's Question framing** +where they conflict; the decisions below are the authority for Phase 4. + +### 17.0 Mental model: field ⊋ question ⊋ reviewable cell (DECIDED) + +The v1 trinity (Question/Suggestion/Response) is kept, but its meaning is corrected for +the schema-centric model. The prior draft conflated *field*, *column*, and *question*; +they are nested, not equal: + +- **`record.fields`** is the **superset** — context-relevant extractions the annotator + needs to do their job. Most fields are read-only context. +- A **question** binds the **reviewable subset** of columns. A field may *reference* a + question, in which case its authoritative value is **not stored** in `record.fields` — + it lives in the suggestion→response for that question. So a stored field and a + question are (mostly) mutually exclusive sources for a column's value. +- The **projection view** (§17.4) is over **questions** — the reviewable cells. Plain + non-question fields ride along as read-only context. +- A field neither implies a question nor a response. + +The **field↔question unification** (a field whose value is *defined by* a question, so a +schema becomes a chain of extraction/annotation steps whose later steps consume earlier +steps' annotated values) is a real generalization of this model but is **out of scope for +Phase 4** — it gets its own design session (§17.6). Phase 4's job is to not preclude it. + +### 17.1 Three entities, reached leanly (DECIDED) + +Keep `question` + `suggestion` + `response`, but implement them by **reusing v1's +validation logic** (§9's "fold v1 annotation into thin v2"), not by porting handlers +wholesale. The alternatives considered and rejected: + +- **Collapse to responses-only** (questions→`columns_cache`/`review_widgets`, + suggestions→`record.fields`). Rejected: (a) `v2_questions` is useful as the entity that + **defines per-cell validators** (its settings drive response/suggestion validation, and + it is the natural anchor for the future field↔question pydantic/Pandera binding); (b) a + **suggestion is a distinct lifecycle layer**, not a synonym for the stored field — it is + the LLM-**pre-populated** proposed value, and **submitting a response converts the + suggestion into a response**. Folding it into `record.fields` loses that loop and its + `score`/`agent` provenance. +- **Wholesale v1 port** (three tables + duplicated handlers/validators). Rejected to avoid + maintaining two parallel annotation code stacks; the validators and settings pydantic + are imported from v1, not re-authored. + +### 17.2 Tables (additive Alembic; v2_-prefixed, same footnote as `V2Record`) + +v1 owns `questions`/`suggestions`/`responses`, and a second declarative class of the same +name breaks v1's string-based `relationship()` registry lookups (§14). So the ORM classes +are `V2Question`/`V2Suggestion`/`V2Response` on `v2_questions`/`v2_suggestions`/ +`v2_responses`; PG enums are `v2_*`-named. Canonical names return at Phase 6 retirement. + +| Table / ORM | Key columns | +|---|---| +| `v2_questions` / `V2Question` | `id`, `schema_id`→schemas CASCADE, `name`, `title`, `description?`, `type` (PG enum `v2_question_type_enum`: `text`\|`label`\|`multi_label`\|`rating`\|`ranking`\|`span`\|`table`), `columns` JSONB (bound column names), `settings` JSONB (v1 `QuestionSettings` shape), `required` bool, timestamps; uniq(`schema_id`,`name`) | +| `v2_suggestions` / `V2Suggestion` | `id`, `record_id`→v2_records CASCADE, `question_id`→v2_questions CASCADE, `value` JSONB, `score` JSONB (float\|float[]\|null), `agent?`, `type?` (PG enum `v2_suggestion_type_enum`, v1 `SuggestionType` values), timestamps; uniq(`record_id`,`question_id`) | +| `v2_responses` / `V2Response` | `id`, `record_id`→v2_records CASCADE, `user_id`→users CASCADE, `values` JSONB (`{question_name: {value}}`, Decision §17.3), `status` (PG enum `v2_response_status_enum`: `draft`\|`submitted`\|`discarded`), timestamps; uniq(`record_id`,`user_id`) | + +### 17.3 Decided items + +- **`response.values` = keyed-by-question map, one row per `(record, user)` (DECIDED).** + `values` is `{question_name: {value}}`; the question resolves to column(s). Rationale: + Phase 5 queue-progress counts **responses per (reference→record, user)** — response-row + granularity, unaffected by the values shape — and the `(record_id, user_id)` unique + constraint is the overlap unit distribution needs. Per-question rows would only serve + per-question analytics on *responses*, which in the schema-centric model is the record + cells (Lance) + suggestions, not the review overlay. Submit is a single-row upsert, + matching the UX (the annotator submits the whole form). A `table` question's + multi-column answer stays one JSONB value under its key. + +- **`span` question type deferred out of Phase 4 (DECIDED).** The enum value is reserved, + but question-create **rejects `type=span` with a documented 422**. v2 cells are short + structured values; the compelling span target is document/PDF text, which is the + chunk-retrieval design's domain (§15 ledger). Defining a v1-style char-offset anchor + into `str(record.fields[column])` now would likely be throwaway against the real + document/chunk anchor. The other six types fully cover the cell-review layer. + Migration of any v1 `span` questions is decided in the migrator phase (§8) — parked or + skipped, not annotatable until the chunk-retrieval design lands. + +- **No `record.status` transitions on response submit in Phase 4 (DECIDED).** Submitting a + response writes only the `v2_responses` row. `v2_records.status` is set solely by record + writes (bulk-upsert). The v1 flip (`responses_submitted >= min_submitted` → + `completed`) *is* distribution logic (§9 folds `contexts/distribution.py` into the + queue); **Phase 5 owns it** with the queue. Consequence: nothing moves a record to + `completed` in Phase 4 — correct, completion is a distribution concept. + +- **`question.columns` binding validated at create/update against the current + `columns_cache` (DECIDED).** Every bound name must exist in + `schema.current_version_id`'s `columns_cache`; **non-table binds exactly 1 column, + `table` binds ≥1**. Questions can only be created against a **published** schema + (`current_version_id` non-null) → 422 otherwise. **Publish-time revalidation** (a later + version renaming/dropping a bound column dangles the binding) and **dtype-compatibility** + checks stay in the §15 ledger. Old-version rendering tolerance: when a record pins an + older version whose `columns_cache` lacks a bound column, the UI renders it as + not-applicable — a display concern, not a validation error. + +- **Value validation is settings-level only (DECIDED).** Reuse v1's per-type validators + (`validators/response_values.py`): text is str, label/rating in options, ranking + ranks/values valid+unique, multi_label unique+available; `table` value is a dict whose + keys ⊆ `question.columns` (structure only — **no Pandera re-run** on the cells). This + keeps annotation **Postgres-only**: responses/suggestions never fetch the S3 Pandera + body. `SuggestionCreateValidator`'s score-cardinality checks (value/score length + + list/scalar match) are ported as-is. Record-cell correctness remains the record-write + path's responsibility. + +### 17.4 The projection view (the "single view" — the product surface) + +The first-class read surface. Not a stored table in Phase 4 — a **read-model** over the +three tables. + +- **Grain:** record × question → one resolved cell, grouped by `reference` (composes with + §6 reference grouping). +- **Resolution precedence:** submitted **response** (requesting user) → **suggestion**. + (No `?? field` term: a reviewable column's value lives in the suggestion→response; plain + non-question fields are separate context, not resolved cells.) The payload also returns + the underlying suggestion + response so the UI shows provenance and lets the annotator + override. +- **Suggestion→response conversion:** on submit, the annotator confirms or edits the + suggestion value; the response stores the human value; the suggestion row remains + (provenance) and is superseded by the response in the view. +- **Backing:** **query-time Postgres** for a single reference/record (a point query, not + OLAP). The endpoint contract is **stable and shipped in Phase 4**; a **materialized OLAP + method** (DuckDB/ibis/Lance across references) is a future iteration that swaps the + backing without changing the contract. The **frontend consumes this endpoint in the + later frontend phase** (§11) — Phase 4 delivers the server contract only. + +### 17.5 API surface (`/api/v2`) and authorization + +- **Questions:** `POST|GET|PUT|DELETE /schemas/{id}/questions`, `GET /questions/{id}`. +- **Suggestions:** `PUT /records/{id}/suggestions` (upsert on `(record, question)`), + `GET /records/{id}/suggestions`. +- **Responses:** `PUT /records/{id}/responses` (upsert the **current user's own** + response), `GET /records/{id}/responses`. +- **Projection view:** `GET /references/{reference:path}/view?workspace_id=` (or an + extension of the existing `GET /references/{reference}`), returning per-record resolved + cells + provenance grouped by reference. +- **Version read (pulled into Phase 4):** `GET /schemas/{id}/versions/{version}` — the + §15 ledger item, needed so the annotation UI can render records pinned to old versions. +- **No Lance sync for annotation.** Questions/suggestions/responses live **only in + Postgres**; annotation contexts never import the index engine. Lance indexes record + cells for search; the review overlay is queried from Postgres. +- **Policies** (alongside `SchemaPolicy` in `api/policies/v1/`, which already targets + `models.v2`): `QuestionPolicy` and `SuggestionPolicy` mirror `SchemaPolicy` + (list/get: workspace member; create/update/delete: owner or admin+member), scoped via + the question's / record's `schema.workspace_id`. **`V2ResponsePolicy` ports v1's + own-response authz**: `owner OR actor.id == response.user_id OR (admin AND + member(workspace))`, workspace resolved via `record.schema.workspace_id` — annotators + write and read their own response, not others'. + +### 17.6 Out of scope (each with a pointer) + +- **Field↔question unification / schema-as-chain-of-steps** — a field whose authoritative + value is *defined by* a question (value not stored), enabling multi-step schemas whose + later steps consume earlier annotated values across `reference`. Phase 4 is compatible + (a question binds a column without requiring a stored field) but does not implement the + cross-schema resolution. **Its own design session.** +- **Chain-of-steps resolution in the view** — following a field's question reference + through suggestion→response across schemas needs the OLAP join above; deferred with the + field↔question spec. +- **Consensus / multi-annotator reconciliation** — the view resolves to the requesting + user's response; cross-annotator agreement (overlap → reconciled cell) is a later spec. + Phase 5 distribution produces the overlap; reconciliation consumes it. +- **OLAP materialization method** for the projection view — query-time per-reference for + now (§17.4). +- **Per-cell suggestion `score`/`agent` surfaced beyond the suggestion row** — if a + review-UI need appears, a `{column: {score, agent}}` map in `record.metadata` is the + cheap step. + +### 17.7 Testing strategy (Phase 4 additions to §10) + +- **Unit:** binding validation (existence + arity, published-schema requirement, span + 422); ported per-type value validators + score cardinality; own-response authz; + projection resolution precedence (response→suggestion, conversion on submit). +- **Contexts (async pytest):** question CRUD; suggestion upsert on `(record, question)`; + response upsert on `(record, user)` with **no `record.status` side-effect** and **no + index-engine call** (assert the engine is untouched); projection assembly for a + reference. +- **API:** `/api/v2` annotation routers happy-path + 422 (span, unbound column, unpublished + schema, bad value shape) + authz (own-response, cross-user 403); single-version read; + projection-view endpoint. +- **Migration (§8):** v1 questions→`v2_questions` column bindings, suggestions, responses + parity; v1 span questions handled per the migrator decision. From 6a9e71c3dc4febd5f3a771fb12f90c7b6e7fa10b Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 00:26:04 +0000 Subject: [PATCH 02/23] docs(plan): Phase 4 v2 annotation implementation plan --- .../plans/2026-07-08-v2-annotation.md | 2002 +++++++++++++++++ 1 file changed, 2002 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-v2-annotation.md diff --git a/docs/superpowers/plans/2026-07-08-v2-annotation.md b/docs/superpowers/plans/2026-07-08-v2-annotation.md new file mode 100644 index 000000000..7e1a17e02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-v2-annotation.md @@ -0,0 +1,2002 @@ +# v2 Annotation (Phase 4) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the schema-centric annotation layer — questions (reviewable column bindings), suggestions (LLM pre-populated values), responses (human submissions), and a query-time projection view that resolves each reviewable cell — on top of the v2 records built in Phases 1–3. + +**Architecture:** Three additive `v2_`-prefixed Postgres tables reached leanly by *reusing* v1's per-type value validators (not re-authoring them). Annotation is Postgres-only (no LanceDB sync). The product surface is a read-model "projection view" that resolves each cell as `submitted response (requesting user) → suggestion`, assembled at query time and exposed as a contract-stable API endpoint. See spec §17 (`docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md`). + +**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), Alembic, Pydantic v2, async pytest + factory-boy. Package `extralit_server` under `extralit-server/src/`. + +## Global Constraints + +- **All commands run from `extralit-server/`** with `uv run` (e.g. `uv run pytest`, `uv run alembic -c src/extralit_server/alembic.ini ...`). Never use pip/poetry. +- **v2 naming (spec §14/§17.2):** ORM classes `V2Question`/`V2Suggestion`/`V2Response` on tables `v2_questions`/`v2_suggestions`/`v2_responses`; PG enums `v2_question_type_enum`/`v2_suggestion_type_enum`/`v2_response_status_enum`. A second declarative class named `Question`/`Suggestion`/`Response` breaks v1's string-based `relationship()` registry lookups. +- **Reuse, don't fork:** import v1's `QuestionType`, `SuggestionType`, `ResponseStatus` enums (`extralit_server.enums`) and v1's per-type value validators (`extralit_server.validators.response_values`). Do not duplicate them. `QuestionType` values are `text|rating|label_selection|multi_label_selection|ranking|span|table`. +- **Policy names are `V2`-prefixed:** `QuestionPolicy`/`SuggestionPolicy`/`ResponsePolicy` already exist for v1 in `api/policies/v1/__init__.py`. Use `V2QuestionPolicy`/`V2SuggestionPolicy`/`V2ResponsePolicy`. +- **No Lance sync for annotation (spec §17.5):** annotation contexts/handlers MUST NOT import `extralit_server.index` or `contexts.v2.index_sync`. +- **span deferred (spec §17.3):** `span` is a reserved enum value; question-create rejects `type=span` with a 422. No span anchor/validation in Phase 4. +- **No `record.status` transitions on response submit (spec §17.3):** response upsert never mutates `V2Record.status`. +- **Settings-level validation only (spec §17.3):** no Pandera re-run in annotation; `table` responses validate structure (dict, keys ⊆ `question.columns`) only. +- **Authorize before mutate:** every handler calls `await authorize(current_user, .(...))` before touching data, mirroring `api/v2/records.py`. +- **Test auth convention:** integration tests use the `async_client` fixture and dict header fixtures `owner_auth_header` / `annotator_auth_header` (defined in `tests/integration/conftest.py` as `{API_KEY_HEADER_NAME: user.api_key}`). For an arbitrary created user, build `{API_KEY_HEADER_NAME: user.api_key}` importing `API_KEY_HEADER_NAME` from `extralit_server.constants`. Never hardcode the header string. +- **Commit convention:** conventional commits (`feat:`, `test:`, `refactor:`); this repo commits plan/impl work directly to `develop`. + +--- + +## File Structure + +**Models** (`src/extralit_server/models/v2/`) +- Create `questions.py` — `V2Question` ORM. +- Create `suggestions.py` — `V2Suggestion` ORM. +- Create `responses.py` — `V2Response` ORM. +- Modify `__init__.py` — export the three classes. +- Modify `schemas.py` — remove `kind`/`SchemaKind` (Task 1). + +**Migrations** (`src/extralit_server/alembic/versions/`) +- Create `_drop_schemas_kind.py` (Task 1). +- Create `_create_v2_annotation_tables.py` (Task 3). + +**Enums** (`src/extralit_server/enums.py`) +- Modify to remove `SchemaKind` (Task 1). No new enums — reuse existing. + +**API schemas** (`src/extralit_server/api/schemas/v2/`) +- Create `questions.py` — `QuestionCreate`/`QuestionUpdate`/`QuestionRead`/`Questions`. +- Create `annotation.py` — `SuggestionUpsert`/`SuggestionRead`, `ResponseUpsert`/`ResponseRead`. +- Create `projection.py` — `ProjectionCell`/`ProjectionRecord`/`ProjectionView`. + +**Validators** (`src/extralit_server/validators/v2/`) +- Create `__init__.py` (empty). +- Create `questions.py` — `QuestionBindingValidator`. +- Create `values.py` — `V2ResponseValueValidator` (dispatch reusing v1 per-type validators) + `V2SuggestionValidator`. + +**Contexts** (`src/extralit_server/contexts/v2/`) +- Create `annotation.py` — question CRUD + suggestion/response upsert/list. +- Create `projection.py` — projection-view assembly. +- Modify `schemas.py` — add `get_version_by_number`. + +**Policies** (`src/extralit_server/api/policies/v1/`) +- Create `v2_annotation_policy.py` — `V2QuestionPolicy`, `V2SuggestionPolicy`, `V2ResponsePolicy`. +- Modify `__init__.py` — export the three. + +**API handlers** (`src/extralit_server/api/v2/`) +- Create `questions.py` — `/schemas/{id}/questions` router. +- Create `annotation.py` — `/records/{id}/suggestions` + `/records/{id}/responses` router. +- Create `projection.py` — `/references/{ref}/view` router. +- Modify `schemas.py` — add `GET /schemas/{id}/versions/{version}`. +- Modify `__init__.py` — include the new routers. + +**Factories** (`tests/factories.py`) +- Add `V2QuestionFactory`, `V2SuggestionFactory`, `V2ResponseFactory`; edit `SchemaFactory` (drop `kind`). + +**Tests** (`tests/integration/`) +- `models/v2/test_annotation_models.py`, `contexts/v2/test_annotation_context.py`, `contexts/v2/test_projection.py`, `api/v2/test_questions.py`, `api/v2/test_annotation.py`, `api/v2/test_projection.py`, `api/v2/test_schema_versions.py`, and unit tests under `tests/unit/validators/v2/`. + +--- + +## Task 1: Drop `schemas.kind` / `SchemaKind` (spec §14 prerequisite) + +`kind` is emergent from question/column bindings, not a stored discriminator; §14 mandates removing it before question bindings are built. + +**Files:** +- Modify: `src/extralit_server/models/v2/schemas.py` (remove `kind`, `SchemaKind`, `SchemaKindEnum`) +- Modify: `src/extralit_server/enums.py` (remove `SchemaKind`) +- Modify: `src/extralit_server/api/schemas/v2/schemas.py` (remove `kind` from `SchemaCreate:14` and `SchemaRead:52`) +- Modify: `src/extralit_server/contexts/v2/schemas.py` (remove the `kind` param from `create_schema:28,35`) +- Modify: `src/extralit_server/api/v2/schemas.py:45` (drop `kind=...` from the `create_schema(...)` call) +- Modify: `tests/factories.py:657` (`SchemaFactory` — remove `kind = SchemaKind.table`) +- Modify: `tests/integration/api/v2/test_schemas.py` (remove `SchemaKind` import + `"kind": ...` from the create body) +- Modify: `tests/integration/contexts/v2/test_schemas_context.py:39,49,74` (drop `kind=SchemaKind.table` from `create_schema(...)` calls + import) +- Create: `src/extralit_server/alembic/versions/_drop_schemas_kind.py` +- Test: `tests/integration/models/v2/test_schema_models.py` (remove any `kind` assertions) + +**Interfaces:** +- Produces: `Schema` model, `SchemaCreate`/`SchemaRead`, and `create_schema(db, *, name, workspace_id, settings=...)` with no `kind`. + +- [ ] **Step 1: Find every reference to `SchemaKind`/`kind`** + +Run: `cd extralit-server && rg -n "SchemaKind|\bkind\b" src/extralit_server tests` +Expected: matches in `models/v2/schemas.py`, `enums.py`, `api/schemas/v2/schemas.py`, `contexts/v2/schemas.py`, `api/v2/schemas.py`, `tests/factories.py`, `tests/integration/api/v2/test_schemas.py`, `tests/integration/contexts/v2/test_schemas_context.py`. Every hit gets removed in the following steps. + +- [ ] **Step 2: Remove `kind` from the `Schema` model** + +In `src/extralit_server/models/v2/schemas.py`: delete the `SchemaKind` import, the `SchemaKindEnum = SAEnum(...)` line, the `kind: Mapped[SchemaKind] = mapped_column(...)` line, and the `kind={self.kind!r}` fragment in `__repr__`. + +- [ ] **Step 3: Remove `kind` from the API schema, context, and handler call** + +- `api/schemas/v2/schemas.py`: delete `kind: SchemaKind = SchemaKind.table` (in `SchemaCreate`) and `kind: SchemaKind` (in `SchemaRead`), and the `SchemaKind` import. +- `contexts/v2/schemas.py`: remove the `kind: "SchemaKind",` parameter from `create_schema` and the `kind=kind,` line in the `Schema(...)` construction; remove the `SchemaKind` import. +- `api/v2/schemas.py:45`: remove the `kind=payload.kind,` argument from the `create_schema(...)` call. + +- [ ] **Step 4: Remove `SchemaKind` from enums, factory, and tests** + +In `src/extralit_server/enums.py` delete the `class SchemaKind(StrEnum): ...` block. In `tests/factories.py` remove `kind = SchemaKind.table` and its import. In `tests/integration/api/v2/test_schemas.py` remove the `SchemaKind` import and the `"kind": SchemaKind.table.value,` body key. In `tests/integration/contexts/v2/test_schemas_context.py` remove `kind=SchemaKind.table` from the three `create_schema(...)` calls and the `SchemaKind` import. + +- [ ] **Step 5: Verify imports resolve** + +Run: `cd extralit-server && uv run python -c "import extralit_server.models.v2.schemas, extralit_server.enums, extralit_server.api.schemas.v2.schemas, extralit_server.contexts.v2.schemas, extralit_server.api.v2.schemas; import tests.factories" && cd extralit-server && rg -n "SchemaKind" src tests || echo "no SchemaKind references remain"` +Expected: no ImportError; `no SchemaKind references remain`. + +- [ ] **Step 6: Autogenerate the drop migration** + +Run: `cd extralit-server && uv run alembic -c src/extralit_server/alembic.ini revision --autogenerate -m "drop schemas.kind"` +Then open the generated file and confirm `upgrade()` contains `op.drop_column("schemas", "kind")`. Add an explicit enum-type drop after it (autogenerate misses PG enums): + +```python +def upgrade() -> None: + op.drop_column("schemas", "kind") + op.execute("DROP TYPE IF EXISTS schema_kind_enum") + + +def downgrade() -> None: + schema_kind = sa.Enum("singleton", "table", name="schema_kind_enum") + schema_kind.create(op.get_bind(), checkfirst=True) + op.add_column("schemas", sa.Column("kind", schema_kind, nullable=False, server_default="table")) +``` + +- [ ] **Step 7: Apply and run the affected schema tests** + +Run: `cd extralit-server && uv run alembic -c src/extralit_server/alembic.ini upgrade head && uv run pytest tests/integration/models/v2/test_schema_models.py tests/integration/contexts/v2/test_schemas_context.py tests/integration/api/v2/test_schemas.py -q` +Expected: migration applies; tests pass (fix any remaining `kind` assertion by deleting it). + +- [ ] **Step 8: Commit** + +```bash +git add -A && git commit -m "refactor(v2): drop schemas.kind (emergent from bindings, spec §14)" +``` + +--- + +## Task 2: v2 annotation ORM models + +**Files:** +- Create: `src/extralit_server/models/v2/questions.py` +- Create: `src/extralit_server/models/v2/suggestions.py` +- Create: `src/extralit_server/models/v2/responses.py` +- Modify: `src/extralit_server/models/v2/__init__.py` + +**Interfaces:** +- Produces: `V2Question(id, schema_id, name, title, description, type, columns, settings, required, inserted_at, updated_at)`; `V2Suggestion(id, record_id, question_id, value, score, agent, type, ...)`; `V2Response(id, record_id, user_id, values, status, ...)`. Imported as `from extralit_server.models.v2 import V2Question, V2Suggestion, V2Response`. + +- [ ] **Step 1: Write `V2Question`** + +Create `src/extralit_server/models/v2/questions.py`: + +```python +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.ext.mutable import MutableDict, MutableList +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import QuestionType +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.v2.schemas import Schema + +# Distinct PG enum name (v1 stores question type inside settings JSON, but v2 promotes it to a +# first-class column). Reuses the v1 QuestionType *values*. +V2QuestionTypeEnum = SAEnum(QuestionType, name="v2_question_type_enum") + + +class V2Question(DatabaseModel): + """Reviewable column binding + review config (spec §17). Its settings drive per-cell + value validation. `columns` binds >=1 schema column (exactly 1 for non-table types).""" + + __tablename__ = "v2_questions" + + schema_id: Mapped[UUID] = mapped_column(ForeignKey("schemas.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String, index=True) + title: Mapped[str] = mapped_column(Text) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + type: Mapped[QuestionType] = mapped_column(V2QuestionTypeEnum, index=True) + columns: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) + settings: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict) + required: Mapped[bool] = mapped_column(default=False) + + schema: Mapped["Schema"] = relationship("Schema") + + __table_args__ = (UniqueConstraint("schema_id", "name", name="v2_question_schema_id_name_uq"),) + + def __repr__(self) -> str: + return f"V2Question(id={self.id!s}, schema_id={self.schema_id!s}, name={self.name!r}, type={self.type!r})" +``` + +- [ ] **Step 2: Write `V2Suggestion`** + +Create `src/extralit_server/models/v2/suggestions.py`: + +```python +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import SuggestionType +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.v2.questions import V2Question + from extralit_server.models.v2.records import V2Record + +V2SuggestionTypeEnum = SAEnum(SuggestionType, name="v2_suggestion_type_enum") + + +class V2Suggestion(DatabaseModel): + """LLM-pre-populated proposed value per (record, question) (spec §17). Superseded by a + submitted response in the projection view, but retained as provenance.""" + + __tablename__ = "v2_suggestions" + + record_id: Mapped[UUID] = mapped_column(ForeignKey("v2_records.id", ondelete="CASCADE"), index=True) + question_id: Mapped[UUID] = mapped_column(ForeignKey("v2_questions.id", ondelete="CASCADE"), index=True) + value: Mapped[object] = mapped_column(JSON) + score: Mapped[float | list[float] | None] = mapped_column(JSON, nullable=True) + agent: Mapped[str | None] = mapped_column(String, nullable=True) + type: Mapped[SuggestionType | None] = mapped_column(V2SuggestionTypeEnum, nullable=True, index=True) + + record: Mapped["V2Record"] = relationship("V2Record") + question: Mapped["V2Question"] = relationship("V2Question") + + __table_args__ = (UniqueConstraint("record_id", "question_id", name="v2_suggestion_record_id_question_id_uq"),) + + def __repr__(self) -> str: + return f"V2Suggestion(id={self.id!s}, record_id={self.record_id!s}, question_id={self.question_id!s})" +``` + +- [ ] **Step 3: Write `V2Response`** + +Create `src/extralit_server/models/v2/responses.py`: + +```python +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import ResponseStatus +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.database import User + from extralit_server.models.v2.records import V2Record + +V2ResponseStatusEnum = SAEnum(ResponseStatus, name="v2_response_status_enum") + + +class V2Response(DatabaseModel): + """Human review per (record, user); `values` keyed by question name -> {value} (spec §17.3). + Multiple users per record = the overlap axis Phase 5 distribution counts.""" + + __tablename__ = "v2_responses" + + record_id: Mapped[UUID] = mapped_column(ForeignKey("v2_records.id", ondelete="CASCADE"), index=True) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + values: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON), nullable=True) + status: Mapped[ResponseStatus] = mapped_column(V2ResponseStatusEnum, default=ResponseStatus.submitted, index=True) + + record: Mapped["V2Record"] = relationship("V2Record") + user: Mapped["User"] = relationship("User") + + __table_args__ = (UniqueConstraint("record_id", "user_id", name="v2_response_record_id_user_id_uq"),) + + @property + def is_submitted(self) -> bool: + return self.status == ResponseStatus.submitted + + def __repr__(self) -> str: + return f"V2Response(id={self.id!s}, record_id={self.record_id!s}, user_id={self.user_id!s}, status={self.status!r})" +``` + +- [ ] **Step 4: Export from the package** + +Replace `src/extralit_server/models/v2/__init__.py` with: + +```python +from extralit_server.models.v2.questions import V2Question +from extralit_server.models.v2.records import V2Record +from extralit_server.models.v2.records import V2Record as Record # v2-namespace alias +from extralit_server.models.v2.responses import V2Response +from extralit_server.models.v2.schemas import Schema, SchemaVersion +from extralit_server.models.v2.suggestions import V2Suggestion + +__all__ = ["Record", "Schema", "SchemaVersion", "V2Question", "V2Record", "V2Response", "V2Suggestion"] +``` + +- [ ] **Step 5: Verify the models import and register** + +Run: `cd extralit-server && uv run python -c "from extralit_server.models.v2 import V2Question, V2Suggestion, V2Response; print(V2Question.__tablename__, V2Suggestion.__tablename__, V2Response.__tablename__)"` +Expected: `v2_questions v2_suggestions v2_responses` + +- [ ] **Step 6: Commit** + +```bash +git add src/extralit_server/models/v2/ && git commit -m "feat(v2): add V2Question/V2Suggestion/V2Response models" +``` + +--- + +## Task 3: Migration + factories + model round-trip test + +**Files:** +- Create: `src/extralit_server/alembic/versions/_create_v2_annotation_tables.py` +- Modify: `tests/factories.py` +- Test: `tests/integration/models/v2/test_annotation_models.py` + +**Interfaces:** +- Consumes: models from Task 2. +- Produces: `V2QuestionFactory`, `V2SuggestionFactory`, `V2ResponseFactory` (in `tests/factories.py`); the three tables in the DB. + +- [ ] **Step 1: Autogenerate the migration** + +Run: `cd extralit-server && uv run alembic -c src/extralit_server/alembic.ini revision --autogenerate -m "create v2 annotation tables"` +Open the generated file. Confirm `upgrade()` creates `v2_questions`, `v2_suggestions`, `v2_responses` with their unique constraints. Ensure the three PG enums are created (SQLAlchemy usually emits `sa.Enum(..., name="v2_question_type_enum")` inline; if the autogenerated file references the enum without creating it, add explicit `create` calls at the top of `upgrade()` and `DROP TYPE` in `downgrade()`), e.g.: + +```python +def downgrade() -> None: + op.drop_table("v2_responses") + op.drop_table("v2_suggestions") + op.drop_table("v2_questions") + op.execute("DROP TYPE IF EXISTS v2_response_status_enum") + op.execute("DROP TYPE IF EXISTS v2_suggestion_type_enum") + op.execute("DROP TYPE IF EXISTS v2_question_type_enum") +``` + +- [ ] **Step 2: Apply the migration** + +Run: `cd extralit-server && uv run alembic -c src/extralit_server/alembic.ini upgrade head` +Expected: no error. Then `uv run alembic -c src/extralit_server/alembic.ini downgrade -1 && uv run alembic -c src/extralit_server/alembic.ini upgrade head` to confirm downgrade/upgrade round-trips cleanly. + +- [ ] **Step 3: Add factories** + +In `tests/factories.py`, add the imports (`V2Question as V2QuestionModel`, etc. following the existing `V2RecordModel` import style) and append after `V2RecordFactory`: + +```python +class V2QuestionFactory(BaseFactory): + class Meta: + model = V2QuestionModel + + schema = factory.SubFactory(SchemaFactory) + name = factory.Sequence(lambda n: f"question-{n}") + title = factory.Sequence(lambda n: f"Question {n}") + type = QuestionType.text + columns = factory.LazyFunction(list) + settings = factory.LazyAttribute(lambda o: {"type": o.type.value}) + required = False + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + schema = kwargs.get("schema") + if inspect.isawaitable(schema): + schema = await schema + kwargs["schema"] = schema + if schema is not None: + kwargs.setdefault("schema_id", schema.id) + return await super()._create(model_class, *args, **kwargs) + + +class V2SuggestionFactory(BaseFactory): + class Meta: + model = V2SuggestionModel + + record = factory.SubFactory(V2RecordFactory) + question = factory.SubFactory(V2QuestionFactory) + value = "suggested" + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + for key in ("record", "question"): + obj = kwargs.get(key) + if inspect.isawaitable(obj): + obj = await obj + kwargs[key] = obj + if obj is not None: + kwargs.setdefault(f"{key}_id", obj.id) + return await super()._create(model_class, *args, **kwargs) + + +class V2ResponseFactory(BaseFactory): + class Meta: + model = V2ResponseModel + + record = factory.SubFactory(V2RecordFactory) + user = factory.SubFactory(UserFactory) + values = factory.LazyFunction(dict) + status = ResponseStatus.submitted + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + for key in ("record", "user"): + obj = kwargs.get(key) + if inspect.isawaitable(obj): + obj = await obj + kwargs[key] = obj + if obj is not None: + kwargs.setdefault(f"{key}_id", obj.id) + return await super()._create(model_class, *args, **kwargs) +``` + +Add `QuestionType`, `ResponseStatus` to the `from extralit_server.enums import ...` line if not already imported. + +- [ ] **Step 4: Write the model round-trip + uniqueness test** + +Create `tests/integration/models/v2/test_annotation_models.py`: + +```python +import pytest +from sqlalchemy.exc import IntegrityError + +from extralit_server.enums import QuestionType, ResponseStatus +from tests.factories import V2QuestionFactory, V2ResponseFactory, V2SuggestionFactory + + +@pytest.mark.asyncio +async def test_question_persists_with_type_and_columns(db): + q = await V2QuestionFactory.create(type=QuestionType.label_selection, columns=["disease"]) + assert q.id is not None + assert q.type == QuestionType.label_selection + assert q.columns == ["disease"] + + +@pytest.mark.asyncio +async def test_question_name_unique_per_schema(db): + q = await V2QuestionFactory.create(name="dup") + with pytest.raises(IntegrityError): + await V2QuestionFactory.create(schema_id=q.schema_id, name="dup") + + +@pytest.mark.asyncio +async def test_suggestion_unique_per_record_question(db): + s = await V2SuggestionFactory.create() + with pytest.raises(IntegrityError): + await V2SuggestionFactory.create(record_id=s.record_id, question_id=s.question_id) + + +@pytest.mark.asyncio +async def test_response_unique_per_record_user(db): + r = await V2ResponseFactory.create(status=ResponseStatus.submitted, values={"q": {"value": "x"}}) + assert r.is_submitted + with pytest.raises(IntegrityError): + await V2ResponseFactory.create(record_id=r.record_id, user_id=r.user_id) +``` + +- [ ] **Step 5: Run the tests** + +Run: `cd extralit-server && uv run pytest tests/integration/models/v2/test_annotation_models.py -q` +Expected: 4 passed. (If `db` fixture name differs, copy the fixture usage from `tests/integration/models/v2/test_record_models.py`.) + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(v2): migration + factories for v2 annotation tables" +``` + +--- + +## Task 4: Question binding validator + +**Files:** +- Create: `src/extralit_server/validators/v2/__init__.py` (empty) +- Create: `src/extralit_server/validators/v2/questions.py` +- Test: `tests/unit/validators/v2/test_question_binding.py` + +**Interfaces:** +- Consumes: `SchemaVersion.columns_cache` (list of `{"name", "dtype", "nullable", "review"}`), `QuestionType`. +- Produces: `QuestionBindingValidator.validate(*, type: QuestionType, columns: list[str], columns_cache: list[dict]) -> None` — raises `UnprocessableEntityError`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/validators/v2/test_question_binding.py`: + +```python +import pytest + +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.v2.questions import QuestionBindingValidator + +COLUMNS_CACHE = [ + {"name": "disease", "dtype": "str", "nullable": True, "review": None}, + {"name": "p_value", "dtype": "float64", "nullable": True, "review": None}, +] + + +def test_non_table_binds_exactly_one_existing_column(): + QuestionBindingValidator.validate(type=QuestionType.label_selection, columns=["disease"], columns_cache=COLUMNS_CACHE) + + +def test_table_binds_one_or_more(): + QuestionBindingValidator.validate(type=QuestionType.table, columns=["disease", "p_value"], columns_cache=COLUMNS_CACHE) + + +def test_span_is_rejected(): + with pytest.raises(UnprocessableEntityError, match="span"): + QuestionBindingValidator.validate(type=QuestionType.span, columns=["disease"], columns_cache=COLUMNS_CACHE) + + +def test_unknown_column_rejected(): + with pytest.raises(UnprocessableEntityError, match="unknown"): + QuestionBindingValidator.validate(type=QuestionType.text, columns=["missing"], columns_cache=COLUMNS_CACHE) + + +def test_non_table_multiple_columns_rejected(): + with pytest.raises(UnprocessableEntityError, match="exactly one"): + QuestionBindingValidator.validate(type=QuestionType.rating, columns=["disease", "p_value"], columns_cache=COLUMNS_CACHE) + + +def test_empty_binding_rejected(): + with pytest.raises(UnprocessableEntityError, match="at least one"): + QuestionBindingValidator.validate(type=QuestionType.table, columns=[], columns_cache=COLUMNS_CACHE) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/unit/validators/v2/test_question_binding.py -q` +Expected: FAIL — `ModuleNotFoundError: extralit_server.validators.v2.questions`. + +- [ ] **Step 3: Implement the validator** + +Create `src/extralit_server/validators/v2/__init__.py` (empty) and `src/extralit_server/validators/v2/questions.py`: + +```python +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError + +# span is reserved in the enum but deferred to the PDF-chunk design session (spec §17.3). +DEFERRED_TYPES = {QuestionType.span} + + +class QuestionBindingValidator: + """Validate a question's column binding against the schema's current columns_cache + (spec §17.3): existence + arity. Publish-time revalidation and dtype-compat are deferred.""" + + @classmethod + def validate(cls, *, type: QuestionType, columns: list[str], columns_cache: list[dict]) -> None: + if type in DEFERRED_TYPES: + raise UnprocessableEntityError( + f"question type {type.value!r} (span) is not supported in this release; " + "it is deferred to the PDF-chunk annotation design" + ) + if not columns: + raise UnprocessableEntityError("a question must bind at least one column") + if type != QuestionType.table and len(columns) != 1: + raise UnprocessableEntityError( + f"question type {type.value!r} must bind exactly one column, got {len(columns)}" + ) + + known = {entry["name"] for entry in columns_cache} + unknown = [name for name in columns if name not in known] + if unknown: + raise UnprocessableEntityError( + f"unknown column(s) {unknown!r} for question binding; available columns: {sorted(known)!r}" + ) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/unit/validators/v2/test_question_binding.py -q` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit_server/validators/v2/ tests/unit/validators/v2/ && git commit -m "feat(v2): question column-binding validator" +``` + +--- + +## Task 5: Value validators (reuse v1 per-type) + +**Files:** +- Create: `src/extralit_server/validators/v2/values.py` +- Test: `tests/unit/validators/v2/test_values.py` + +**Interfaces:** +- Consumes: v1 per-type validators in `extralit_server.validators.response_values`; `QuestionType`. +- Produces: + - `V2ResponseValueValidator.validate(value, *, type: QuestionType, settings: dict, columns: list[str]) -> None` + - `V2SuggestionValidator.validate(value, score, *, type: QuestionType, settings: dict, columns: list[str]) -> None` + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/validators/v2/test_values.py`: + +```python +import pytest + +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.v2.values import V2ResponseValueValidator, V2SuggestionValidator + +LABEL_SETTINGS = {"type": "label_selection", "options": [{"value": "yes"}, {"value": "no"}], "strict": True} +TABLE_SETTINGS = {"type": "table"} + + +def test_text_value_must_be_str(): + V2ResponseValueValidator.validate("ok", type=QuestionType.text, settings={"type": "text"}, columns=["c"]) + with pytest.raises(UnprocessableEntityError): + V2ResponseValueValidator.validate(5, type=QuestionType.text, settings={"type": "text"}, columns=["c"]) + + +def test_label_must_be_in_options(): + V2ResponseValueValidator.validate("yes", type=QuestionType.label_selection, settings=LABEL_SETTINGS, columns=["c"]) + with pytest.raises(UnprocessableEntityError): + V2ResponseValueValidator.validate("maybe", type=QuestionType.label_selection, settings=LABEL_SETTINGS, columns=["c"]) + + +def test_table_value_keys_must_be_subset_of_columns(): + V2ResponseValueValidator.validate({"a": 1}, type=QuestionType.table, settings=TABLE_SETTINGS, columns=["a", "b"]) + with pytest.raises(UnprocessableEntityError, match="not bound"): + V2ResponseValueValidator.validate({"z": 1}, type=QuestionType.table, settings=TABLE_SETTINGS, columns=["a", "b"]) + + +def test_span_value_is_rejected(): + with pytest.raises(UnprocessableEntityError, match="span"): + V2ResponseValueValidator.validate([], type=QuestionType.span, settings={"type": "span"}, columns=["c"]) + + +def test_suggestion_score_length_must_match_list_value(): + V2SuggestionValidator.validate(["yes"], [0.9], type=QuestionType.multi_label_selection, + settings={"type": "multi_label_selection", "options": [{"value": "yes"}]}, columns=["c"]) + with pytest.raises(UnprocessableEntityError): + V2SuggestionValidator.validate(["yes"], [0.9, 0.1], type=QuestionType.multi_label_selection, + settings={"type": "multi_label_selection", "options": [{"value": "yes"}]}, columns=["c"]) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/unit/validators/v2/test_values.py -q` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement, reusing v1 per-type validators** + +Create `src/extralit_server/validators/v2/values.py`: + +```python +from pydantic import TypeAdapter + +from extralit_server.api.schemas.v1.questions import QuestionSettings +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.response_values import ( + LabelSelectionQuestionResponseValueValidator, + MultiLabelSelectionQuestionResponseValueValidator, + RankingQuestionResponseValueValidator, + RatingQuestionResponseValueValidator, + TextQuestionResponseValueValidator, +) + +DEFERRED_TYPES = {QuestionType.span} + + +def _parsed(settings: dict): + # Reuse v1's discriminated QuestionSettings union so options/ranges are typed like v1. + return TypeAdapter(QuestionSettings).validate_python(settings) + + +class V2ResponseValueValidator: + """Settings-level value validation reusing v1's per-type validators (spec §17.3). + span is rejected (deferred); table validates structure only (no Pandera re-run).""" + + @classmethod + def validate(cls, value, *, type: QuestionType, settings: dict, columns: list[str]) -> None: + if type in DEFERRED_TYPES: + raise UnprocessableEntityError(f"question type {type.value!r} (span) is not supported in this release") + if type == QuestionType.text: + TextQuestionResponseValueValidator(value).validate() + elif type == QuestionType.label_selection: + LabelSelectionQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.multi_label_selection: + MultiLabelSelectionQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.rating: + RatingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.ranking: + RankingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.table: + cls._validate_table(value, columns) + else: + raise UnprocessableEntityError(f"unknown question type {type!r}") + + @staticmethod + def _validate_table(value, columns: list[str]) -> None: + if not isinstance(value, dict): + raise UnprocessableEntityError(f"table question expects a dict of values, found {type(value)}") + bound = set(columns) + extra = sorted(k for k in value if k not in bound) + if extra: + raise UnprocessableEntityError(f"table value keys {extra!r} are not bound columns; bound: {sorted(bound)!r}") + + +class V2SuggestionValidator: + """Value validation (same as responses) + v1 score-cardinality checks (spec §17.3).""" + + @classmethod + def validate(cls, value, score, *, type: QuestionType, settings: dict, columns: list[str]) -> None: + V2ResponseValueValidator.validate(value, type=type, settings=settings, columns=columns) + cls._validate_score(value, score) + + @staticmethod + def _validate_score(value, score) -> None: + if not isinstance(value, list) and isinstance(score, list): + raise UnprocessableEntityError("a list of scores is not allowed for a single-value suggestion") + if isinstance(value, list) and score is not None and not isinstance(score, list): + raise UnprocessableEntityError("a single score is not allowed for a multi-item suggestion value") + if isinstance(value, list) and isinstance(score, list) and len(value) != len(score): + raise UnprocessableEntityError("number of items on value and score doesn't match") +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/unit/validators/v2/test_values.py -q` +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/extralit_server/validators/v2/values.py tests/unit/validators/v2/test_values.py && git commit -m "feat(v2): value validators reusing v1 per-type validators" +``` + +--- + +## Task 6: Questions context + API + policy + +**Files:** +- Create: `src/extralit_server/api/schemas/v2/questions.py` +- Create: `src/extralit_server/contexts/v2/annotation.py` (question functions) +- Create: `src/extralit_server/api/policies/v1/v2_annotation_policy.py` (`V2QuestionPolicy` now; suggestion/response classes added in Tasks 7–8) +- Modify: `src/extralit_server/api/policies/v1/__init__.py` +- Create: `src/extralit_server/api/v2/questions.py` +- Modify: `src/extralit_server/api/v2/__init__.py` +- Test: `tests/integration/contexts/v2/test_annotation_context.py`, `tests/integration/api/v2/test_questions.py` + +**Interfaces:** +- Consumes: `QuestionBindingValidator`; `schemas_ctx.get_schema`; `SchemaVersion.columns_cache`. +- Produces: + - `contexts.v2.annotation.create_question(db, schema, *, create: QuestionCreate) -> V2Question`, `list_questions(db, schema)`, `get_question(db, question_id)`, `update_question(db, question, *, update)`, `delete_question(db, question)`. + - `V2QuestionPolicy.{list,get,create,update,delete}`. + - Router at `POST|GET /schemas/{id}/questions`, `GET|PUT|DELETE /questions/{id}`. + +- [ ] **Step 1: Write the API schemas** + +Create `src/extralit_server/api/schemas/v2/questions.py`: + +```python +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from extralit_server.enums import QuestionType + +QUESTION_COLUMNS_MIN = 1 + + +class QuestionCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + title: str = Field(..., min_length=1) + description: str | None = None + type: QuestionType + columns: list[str] = Field(..., min_length=QUESTION_COLUMNS_MIN) + settings: dict[str, Any] = Field(default_factory=dict) + required: bool = False + + +class QuestionUpdate(BaseModel): + title: str | None = None + description: str | None = None + columns: list[str] | None = None + settings: dict[str, Any] | None = None + required: bool | None = None + + +class QuestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + schema_id: UUID + name: str + title: str + description: str | None + type: QuestionType + columns: list[str] + settings: dict[str, Any] + required: bool + inserted_at: datetime + updated_at: datetime + + +class Questions(BaseModel): + items: list[QuestionRead] +``` + +- [ ] **Step 2: Write the failing context test** + +Create `tests/integration/contexts/v2/test_annotation_context.py`: + +```python +import pytest + +from extralit_server.api.schemas.v2.questions import QuestionCreate +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.enums import QuestionType, SchemaStatus +from extralit_server.errors.future import UnprocessableEntityError +from tests.factories import SchemaFactory, SchemaVersionFactory + + +async def _published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create( + schema=schema, + columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}], + ) + schema.current_version_id = version.id + await db.commit() + return schema + + +@pytest.mark.asyncio +async def test_create_question_validates_binding(db): + schema = await _published_schema(db) + q = await annotation_ctx.create_question( + db, schema, create=QuestionCreate(name="dx", title="Diagnosis", type=QuestionType.label_selection, + columns=["disease"], settings={"type": "label_selection", "options": [{"value": "x"}]}), + ) + assert q.id is not None and q.columns == ["disease"] + + +@pytest.mark.asyncio +async def test_create_question_rejects_unknown_column(db): + schema = await _published_schema(db) + with pytest.raises(UnprocessableEntityError, match="unknown"): + await annotation_ctx.create_question( + db, schema, create=QuestionCreate(name="bad", title="Bad", type=QuestionType.text, columns=["nope"]), + ) + + +@pytest.mark.asyncio +async def test_create_question_requires_published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.draft) # current_version_id is None + with pytest.raises(UnprocessableEntityError, match="published"): + await annotation_ctx.create_question( + db, schema, create=QuestionCreate(name="q", title="Q", type=QuestionType.text, columns=["disease"]), + ) +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: FAIL — `annotation` module / functions missing. + +- [ ] **Step 4: Implement the question context** + +Create `src/extralit_server/contexts/v2/annotation.py`: + +```python +"""Business logic for v2 annotation: questions, suggestions, responses (spec §17). + +Postgres-only — this module MUST NOT import the LanceDB index engine.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.models.v2 import Schema, SchemaVersion, V2Question +from extralit_server.validators.v2.questions import QuestionBindingValidator + + +async def _current_columns_cache(db: AsyncSession, schema: Schema) -> list[dict]: + if schema.current_version_id is None: + raise UnprocessableEntityError( + f"schema `{schema.id}` has no published version; publish a version before adding questions" + ) + version = await SchemaVersion.get(db, schema.current_version_id) + return list(version.columns_cache or []) + + +async def create_question(db: AsyncSession, schema: Schema, *, create: QuestionCreate) -> V2Question: + columns_cache = await _current_columns_cache(db, schema) + QuestionBindingValidator.validate(type=create.type, columns=create.columns, columns_cache=columns_cache) + question = V2Question( + schema_id=schema.id, + name=create.name, + title=create.title, + description=create.description, + type=create.type, + columns=list(create.columns), + settings=dict(create.settings), + required=create.required, + ) + db.add(question) + await db.commit() + return question + + +async def list_questions(db: AsyncSession, schema: Schema) -> list[V2Question]: + stmt = select(V2Question).where(V2Question.schema_id == schema.id).order_by(V2Question.inserted_at.asc()) + return (await db.execute(stmt)).scalars().all() + + +async def get_question(db: AsyncSession, question_id: UUID) -> V2Question | None: + return await V2Question.get(db, question_id) + + +async def update_question(db: AsyncSession, question: V2Question, *, update: QuestionUpdate) -> V2Question: + if update.columns is not None: + schema = await Schema.get_or_raise(db, question.schema_id) + columns_cache = await _current_columns_cache(db, schema) + QuestionBindingValidator.validate(type=question.type, columns=update.columns, columns_cache=columns_cache) + question.columns = list(update.columns) + for attr in ("title", "description", "settings", "required"): + value = getattr(update, attr) + if value is not None: + setattr(question, attr, value) + await db.commit() + return question + + +async def delete_question(db: AsyncSession, question: V2Question) -> V2Question: + await question.delete(db) + return question +``` + +(If `DatabaseModel` lacks `.get`/`.get_or_raise`/`.delete`, copy the accessors used in `contexts/v2/schemas.py` — check that file for the exact helper names before implementing.) + +- [ ] **Step 5: Run to verify the context tests pass** + +Run: `cd extralit-server && uv run pytest tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: 3 passed. + +- [ ] **Step 6: Write the policy** + +Create `src/extralit_server/api/policies/v1/v2_annotation_policy.py`: + +```python +from uuid import UUID + +from extralit_server.api.policies.v1.commons import PolicyAction +from extralit_server.models import User +from extralit_server.models.v2 import Schema, V2Question + + +class V2QuestionPolicy: + @classmethod + def list(cls, schema: Schema) -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(schema.workspace_id) + + return is_allowed + + get = list + + @classmethod + def create(cls, schema: Schema) -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id)) + + return is_allowed + + @classmethod + def _write(cls, question: V2Question) -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(question.schema.workspace_id)) + + return is_allowed + + update = _write + delete = _write +``` + +Add exports to `src/extralit_server/api/policies/v1/__init__.py`: `from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy` and add `"V2QuestionPolicy"` to `__all__`. + +- [ ] **Step 7: Write the questions router** + +Create `src/extralit_server/api/v2/questions.py`: + +```python +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Security, status +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.policies.v1 import V2QuestionPolicy, authorize +from extralit_server.api.schemas.v2.questions import ( + QuestionCreate, + QuestionRead, + Questions, + QuestionUpdate, +) +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.contexts.v2 import schemas as schemas_ctx +from extralit_server.database import get_async_db +from extralit_server.errors.future import NotFoundError +from extralit_server.models import User +from extralit_server.models.v2 import Schema, V2Question +from extralit_server.security import auth + +router = APIRouter(tags=["v2: questions"]) + + +async def _get_schema_or_404(db: AsyncSession, schema_id: UUID) -> Schema: + schema = await schemas_ctx.get_schema(db, schema_id) + if schema is None: + raise NotFoundError(f"Schema with id `{schema_id}` not found") + return schema + + +async def _get_question_or_404(db: AsyncSession, question_id: UUID) -> V2Question: + question = await annotation_ctx.get_question(db, question_id) + if question is None: + raise NotFoundError(f"Question with id `{question_id}` not found") + return question + + +@router.post("/schemas/{schema_id}/questions", response_model=QuestionRead, status_code=status.HTTP_201_CREATED) +async def create_question( + *, + schema_id: UUID, + payload: QuestionCreate, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, V2QuestionPolicy.create(schema)) + return await annotation_ctx.create_question(db, schema, create=payload) + + +@router.get("/schemas/{schema_id}/questions", response_model=Questions) +async def list_questions( + *, + schema_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, V2QuestionPolicy.list(schema)) + return Questions(items=await annotation_ctx.list_questions(db, schema)) + + +@router.get("/questions/{question_id}", response_model=QuestionRead) +async def get_question( + *, + question_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + schema = await _get_schema_or_404(db, question.schema_id) + await authorize(current_user, V2QuestionPolicy.get(schema)) + return question + + +@router.put("/questions/{question_id}", response_model=QuestionRead) +async def update_question( + *, + question_id: UUID, + payload: QuestionUpdate, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + await authorize(current_user, V2QuestionPolicy.update(question)) + return await annotation_ctx.update_question(db, question, update=payload) + + +@router.delete("/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_question( + *, + question_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + await authorize(current_user, V2QuestionPolicy.delete(question)) + await annotation_ctx.delete_question(db, question) +``` + +Add `from extralit_server.api.v2 import questions as questions_v2` and `api_v2.include_router(questions_v2.router)` in `src/extralit_server/api/v2/__init__.py`. + +- [ ] **Step 8: Write the API test** + +Create `tests/integration/api/v2/test_questions.py` mirroring `tests/integration/api/v2/test_schemas.py` for auth/client fixtures. Cover: 201 create (happy), 422 unknown column, 422 span, 201→list returns it, 403 for a non-member annotator on create. Example core case: + +```python +import pytest + +from extralit_server.enums import QuestionType, SchemaStatus +from tests.factories import SchemaFactory, SchemaVersionFactory + +pytestmark = pytest.mark.asyncio + +COLUMNS_CACHE = [{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + + +async def _published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create(schema=schema, columns_cache=COLUMNS_CACHE) + schema.current_version_id = version.id + await db.commit() + return schema + + +async def test_create_question_happy(async_client, owner_auth_header, db): + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["disease"]}, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["columns"] == ["disease"] + + +async def test_create_question_span_rejected(async_client, owner_auth_header, db): + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "s", "title": "S", "type": QuestionType.span.value, "columns": ["disease"]}, + ) + assert resp.status_code == 422 +``` + +(The `owner` / `annotator` users and their `*_auth_header` fixtures come from `tests/integration/conftest.py`.) + +- [ ] **Step 9: Run the question tests** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_questions.py tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: all pass. + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "feat(v2): questions context, API, and policy" +``` + +--- + +## Task 7: Suggestions context + API + policy + +**Files:** +- Create: `src/extralit_server/api/schemas/v2/annotation.py` (suggestion half) +- Modify: `src/extralit_server/contexts/v2/annotation.py` (add suggestion functions) +- Modify: `src/extralit_server/api/policies/v1/v2_annotation_policy.py` (add `V2SuggestionPolicy`) + `__init__.py` +- Create: `src/extralit_server/api/v2/annotation.py` (suggestion routes) +- Modify: `src/extralit_server/api/v2/__init__.py` +- Test: `tests/integration/api/v2/test_annotation.py` (suggestion cases) + +**Interfaces:** +- Consumes: `V2SuggestionValidator`; `V2Record`, `V2Question`. +- Produces: `annotation.upsert_suggestion(db, record, question, *, upsert) -> V2Suggestion`, `annotation.list_suggestions(db, record) -> list[V2Suggestion]`; `V2SuggestionPolicy.{write,read}`; routes `PUT|GET /records/{id}/suggestions`. + +- [ ] **Step 1: Write the suggestion API schema** + +Create `src/extralit_server/api/schemas/v2/annotation.py`: + +```python +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from extralit_server.enums import ResponseStatus, SuggestionType + + +class SuggestionUpsert(BaseModel): + question_id: UUID + value: Any + score: float | list[float] | None = None + agent: str | None = None + type: SuggestionType | None = None + + +class SuggestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + record_id: UUID + question_id: UUID + value: Any + score: float | list[float] | None + agent: str | None + type: SuggestionType | None + inserted_at: datetime + updated_at: datetime + + +class Suggestions(BaseModel): + items: list[SuggestionRead] + + +class ResponseUpsert(BaseModel): + values: dict[str, dict[str, Any]] | None = None # {question_name: {"value": ...}} + status: ResponseStatus + + +class ResponseRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + record_id: UUID + user_id: UUID + values: dict[str, Any] | None + status: ResponseStatus + inserted_at: datetime + updated_at: datetime +``` + +- [ ] **Step 2: Write the failing suggestion context test** + +Append to `tests/integration/contexts/v2/test_annotation_context.py`: + +```python +from extralit_server.api.schemas.v2.annotation import SuggestionUpsert +from tests.factories import V2QuestionFactory, V2RecordFactory + + +@pytest.mark.asyncio +async def test_upsert_suggestion_is_idempotent_per_record_question(db): + schema = await _published_schema(db) + question = await V2QuestionFactory.create(schema=schema, type=QuestionType.text, columns=["disease"], + settings={"type": "text"}) + record = await V2RecordFactory.create(version__schema=schema) + + s1 = await annotation_ctx.upsert_suggestion(db, record, question, + upsert=SuggestionUpsert(question_id=question.id, value="a")) + s2 = await annotation_ctx.upsert_suggestion(db, record, question, + upsert=SuggestionUpsert(question_id=question.id, value="b")) + assert s1.id == s2.id and s2.value == "b" +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest "tests/integration/contexts/v2/test_annotation_context.py::test_upsert_suggestion_is_idempotent_per_record_question" -q` +Expected: FAIL — `upsert_suggestion` missing. + +- [ ] **Step 4: Implement the suggestion context** + +Append to `src/extralit_server/contexts/v2/annotation.py` (add imports for `select` already present, `V2Record`, `V2Suggestion`, `SuggestionUpsert`, `V2SuggestionValidator`): + +```python +async def upsert_suggestion(db, record, question, *, upsert) -> "V2Suggestion": + V2SuggestionValidator.validate( + upsert.value, upsert.score, type=question.type, settings=question.settings, columns=question.columns + ) + stmt = select(V2Suggestion).where( + V2Suggestion.record_id == record.id, V2Suggestion.question_id == question.id + ) + suggestion = (await db.execute(stmt)).scalar_one_or_none() + if suggestion is None: + suggestion = V2Suggestion(record_id=record.id, question_id=question.id) + db.add(suggestion) + suggestion.value = upsert.value + suggestion.score = upsert.score + suggestion.agent = upsert.agent + suggestion.type = upsert.type + await db.commit() + return suggestion + + +async def list_suggestions(db, record) -> list["V2Suggestion"]: + stmt = select(V2Suggestion).where(V2Suggestion.record_id == record.id).order_by(V2Suggestion.inserted_at.asc()) + return (await db.execute(stmt)).scalars().all() +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest "tests/integration/contexts/v2/test_annotation_context.py::test_upsert_suggestion_is_idempotent_per_record_question" -q` +Expected: PASS. + +- [ ] **Step 6: Add `V2SuggestionPolicy`** + +Append to `src/extralit_server/api/policies/v1/v2_annotation_policy.py` (import `V2Record`): + +```python +class V2SuggestionPolicy: + @classmethod + def read(cls, record: "V2Record") -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed + + @classmethod + def write(cls, record: "V2Record") -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(record.schema.workspace_id)) + + return is_allowed +``` + +Export `V2SuggestionPolicy` from `api/policies/v1/__init__.py`. + +- [ ] **Step 7: Write the suggestion routes** + +Create `src/extralit_server/api/v2/annotation.py` with a shared `_get_record_or_404` (loading the record with its `schema` relationship so policies can read `record.schema.workspace_id`) and the suggestion endpoints: + +```python +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Security, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from extralit_server.api.policies.v1 import V2SuggestionPolicy, authorize +from extralit_server.api.schemas.v2.annotation import SuggestionRead, Suggestions, SuggestionUpsert +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.database import get_async_db +from extralit_server.errors.future import NotFoundError, UnprocessableEntityError +from extralit_server.models import User +from extralit_server.models.v2 import V2Record +from extralit_server.security import auth + +router = APIRouter(tags=["v2: annotation"]) + + +async def _get_record_or_404(db: AsyncSession, record_id: UUID) -> V2Record: + record = await V2Record.get(db, record_id, options=[selectinload(V2Record.schema)]) + if record is None: + raise NotFoundError(f"Record with id `{record_id}` not found") + return record + + +@router.put("/records/{record_id}/suggestions", response_model=SuggestionRead) +async def upsert_suggestion( + *, + record_id: UUID, + payload: SuggestionUpsert, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2SuggestionPolicy.write(record)) + question = await annotation_ctx.get_question(db, payload.question_id) + if question is None or question.schema_id != record.schema_id: + raise UnprocessableEntityError(f"question `{payload.question_id}` does not belong to this record's schema") + return await annotation_ctx.upsert_suggestion(db, record, question, upsert=payload) + + +@router.get("/records/{record_id}/suggestions", response_model=Suggestions) +async def list_suggestions( + *, + record_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2SuggestionPolicy.read(record)) + return Suggestions(items=await annotation_ctx.list_suggestions(db, record)) +``` + +Add `from extralit_server.api.v2 import annotation as annotation_v2` and `api_v2.include_router(annotation_v2.router)` in `api/v2/__init__.py`. (Confirm `V2Record.get` accepts an `options=` kwarg by checking `DatabaseModel.get`; if not, load via a `select(...).options(...)` in the helper.) + +- [ ] **Step 8: Write the suggestion API test** + +Create `tests/integration/api/v2/test_annotation.py` with an owner PUT-ing a suggestion for a text question and asserting 200 + idempotent re-PUT, plus a 422 when the question belongs to a different schema. Reuse the schema/question/record setup pattern from `test_questions.py`. + +- [ ] **Step 9: Run the tests** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_annotation.py tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: all pass. + +- [ ] **Step 10: Commit** + +```bash +git add -A && git commit -m "feat(v2): suggestions context, API, and policy" +``` + +--- + +## Task 8: Responses context + API + own-response policy + +**Files:** +- Modify: `src/extralit_server/contexts/v2/annotation.py` (add response functions) +- Modify: `src/extralit_server/api/policies/v1/v2_annotation_policy.py` (add `V2ResponsePolicy`) + `__init__.py` +- Modify: `src/extralit_server/api/v2/annotation.py` (add response routes) +- Test: `tests/integration/contexts/v2/test_annotation_context.py`, `tests/integration/api/v2/test_annotation.py` (response cases) + +**Interfaces:** +- Consumes: `V2ResponseValueValidator`; `V2Question`, `V2Record`, `V2Response`. +- Produces: `annotation.upsert_response(db, record, user, *, upsert) -> V2Response`, `annotation.get_response(db, record, user) -> V2Response | None`; `V2ResponsePolicy.{read,write}`; routes `PUT|GET /records/{id}/responses`. + +- [ ] **Step 1: Write the failing response context test** + +Append to `tests/integration/contexts/v2/test_annotation_context.py`: + +```python +from extralit_server.api.schemas.v2.annotation import ResponseUpsert +from extralit_server.enums import ResponseStatus, V2RecordStatus +from tests.factories import UserFactory + + +@pytest.mark.asyncio +async def test_upsert_response_keyed_by_question_no_record_status_change(db): + schema = await _published_schema(db) + await V2QuestionFactory.create(schema=schema, name="dx", type=QuestionType.text, columns=["disease"], + settings={"type": "text"}, required=True) + record = await V2RecordFactory.create(version__schema=schema, status=V2RecordStatus.pending) + user = await UserFactory.create() + + resp = await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"dx": {"value": "flu"}})) + assert resp.values == {"dx": {"value": "flu"}} + assert record.status == V2RecordStatus.pending # spec §17.3: no status side-effect + + +@pytest.mark.asyncio +async def test_submitted_response_requires_required_question(db): + schema = await _published_schema(db) + await V2QuestionFactory.create(schema=schema, name="dx", type=QuestionType.text, columns=["disease"], + settings={"type": "text"}, required=True) + record = await V2RecordFactory.create(version__schema=schema) + user = await UserFactory.create() + + with pytest.raises(UnprocessableEntityError, match="required"): + await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={})) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest "tests/integration/contexts/v2/test_annotation_context.py::test_upsert_response_keyed_by_question_no_record_status_change" -q` +Expected: FAIL — `upsert_response` missing. + +- [ ] **Step 3: Implement the response context** + +Append to `src/extralit_server/contexts/v2/annotation.py` (imports: `V2Response`, `ResponseStatus`, `ResponseUpsert`, `V2ResponseValueValidator`): + +```python +async def _schema_questions(db, schema_id) -> list["V2Question"]: + stmt = select(V2Question).where(V2Question.schema_id == schema_id) + return (await db.execute(stmt)).scalars().all() + + +def _validate_response_values(upsert, questions: list["V2Question"]) -> None: + values = upsert.values or {} + submitted = upsert.status == ResponseStatus.submitted + if submitted and not values: + raise UnprocessableEntityError("missing response values for submitted response") + + by_name = {q.name: q for q in questions} + for name in values: + if name not in by_name: + raise UnprocessableEntityError(f"response value for non-configured question {name!r}") + for question in questions: + if submitted and question.required and question.name not in values: + raise UnprocessableEntityError(f"missing response value for required question {question.name!r}") + for name, wrapped in values.items(): + question = by_name[name] + V2ResponseValueValidator.validate( + wrapped.get("value"), type=question.type, settings=question.settings, columns=question.columns + ) + + +async def upsert_response(db, record, user, *, upsert) -> "V2Response": + questions = await _schema_questions(db, record.schema_id) + _validate_response_values(upsert, questions) + + stmt = select(V2Response).where(V2Response.record_id == record.id, V2Response.user_id == user.id) + response = (await db.execute(stmt)).scalar_one_or_none() + if response is None: + response = V2Response(record_id=record.id, user_id=user.id) + db.add(response) + response.values = upsert.values + response.status = upsert.status + await db.commit() # NOTE: never touches record.status (spec §17.3) and never syncs Lance. + return response + + +async def get_response(db, record, user) -> "V2Response | None": + stmt = select(V2Response).where(V2Response.record_id == record.id, V2Response.user_id == user.id) + return (await db.execute(stmt)).scalar_one_or_none() +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: all pass (both new response tests + earlier). + +- [ ] **Step 5: Add `V2ResponsePolicy` (own-response authz)** + +Append to `src/extralit_server/api/policies/v1/v2_annotation_policy.py` (import `V2Response`): + +```python +class V2ResponsePolicy: + """Own-response authz (spec §17.5), ported from v1 ResponsePolicy with the workspace + resolved via record.schema.workspace_id.""" + + @classmethod + def read(cls, record: "V2Record") -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed + + @classmethod + def upsert_own(cls, record: "V2Record") -> PolicyAction: + # PUT writes the current user's own response; any workspace member (incl. annotators) + # may write their own, matching v1 (actor.id == response.user_id). + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed +``` + +Export `V2ResponsePolicy` from `api/policies/v1/__init__.py`. + +- [ ] **Step 6: Add the response routes** + +Append to `src/extralit_server/api/v2/annotation.py` (imports: `V2ResponsePolicy`, `ResponseRead`, `ResponseUpsert`, `annotation_ctx.get_response`): + +```python +@router.put("/records/{record_id}/responses", response_model=ResponseRead) +async def upsert_response( + *, + record_id: UUID, + payload: ResponseUpsert, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2ResponsePolicy.upsert_own(record)) + return await annotation_ctx.upsert_response(db, record, current_user, upsert=payload) + + +@router.get("/records/{record_id}/responses", response_model=ResponseRead | None) +async def get_own_response( + *, + record_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2ResponsePolicy.read(record)) + return await annotation_ctx.get_response(db, record, current_user) +``` + +- [ ] **Step 7: Write the response API test (incl. no-Lance-sync assertion)** + +Add to `tests/integration/api/v2/test_annotation.py`: an annotator PUT-ing their own response (200), a second annotator getting only their own via GET, and an assertion that the index engine is never called. The no-sync assertion patches the engine and asserts zero calls: + +```python +from unittest.mock import AsyncMock, patch + + +async def test_response_upsert_does_not_sync_lance(async_client, annotator_auth_header, db): + # ... set up published schema + text question "dx" + record; make `annotator` a member of the + # schema's workspace (use WorkspaceUserFactory as v1 response tests do) ... + with patch("extralit_server.contexts.v2.index_sync.sync_upserted_records", new=AsyncMock()) as synced: + resp = await async_client.put( + f"/api/v2/records/{record.id}/responses", + headers=annotator_auth_header, + json={"status": "submitted", "values": {"dx": {"value": "flu"}}}, + ) + assert resp.status_code == 200, resp.text + synced.assert_not_called() # annotation never touches the index engine (spec §17.5) +``` + +- [ ] **Step 8: Run the tests** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_annotation.py tests/integration/contexts/v2/test_annotation_context.py -q` +Expected: all pass. + +- [ ] **Step 9: Commit** + +```bash +git add -A && git commit -m "feat(v2): responses context, API, and own-response policy" +``` + +--- + +## Task 9: Single-version read endpoint + +Pulled into Phase 4 (spec §17.5) so the annotation UI can render records pinned to old versions. + +**Files:** +- Modify: `src/extralit_server/contexts/v2/schemas.py` (add `get_version_by_number`) +- Modify: `src/extralit_server/api/v2/schemas.py` (add route) +- Modify: `src/extralit_server/api/schemas/v2/schemas.py` (add `SchemaVersionRead` if absent — check first) +- Test: `tests/integration/api/v2/test_schema_versions.py` + +**Interfaces:** +- Consumes: `SchemaVersion`. +- Produces: `schemas_ctx.get_version_by_number(db, schema_id, version) -> SchemaVersion | None`; route `GET /schemas/{id}/versions/{version}`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/integration/api/v2/test_schema_versions.py`: + +```python +import pytest + +from tests.factories import SchemaFactory, SchemaVersionFactory + +pytestmark = pytest.mark.asyncio + + +async def test_get_single_version_by_number(async_client, owner_auth_header, db): + schema = await SchemaFactory.create() + await SchemaVersionFactory.create( + schema=schema, version=1, columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}]) + await db.commit() + + resp = await async_client.get(f"/api/v2/schemas/{schema.id}/versions/1", headers=owner_auth_header) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["version"] == 1 + assert body["columns_cache"][0]["name"] == "disease" + + +async def test_get_unknown_version_404(async_client, owner_auth_header, db): + schema = await SchemaFactory.create() + await db.commit() + resp = await async_client.get(f"/api/v2/schemas/{schema.id}/versions/999", headers=owner_auth_header) + assert resp.status_code == 404 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_schema_versions.py -q` +Expected: FAIL (404 route missing or wrong shape). + +- [ ] **Step 3: Add the context function** + +Append to `src/extralit_server/contexts/v2/schemas.py`: + +```python +async def get_version_by_number(db: AsyncSession, schema_id: UUID, version: int) -> SchemaVersion | None: + stmt = select(SchemaVersion).where(SchemaVersion.schema_id == schema_id, SchemaVersion.version == version) + return (await db.execute(stmt)).scalar_one_or_none() +``` + +(Ensure `select` and `SchemaVersion` are imported in that file.) + +- [ ] **Step 4: Add the route** + +In `src/extralit_server/api/v2/schemas.py`, reuse or add a `SchemaVersionRead` response model (check `api/schemas/v2/schemas.py` for an existing version read schema; if none, add one exposing `id, schema_id, version, columns_cache, review_widgets, parent_version_id, inserted_at`) and add: + +```python +@router.get("/schemas/{schema_id}/versions/{version}", response_model=SchemaVersionRead) +async def get_schema_version( + *, + schema_id: UUID, + version: int, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, SchemaPolicy.get(schema)) + version_row = await schemas_ctx.get_version_by_number(db, schema_id, version) + if version_row is None: + raise NotFoundError(f"Version `{version}` not found for schema `{schema_id}`") + return version_row +``` + +(Match the existing imports/helpers in `api/v2/schemas.py`; it already has `_get_schema_or_404`, `SchemaPolicy`, `authorize`, `NotFoundError`.) + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_schema_versions.py -q` +Expected: 2 passed. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat(v2): single schema-version read endpoint (§17.5)" +``` + +--- + +## Task 10: Projection view + +The product read-surface (spec §17.4): per reference, resolve each reviewable cell as `submitted response (requesting user) → suggestion`, grouped by schema/record. + +**Files:** +- Create: `src/extralit_server/api/schemas/v2/projection.py` +- Create: `src/extralit_server/contexts/v2/projection.py` +- Create: `src/extralit_server/api/v2/projection.py` +- Modify: `src/extralit_server/api/v2/__init__.py` +- Test: `tests/integration/contexts/v2/test_projection.py`, `tests/integration/api/v2/test_projection.py` + +**Interfaces:** +- Consumes: `records_ctx.list_records_by_reference`; `annotation` question/suggestion/response reads; `V2Question`, `V2Suggestion`, `V2Response`. +- Produces: `projection.build_reference_view(db, *, workspace_id, reference, user) -> ProjectionView`; route `GET /references/{reference:path}/view`. + +- [ ] **Step 1: Write the projection schemas** + +Create `src/extralit_server/api/schemas/v2/projection.py`: + +```python +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel + + +class ProjectionCell(BaseModel): + question_name: str + value: Any | None = None + source: Literal["response", "suggestion"] | None = None # None => neither exists yet + + +class ProjectionRecord(BaseModel): + record_id: UUID + schema_id: UUID + reference: str + cells: list[ProjectionCell] + + +class ProjectionView(BaseModel): + reference: str + records: list[ProjectionRecord] + total_records: int +``` + +- [ ] **Step 2: Write the failing context test** + +Create `tests/integration/contexts/v2/test_projection.py`: + +```python +import pytest + +from extralit_server.contexts.v2 import projection as projection_ctx +from extralit_server.enums import QuestionType, ResponseStatus, SchemaStatus +from tests.factories import ( + SchemaFactory, SchemaVersionFactory, UserFactory, V2QuestionFactory, V2RecordFactory, + V2ResponseFactory, V2SuggestionFactory, +) + + +async def _schema_with_question(db): + schema = await SchemaFactory.create(status=SchemaStatus.published, workspace__name="wsp") + version = await SchemaVersionFactory.create( + schema=schema, columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}]) + schema.current_version_id = version.id + q = await V2QuestionFactory.create(schema=schema, name="dx", type=QuestionType.text, columns=["disease"], + settings={"type": "text"}) + await db.commit() + return schema, version, q + + +@pytest.mark.asyncio +async def test_cell_resolves_to_suggestion_when_no_response(db): + schema, version, q = await _schema_with_question(db) + record = await V2RecordFactory.create(version=version, reference="doc-1") + await V2SuggestionFactory.create(record=record, question=q, value="flu") + user = await UserFactory.create() + + view = await projection_ctx.build_reference_view( + db, workspace_id=schema.workspace_id, reference="doc-1", user=user) + cell = view.records[0].cells[0] + assert cell.value == "flu" and cell.source == "suggestion" + + +@pytest.mark.asyncio +async def test_cell_resolves_to_response_over_suggestion(db): + schema, version, q = await _schema_with_question(db) + record = await V2RecordFactory.create(version=version, reference="doc-2") + await V2SuggestionFactory.create(record=record, question=q, value="flu") + user = await UserFactory.create() + await V2ResponseFactory.create(record=record, user=user, status=ResponseStatus.submitted, + values={"dx": {"value": "covid"}}) + + view = await projection_ctx.build_reference_view( + db, workspace_id=schema.workspace_id, reference="doc-2", user=user) + cell = view.records[0].cells[0] + assert cell.value == "covid" and cell.source == "response" +``` + +- [ ] **Step 3: Run to verify it fails** + +Run: `cd extralit-server && uv run pytest tests/integration/contexts/v2/test_projection.py -q` +Expected: FAIL — `projection` module missing. + +- [ ] **Step 4: Implement the projection context** + +Create `src/extralit_server/contexts/v2/projection.py`: + +```python +"""Projection view (spec §17.4): resolve each reviewable cell as +submitted-response(requesting user) -> suggestion, grouped by reference. Query-time, +Postgres-only. A future OLAP materialization can replace this without changing the API.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.schemas.v2.projection import ProjectionCell, ProjectionRecord, ProjectionView +from extralit_server.contexts.v2 import records as records_ctx +from extralit_server.enums import ResponseStatus +from extralit_server.models.v2 import V2Question, V2Response, V2Suggestion + + +async def build_reference_view(db: AsyncSession, *, workspace_id: UUID, reference: str, user) -> ProjectionView: + records = await records_ctx.list_records_by_reference(db, workspace_id=workspace_id, reference=reference) + if not records: + return ProjectionView(reference=reference, records=[], total_records=0) + + schema_ids = {r.schema_id for r in records} + record_ids = [r.id for r in records] + + questions_by_schema: dict[UUID, list[V2Question]] = {} + q_rows = (await db.execute(select(V2Question).where(V2Question.schema_id.in_(schema_ids)))).scalars().all() + for q in q_rows: + questions_by_schema.setdefault(q.schema_id, []).append(q) + + # (record_id, question_id) -> suggestion value + sugg_rows = (await db.execute(select(V2Suggestion).where(V2Suggestion.record_id.in_(record_ids)))).scalars().all() + suggestions = {(s.record_id, s.question_id): s.value for s in sugg_rows} + + # requesting user's submitted responses only: record_id -> {question_name: value} + resp_rows = ( + await db.execute( + select(V2Response).where( + V2Response.record_id.in_(record_ids), + V2Response.user_id == user.id, + V2Response.status == ResponseStatus.submitted, + ) + ) + ).scalars().all() + responses = {r.record_id: (r.values or {}) for r in resp_rows} + + projection_records: list[ProjectionRecord] = [] + for record in records: + cells: list[ProjectionCell] = [] + for question in questions_by_schema.get(record.schema_id, []): + wrapped = responses.get(record.id, {}).get(question.name) + if wrapped is not None: + cells.append(ProjectionCell(question_name=question.name, value=wrapped.get("value"), source="response")) + elif (record.id, question.id) in suggestions: + cells.append(ProjectionCell(question_name=question.name, + value=suggestions[(record.id, question.id)], source="suggestion")) + else: + cells.append(ProjectionCell(question_name=question.name, value=None, source=None)) + projection_records.append( + ProjectionRecord(record_id=record.id, schema_id=record.schema_id, reference=record.reference, cells=cells) + ) + + return ProjectionView(reference=reference, records=projection_records, total_records=len(records)) +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd extralit-server && uv run pytest tests/integration/contexts/v2/test_projection.py -q` +Expected: 2 passed. + +- [ ] **Step 6: Add the projection route** + +Create `src/extralit_server/api/v2/projection.py`: + +```python +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Security +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.policies.v1 import SchemaPolicy, authorize +from extralit_server.api.schemas.v2.projection import ProjectionView +from extralit_server.contexts.v2 import projection as projection_ctx +from extralit_server.database import get_async_db +from extralit_server.models import User +from extralit_server.security import auth + +router = APIRouter(tags=["v2: projection"]) + + +@router.get("/references/{reference:path}/view", response_model=ProjectionView) +async def get_reference_projection( + *, + reference: str, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], + workspace_id: Annotated[UUID, Query(description="Workspace to scope the view (required)")], +): + await authorize(current_user, SchemaPolicy.list(workspace_id)) + return await projection_ctx.build_reference_view( + db, workspace_id=workspace_id, reference=reference, user=current_user + ) +``` + +Add `from extralit_server.api.v2 import projection as projection_v2` and `api_v2.include_router(projection_v2.router)` in `api/v2/__init__.py`. **Ordering:** include `projection_v2.router` *before* `records_v2.router` if a routing conflict arises between `/references/{reference:path}/view` and `/references/{reference:path}` — verify by test; FastAPI matches in include order. + +- [ ] **Step 7: Write the projection API test** + +Create `tests/integration/api/v2/test_projection.py`: owner GETs `/api/v2/references/doc-1/view?workspace_id=` after seeding a suggestion, asserts 200 + the resolved cell; and an unknown reference returns 200 with `total_records == 0`. + +- [ ] **Step 8: Run the tests** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2/test_projection.py tests/integration/contexts/v2/test_projection.py -q` +Expected: all pass. + +- [ ] **Step 9: Commit** + +```bash +git add -A && git commit -m "feat(v2): projection view (resolve response->suggestion per cell)" +``` + +--- + +## Task 11: Full-suite verification + no-Lance-import guard + +**Files:** +- Test: `tests/unit/test_annotation_no_index_import.py` + +**Interfaces:** +- Consumes: everything above. + +- [ ] **Step 1: Write a guard test that annotation never imports the index engine** + +Create `tests/unit/test_annotation_no_index_import.py`: + +```python +import ast +from pathlib import Path + +import extralit_server + +ROOT = Path(extralit_server.__file__).parent +GUARDED = [ + ROOT / "contexts" / "v2" / "annotation.py", + ROOT / "contexts" / "v2" / "projection.py", + ROOT / "api" / "v2" / "annotation.py", + ROOT / "api" / "v2" / "questions.py", +] + + +def test_annotation_modules_do_not_import_index_engine(): + for path in GUARDED: + tree = ast.parse(path.read_text()) + imported = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + elif isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + assert not any("index" in m and "extralit_server" in m for m in imported), f"{path} imports the index engine" + assert not any(m.endswith("index_sync") for m in imported), f"{path} imports index_sync" +``` + +- [ ] **Step 2: Run the guard test** + +Run: `cd extralit-server && uv run pytest tests/unit/test_annotation_no_index_import.py -q` +Expected: PASS. + +- [ ] **Step 3: Run the full v2 + validator suite** + +Run: `cd extralit-server && uv run pytest tests/integration/api/v2 tests/integration/contexts/v2 tests/integration/models/v2 tests/unit/validators/v2 tests/unit/test_annotation_no_index_import.py -q` +Expected: all pass. Fix any regressions before committing. + +- [ ] **Step 4: Lint** + +Run: `cd extralit-server && uv run ruff check src/extralit_server/api/v2 src/extralit_server/contexts/v2 src/extralit_server/validators/v2 src/extralit_server/models/v2 && uv run ruff format --check src/extralit_server/api/v2 src/extralit_server/contexts/v2 src/extralit_server/validators/v2 src/extralit_server/models/v2` +Expected: clean (run `uv run ruff format` to fix formatting if needed). + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "test(v2): guard annotation against index-engine imports; suite green" +``` + +--- + +## Self-Review Notes (for the executor) + +- **Spec coverage:** §17.1 three tables → Tasks 2–3; §17.3 keyed responses → Task 8; span deferral → Tasks 4–5; no status transitions → Task 8 test; binding validation → Task 4; settings-level validation → Task 5; §17.4 projection view → Task 10; §17.5 endpoints + own-response authz + no-Lance-sync → Tasks 6–8, 10, 11; single-version read → Task 9; §14 drop `kind` → Task 1. +- **Out of scope (do NOT build):** field↔question unification / chain-of-steps, consensus/multi-annotator reconciliation, OLAP materialization, span anchoring, `record.status` distribution transitions (Phase 5). +- **Before each task:** verify helper names against the real files named in the task (`DatabaseModel.get`/`get_or_raise`/`delete`, the async client + auth-header fixture in `tests/integration/api/v2/test_schemas.py`, `SchemaVersionRead` in `api/schemas/v2/schemas.py`). The plan flags each spot where a name must be confirmed rather than assumed. From a2ef1cdf282db6e1f02aa0cf51a36d3ab5c4c446 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 00:31:30 +0000 Subject: [PATCH 03/23] docs(plan): fix Task 8 required-question test, projection route collision, uniqueness-test factories (roborev job 101) --- .../plans/2026-07-08-v2-annotation.md | 50 ++++++++++++++----- ...-06-27-schema-centric-data-model-design.md | 8 +-- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-07-08-v2-annotation.md b/docs/superpowers/plans/2026-07-08-v2-annotation.md index 7e1a17e02..7ae662e83 100644 --- a/docs/superpowers/plans/2026-07-08-v2-annotation.md +++ b/docs/superpowers/plans/2026-07-08-v2-annotation.md @@ -435,7 +435,14 @@ import pytest from sqlalchemy.exc import IntegrityError from extralit_server.enums import QuestionType, ResponseStatus -from tests.factories import V2QuestionFactory, V2ResponseFactory, V2SuggestionFactory +from tests.factories import ( + SchemaFactory, + UserFactory, + V2QuestionFactory, + V2RecordFactory, + V2ResponseFactory, + V2SuggestionFactory, +) @pytest.mark.asyncio @@ -448,24 +455,34 @@ async def test_question_persists_with_type_and_columns(db): @pytest.mark.asyncio async def test_question_name_unique_per_schema(db): - q = await V2QuestionFactory.create(name="dup") + # Same schema OBJECT passed twice (not schema_id) so the (schema_id, name) constraint trips. + schema = await SchemaFactory.create() + await V2QuestionFactory.create(schema=schema, name="dup") with pytest.raises(IntegrityError): - await V2QuestionFactory.create(schema_id=q.schema_id, name="dup") + await V2QuestionFactory.create(schema=schema, name="dup") @pytest.mark.asyncio async def test_suggestion_unique_per_record_question(db): - s = await V2SuggestionFactory.create() + # Pass the parent OBJECTS (not *_id): the factories declare record/question as SubFactory + # defaults, so passing only ids would let factory-boy create fresh parents and the + # relationship would win on flush — the (record_id, question_id) constraint would never trip. + record = await V2RecordFactory.create() + question = await V2QuestionFactory.create() + await V2SuggestionFactory.create(record=record, question=question) with pytest.raises(IntegrityError): - await V2SuggestionFactory.create(record_id=s.record_id, question_id=s.question_id) + await V2SuggestionFactory.create(record=record, question=question) @pytest.mark.asyncio async def test_response_unique_per_record_user(db): - r = await V2ResponseFactory.create(status=ResponseStatus.submitted, values={"q": {"value": "x"}}) + record = await V2RecordFactory.create() + user = await UserFactory.create() + r = await V2ResponseFactory.create(record=record, user=user, status=ResponseStatus.submitted, + values={"q": {"value": "x"}}) assert r.is_submitted with pytest.raises(IntegrityError): - await V2ResponseFactory.create(record_id=r.record_id, user_id=r.user_id) + await V2ResponseFactory.create(record=record, user=user) ``` - [ ] **Step 5: Run the tests** @@ -1442,12 +1459,18 @@ async def test_submitted_response_requires_required_question(db): schema = await _published_schema(db) await V2QuestionFactory.create(schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True) + # A second, optional question (also bound to "disease" — binding validation allows reuse) so the + # payload can be non-empty while omitting the required one. Submitting empty values would trip the + # earlier "missing response values" guard instead of the required-question path under test. + await V2QuestionFactory.create(schema=schema, name="notes", type=QuestionType.text, columns=["disease"], + settings={"type": "text"}, required=False) record = await V2RecordFactory.create(version__schema=schema) user = await UserFactory.create() with pytest.raises(UnprocessableEntityError, match="required"): await annotation_ctx.upsert_response( - db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={})) + db, record, user, + upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"notes": {"value": "n"}})) ``` - [ ] **Step 2: Run to verify it fails** @@ -1714,7 +1737,7 @@ The product read-surface (spec §17.4): per reference, resolve each reviewable c **Interfaces:** - Consumes: `records_ctx.list_records_by_reference`; `annotation` question/suggestion/response reads; `V2Question`, `V2Suggestion`, `V2Response`. -- Produces: `projection.build_reference_view(db, *, workspace_id, reference, user) -> ProjectionView`; route `GET /references/{reference:path}/view`. +- Produces: `projection.build_reference_view(db, *, workspace_id, reference, user) -> ProjectionView`; route `GET /projection/references/{reference:path}`. - [ ] **Step 1: Write the projection schemas** @@ -1899,7 +1922,10 @@ from extralit_server.security import auth router = APIRouter(tags=["v2: projection"]) -@router.get("/references/{reference:path}/view", response_model=ProjectionView) +# Distinct `/projection/...` prefix, NOT `/references/{reference:path}/view`: the greedy `:path` +# converter on the existing GET /references/{reference:path} (Phase 3) would otherwise shadow a +# `/view` suffix, and a real reference ending in "/view" would collide. See spec §17.4. +@router.get("/projection/references/{reference:path}", response_model=ProjectionView) async def get_reference_projection( *, reference: str, @@ -1913,11 +1939,11 @@ async def get_reference_projection( ) ``` -Add `from extralit_server.api.v2 import projection as projection_v2` and `api_v2.include_router(projection_v2.router)` in `api/v2/__init__.py`. **Ordering:** include `projection_v2.router` *before* `records_v2.router` if a routing conflict arises between `/references/{reference:path}/view` and `/references/{reference:path}` — verify by test; FastAPI matches in include order. +Add `from extralit_server.api.v2 import projection as projection_v2` and `api_v2.include_router(projection_v2.router)` in `api/v2/__init__.py`. The `/projection/...` prefix does not overlap the Phase 3 `/references/{reference:path}` route, so include order is irrelevant. - [ ] **Step 7: Write the projection API test** -Create `tests/integration/api/v2/test_projection.py`: owner GETs `/api/v2/references/doc-1/view?workspace_id=` after seeding a suggestion, asserts 200 + the resolved cell; and an unknown reference returns 200 with `total_records == 0`. +Create `tests/integration/api/v2/test_projection.py`: owner GETs `/api/v2/projection/references/doc-1?workspace_id=` after seeding a suggestion, asserts 200 + the resolved cell; and an unknown reference returns 200 with `total_records == 0`. - [ ] **Step 8: Run the tests** diff --git a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md index dd07d20cc..17d576d4b 100644 --- a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md +++ b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md @@ -559,9 +559,11 @@ three tables. `GET /records/{id}/suggestions`. - **Responses:** `PUT /records/{id}/responses` (upsert the **current user's own** response), `GET /records/{id}/responses`. -- **Projection view:** `GET /references/{reference:path}/view?workspace_id=` (or an - extension of the existing `GET /references/{reference}`), returning per-record resolved - cells + provenance grouped by reference. +- **Projection view:** `GET /projection/references/{reference:path}?workspace_id=`, + returning per-record resolved cells + provenance grouped by reference. A distinct + `/projection/...` prefix (not a `/view` suffix on `/references/{reference:path}`): the + greedy `:path` converter would otherwise shadow the endpoint, and a real reference ending + in `/view` would collide. - **Version read (pulled into Phase 4):** `GET /schemas/{id}/versions/{version}` — the §15 ledger item, needed so the annotation UI can render records pinned to old versions. - **No Lance sync for annotation.** Questions/suggestions/responses live **only in From d2334ebf140b818f3317f62608aabcb39ba09d88 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 00:45:30 +0000 Subject: [PATCH 04/23] =?UTF-8?q?refactor(v2):=20drop=20schemas.kind=20(em?= =?UTF-8?q?ergent=20from=20bindings,=20spec=20=C2=A714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../6393b1a01aa0_drop_schemas_kind.py | 28 +++++++++++++++++++ .../extralit_server/api/schemas/v2/schemas.py | 4 +-- .../src/extralit_server/api/v2/schemas.py | 1 - .../extralit_server/contexts/v2/schemas.py | 4 --- extralit-server/src/extralit_server/enums.py | 5 ---- .../src/extralit_server/models/v2/schemas.py | 6 ++-- extralit-server/tests/factories.py | 2 -- .../api/schemas/v2/test_schema_models.py | 3 +- .../tests/integration/api/v2/test_schemas.py | 7 ++--- .../contexts/v2/test_records_context.py | 4 +-- .../contexts/v2/test_schemas_context.py | 8 +++--- .../models/v2/test_schema_models.py | 7 ++--- .../tests/integration/test_enums_v2.py | 8 +----- 13 files changed, 44 insertions(+), 43 deletions(-) create mode 100644 extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py diff --git a/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py b/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py new file mode 100644 index 000000000..d12ef5dfa --- /dev/null +++ b/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py @@ -0,0 +1,28 @@ +"""drop schemas.kind + +Revision ID: 6393b1a01aa0 +Revises: 8136bc88ee3a +Create Date: 2026-07-08 00:43:19.096246 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6393b1a01aa0" +down_revision = "8136bc88ee3a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("schemas", "kind") + # `kind` is emergent from question/column bindings (spec §14), not a stored discriminator. + sa.Enum(name="schema_kind_enum").drop(op.get_bind(), checkfirst=True) + + +def downgrade() -> None: + schema_kind = sa.Enum("singleton", "table", name="schema_kind_enum") + schema_kind.create(op.get_bind(), checkfirst=True) + op.add_column("schemas", sa.Column("kind", schema_kind, nullable=False, server_default="table")) diff --git a/extralit-server/src/extralit_server/api/schemas/v2/schemas.py b/extralit-server/src/extralit_server/api/schemas/v2/schemas.py index e9a77a699..cffdb26aa 100644 --- a/extralit-server/src/extralit_server/api/schemas/v2/schemas.py +++ b/extralit-server/src/extralit_server/api/schemas/v2/schemas.py @@ -4,14 +4,13 @@ from pydantic import BaseModel, ConfigDict, Field, constr -from extralit_server.enums import SchemaKind, SchemaStatus +from extralit_server.enums import SchemaStatus SchemaName = constr(min_length=1, max_length=200) class SchemaCreate(BaseModel): name: SchemaName - kind: SchemaKind = SchemaKind.table workspace_id: UUID settings: dict[str, Any] = Field(default_factory=dict) @@ -49,7 +48,6 @@ class SchemaRead(BaseModel): id: UUID name: str - kind: SchemaKind status: SchemaStatus current_version_id: UUID | None settings: dict[str, Any] diff --git a/extralit-server/src/extralit_server/api/v2/schemas.py b/extralit-server/src/extralit_server/api/v2/schemas.py index 9fa8a7607..de0c8d2c1 100644 --- a/extralit-server/src/extralit_server/api/v2/schemas.py +++ b/extralit-server/src/extralit_server/api/v2/schemas.py @@ -45,7 +45,6 @@ async def create_schema( return await schemas_ctx.create_schema( db, name=payload.name, - kind=payload.kind, workspace_id=payload.workspace_id, settings=payload.settings, ) diff --git a/extralit-server/src/extralit_server/contexts/v2/schemas.py b/extralit-server/src/extralit_server/contexts/v2/schemas.py index cb451b8b0..6b71a365f 100644 --- a/extralit-server/src/extralit_server/contexts/v2/schemas.py +++ b/extralit-server/src/extralit_server/contexts/v2/schemas.py @@ -14,8 +14,6 @@ if TYPE_CHECKING: from types_aiobotocore_s3.client import S3Client - from extralit_server.enums import SchemaKind - def object_key_for(schema_id: UUID, version: int) -> str: return f"schemas/{schema_id}/v{version}.json" @@ -25,14 +23,12 @@ async def create_schema( db: AsyncSession, *, name: str, - kind: "SchemaKind", workspace_id: UUID, settings: dict[str, Any] | None = None, ) -> Schema: return await Schema.create( db, name=name, - kind=kind, workspace_id=workspace_id, settings=settings or {}, status=SchemaStatus.draft, diff --git a/extralit-server/src/extralit_server/enums.py b/extralit-server/src/extralit_server/enums.py index a8c12fc7b..85762da73 100644 --- a/extralit-server/src/extralit_server/enums.py +++ b/extralit-server/src/extralit_server/enums.py @@ -97,11 +97,6 @@ class OptionsOrder(StrEnum): suggestion = "suggestion" -class SchemaKind(StrEnum): - singleton = "singleton" # exactly one row per `reference` (document-level extraction) - table = "table" # many rows per `reference` (table extraction) - - class SchemaStatus(StrEnum): draft = "draft" published = "published" diff --git a/extralit-server/src/extralit_server/models/v2/schemas.py b/extralit-server/src/extralit_server/models/v2/schemas.py index 9378a50fb..20f30dc11 100644 --- a/extralit-server/src/extralit_server/models/v2/schemas.py +++ b/extralit-server/src/extralit_server/models/v2/schemas.py @@ -6,13 +6,12 @@ from sqlalchemy.ext.mutable import MutableDict, MutableList from sqlalchemy.orm import Mapped, mapped_column, relationship -from extralit_server.enums import SchemaKind, SchemaStatus +from extralit_server.enums import SchemaStatus from extralit_server.models.base import DatabaseModel if TYPE_CHECKING: from extralit_server.models.database import Workspace -SchemaKindEnum = SAEnum(SchemaKind, name="schema_kind_enum") SchemaStatusEnum = SAEnum(SchemaStatus, name="schema_status_enum") @@ -20,7 +19,6 @@ class Schema(DatabaseModel): __tablename__ = "schemas" name: Mapped[str] = mapped_column(String, index=True) - kind: Mapped[SchemaKind] = mapped_column(SchemaKindEnum, default=SchemaKind.table) status: Mapped[SchemaStatus] = mapped_column(SchemaStatusEnum, default=SchemaStatus.draft, index=True) current_version_id: Mapped[UUID | None] = mapped_column( ForeignKey("schema_versions.id", ondelete="SET NULL", use_alter=True), nullable=True @@ -41,7 +39,7 @@ class Schema(DatabaseModel): __table_args__ = (UniqueConstraint("workspace_id", "name", name="schema_workspace_id_name_uq"),) def __repr__(self) -> str: - return f"Schema(id={self.id!s}, name={self.name!r}, kind={self.kind!r}, status={self.status!r})" + return f"Schema(id={self.id!s}, name={self.name!r}, status={self.status!r})" class SchemaVersion(DatabaseModel): diff --git a/extralit-server/tests/factories.py b/extralit-server/tests/factories.py index 15b9e8ca2..8dbbd37fb 100644 --- a/extralit-server/tests/factories.py +++ b/extralit-server/tests/factories.py @@ -14,7 +14,6 @@ FieldType, MetadataPropertyType, OptionsOrder, - SchemaKind, SchemaStatus, ) from extralit_server.models import ( @@ -654,7 +653,6 @@ class Meta: model = SchemaModel name = factory.Sequence(lambda n: f"schema-{n}") - kind = SchemaKind.table status = SchemaStatus.draft workspace = factory.SubFactory(WorkspaceFactory) diff --git a/extralit-server/tests/integration/api/schemas/v2/test_schema_models.py b/extralit-server/tests/integration/api/schemas/v2/test_schema_models.py index 8440edf68..613efac9a 100644 --- a/extralit-server/tests/integration/api/schemas/v2/test_schema_models.py +++ b/extralit-server/tests/integration/api/schemas/v2/test_schema_models.py @@ -1,11 +1,10 @@ from uuid import uuid4 from extralit_server.api.schemas.v2.schemas import SchemaCreate, SchemaVersionCreate -from extralit_server.enums import SchemaKind def test_schema_create_defaults_settings_to_empty_dict(): - payload = SchemaCreate(name="population", kind=SchemaKind.table, workspace_id=uuid4()) + payload = SchemaCreate(name="population", workspace_id=uuid4()) assert payload.settings == {} diff --git a/extralit-server/tests/integration/api/v2/test_schemas.py b/extralit-server/tests/integration/api/v2/test_schemas.py index 81451fc45..c347e9489 100644 --- a/extralit-server/tests/integration/api/v2/test_schemas.py +++ b/extralit-server/tests/integration/api/v2/test_schemas.py @@ -1,7 +1,6 @@ import pandera.pandas as pa import pytest -from extralit_server.enums import SchemaKind from tests.factories import WorkspaceFactory pytestmark = pytest.mark.asyncio @@ -16,7 +15,7 @@ async def test_create_get_list_schema(async_client, owner_auth_header): resp = await async_client.post( "/api/v2/schemas", headers=owner_auth_header, - json={"name": "population", "kind": SchemaKind.table.value, "workspace_id": str(ws.id)}, + json={"name": "population", "workspace_id": str(ws.id)}, ) assert resp.status_code == 201, resp.text schema_id = resp.json()["id"] @@ -56,7 +55,7 @@ async def test_publish_version_and_columns(async_client, owner_auth_header, monk resp = await async_client.post( "/api/v2/schemas", headers=owner_auth_header, - json={"name": "outcomes", "kind": SchemaKind.table.value, "workspace_id": str(ws.id)}, + json={"name": "outcomes", "workspace_id": str(ws.id)}, ) schema_id = resp.json()["id"] @@ -79,7 +78,7 @@ async def test_non_member_cannot_create_or_read_schema(async_client, annotator_a resp = await async_client.post( "/api/v2/schemas", headers=annotator_auth_header, - json={"name": "secret", "kind": SchemaKind.table.value, "workspace_id": str(ws.id)}, + json={"name": "secret", "workspace_id": str(ws.id)}, ) assert resp.status_code == 403, resp.text diff --git a/extralit-server/tests/integration/contexts/v2/test_records_context.py b/extralit-server/tests/integration/contexts/v2/test_records_context.py index d10093ead..65b783ea1 100644 --- a/extralit-server/tests/integration/contexts/v2/test_records_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_records_context.py @@ -4,7 +4,7 @@ import pytest from extralit_server.contexts.v2 import records as records_ctx -from extralit_server.enums import SchemaKind, V2RecordStatus +from extralit_server.enums import V2RecordStatus from extralit_server.errors.future import UnprocessableEntityError from extralit_server.models.v2 import Schema, SchemaVersion, V2Record from tests.factories import SchemaFactory, SchemaVersionFactory, V2RecordFactory, WorkspaceFactory @@ -26,7 +26,7 @@ def _patch_fetch(monkeypatch, body: str = BODY) -> AsyncMock: async def _published_schema(db) -> tuple[Schema, SchemaVersion]: - schema = await SchemaFactory.create(kind=SchemaKind.table) + schema = await SchemaFactory.create() version = await SchemaVersionFactory.create(schema=schema, version=1) await schema.update(db, current_version_id=version.id) return schema, version diff --git a/extralit-server/tests/integration/contexts/v2/test_schemas_context.py b/extralit-server/tests/integration/contexts/v2/test_schemas_context.py index de6748f1a..95bd58116 100644 --- a/extralit-server/tests/integration/contexts/v2/test_schemas_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_schemas_context.py @@ -6,7 +6,7 @@ from extralit_server.contexts.files import ObjectMetadata from extralit_server.contexts.v2 import schemas as schemas_ctx -from extralit_server.enums import SchemaKind, SchemaStatus +from extralit_server.enums import SchemaStatus from extralit_server.models.v2 import Schema, SchemaVersion from tests.factories import WorkspaceFactory @@ -36,7 +36,7 @@ def _patch_put_object(monkeypatch, bucket: str) -> AsyncMock: async def test_create_and_list_schema(db): ws = await WorkspaceFactory.create() - schema = await schemas_ctx.create_schema(db, name="population", kind=SchemaKind.table, workspace_id=ws.id) + schema = await schemas_ctx.create_schema(db, name="population", workspace_id=ws.id) assert isinstance(schema, Schema) listed = await schemas_ctx.list_schemas(db, workspace_id=ws.id) @@ -46,7 +46,7 @@ async def test_create_and_list_schema(db): async def test_publish_version_uploads_body_and_advances_pointer(db, monkeypatch): ws = await WorkspaceFactory.create() _patch_put_object(monkeypatch, ws.name) - schema = await schemas_ctx.create_schema(db, name="population", kind=SchemaKind.table, workspace_id=ws.id) + schema = await schemas_ctx.create_schema(db, name="population", workspace_id=ws.id) s3 = AsyncMock() version = await schemas_ctx.publish_version(db, s3, schema, body=_body(), bucket=ws.name, created_by=None) @@ -71,7 +71,7 @@ async def test_publish_version_uploads_body_and_advances_pointer(db, monkeypatch async def test_publish_version_merges_review_widgets_into_columns_cache(db, monkeypatch): ws = await WorkspaceFactory.create() _patch_put_object(monkeypatch, ws.name) - schema = await schemas_ctx.create_schema(db, name="ratings", kind=SchemaKind.table, workspace_id=ws.id) + schema = await schemas_ctx.create_schema(db, name="ratings", workspace_id=ws.id) s3 = AsyncMock() version = await schemas_ctx.publish_version( diff --git a/extralit-server/tests/integration/models/v2/test_schema_models.py b/extralit-server/tests/integration/models/v2/test_schema_models.py index 69411c5a5..ac3d0208c 100644 --- a/extralit-server/tests/integration/models/v2/test_schema_models.py +++ b/extralit-server/tests/integration/models/v2/test_schema_models.py @@ -1,7 +1,7 @@ import pytest from sqlalchemy.exc import IntegrityError -from extralit_server.enums import SchemaKind, SchemaStatus +from extralit_server.enums import SchemaStatus from extralit_server.models.v2 import Schema, SchemaVersion from tests.factories import SchemaFactory, WorkspaceFactory @@ -13,7 +13,6 @@ async def test_create_schema_and_version(db): schema = await Schema.create( db, name="population", - kind=SchemaKind.table, status=SchemaStatus.draft, workspace_id=workspace.id, ) @@ -32,14 +31,12 @@ async def test_create_schema_and_version(db): loaded = await Schema.get(db, schema.id) assert loaded.name == "population" - assert loaded.kind == SchemaKind.table async def test_schema_and_version_apply_column_defaults(db): workspace = await WorkspaceFactory.create() - # Schema created with only the required fields applies kind/status/settings defaults. + # Schema created with only the required fields applies status/settings defaults. schema = await Schema.create(db, name="defaults", workspace_id=workspace.id) - assert schema.kind == SchemaKind.table assert schema.status == SchemaStatus.draft assert schema.settings == {} assert schema.current_version_id is None diff --git a/extralit-server/tests/integration/test_enums_v2.py b/extralit-server/tests/integration/test_enums_v2.py index 3538770ec..2992f03e5 100644 --- a/extralit-server/tests/integration/test_enums_v2.py +++ b/extralit-server/tests/integration/test_enums_v2.py @@ -1,10 +1,4 @@ -from extralit_server.enums import SchemaKind, SchemaStatus, V2RecordStatus - - -def test_schema_kind_values(): - assert SchemaKind.singleton == "singleton" - assert SchemaKind.table == "table" - assert {k.value for k in SchemaKind} == {"singleton", "table"} +from extralit_server.enums import SchemaStatus, V2RecordStatus def test_schema_status_values(): From 5bc2c3e30d3d4c757ccb1c4a0679506f109de37a Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 00:51:30 +0000 Subject: [PATCH 05/23] fix(v2): reset schemas.kind server_default in migration downgrade roborev job 103 (Low): downgrade() re-added kind with server_default="table" but never cleared it, leaving the schema slightly divergent from the original (Python-side model default only) after a downgrade+upgrade cycle. --- .../alembic/versions/6393b1a01aa0_drop_schemas_kind.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py b/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py index d12ef5dfa..2ca4111fb 100644 --- a/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py +++ b/extralit-server/src/extralit_server/alembic/versions/6393b1a01aa0_drop_schemas_kind.py @@ -26,3 +26,7 @@ def downgrade() -> None: schema_kind = sa.Enum("singleton", "table", name="schema_kind_enum") schema_kind.create(op.get_bind(), checkfirst=True) op.add_column("schemas", sa.Column("kind", schema_kind, nullable=False, server_default="table")) + # The original column had no DB-level server_default (only a Python-side model default); + # drop it post-backfill so downgrade restores the exact prior DDL. + with op.batch_alter_table("schemas") as batch_op: + batch_op.alter_column("kind", server_default=None) From 99e387c23707a9256910ebddc291defc7959332e Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:00:17 +0000 Subject: [PATCH 06/23] feat(v2): add V2Question/V2Suggestion/V2Response models --- .../src/extralit_server/models/v2/__init__.py | 5 ++- .../extralit_server/models/v2/questions.py | 40 +++++++++++++++++++ .../extralit_server/models/v2/responses.py | 40 +++++++++++++++++++ .../extralit_server/models/v2/suggestions.py | 37 +++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 extralit-server/src/extralit_server/models/v2/questions.py create mode 100644 extralit-server/src/extralit_server/models/v2/responses.py create mode 100644 extralit-server/src/extralit_server/models/v2/suggestions.py diff --git a/extralit-server/src/extralit_server/models/v2/__init__.py b/extralit-server/src/extralit_server/models/v2/__init__.py index caae0054b..df61bacb1 100644 --- a/extralit-server/src/extralit_server/models/v2/__init__.py +++ b/extralit-server/src/extralit_server/models/v2/__init__.py @@ -1,5 +1,8 @@ +from extralit_server.models.v2.questions import V2Question from extralit_server.models.v2.records import V2Record from extralit_server.models.v2.records import V2Record as Record # v2-namespace alias +from extralit_server.models.v2.responses import V2Response from extralit_server.models.v2.schemas import Schema, SchemaVersion +from extralit_server.models.v2.suggestions import V2Suggestion -__all__ = ["Record", "Schema", "SchemaVersion", "V2Record"] +__all__ = ["Record", "Schema", "SchemaVersion", "V2Question", "V2Record", "V2Response", "V2Suggestion"] diff --git a/extralit-server/src/extralit_server/models/v2/questions.py b/extralit-server/src/extralit_server/models/v2/questions.py new file mode 100644 index 000000000..76229388f --- /dev/null +++ b/extralit-server/src/extralit_server/models/v2/questions.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.ext.mutable import MutableDict, MutableList +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import QuestionType +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.v2.schemas import Schema + +# Distinct PG enum name (v1 stores question type inside settings JSON, but v2 promotes it to a +# first-class column). Reuses the v1 QuestionType *values*. +V2QuestionTypeEnum = SAEnum(QuestionType, name="v2_question_type_enum") + + +class V2Question(DatabaseModel): + """Reviewable column binding + review config (spec §17). Its settings drive per-cell + value validation. `columns` binds >=1 schema column (exactly 1 for non-table types).""" + + __tablename__ = "v2_questions" + + schema_id: Mapped[UUID] = mapped_column(ForeignKey("schemas.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String, index=True) + title: Mapped[str] = mapped_column(Text) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + type: Mapped[QuestionType] = mapped_column(V2QuestionTypeEnum, index=True) + columns: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) + settings: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSON), default=dict) + required: Mapped[bool] = mapped_column(default=False) + + schema: Mapped["Schema"] = relationship("Schema") + + __table_args__ = (UniqueConstraint("schema_id", "name", name="v2_question_schema_id_name_uq"),) + + def __repr__(self) -> str: + return f"V2Question(id={self.id!s}, schema_id={self.schema_id!s}, name={self.name!r}, type={self.type!r})" diff --git a/extralit-server/src/extralit_server/models/v2/responses.py b/extralit-server/src/extralit_server/models/v2/responses.py new file mode 100644 index 000000000..aba3031bb --- /dev/null +++ b/extralit-server/src/extralit_server/models/v2/responses.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import ResponseStatus +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.database import User + from extralit_server.models.v2.records import V2Record + +V2ResponseStatusEnum = SAEnum(ResponseStatus, name="v2_response_status_enum") + + +class V2Response(DatabaseModel): + """Human review per (record, user); `values` keyed by question name -> {value} (spec §17.3). + Multiple users per record = the overlap axis Phase 5 distribution counts.""" + + __tablename__ = "v2_responses" + + record_id: Mapped[UUID] = mapped_column(ForeignKey("v2_records.id", ondelete="CASCADE"), index=True) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + values: Mapped[dict | None] = mapped_column(MutableDict.as_mutable(JSON), nullable=True) + status: Mapped[ResponseStatus] = mapped_column(V2ResponseStatusEnum, default=ResponseStatus.submitted, index=True) + + record: Mapped["V2Record"] = relationship("V2Record") + user: Mapped["User"] = relationship("User") + + __table_args__ = (UniqueConstraint("record_id", "user_id", name="v2_response_record_id_user_id_uq"),) + + @property + def is_submitted(self) -> bool: + return self.status == ResponseStatus.submitted + + def __repr__(self) -> str: + return f"V2Response(id={self.id!s}, record_id={self.record_id!s}, user_id={self.user_id!s}, status={self.status!r})" diff --git a/extralit-server/src/extralit_server/models/v2/suggestions.py b/extralit-server/src/extralit_server/models/v2/suggestions.py new file mode 100644 index 000000000..d7b20312f --- /dev/null +++ b/extralit-server/src/extralit_server/models/v2/suggestions.py @@ -0,0 +1,37 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from extralit_server.enums import SuggestionType +from extralit_server.models.base import DatabaseModel + +if TYPE_CHECKING: + from extralit_server.models.v2.questions import V2Question + from extralit_server.models.v2.records import V2Record + +V2SuggestionTypeEnum = SAEnum(SuggestionType, name="v2_suggestion_type_enum") + + +class V2Suggestion(DatabaseModel): + """LLM-pre-populated proposed value per (record, question) (spec §17). Superseded by a + submitted response in the projection view, but retained as provenance.""" + + __tablename__ = "v2_suggestions" + + record_id: Mapped[UUID] = mapped_column(ForeignKey("v2_records.id", ondelete="CASCADE"), index=True) + question_id: Mapped[UUID] = mapped_column(ForeignKey("v2_questions.id", ondelete="CASCADE"), index=True) + value: Mapped[object] = mapped_column(JSON) + score: Mapped[float | list[float] | None] = mapped_column(JSON, nullable=True) + agent: Mapped[str | None] = mapped_column(String, nullable=True) + type: Mapped[SuggestionType | None] = mapped_column(V2SuggestionTypeEnum, nullable=True, index=True) + + record: Mapped["V2Record"] = relationship("V2Record") + question: Mapped["V2Question"] = relationship("V2Question") + + __table_args__ = (UniqueConstraint("record_id", "question_id", name="v2_suggestion_record_id_question_id_uq"),) + + def __repr__(self) -> str: + return f"V2Suggestion(id={self.id!s}, record_id={self.record_id!s}, question_id={self.question_id!s})" From 6c7f8e6ef271f706ba0d89c2fc72ba2c4b5ceb36 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:08:59 +0000 Subject: [PATCH 07/23] feat(v2): migration + factories for v2 annotation tables --- ...1510e93882a_create_v2_annotation_tables.py | 119 ++++++++++++++++++ extralit-server/tests/factories.py | 68 ++++++++++ .../models/v2/test_annotation_models.py | 53 ++++++++ 3 files changed, 240 insertions(+) create mode 100644 extralit-server/src/extralit_server/alembic/versions/c1510e93882a_create_v2_annotation_tables.py create mode 100644 extralit-server/tests/integration/models/v2/test_annotation_models.py diff --git a/extralit-server/src/extralit_server/alembic/versions/c1510e93882a_create_v2_annotation_tables.py b/extralit-server/src/extralit_server/alembic/versions/c1510e93882a_create_v2_annotation_tables.py new file mode 100644 index 000000000..27a72d4e0 --- /dev/null +++ b/extralit-server/src/extralit_server/alembic/versions/c1510e93882a_create_v2_annotation_tables.py @@ -0,0 +1,119 @@ +"""create v2 annotation tables + +Revision ID: c1510e93882a +Revises: 6393b1a01aa0 +Create Date: 2026-07-08 01:04:40.245236 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c1510e93882a" +down_revision = "6393b1a01aa0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "v2_questions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("schema_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "type", + sa.Enum( + "text", + "rating", + "label_selection", + "multi_label_selection", + "ranking", + "span", + "table", + name="v2_question_type_enum", + ), + nullable=False, + ), + sa.Column("columns", sa.JSON(), nullable=False), + sa.Column("settings", sa.JSON(), nullable=False), + sa.Column("required", sa.Boolean(), nullable=False), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["schema_id"], ["schemas.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("schema_id", "name", name="v2_question_schema_id_name_uq"), + ) + op.create_index(op.f("ix_v2_questions_schema_id"), "v2_questions", ["schema_id"], unique=False) + op.create_index(op.f("ix_v2_questions_name"), "v2_questions", ["name"], unique=False) + op.create_index(op.f("ix_v2_questions_type"), "v2_questions", ["type"], unique=False) + + op.create_table( + "v2_suggestions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("record_id", sa.Uuid(), nullable=False), + sa.Column("question_id", sa.Uuid(), nullable=False), + sa.Column("value", sa.JSON(), nullable=False), + sa.Column("score", sa.JSON(), nullable=True), + sa.Column("agent", sa.String(), nullable=True), + sa.Column( + "type", + sa.Enum("model", "human", "selection", name="v2_suggestion_type_enum"), + nullable=True, + ), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["record_id"], ["v2_records.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["question_id"], ["v2_questions.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("record_id", "question_id", name="v2_suggestion_record_id_question_id_uq"), + ) + op.create_index(op.f("ix_v2_suggestions_record_id"), "v2_suggestions", ["record_id"], unique=False) + op.create_index(op.f("ix_v2_suggestions_question_id"), "v2_suggestions", ["question_id"], unique=False) + op.create_index(op.f("ix_v2_suggestions_type"), "v2_suggestions", ["type"], unique=False) + + op.create_table( + "v2_responses", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("record_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("values", sa.JSON(), nullable=True), + sa.Column( + "status", + sa.Enum("draft", "submitted", "discarded", name="v2_response_status_enum"), + nullable=False, + ), + sa.Column("inserted_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["record_id"], ["v2_records.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("record_id", "user_id", name="v2_response_record_id_user_id_uq"), + ) + op.create_index(op.f("ix_v2_responses_record_id"), "v2_responses", ["record_id"], unique=False) + op.create_index(op.f("ix_v2_responses_user_id"), "v2_responses", ["user_id"], unique=False) + op.create_index(op.f("ix_v2_responses_status"), "v2_responses", ["status"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_v2_responses_status"), table_name="v2_responses") + op.drop_index(op.f("ix_v2_responses_user_id"), table_name="v2_responses") + op.drop_index(op.f("ix_v2_responses_record_id"), table_name="v2_responses") + op.drop_table("v2_responses") + + op.drop_index(op.f("ix_v2_suggestions_type"), table_name="v2_suggestions") + op.drop_index(op.f("ix_v2_suggestions_question_id"), table_name="v2_suggestions") + op.drop_index(op.f("ix_v2_suggestions_record_id"), table_name="v2_suggestions") + op.drop_table("v2_suggestions") + + op.drop_index(op.f("ix_v2_questions_type"), table_name="v2_questions") + op.drop_index(op.f("ix_v2_questions_name"), table_name="v2_questions") + op.drop_index(op.f("ix_v2_questions_schema_id"), table_name="v2_questions") + op.drop_table("v2_questions") + + sa.Enum(name="v2_response_status_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="v2_suggestion_type_enum").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="v2_question_type_enum").drop(op.get_bind(), checkfirst=True) diff --git a/extralit-server/tests/factories.py b/extralit-server/tests/factories.py index 8dbbd37fb..b152b1eb0 100644 --- a/extralit-server/tests/factories.py +++ b/extralit-server/tests/factories.py @@ -14,6 +14,7 @@ FieldType, MetadataPropertyType, OptionsOrder, + ResponseStatus, SchemaStatus, ) from extralit_server.models import ( @@ -39,7 +40,10 @@ from extralit_server.models.base import DatabaseModel from extralit_server.models.v2 import Schema as SchemaModel from extralit_server.models.v2 import SchemaVersion as SchemaVersionModel +from extralit_server.models.v2 import V2Question as V2QuestionModel from extralit_server.models.v2 import V2Record as V2RecordModel +from extralit_server.models.v2 import V2Response as V2ResponseModel +from extralit_server.models.v2 import V2Suggestion as V2SuggestionModel from extralit_server.webhooks.v1.enums import WebhookEvent from tests.database import SyncTestSession, TestSession @@ -694,3 +698,67 @@ async def _create(cls, model_class, *args, **kwargs): kwargs.setdefault("schema_version_id", version.id) kwargs.setdefault("schema_id", version.schema_id) return await super()._create(model_class, *args, **kwargs) + + +class V2QuestionFactory(BaseFactory): + class Meta: + model = V2QuestionModel + + schema = factory.SubFactory(SchemaFactory) + name = factory.Sequence(lambda n: f"question-{n}") + title = factory.Sequence(lambda n: f"Question {n}") + type = QuestionType.text + columns = factory.LazyFunction(list) + settings = factory.LazyAttribute(lambda o: {"type": o.type.value}) + required = False + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + schema = kwargs.get("schema") + if inspect.isawaitable(schema): + schema = await schema + kwargs["schema"] = schema + if schema is not None: + kwargs.setdefault("schema_id", schema.id) + return await super()._create(model_class, *args, **kwargs) + + +class V2SuggestionFactory(BaseFactory): + class Meta: + model = V2SuggestionModel + + record = factory.SubFactory(V2RecordFactory) + question = factory.SubFactory(V2QuestionFactory) + value = "suggested" + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + for key in ("record", "question"): + obj = kwargs.get(key) + if inspect.isawaitable(obj): + obj = await obj + kwargs[key] = obj + if obj is not None: + kwargs.setdefault(f"{key}_id", obj.id) + return await super()._create(model_class, *args, **kwargs) + + +class V2ResponseFactory(BaseFactory): + class Meta: + model = V2ResponseModel + + record = factory.SubFactory(V2RecordFactory) + user = factory.SubFactory(UserFactory) + values = factory.LazyFunction(dict) + status = ResponseStatus.submitted + + @classmethod + async def _create(cls, model_class, *args, **kwargs): + for key in ("record", "user"): + obj = kwargs.get(key) + if inspect.isawaitable(obj): + obj = await obj + kwargs[key] = obj + if obj is not None: + kwargs.setdefault(f"{key}_id", obj.id) + return await super()._create(model_class, *args, **kwargs) diff --git a/extralit-server/tests/integration/models/v2/test_annotation_models.py b/extralit-server/tests/integration/models/v2/test_annotation_models.py new file mode 100644 index 000000000..35bff0e1d --- /dev/null +++ b/extralit-server/tests/integration/models/v2/test_annotation_models.py @@ -0,0 +1,53 @@ +import pytest +from sqlalchemy.exc import IntegrityError + +from extralit_server.enums import QuestionType, ResponseStatus +from tests.factories import ( + SchemaFactory, + UserFactory, + V2QuestionFactory, + V2RecordFactory, + V2ResponseFactory, + V2SuggestionFactory, +) + + +@pytest.mark.asyncio +async def test_question_persists_with_type_and_columns(db): + q = await V2QuestionFactory.create(type=QuestionType.label_selection, columns=["disease"]) + assert q.id is not None + assert q.type == QuestionType.label_selection + assert q.columns == ["disease"] + + +@pytest.mark.asyncio +async def test_question_name_unique_per_schema(db): + # Same schema OBJECT passed twice (not schema_id) so the (schema_id, name) constraint trips. + schema = await SchemaFactory.create() + await V2QuestionFactory.create(schema=schema, name="dup") + with pytest.raises(IntegrityError): + await V2QuestionFactory.create(schema=schema, name="dup") + + +@pytest.mark.asyncio +async def test_suggestion_unique_per_record_question(db): + # Pass the parent OBJECTS (not *_id): the factories declare record/question as SubFactory + # defaults, so passing only ids would let factory-boy create fresh parents and the + # relationship would win on flush — the (record_id, question_id) constraint would never trip. + record = await V2RecordFactory.create() + question = await V2QuestionFactory.create() + await V2SuggestionFactory.create(record=record, question=question) + with pytest.raises(IntegrityError): + await V2SuggestionFactory.create(record=record, question=question) + + +@pytest.mark.asyncio +async def test_response_unique_per_record_user(db): + record = await V2RecordFactory.create() + user = await UserFactory.create() + r = await V2ResponseFactory.create( + record=record, user=user, status=ResponseStatus.submitted, values={"q": {"value": "x"}} + ) + assert r.is_submitted + with pytest.raises(IntegrityError): + await V2ResponseFactory.create(record=record, user=user) From d083ca3a615aada7d3bb06ca7dbbde1398edc87b Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:15:42 +0000 Subject: [PATCH 08/23] feat(v2): question column-binding validator --- .../extralit_server/validators/v2/__init__.py | 0 .../validators/v2/questions.py | 31 +++++++++++++ .../tests/unit/validators/v2/__init__.py | 0 .../validators/v2/test_question_binding.py | 44 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 extralit-server/src/extralit_server/validators/v2/__init__.py create mode 100644 extralit-server/src/extralit_server/validators/v2/questions.py create mode 100644 extralit-server/tests/unit/validators/v2/__init__.py create mode 100644 extralit-server/tests/unit/validators/v2/test_question_binding.py diff --git a/extralit-server/src/extralit_server/validators/v2/__init__.py b/extralit-server/src/extralit_server/validators/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extralit-server/src/extralit_server/validators/v2/questions.py b/extralit-server/src/extralit_server/validators/v2/questions.py new file mode 100644 index 000000000..177b4eb6b --- /dev/null +++ b/extralit-server/src/extralit_server/validators/v2/questions.py @@ -0,0 +1,31 @@ +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError + +# span is reserved in the enum but deferred to the PDF-chunk design session (spec §17.3). +DEFERRED_TYPES = {QuestionType.span} + + +class QuestionBindingValidator: + """Validate a question's column binding against the schema's current columns_cache + (spec §17.3): existence + arity. Publish-time revalidation and dtype-compat are deferred.""" + + @classmethod + def validate(cls, *, type: QuestionType, columns: list[str], columns_cache: list[dict]) -> None: + if type in DEFERRED_TYPES: + raise UnprocessableEntityError( + f"question type {type.value!r} (span) is not supported in this release; " + "it is deferred to the PDF-chunk annotation design" + ) + if not columns: + raise UnprocessableEntityError("a question must bind at least one column") + if type != QuestionType.table and len(columns) != 1: + raise UnprocessableEntityError( + f"question type {type.value!r} must bind exactly one column, got {len(columns)}" + ) + + known = {entry["name"] for entry in columns_cache} + unknown = [name for name in columns if name not in known] + if unknown: + raise UnprocessableEntityError( + f"unknown column(s) {unknown!r} for question binding; available columns: {sorted(known)!r}" + ) diff --git a/extralit-server/tests/unit/validators/v2/__init__.py b/extralit-server/tests/unit/validators/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extralit-server/tests/unit/validators/v2/test_question_binding.py b/extralit-server/tests/unit/validators/v2/test_question_binding.py new file mode 100644 index 000000000..c7851ff48 --- /dev/null +++ b/extralit-server/tests/unit/validators/v2/test_question_binding.py @@ -0,0 +1,44 @@ +import pytest + +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.v2.questions import QuestionBindingValidator + +COLUMNS_CACHE = [ + {"name": "disease", "dtype": "str", "nullable": True, "review": None}, + {"name": "p_value", "dtype": "float64", "nullable": True, "review": None}, +] + + +def test_non_table_binds_exactly_one_existing_column(): + QuestionBindingValidator.validate( + type=QuestionType.label_selection, columns=["disease"], columns_cache=COLUMNS_CACHE + ) + + +def test_table_binds_one_or_more(): + QuestionBindingValidator.validate( + type=QuestionType.table, columns=["disease", "p_value"], columns_cache=COLUMNS_CACHE + ) + + +def test_span_is_rejected(): + with pytest.raises(UnprocessableEntityError, match="span"): + QuestionBindingValidator.validate(type=QuestionType.span, columns=["disease"], columns_cache=COLUMNS_CACHE) + + +def test_unknown_column_rejected(): + with pytest.raises(UnprocessableEntityError, match="unknown"): + QuestionBindingValidator.validate(type=QuestionType.text, columns=["missing"], columns_cache=COLUMNS_CACHE) + + +def test_non_table_multiple_columns_rejected(): + with pytest.raises(UnprocessableEntityError, match="exactly one"): + QuestionBindingValidator.validate( + type=QuestionType.rating, columns=["disease", "p_value"], columns_cache=COLUMNS_CACHE + ) + + +def test_empty_binding_rejected(): + with pytest.raises(UnprocessableEntityError, match="at least one"): + QuestionBindingValidator.validate(type=QuestionType.table, columns=[], columns_cache=COLUMNS_CACHE) From ea54b89197269cc1e9c43e01a171499f6f223187 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:23:31 +0000 Subject: [PATCH 09/23] feat(v2): value validators reusing v1 per-type validators --- .../extralit_server/validators/v2/values.py | 72 +++++++++++++++++++ .../tests/unit/validators/v2/test_values.py | 55 ++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 extralit-server/src/extralit_server/validators/v2/values.py create mode 100644 extralit-server/tests/unit/validators/v2/test_values.py diff --git a/extralit-server/src/extralit_server/validators/v2/values.py b/extralit-server/src/extralit_server/validators/v2/values.py new file mode 100644 index 000000000..3ccb74476 --- /dev/null +++ b/extralit-server/src/extralit_server/validators/v2/values.py @@ -0,0 +1,72 @@ +from pydantic import TypeAdapter + +from extralit_server.api.schemas.v1.questions import QuestionSettings +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.response_values import ( + LabelSelectionQuestionResponseValueValidator, + MultiLabelSelectionQuestionResponseValueValidator, + RankingQuestionResponseValueValidator, + RatingQuestionResponseValueValidator, + TextQuestionResponseValueValidator, +) + +DEFERRED_TYPES = {QuestionType.span} + + +def _parsed(settings: dict): + # Reuse v1's discriminated QuestionSettings union so options/ranges are typed like v1. + return TypeAdapter(QuestionSettings).validate_python(settings) + + +class V2ResponseValueValidator: + """Settings-level value validation reusing v1's per-type validators (spec §17.3). + span is rejected (deferred); table validates structure only (no Pandera re-run).""" + + @classmethod + def validate(cls, value, *, type: QuestionType, settings: dict, columns: list[str]) -> None: + if type in DEFERRED_TYPES: + raise UnprocessableEntityError(f"question type {type.value!r} (span) is not supported in this release") + if type == QuestionType.text: + TextQuestionResponseValueValidator(value).validate() + elif type == QuestionType.label_selection: + LabelSelectionQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.multi_label_selection: + MultiLabelSelectionQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.rating: + RatingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.ranking: + RankingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) + elif type == QuestionType.table: + cls._validate_table(value, columns) + else: + raise UnprocessableEntityError(f"unknown question type {type!r}") + + @staticmethod + def _validate_table(value, columns: list[str]) -> None: + if not isinstance(value, dict): + raise UnprocessableEntityError(f"table question expects a dict of values, found {type(value)}") + bound = set(columns) + extra = sorted(k for k in value if k not in bound) + if extra: + raise UnprocessableEntityError( + f"table value keys {extra!r} are not bound columns; bound: {sorted(bound)!r}" + ) + + +class V2SuggestionValidator: + """Value validation (same as responses) + v1 score-cardinality checks (spec §17.3).""" + + @classmethod + def validate(cls, value, score, *, type: QuestionType, settings: dict, columns: list[str]) -> None: + V2ResponseValueValidator.validate(value, type=type, settings=settings, columns=columns) + cls._validate_score(value, score) + + @staticmethod + def _validate_score(value, score) -> None: + if not isinstance(value, list) and isinstance(score, list): + raise UnprocessableEntityError("a list of scores is not allowed for a single-value suggestion") + if isinstance(value, list) and score is not None and not isinstance(score, list): + raise UnprocessableEntityError("a single score is not allowed for a multi-item suggestion value") + if isinstance(value, list) and isinstance(score, list) and len(value) != len(score): + raise UnprocessableEntityError("number of items on value and score doesn't match") diff --git a/extralit-server/tests/unit/validators/v2/test_values.py b/extralit-server/tests/unit/validators/v2/test_values.py new file mode 100644 index 000000000..62b1a7ac9 --- /dev/null +++ b/extralit-server/tests/unit/validators/v2/test_values.py @@ -0,0 +1,55 @@ +import pytest + +from extralit_server.enums import QuestionType +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.validators.v2.values import V2ResponseValueValidator, V2SuggestionValidator + +LABEL_SETTINGS = { + "type": "label_selection", + "options": [{"value": "yes", "text": "Yes"}, {"value": "no", "text": "No"}], + "strict": True, +} +TABLE_SETTINGS = {"type": "table"} + + +def test_text_value_must_be_str(): + V2ResponseValueValidator.validate("ok", type=QuestionType.text, settings={"type": "text"}, columns=["c"]) + with pytest.raises(UnprocessableEntityError): + V2ResponseValueValidator.validate(5, type=QuestionType.text, settings={"type": "text"}, columns=["c"]) + + +def test_label_must_be_in_options(): + V2ResponseValueValidator.validate("yes", type=QuestionType.label_selection, settings=LABEL_SETTINGS, columns=["c"]) + with pytest.raises(UnprocessableEntityError): + V2ResponseValueValidator.validate( + "maybe", type=QuestionType.label_selection, settings=LABEL_SETTINGS, columns=["c"] + ) + + +def test_table_value_keys_must_be_subset_of_columns(): + V2ResponseValueValidator.validate({"a": 1}, type=QuestionType.table, settings=TABLE_SETTINGS, columns=["a", "b"]) + with pytest.raises(UnprocessableEntityError, match="not bound"): + V2ResponseValueValidator.validate( + {"z": 1}, type=QuestionType.table, settings=TABLE_SETTINGS, columns=["a", "b"] + ) + + +def test_span_value_is_rejected(): + with pytest.raises(UnprocessableEntityError, match="span"): + V2ResponseValueValidator.validate([], type=QuestionType.span, settings={"type": "span"}, columns=["c"]) + + +MULTI_LABEL_SETTINGS = { + "type": "multi_label_selection", + "options": [{"value": "yes", "text": "Yes"}], +} + + +def test_suggestion_score_length_must_match_list_value(): + V2SuggestionValidator.validate( + ["yes"], [0.9], type=QuestionType.multi_label_selection, settings=MULTI_LABEL_SETTINGS, columns=["c"] + ) + with pytest.raises(UnprocessableEntityError): + V2SuggestionValidator.validate( + ["yes"], [0.9, 0.1], type=QuestionType.multi_label_selection, settings=MULTI_LABEL_SETTINGS, columns=["c"] + ) From 1a90997f853d67098dbdb960f631bc376fdf7e1f Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:36:05 +0000 Subject: [PATCH 10/23] feat(v2): questions context, API, and policy Wire the questions vertical slice end-to-end: QuestionCreate/Update/Read schemas, contexts.v2.annotation (create/list/get/update/delete backed by QuestionBindingValidator + SchemaVersion.columns_cache), V2QuestionPolicy, and the /schemas/{id}/questions + /questions/{id} router. Eager-loads question.schema via selectinload for the write policies since AsyncSession cannot lazy-load relationships outside an active greenlet. --- .../api/policies/v1/__init__.py | 2 + .../api/policies/v1/v2_annotation_policy.py | 33 ++++++ .../api/schemas/v2/questions.py | 47 ++++++++ .../src/extralit_server/api/v2/__init__.py | 2 + .../src/extralit_server/api/v2/questions.py | 102 +++++++++++++++++ .../extralit_server/contexts/v2/annotation.py | 70 ++++++++++++ .../integration/api/v2/test_questions.py | 107 ++++++++++++++++++ .../contexts/v2/test_annotation_context.py | 56 +++++++++ 8 files changed, 419 insertions(+) create mode 100644 extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py create mode 100644 extralit-server/src/extralit_server/api/schemas/v2/questions.py create mode 100644 extralit-server/src/extralit_server/api/v2/questions.py create mode 100644 extralit-server/src/extralit_server/contexts/v2/annotation.py create mode 100644 extralit-server/tests/integration/api/v2/test_questions.py create mode 100644 extralit-server/tests/integration/contexts/v2/test_annotation_context.py diff --git a/extralit-server/src/extralit_server/api/policies/v1/__init__.py b/extralit-server/src/extralit_server/api/policies/v1/__init__.py index ef07848f5..2f39de4a0 100644 --- a/extralit-server/src/extralit_server/api/policies/v1/__init__.py +++ b/extralit-server/src/extralit_server/api/policies/v1/__init__.py @@ -11,6 +11,7 @@ from extralit_server.api.policies.v1.schema_policy import SchemaPolicy from extralit_server.api.policies.v1.suggestion_policy import SuggestionPolicy from extralit_server.api.policies.v1.user_policy import UserPolicy +from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy from extralit_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy from extralit_server.api.policies.v1.webhook_policy import WebhookPolicy from extralit_server.api.policies.v1.workspace_policy import WorkspacePolicy @@ -29,6 +30,7 @@ "SchemaPolicy", "SuggestionPolicy", "UserPolicy", + "V2QuestionPolicy", "VectorSettingsPolicy", "WebhookPolicy", "WorkspacePolicy", diff --git a/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py new file mode 100644 index 000000000..97d62ab7b --- /dev/null +++ b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py @@ -0,0 +1,33 @@ +from extralit_server.api.policies.v1.commons import PolicyAction +from extralit_server.models import User +from extralit_server.models.v2 import Schema, V2Question + + +class V2QuestionPolicy: + @classmethod + def list(cls, schema: Schema) -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(schema.workspace_id) + + return is_allowed + + get = list + + @classmethod + def create(cls, schema: Schema) -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id)) + + return is_allowed + + @classmethod + def _write(cls, question: V2Question) -> PolicyAction: + # `question.schema` must be eagerly loaded (see the router's `selectinload` option) — + # AsyncSession does not support implicit lazy-loading outside an active greenlet. + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(question.schema.workspace_id)) + + return is_allowed + + update = _write + delete = _write diff --git a/extralit-server/src/extralit_server/api/schemas/v2/questions.py b/extralit-server/src/extralit_server/api/schemas/v2/questions.py new file mode 100644 index 000000000..cb5c7257a --- /dev/null +++ b/extralit-server/src/extralit_server/api/schemas/v2/questions.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from extralit_server.enums import QuestionType + +QUESTION_COLUMNS_MIN = 1 + + +class QuestionCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + title: str = Field(..., min_length=1) + description: str | None = None + type: QuestionType + columns: list[str] = Field(..., min_length=QUESTION_COLUMNS_MIN) + settings: dict[str, Any] = Field(default_factory=dict) + required: bool = False + + +class QuestionUpdate(BaseModel): + title: str | None = None + description: str | None = None + columns: list[str] | None = None + settings: dict[str, Any] | None = None + required: bool | None = None + + +class QuestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + schema_id: UUID + name: str + title: str + description: str | None + type: QuestionType + columns: list[str] + settings: dict[str, Any] + required: bool + inserted_at: datetime + updated_at: datetime + + +class Questions(BaseModel): + items: list[QuestionRead] diff --git a/extralit-server/src/extralit_server/api/v2/__init__.py b/extralit-server/src/extralit_server/api/v2/__init__.py index c5679d131..916a58ce1 100644 --- a/extralit-server/src/extralit_server/api/v2/__init__.py +++ b/extralit-server/src/extralit_server/api/v2/__init__.py @@ -3,6 +3,7 @@ from extralit_server._version import __version__ as extralit_version from extralit_server.api.errors.v1.exception_handlers import add_exception_handlers as add_exception_handlers_v1 from extralit_server.api.handlers.v1 import authentication as authentication_v1 +from extralit_server.api.v2 import questions as questions_v2 from extralit_server.api.v2 import records as records_v2 from extralit_server.api.v2 import schemas as schemas_v2 from extralit_server.errors.base_errors import __ALL__ @@ -23,6 +24,7 @@ def create_api_v2() -> FastAPI: api_v2.include_router(authentication_v1.router) api_v2.include_router(schemas_v2.router) api_v2.include_router(records_v2.router) + api_v2.include_router(questions_v2.router) return api_v2 diff --git a/extralit-server/src/extralit_server/api/v2/questions.py b/extralit-server/src/extralit_server/api/v2/questions.py new file mode 100644 index 000000000..0ab0e62f6 --- /dev/null +++ b/extralit-server/src/extralit_server/api/v2/questions.py @@ -0,0 +1,102 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Security, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from extralit_server.api.policies.v1 import V2QuestionPolicy, authorize +from extralit_server.api.schemas.v2.questions import ( + QuestionCreate, + QuestionRead, + Questions, + QuestionUpdate, +) +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.contexts.v2 import schemas as schemas_ctx +from extralit_server.database import get_async_db +from extralit_server.errors.future import NotFoundError +from extralit_server.models import User +from extralit_server.models.v2 import Schema, V2Question +from extralit_server.security import auth + +router = APIRouter(tags=["v2: questions"]) + + +async def _get_schema_or_404(db: AsyncSession, schema_id: UUID) -> Schema: + schema = await schemas_ctx.get_schema(db, schema_id) + if schema is None: + raise NotFoundError(f"Schema with id `{schema_id}` not found") + return schema + + +async def _get_question_or_404(db: AsyncSession, question_id: UUID) -> V2Question: + # `schema` is eager-loaded here (rather than lazily accessed) because the write policies + # read `question.schema.workspace_id` synchronously, which AsyncSession cannot lazy-load + # outside an active greenlet. + question = await annotation_ctx.get_question(db, question_id, options=[selectinload(V2Question.schema)]) + if question is None: + raise NotFoundError(f"Question with id `{question_id}` not found") + return question + + +@router.post("/schemas/{schema_id}/questions", response_model=QuestionRead, status_code=status.HTTP_201_CREATED) +async def create_question( + *, + schema_id: UUID, + payload: QuestionCreate, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, V2QuestionPolicy.create(schema)) + return await annotation_ctx.create_question(db, schema, create=payload) + + +@router.get("/schemas/{schema_id}/questions", response_model=Questions) +async def list_questions( + *, + schema_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, V2QuestionPolicy.list(schema)) + return Questions(items=await annotation_ctx.list_questions(db, schema)) + + +@router.get("/questions/{question_id}", response_model=QuestionRead) +async def get_question( + *, + question_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + await authorize(current_user, V2QuestionPolicy.get(question.schema)) + return question + + +@router.put("/questions/{question_id}", response_model=QuestionRead) +async def update_question( + *, + question_id: UUID, + payload: QuestionUpdate, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + await authorize(current_user, V2QuestionPolicy.update(question)) + return await annotation_ctx.update_question(db, question, update=payload) + + +@router.delete("/questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_question( + *, + question_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + question = await _get_question_or_404(db, question_id) + await authorize(current_user, V2QuestionPolicy.delete(question)) + await annotation_ctx.delete_question(db, question) diff --git a/extralit-server/src/extralit_server/contexts/v2/annotation.py b/extralit-server/src/extralit_server/contexts/v2/annotation.py new file mode 100644 index 000000000..326704038 --- /dev/null +++ b/extralit-server/src/extralit_server/contexts/v2/annotation.py @@ -0,0 +1,70 @@ +"""Business logic for v2 annotation: questions, suggestions, responses (spec §17). + +Postgres-only — this module MUST NOT import the LanceDB index engine.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.base import ExecutableOption + +from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate +from extralit_server.errors.future import UnprocessableEntityError +from extralit_server.models.v2 import Schema, SchemaVersion, V2Question +from extralit_server.validators.v2.questions import QuestionBindingValidator + + +async def _current_columns_cache(db: AsyncSession, schema: Schema) -> list[dict]: + if schema.current_version_id is None: + raise UnprocessableEntityError( + f"schema `{schema.id}` has no published version; publish a version before adding questions" + ) + version = await SchemaVersion.get(db, schema.current_version_id) + return list(version.columns_cache or []) + + +async def create_question(db: AsyncSession, schema: Schema, *, create: QuestionCreate) -> V2Question: + columns_cache = await _current_columns_cache(db, schema) + QuestionBindingValidator.validate(type=create.type, columns=create.columns, columns_cache=columns_cache) + question = V2Question( + schema_id=schema.id, + name=create.name, + title=create.title, + description=create.description, + type=create.type, + columns=list(create.columns), + settings=dict(create.settings), + required=create.required, + ) + db.add(question) + await db.commit() + return question + + +async def list_questions(db: AsyncSession, schema: Schema) -> list[V2Question]: + stmt = select(V2Question).where(V2Question.schema_id == schema.id).order_by(V2Question.inserted_at.asc()) + return (await db.execute(stmt)).scalars().all() + + +async def get_question( + db: AsyncSession, question_id: UUID, options: list[ExecutableOption] | None = None +) -> V2Question | None: + return await V2Question.get(db, question_id, options=options) + + +async def update_question(db: AsyncSession, question: V2Question, *, update: QuestionUpdate) -> V2Question: + if update.columns is not None: + schema = await Schema.get_or_raise(db, question.schema_id) + columns_cache = await _current_columns_cache(db, schema) + QuestionBindingValidator.validate(type=question.type, columns=update.columns, columns_cache=columns_cache) + question.columns = list(update.columns) + for attr in ("title", "description", "settings", "required"): + value = getattr(update, attr) + if value is not None: + setattr(question, attr, value) + await db.commit() + return question + + +async def delete_question(db: AsyncSession, question: V2Question) -> V2Question: + return await question.delete(db) diff --git a/extralit-server/tests/integration/api/v2/test_questions.py b/extralit-server/tests/integration/api/v2/test_questions.py new file mode 100644 index 000000000..e02fe1a6a --- /dev/null +++ b/extralit-server/tests/integration/api/v2/test_questions.py @@ -0,0 +1,107 @@ +import pytest + +from extralit_server.enums import QuestionType, SchemaStatus +from tests.factories import SchemaFactory, SchemaVersionFactory + +pytestmark = pytest.mark.asyncio + +COLUMNS_CACHE = [{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + + +async def _published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create(schema=schema, columns_cache=COLUMNS_CACHE) + schema.current_version_id = version.id + await db.commit() + return schema + + +async def test_create_question_happy(async_client, owner_auth_header, db): + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["disease"]}, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["columns"] == ["disease"] + assert body["schema_id"] == str(schema.id) + assert body["name"] == "dx" + assert body["required"] is False + + +async def test_create_question_unknown_column_rejected(async_client, owner_auth_header, db): + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["nope"]}, + ) + assert resp.status_code == 422, resp.text + + +async def test_create_question_span_rejected(async_client, owner_auth_header, db): + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "s", "title": "S", "type": QuestionType.span.value, "columns": ["disease"]}, + ) + assert resp.status_code == 422 + + +async def test_list_questions_returns_created(async_client, owner_auth_header, db): + schema = await _published_schema(db) + create_resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["disease"]}, + ) + assert create_resp.status_code == 201, create_resp.text + question_id = create_resp.json()["id"] + + list_resp = await async_client.get(f"/api/v2/schemas/{schema.id}/questions", headers=owner_auth_header) + assert list_resp.status_code == 200, list_resp.text + ids = [item["id"] for item in list_resp.json()["items"]] + assert ids == [question_id] + + +async def test_get_update_delete_question(async_client, owner_auth_header, db): + schema = await _published_schema(db) + create_resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=owner_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["disease"]}, + ) + question_id = create_resp.json()["id"] + + get_resp = await async_client.get(f"/api/v2/questions/{question_id}", headers=owner_auth_header) + assert get_resp.status_code == 200 + assert get_resp.json()["id"] == question_id + + put_resp = await async_client.put( + f"/api/v2/questions/{question_id}", + headers=owner_auth_header, + json={"title": "Diagnosis (updated)", "required": True}, + ) + assert put_resp.status_code == 200, put_resp.text + assert put_resp.json()["title"] == "Diagnosis (updated)" + assert put_resp.json()["required"] is True + + delete_resp = await async_client.delete(f"/api/v2/questions/{question_id}", headers=owner_auth_header) + assert delete_resp.status_code == 204 + + missing_resp = await async_client.get(f"/api/v2/questions/{question_id}", headers=owner_auth_header) + assert missing_resp.status_code == 404 + + +async def test_non_member_annotator_cannot_create_question(async_client, annotator_auth_header, db): + # The annotator behind annotator_auth_header is NOT a member of this schema's workspace. + schema = await _published_schema(db) + resp = await async_client.post( + f"/api/v2/schemas/{schema.id}/questions", + headers=annotator_auth_header, + json={"name": "dx", "title": "Dx", "type": QuestionType.text.value, "columns": ["disease"]}, + ) + assert resp.status_code == 403, resp.text diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py new file mode 100644 index 000000000..75ffbf723 --- /dev/null +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -0,0 +1,56 @@ +import pytest + +from extralit_server.api.schemas.v2.questions import QuestionCreate +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.enums import QuestionType, SchemaStatus +from extralit_server.errors.future import UnprocessableEntityError +from tests.factories import SchemaFactory, SchemaVersionFactory + +pytestmark = pytest.mark.asyncio + + +async def _published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create( + schema=schema, + columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}], + ) + schema.current_version_id = version.id + await db.commit() + return schema + + +async def test_create_question_validates_binding(db): + schema = await _published_schema(db) + q = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="dx", + title="Diagnosis", + type=QuestionType.label_selection, + columns=["disease"], + settings={"type": "label_selection", "options": [{"value": "x"}]}, + ), + ) + assert q.id is not None and q.columns == ["disease"] + + +async def test_create_question_rejects_unknown_column(db): + schema = await _published_schema(db) + with pytest.raises(UnprocessableEntityError, match="unknown"): + await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="bad", title="Bad", type=QuestionType.text, columns=["nope"]), + ) + + +async def test_create_question_requires_published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.draft) # current_version_id is None + with pytest.raises(UnprocessableEntityError, match="published"): + await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="q", title="Q", type=QuestionType.text, columns=["disease"]), + ) From e99814b291514e91228c866d5563279594ca68ff Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:47:26 +0000 Subject: [PATCH 11/23] feat(v2): suggestions context, API, and policy Adds Task 7 of the v2 Phase 4 annotation vertical: SuggestionUpsert/ SuggestionRead schema, upsert_suggestion/list_suggestions context functions (idempotent per record/question via the unique constraint from Task 3), V2SuggestionPolicy (owner or member for read; owner or admin+member for write), and the PUT|GET /records/{id}/suggestions router. The PUT handler 422s when the payload's question does not belong to the record's schema. --- .../api/policies/v1/__init__.py | 3 +- .../api/policies/v1/v2_annotation_policy.py | 20 +++- .../api/schemas/v2/annotation.py | 50 ++++++++++ .../src/extralit_server/api/v2/__init__.py | 2 + .../src/extralit_server/api/v2/annotation.py | 55 +++++++++++ .../extralit_server/contexts/v2/annotation.py | 28 +++++- .../integration/api/v2/test_annotation.py | 95 +++++++++++++++++++ .../contexts/v2/test_annotation_context.py | 19 +++- 8 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 extralit-server/src/extralit_server/api/schemas/v2/annotation.py create mode 100644 extralit-server/src/extralit_server/api/v2/annotation.py create mode 100644 extralit-server/tests/integration/api/v2/test_annotation.py diff --git a/extralit-server/src/extralit_server/api/policies/v1/__init__.py b/extralit-server/src/extralit_server/api/policies/v1/__init__.py index 2f39de4a0..612013938 100644 --- a/extralit-server/src/extralit_server/api/policies/v1/__init__.py +++ b/extralit-server/src/extralit_server/api/policies/v1/__init__.py @@ -11,7 +11,7 @@ from extralit_server.api.policies.v1.schema_policy import SchemaPolicy from extralit_server.api.policies.v1.suggestion_policy import SuggestionPolicy from extralit_server.api.policies.v1.user_policy import UserPolicy -from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy +from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy, V2SuggestionPolicy from extralit_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy from extralit_server.api.policies.v1.webhook_policy import WebhookPolicy from extralit_server.api.policies.v1.workspace_policy import WorkspacePolicy @@ -31,6 +31,7 @@ "SuggestionPolicy", "UserPolicy", "V2QuestionPolicy", + "V2SuggestionPolicy", "VectorSettingsPolicy", "WebhookPolicy", "WorkspacePolicy", diff --git a/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py index 97d62ab7b..dc6bc28aa 100644 --- a/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py +++ b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py @@ -1,6 +1,6 @@ from extralit_server.api.policies.v1.commons import PolicyAction from extralit_server.models import User -from extralit_server.models.v2 import Schema, V2Question +from extralit_server.models.v2 import Schema, V2Question, V2Record class V2QuestionPolicy: @@ -31,3 +31,21 @@ async def is_allowed(actor: User) -> bool: update = _write delete = _write + + +class V2SuggestionPolicy: + @classmethod + def read(cls, record: "V2Record") -> PolicyAction: + # `record.schema` must be eagerly loaded (see the router's `selectinload` option) — + # AsyncSession does not support implicit lazy-loading outside an active greenlet. + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed + + @classmethod + def write(cls, record: "V2Record") -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or (actor.is_admin and await actor.is_member(record.schema.workspace_id)) + + return is_allowed diff --git a/extralit-server/src/extralit_server/api/schemas/v2/annotation.py b/extralit-server/src/extralit_server/api/schemas/v2/annotation.py new file mode 100644 index 000000000..2c30e2d73 --- /dev/null +++ b/extralit-server/src/extralit_server/api/schemas/v2/annotation.py @@ -0,0 +1,50 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from extralit_server.enums import ResponseStatus, SuggestionType + + +class SuggestionUpsert(BaseModel): + question_id: UUID + value: Any + score: float | list[float] | None = None + agent: str | None = None + type: SuggestionType | None = None + + +class SuggestionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + record_id: UUID + question_id: UUID + value: Any + score: float | list[float] | None + agent: str | None + type: SuggestionType | None + inserted_at: datetime + updated_at: datetime + + +class Suggestions(BaseModel): + items: list[SuggestionRead] + + +class ResponseUpsert(BaseModel): + values: dict[str, dict[str, Any]] | None = None # {question_name: {"value": ...}} + status: ResponseStatus + + +class ResponseRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + record_id: UUID + user_id: UUID + values: dict[str, Any] | None + status: ResponseStatus + inserted_at: datetime + updated_at: datetime diff --git a/extralit-server/src/extralit_server/api/v2/__init__.py b/extralit-server/src/extralit_server/api/v2/__init__.py index 916a58ce1..2fe9da101 100644 --- a/extralit-server/src/extralit_server/api/v2/__init__.py +++ b/extralit-server/src/extralit_server/api/v2/__init__.py @@ -3,6 +3,7 @@ from extralit_server._version import __version__ as extralit_version from extralit_server.api.errors.v1.exception_handlers import add_exception_handlers as add_exception_handlers_v1 from extralit_server.api.handlers.v1 import authentication as authentication_v1 +from extralit_server.api.v2 import annotation as annotation_v2 from extralit_server.api.v2 import questions as questions_v2 from extralit_server.api.v2 import records as records_v2 from extralit_server.api.v2 import schemas as schemas_v2 @@ -25,6 +26,7 @@ def create_api_v2() -> FastAPI: api_v2.include_router(schemas_v2.router) api_v2.include_router(records_v2.router) api_v2.include_router(questions_v2.router) + api_v2.include_router(annotation_v2.router) return api_v2 diff --git a/extralit-server/src/extralit_server/api/v2/annotation.py b/extralit-server/src/extralit_server/api/v2/annotation.py new file mode 100644 index 000000000..9de843a8b --- /dev/null +++ b/extralit-server/src/extralit_server/api/v2/annotation.py @@ -0,0 +1,55 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Security +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from extralit_server.api.policies.v1 import V2SuggestionPolicy, authorize +from extralit_server.api.schemas.v2.annotation import SuggestionRead, Suggestions, SuggestionUpsert +from extralit_server.contexts.v2 import annotation as annotation_ctx +from extralit_server.database import get_async_db +from extralit_server.errors.future import NotFoundError, UnprocessableEntityError +from extralit_server.models import User +from extralit_server.models.v2 import V2Record +from extralit_server.security import auth + +router = APIRouter(tags=["v2: annotation"]) + + +async def _get_record_or_404(db: AsyncSession, record_id: UUID) -> V2Record: + # `schema` is eager-loaded here (rather than lazily accessed) because the suggestion + # policies read `record.schema.workspace_id` synchronously, which AsyncSession cannot + # lazy-load outside an active greenlet. + record = await V2Record.get(db, record_id, options=[selectinload(V2Record.schema)]) + if record is None: + raise NotFoundError(f"Record with id `{record_id}` not found") + return record + + +@router.put("/records/{record_id}/suggestions", response_model=SuggestionRead) +async def upsert_suggestion( + *, + record_id: UUID, + payload: SuggestionUpsert, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2SuggestionPolicy.write(record)) + question = await annotation_ctx.get_question(db, payload.question_id) + if question is None or question.schema_id != record.schema_id: + raise UnprocessableEntityError(f"question `{payload.question_id}` does not belong to this record's schema") + return await annotation_ctx.upsert_suggestion(db, record, question, upsert=payload) + + +@router.get("/records/{record_id}/suggestions", response_model=Suggestions) +async def list_suggestions( + *, + record_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2SuggestionPolicy.read(record)) + return Suggestions(items=await annotation_ctx.list_suggestions(db, record)) diff --git a/extralit-server/src/extralit_server/contexts/v2/annotation.py b/extralit-server/src/extralit_server/contexts/v2/annotation.py index 326704038..97834ef23 100644 --- a/extralit-server/src/extralit_server/contexts/v2/annotation.py +++ b/extralit-server/src/extralit_server/contexts/v2/annotation.py @@ -8,10 +8,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.base import ExecutableOption +from extralit_server.api.schemas.v2.annotation import SuggestionUpsert from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate from extralit_server.errors.future import UnprocessableEntityError -from extralit_server.models.v2 import Schema, SchemaVersion, V2Question +from extralit_server.models.v2 import Schema, SchemaVersion, V2Question, V2Record, V2Suggestion from extralit_server.validators.v2.questions import QuestionBindingValidator +from extralit_server.validators.v2.values import V2SuggestionValidator async def _current_columns_cache(db: AsyncSession, schema: Schema) -> list[dict]: @@ -68,3 +70,27 @@ async def update_question(db: AsyncSession, question: V2Question, *, update: Que async def delete_question(db: AsyncSession, question: V2Question) -> V2Question: return await question.delete(db) + + +async def upsert_suggestion( + db: AsyncSession, record: V2Record, question: V2Question, *, upsert: SuggestionUpsert +) -> V2Suggestion: + V2SuggestionValidator.validate( + upsert.value, upsert.score, type=question.type, settings=question.settings, columns=question.columns + ) + stmt = select(V2Suggestion).where(V2Suggestion.record_id == record.id, V2Suggestion.question_id == question.id) + suggestion = (await db.execute(stmt)).scalar_one_or_none() + if suggestion is None: + suggestion = V2Suggestion(record_id=record.id, question_id=question.id) + db.add(suggestion) + suggestion.value = upsert.value + suggestion.score = upsert.score + suggestion.agent = upsert.agent + suggestion.type = upsert.type + await db.commit() + return suggestion + + +async def list_suggestions(db: AsyncSession, record: V2Record) -> list[V2Suggestion]: + stmt = select(V2Suggestion).where(V2Suggestion.record_id == record.id).order_by(V2Suggestion.inserted_at.asc()) + return (await db.execute(stmt)).scalars().all() diff --git a/extralit-server/tests/integration/api/v2/test_annotation.py b/extralit-server/tests/integration/api/v2/test_annotation.py new file mode 100644 index 000000000..e77a87b42 --- /dev/null +++ b/extralit-server/tests/integration/api/v2/test_annotation.py @@ -0,0 +1,95 @@ +import pytest + +from extralit_server.enums import QuestionType, SchemaStatus +from tests.factories import SchemaFactory, SchemaVersionFactory, V2QuestionFactory, V2RecordFactory + +pytestmark = pytest.mark.asyncio + +COLUMNS_CACHE = [{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + + +async def _published_schema(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create(schema=schema, columns_cache=COLUMNS_CACHE) + schema.current_version_id = version.id + await db.commit() + return schema + + +async def test_upsert_suggestion_happy_and_idempotent(async_client, owner_auth_header, db): + schema = await _published_schema(db) + question = await V2QuestionFactory.create( + schema=schema, type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + + resp = await async_client.put( + f"/api/v2/records/{record.id}/suggestions", + headers=owner_auth_header, + json={"question_id": str(question.id), "value": "a"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["value"] == "a" + assert body["record_id"] == str(record.id) + assert body["question_id"] == str(question.id) + suggestion_id = body["id"] + + # Re-PUT with the same (record, question) pair updates the same row idempotently. + resp = await async_client.put( + f"/api/v2/records/{record.id}/suggestions", + headers=owner_auth_header, + json={"question_id": str(question.id), "value": "b"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == suggestion_id + assert body["value"] == "b" + + +async def test_upsert_suggestion_rejects_question_from_other_schema(async_client, owner_auth_header, db): + schema = await _published_schema(db) + record = await V2RecordFactory.create(version__schema=schema) + + other_schema = await _published_schema(db) + other_question = await V2QuestionFactory.create( + schema=other_schema, type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + + resp = await async_client.put( + f"/api/v2/records/{record.id}/suggestions", + headers=owner_auth_header, + json={"question_id": str(other_question.id), "value": "a"}, + ) + assert resp.status_code == 422, resp.text + + +async def test_list_suggestions_returns_upserted(async_client, owner_auth_header, db): + schema = await _published_schema(db) + question = await V2QuestionFactory.create( + schema=schema, type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + + put_resp = await async_client.put( + f"/api/v2/records/{record.id}/suggestions", + headers=owner_auth_header, + json={"question_id": str(question.id), "value": "a"}, + ) + assert put_resp.status_code == 200, put_resp.text + + list_resp = await async_client.get(f"/api/v2/records/{record.id}/suggestions", headers=owner_auth_header) + assert list_resp.status_code == 200, list_resp.text + items = list_resp.json()["items"] + assert len(items) == 1 + assert items[0]["question_id"] == str(question.id) + assert items[0]["value"] == "a" + + +async def test_non_member_annotator_cannot_read_suggestions(async_client, annotator_auth_header, db): + # The annotator behind annotator_auth_header is NOT a member of this schema's workspace. + schema = await _published_schema(db) + record = await V2RecordFactory.create(version__schema=schema) + + resp = await async_client.get(f"/api/v2/records/{record.id}/suggestions", headers=annotator_auth_header) + assert resp.status_code == 403, resp.text diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index 75ffbf723..32f4cc94f 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -1,10 +1,11 @@ import pytest +from extralit_server.api.schemas.v2.annotation import SuggestionUpsert from extralit_server.api.schemas.v2.questions import QuestionCreate from extralit_server.contexts.v2 import annotation as annotation_ctx from extralit_server.enums import QuestionType, SchemaStatus from extralit_server.errors.future import UnprocessableEntityError -from tests.factories import SchemaFactory, SchemaVersionFactory +from tests.factories import SchemaFactory, SchemaVersionFactory, V2QuestionFactory, V2RecordFactory pytestmark = pytest.mark.asyncio @@ -54,3 +55,19 @@ async def test_create_question_requires_published_schema(db): schema, create=QuestionCreate(name="q", title="Q", type=QuestionType.text, columns=["disease"]), ) + + +async def test_upsert_suggestion_is_idempotent_per_record_question(db): + schema = await _published_schema(db) + question = await V2QuestionFactory.create( + schema=schema, type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + + s1 = await annotation_ctx.upsert_suggestion( + db, record, question, upsert=SuggestionUpsert(question_id=question.id, value="a") + ) + s2 = await annotation_ctx.upsert_suggestion( + db, record, question, upsert=SuggestionUpsert(question_id=question.id, value="b") + ) + assert s1.id == s2.id and s2.value == "b" From 5fb2505f6c09e1b0a9330a452061467b2055d5d6 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:49:06 +0000 Subject: [PATCH 12/23] test(v2): cover update_question column re-validation (roborev job 109) Adds coverage for the update.columns branch flagged by roborev job 109: a valid rebind, an unknown-column rejection, and an arity mismatch for a non-table question type re-running QuestionBindingValidator on update. --- .../contexts/v2/test_annotation_context.py | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index 32f4cc94f..a2e6dc7c5 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -1,7 +1,7 @@ import pytest from extralit_server.api.schemas.v2.annotation import SuggestionUpsert -from extralit_server.api.schemas.v2.questions import QuestionCreate +from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate from extralit_server.contexts.v2 import annotation as annotation_ctx from extralit_server.enums import QuestionType, SchemaStatus from extralit_server.errors.future import UnprocessableEntityError @@ -57,6 +57,62 @@ async def test_create_question_requires_published_schema(db): ) +async def test_update_question_columns_revalidates_binding(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create( + schema=schema, + columns_cache=[ + {"name": "disease", "dtype": "str", "nullable": True, "review": None}, + {"name": "outcome", "dtype": "str", "nullable": True, "review": None}, + ], + ) + schema.current_version_id = version.id + await db.commit() + + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="dx", title="Dx", type=QuestionType.text, columns=["disease"]), + ) + + updated = await annotation_ctx.update_question(db, question, update=QuestionUpdate(columns=["outcome"])) + assert updated.columns == ["outcome"] + + +async def test_update_question_rejects_unknown_column(db): + schema = await _published_schema(db) + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="dx", title="Dx", type=QuestionType.text, columns=["disease"]), + ) + + with pytest.raises(UnprocessableEntityError, match="unknown"): + await annotation_ctx.update_question(db, question, update=QuestionUpdate(columns=["nope"])) + + +async def test_update_question_rejects_arity_mismatch_for_non_table_type(db): + schema = await SchemaFactory.create(status=SchemaStatus.published) + version = await SchemaVersionFactory.create( + schema=schema, + columns_cache=[ + {"name": "disease", "dtype": "str", "nullable": True, "review": None}, + {"name": "outcome", "dtype": "str", "nullable": True, "review": None}, + ], + ) + schema.current_version_id = version.id + await db.commit() + + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="dx", title="Dx", type=QuestionType.text, columns=["disease"]), + ) + + with pytest.raises(UnprocessableEntityError, match="exactly one column"): + await annotation_ctx.update_question(db, question, update=QuestionUpdate(columns=["disease", "outcome"])) + + async def test_upsert_suggestion_is_idempotent_per_record_question(db): schema = await _published_schema(db) question = await V2QuestionFactory.create( From 28334d3e4d92cef1256fc577cc88b681d9d97e32 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:51:29 +0000 Subject: [PATCH 13/23] test(v2): cover suggestion write-authz for non-admin members (roborev job 110) Adds a negative test asserting a workspace-member-but-non-admin annotator gets 403 from PUT /records/{id}/suggestions, mirroring the existing read-path negative test and guarding V2SuggestionPolicy.write's owner-or-admin+member requirement against regressing to mere membership. --- .../integration/api/v2/test_annotation.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/extralit-server/tests/integration/api/v2/test_annotation.py b/extralit-server/tests/integration/api/v2/test_annotation.py index e77a87b42..d5076913d 100644 --- a/extralit-server/tests/integration/api/v2/test_annotation.py +++ b/extralit-server/tests/integration/api/v2/test_annotation.py @@ -1,7 +1,13 @@ import pytest from extralit_server.enums import QuestionType, SchemaStatus -from tests.factories import SchemaFactory, SchemaVersionFactory, V2QuestionFactory, V2RecordFactory +from tests.factories import ( + SchemaFactory, + SchemaVersionFactory, + V2QuestionFactory, + V2RecordFactory, + WorkspaceUserFactory, +) pytestmark = pytest.mark.asyncio @@ -93,3 +99,21 @@ async def test_non_member_annotator_cannot_read_suggestions(async_client, annota resp = await async_client.get(f"/api/v2/records/{record.id}/suggestions", headers=annotator_auth_header) assert resp.status_code == 403, resp.text + + +async def test_member_non_admin_annotator_cannot_upsert_suggestion(async_client, annotator, annotator_auth_header, db): + # V2SuggestionPolicy.write requires owner or admin+member; a plain (non-admin) member + # must still be forbidden from writing suggestions even though they can read them. + schema = await _published_schema(db) + question = await V2QuestionFactory.create( + schema=schema, type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + await WorkspaceUserFactory.create(workspace_id=schema.workspace_id, user_id=annotator.id) + + resp = await async_client.put( + f"/api/v2/records/{record.id}/suggestions", + headers=annotator_auth_header, + json={"question_id": str(question.id), "value": "a"}, + ) + assert resp.status_code == 403, resp.text From de217b6e6561d4387e7b8e627b09ee6fce59d09e Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 01:59:59 +0000 Subject: [PATCH 14/23] feat(v2): responses context, API, and own-response policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds annotation_ctx.upsert_response/get_response (keyed by question name, idempotent per record+user, never mutates record.status or touches the LanceDB index engine per spec §17.3/§17.5), V2ResponsePolicy for own-response authz, and PUT/GET /records/{id}/responses routes. --- .../api/policies/v1/__init__.py | 3 +- .../api/policies/v1/v2_annotation_policy.py | 21 ++++++ .../src/extralit_server/api/v2/annotation.py | 35 ++++++++- .../extralit_server/contexts/v2/annotation.py | 54 +++++++++++++- .../integration/api/v2/test_annotation.py | 71 +++++++++++++++++++ .../contexts/v2/test_annotation_context.py | 49 ++++++++++++- 6 files changed, 224 insertions(+), 9 deletions(-) diff --git a/extralit-server/src/extralit_server/api/policies/v1/__init__.py b/extralit-server/src/extralit_server/api/policies/v1/__init__.py index 612013938..828c865b1 100644 --- a/extralit-server/src/extralit_server/api/policies/v1/__init__.py +++ b/extralit-server/src/extralit_server/api/policies/v1/__init__.py @@ -11,7 +11,7 @@ from extralit_server.api.policies.v1.schema_policy import SchemaPolicy from extralit_server.api.policies.v1.suggestion_policy import SuggestionPolicy from extralit_server.api.policies.v1.user_policy import UserPolicy -from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy, V2SuggestionPolicy +from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy, V2ResponsePolicy, V2SuggestionPolicy from extralit_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy from extralit_server.api.policies.v1.webhook_policy import WebhookPolicy from extralit_server.api.policies.v1.workspace_policy import WorkspacePolicy @@ -31,6 +31,7 @@ "SuggestionPolicy", "UserPolicy", "V2QuestionPolicy", + "V2ResponsePolicy", "V2SuggestionPolicy", "VectorSettingsPolicy", "WebhookPolicy", diff --git a/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py index dc6bc28aa..d2db45626 100644 --- a/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py +++ b/extralit-server/src/extralit_server/api/policies/v1/v2_annotation_policy.py @@ -49,3 +49,24 @@ async def is_allowed(actor: User) -> bool: return actor.is_owner or (actor.is_admin and await actor.is_member(record.schema.workspace_id)) return is_allowed + + +class V2ResponsePolicy: + """Own-response authz (spec §17.5), ported from v1 ResponsePolicy with the workspace + resolved via record.schema.workspace_id.""" + + @classmethod + def read(cls, record: "V2Record") -> PolicyAction: + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed + + @classmethod + def upsert_own(cls, record: "V2Record") -> PolicyAction: + # PUT writes the current user's own response; any workspace member (incl. annotators) + # may write their own, matching v1 (actor.id == response.user_id). + async def is_allowed(actor: User) -> bool: + return actor.is_owner or await actor.is_member(record.schema.workspace_id) + + return is_allowed diff --git a/extralit-server/src/extralit_server/api/v2/annotation.py b/extralit-server/src/extralit_server/api/v2/annotation.py index 9de843a8b..a2458862a 100644 --- a/extralit-server/src/extralit_server/api/v2/annotation.py +++ b/extralit-server/src/extralit_server/api/v2/annotation.py @@ -5,8 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from extralit_server.api.policies.v1 import V2SuggestionPolicy, authorize -from extralit_server.api.schemas.v2.annotation import SuggestionRead, Suggestions, SuggestionUpsert +from extralit_server.api.policies.v1 import V2ResponsePolicy, V2SuggestionPolicy, authorize +from extralit_server.api.schemas.v2.annotation import ( + ResponseRead, + ResponseUpsert, + SuggestionRead, + Suggestions, + SuggestionUpsert, +) from extralit_server.contexts.v2 import annotation as annotation_ctx from extralit_server.database import get_async_db from extralit_server.errors.future import NotFoundError, UnprocessableEntityError @@ -53,3 +59,28 @@ async def list_suggestions( record = await _get_record_or_404(db, record_id) await authorize(current_user, V2SuggestionPolicy.read(record)) return Suggestions(items=await annotation_ctx.list_suggestions(db, record)) + + +@router.put("/records/{record_id}/responses", response_model=ResponseRead) +async def upsert_response( + *, + record_id: UUID, + payload: ResponseUpsert, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2ResponsePolicy.upsert_own(record)) + return await annotation_ctx.upsert_response(db, record, current_user, upsert=payload) + + +@router.get("/records/{record_id}/responses", response_model=ResponseRead | None) +async def get_own_response( + *, + record_id: UUID, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + record = await _get_record_or_404(db, record_id) + await authorize(current_user, V2ResponsePolicy.read(record)) + return await annotation_ctx.get_response(db, record, current_user) diff --git a/extralit-server/src/extralit_server/contexts/v2/annotation.py b/extralit-server/src/extralit_server/contexts/v2/annotation.py index 97834ef23..a989b3738 100644 --- a/extralit-server/src/extralit_server/contexts/v2/annotation.py +++ b/extralit-server/src/extralit_server/contexts/v2/annotation.py @@ -8,12 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.base import ExecutableOption -from extralit_server.api.schemas.v2.annotation import SuggestionUpsert +from extralit_server.api.schemas.v2.annotation import ResponseUpsert, SuggestionUpsert from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate +from extralit_server.enums import ResponseStatus from extralit_server.errors.future import UnprocessableEntityError -from extralit_server.models.v2 import Schema, SchemaVersion, V2Question, V2Record, V2Suggestion +from extralit_server.models.v2 import Schema, SchemaVersion, V2Question, V2Record, V2Response, V2Suggestion from extralit_server.validators.v2.questions import QuestionBindingValidator -from extralit_server.validators.v2.values import V2SuggestionValidator +from extralit_server.validators.v2.values import V2ResponseValueValidator, V2SuggestionValidator async def _current_columns_cache(db: AsyncSession, schema: Schema) -> list[dict]: @@ -94,3 +95,50 @@ async def upsert_suggestion( async def list_suggestions(db: AsyncSession, record: V2Record) -> list[V2Suggestion]: stmt = select(V2Suggestion).where(V2Suggestion.record_id == record.id).order_by(V2Suggestion.inserted_at.asc()) return (await db.execute(stmt)).scalars().all() + + +async def _schema_questions(db: AsyncSession, schema_id) -> list[V2Question]: + stmt = select(V2Question).where(V2Question.schema_id == schema_id) + return (await db.execute(stmt)).scalars().all() + + +def _validate_response_values(upsert: ResponseUpsert, questions: list[V2Question]) -> None: + values = upsert.values or {} + submitted = upsert.status == ResponseStatus.submitted + if submitted and not values: + raise UnprocessableEntityError("missing response values for submitted response") + + by_name = {q.name: q for q in questions} + for name in values: + if name not in by_name: + raise UnprocessableEntityError(f"response value for non-configured question {name!r}") + for question in questions: + if submitted and question.required and question.name not in values: + raise UnprocessableEntityError(f"missing response value for required question {question.name!r}") + for name, wrapped in values.items(): + question = by_name[name] + V2ResponseValueValidator.validate( + wrapped.get("value"), type=question.type, settings=question.settings, columns=question.columns + ) + + +async def upsert_response(db: AsyncSession, record: V2Record, user, *, upsert: ResponseUpsert) -> V2Response: + # NOTE (spec §17.3, §17.5): must never mutate `record.status` and must never touch the + # LanceDB index engine — this module is Postgres-only (see the module docstring). + questions = await _schema_questions(db, record.schema_id) + _validate_response_values(upsert, questions) + + stmt = select(V2Response).where(V2Response.record_id == record.id, V2Response.user_id == user.id) + response = (await db.execute(stmt)).scalar_one_or_none() + if response is None: + response = V2Response(record_id=record.id, user_id=user.id) + db.add(response) + response.values = upsert.values + response.status = upsert.status + await db.commit() + return response + + +async def get_response(db: AsyncSession, record: V2Record, user) -> V2Response | None: + stmt = select(V2Response).where(V2Response.record_id == record.id, V2Response.user_id == user.id) + return (await db.execute(stmt)).scalar_one_or_none() diff --git a/extralit-server/tests/integration/api/v2/test_annotation.py b/extralit-server/tests/integration/api/v2/test_annotation.py index d5076913d..ac49ccd90 100644 --- a/extralit-server/tests/integration/api/v2/test_annotation.py +++ b/extralit-server/tests/integration/api/v2/test_annotation.py @@ -1,7 +1,11 @@ +from unittest.mock import AsyncMock, patch + import pytest +from extralit_server.constants import API_KEY_HEADER_NAME from extralit_server.enums import QuestionType, SchemaStatus from tests.factories import ( + AnnotatorFactory, SchemaFactory, SchemaVersionFactory, V2QuestionFactory, @@ -117,3 +121,70 @@ async def test_member_non_admin_annotator_cannot_upsert_suggestion(async_client, json={"question_id": str(question.id), "value": "a"}, ) assert resp.status_code == 403, resp.text + + +async def test_annotator_upserts_own_response(async_client, annotator, annotator_auth_header, db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True + ) + record = await V2RecordFactory.create(version__schema=schema) + await WorkspaceUserFactory.create(workspace_id=schema.workspace_id, user_id=annotator.id) + + resp = await async_client.put( + f"/api/v2/records/{record.id}/responses", + headers=annotator_auth_header, + json={"status": "submitted", "values": {"dx": {"value": "flu"}}}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["values"] == {"dx": {"value": "flu"}} + assert body["status"] == "submitted" + assert body["user_id"] == str(annotator.id) + assert body["record_id"] == str(record.id) + + get_resp = await async_client.get(f"/api/v2/records/{record.id}/responses", headers=annotator_auth_header) + assert get_resp.status_code == 200, get_resp.text + assert get_resp.json()["values"] == {"dx": {"value": "flu"}} + + +async def test_second_annotator_gets_only_their_own_response(async_client, annotator, annotator_auth_header, db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True + ) + record = await V2RecordFactory.create(version__schema=schema) + await WorkspaceUserFactory.create(workspace_id=schema.workspace_id, user_id=annotator.id) + + put_resp = await async_client.put( + f"/api/v2/records/{record.id}/responses", + headers=annotator_auth_header, + json={"status": "submitted", "values": {"dx": {"value": "flu"}}}, + ) + assert put_resp.status_code == 200, put_resp.text + + second_annotator = await AnnotatorFactory.create(username="annotator-2", api_key="annotator-2.apikey") + await WorkspaceUserFactory.create(workspace_id=schema.workspace_id, user_id=second_annotator.id) + second_auth_header = {API_KEY_HEADER_NAME: second_annotator.api_key} + + get_resp = await async_client.get(f"/api/v2/records/{record.id}/responses", headers=second_auth_header) + assert get_resp.status_code == 200, get_resp.text + assert get_resp.json() is None # the second annotator has not submitted their own response yet + + +async def test_response_upsert_does_not_sync_lance(async_client, annotator, annotator_auth_header, db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True + ) + record = await V2RecordFactory.create(version__schema=schema) + await WorkspaceUserFactory.create(workspace_id=schema.workspace_id, user_id=annotator.id) + + with patch("extralit_server.contexts.v2.index_sync.sync_upserted_records", new=AsyncMock()) as synced: + resp = await async_client.put( + f"/api/v2/records/{record.id}/responses", + headers=annotator_auth_header, + json={"status": "submitted", "values": {"dx": {"value": "flu"}}}, + ) + assert resp.status_code == 200, resp.text + synced.assert_not_called() # annotation never touches the index engine (spec §17.5) diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index a2e6dc7c5..e7a7bfb50 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -1,11 +1,11 @@ import pytest -from extralit_server.api.schemas.v2.annotation import SuggestionUpsert +from extralit_server.api.schemas.v2.annotation import ResponseUpsert, SuggestionUpsert from extralit_server.api.schemas.v2.questions import QuestionCreate, QuestionUpdate from extralit_server.contexts.v2 import annotation as annotation_ctx -from extralit_server.enums import QuestionType, SchemaStatus +from extralit_server.enums import QuestionType, ResponseStatus, SchemaStatus, V2RecordStatus from extralit_server.errors.future import UnprocessableEntityError -from tests.factories import SchemaFactory, SchemaVersionFactory, V2QuestionFactory, V2RecordFactory +from tests.factories import SchemaFactory, SchemaVersionFactory, UserFactory, V2QuestionFactory, V2RecordFactory pytestmark = pytest.mark.asyncio @@ -127,3 +127,46 @@ async def test_upsert_suggestion_is_idempotent_per_record_question(db): db, record, question, upsert=SuggestionUpsert(question_id=question.id, value="b") ) assert s1.id == s2.id and s2.value == "b" + + +async def test_upsert_response_keyed_by_question_no_record_status_change(db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True + ) + record = await V2RecordFactory.create(version__schema=schema, status=V2RecordStatus.pending) + user = await UserFactory.create() + + resp = await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"dx": {"value": "flu"}}) + ) + assert resp.values == {"dx": {"value": "flu"}} + assert record.status == V2RecordStatus.pending # spec §17.3: no status side-effect + + +async def test_submitted_response_requires_required_question(db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"}, required=True + ) + # A second, optional question (also bound to "disease" — binding validation allows reuse) so the + # payload can be non-empty while omitting the required one. Submitting empty values would trip the + # earlier "missing response values" guard instead of the required-question path under test. + await V2QuestionFactory.create( + schema=schema, + name="notes", + type=QuestionType.text, + columns=["disease"], + settings={"type": "text"}, + required=False, + ) + record = await V2RecordFactory.create(version__schema=schema) + user = await UserFactory.create() + + with pytest.raises(UnprocessableEntityError, match="required"): + await annotation_ctx.upsert_response( + db, + record, + user, + upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"notes": {"value": "n"}}), + ) From 1ae433fa0a35cf7abbdbae32dc3a44873c6960e7 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:06:35 +0000 Subject: [PATCH 15/23] =?UTF-8?q?feat(v2):=20single=20schema-version=20rea?= =?UTF-8?q?d=20endpoint=20(=C2=A717.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/extralit_server/api/v2/schemas.py | 16 ++++++++++++ .../extralit_server/contexts/v2/schemas.py | 5 ++++ .../api/v2/test_schema_versions.py | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 extralit-server/tests/integration/api/v2/test_schema_versions.py diff --git a/extralit-server/src/extralit_server/api/v2/schemas.py b/extralit-server/src/extralit_server/api/v2/schemas.py index de0c8d2c1..f2f6f60ee 100644 --- a/extralit-server/src/extralit_server/api/v2/schemas.py +++ b/extralit-server/src/extralit_server/api/v2/schemas.py @@ -143,6 +143,22 @@ async def list_schema_versions( return sorted(await schema.awaitable_attrs.versions, key=lambda v: v.version) +@router.get("/schemas/{schema_id}/versions/{version}", response_model=SchemaVersionRead) +async def get_schema_version( + *, + schema_id: UUID, + version: int, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], +): + schema = await _get_schema_or_404(db, schema_id) + await authorize(current_user, SchemaPolicy.get(schema)) + version_row = await schemas_ctx.get_version_by_number(db, schema_id, version) + if version_row is None: + raise NotFoundError(f"Version `{version}` not found for schema `{schema_id}`") + return version_row + + @router.get("/schemas/{schema_id}/columns", response_model=list[dict]) async def get_schema_columns( *, diff --git a/extralit-server/src/extralit_server/contexts/v2/schemas.py b/extralit-server/src/extralit_server/contexts/v2/schemas.py index 6b71a365f..af083297f 100644 --- a/extralit-server/src/extralit_server/contexts/v2/schemas.py +++ b/extralit-server/src/extralit_server/contexts/v2/schemas.py @@ -39,6 +39,11 @@ async def get_schema(db: AsyncSession, schema_id: UUID) -> Schema | None: return await Schema.get(db, schema_id) +async def get_version_by_number(db: AsyncSession, schema_id: UUID, version: int) -> SchemaVersion | None: + stmt = select(SchemaVersion).where(SchemaVersion.schema_id == schema_id, SchemaVersion.version == version) + return (await db.execute(stmt)).scalar_one_or_none() + + async def list_schemas(db: AsyncSession, *, workspace_id: UUID | None = None) -> list[Schema]: stmt = select(Schema) if workspace_id is not None: diff --git a/extralit-server/tests/integration/api/v2/test_schema_versions.py b/extralit-server/tests/integration/api/v2/test_schema_versions.py new file mode 100644 index 000000000..a012ca7b8 --- /dev/null +++ b/extralit-server/tests/integration/api/v2/test_schema_versions.py @@ -0,0 +1,26 @@ +import pytest + +from tests.factories import SchemaFactory, SchemaVersionFactory + +pytestmark = pytest.mark.asyncio + + +async def test_get_single_version_by_number(async_client, owner_auth_header, db): + schema = await SchemaFactory.create() + await SchemaVersionFactory.create( + schema=schema, version=1, columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + ) + await db.commit() + + resp = await async_client.get(f"/api/v2/schemas/{schema.id}/versions/1", headers=owner_auth_header) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["version"] == 1 + assert body["columns_cache"][0]["name"] == "disease" + + +async def test_get_unknown_version_404(async_client, owner_auth_header, db): + schema = await SchemaFactory.create() + await db.commit() + resp = await async_client.get(f"/api/v2/schemas/{schema.id}/versions/999", headers=owner_auth_header) + assert resp.status_code == 404 From c9318a69bd0bd32ba39ec9105b50e708a0435e49 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:07:58 +0000 Subject: [PATCH 16/23] test(v2): add non-member forbidden-path coverage for responses routes (roborev #113) --- .../integration/api/v2/test_annotation.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/extralit-server/tests/integration/api/v2/test_annotation.py b/extralit-server/tests/integration/api/v2/test_annotation.py index ac49ccd90..82312f5f8 100644 --- a/extralit-server/tests/integration/api/v2/test_annotation.py +++ b/extralit-server/tests/integration/api/v2/test_annotation.py @@ -172,6 +172,24 @@ async def test_second_annotator_gets_only_their_own_response(async_client, annot assert get_resp.json() is None # the second annotator has not submitted their own response yet +async def test_non_member_annotator_cannot_read_or_upsert_response(async_client, annotator_auth_header, db): + # The annotator behind annotator_auth_header is NOT a member of this schema's workspace. + # V2ResponsePolicy.read/upsert_own both require owner-or-member, so authorization is + # denied before question/value validation runs regardless of the request payload. + schema = await _published_schema(db) + record = await V2RecordFactory.create(version__schema=schema) + + get_resp = await async_client.get(f"/api/v2/records/{record.id}/responses", headers=annotator_auth_header) + assert get_resp.status_code == 403, get_resp.text + + put_resp = await async_client.put( + f"/api/v2/records/{record.id}/responses", + headers=annotator_auth_header, + json={"status": "submitted", "values": {"dx": {"value": "flu"}}}, + ) + assert put_resp.status_code == 403, put_resp.text + + async def test_response_upsert_does_not_sync_lance(async_client, annotator, annotator_auth_header, db): schema = await _published_schema(db) await V2QuestionFactory.create( From b79c33d2a2576b647e1ecca9c4aa8973a299d917 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:14:44 +0000 Subject: [PATCH 17/23] feat(v2): projection view (resolve response->suggestion per cell) --- .../api/schemas/v2/projection.py | 23 ++++++ .../src/extralit_server/api/v2/__init__.py | 2 + .../src/extralit_server/api/v2/projection.py | 31 ++++++++ .../extralit_server/contexts/v2/projection.py | 70 +++++++++++++++++++ .../integration/api/v2/test_projection.py | 58 +++++++++++++++ .../contexts/v2/test_projection.py | 53 ++++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 extralit-server/src/extralit_server/api/schemas/v2/projection.py create mode 100644 extralit-server/src/extralit_server/api/v2/projection.py create mode 100644 extralit-server/src/extralit_server/contexts/v2/projection.py create mode 100644 extralit-server/tests/integration/api/v2/test_projection.py create mode 100644 extralit-server/tests/integration/contexts/v2/test_projection.py diff --git a/extralit-server/src/extralit_server/api/schemas/v2/projection.py b/extralit-server/src/extralit_server/api/schemas/v2/projection.py new file mode 100644 index 000000000..bfc901f97 --- /dev/null +++ b/extralit-server/src/extralit_server/api/schemas/v2/projection.py @@ -0,0 +1,23 @@ +from typing import Any, Literal +from uuid import UUID + +from pydantic import BaseModel + + +class ProjectionCell(BaseModel): + question_name: str + value: Any | None = None + source: Literal["response", "suggestion"] | None = None # None => neither exists yet + + +class ProjectionRecord(BaseModel): + record_id: UUID + schema_id: UUID + reference: str + cells: list[ProjectionCell] + + +class ProjectionView(BaseModel): + reference: str + records: list[ProjectionRecord] + total_records: int diff --git a/extralit-server/src/extralit_server/api/v2/__init__.py b/extralit-server/src/extralit_server/api/v2/__init__.py index 2fe9da101..beda8e13a 100644 --- a/extralit-server/src/extralit_server/api/v2/__init__.py +++ b/extralit-server/src/extralit_server/api/v2/__init__.py @@ -4,6 +4,7 @@ from extralit_server.api.errors.v1.exception_handlers import add_exception_handlers as add_exception_handlers_v1 from extralit_server.api.handlers.v1 import authentication as authentication_v1 from extralit_server.api.v2 import annotation as annotation_v2 +from extralit_server.api.v2 import projection as projection_v2 from extralit_server.api.v2 import questions as questions_v2 from extralit_server.api.v2 import records as records_v2 from extralit_server.api.v2 import schemas as schemas_v2 @@ -27,6 +28,7 @@ def create_api_v2() -> FastAPI: api_v2.include_router(records_v2.router) api_v2.include_router(questions_v2.router) api_v2.include_router(annotation_v2.router) + api_v2.include_router(projection_v2.router) return api_v2 diff --git a/extralit-server/src/extralit_server/api/v2/projection.py b/extralit-server/src/extralit_server/api/v2/projection.py new file mode 100644 index 000000000..da1ba1223 --- /dev/null +++ b/extralit-server/src/extralit_server/api/v2/projection.py @@ -0,0 +1,31 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Security +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.policies.v1 import SchemaPolicy, authorize +from extralit_server.api.schemas.v2.projection import ProjectionView +from extralit_server.contexts.v2 import projection as projection_ctx +from extralit_server.database import get_async_db +from extralit_server.models import User +from extralit_server.security import auth + +router = APIRouter(tags=["v2: projection"]) + + +# Distinct `/projection/...` prefix, NOT `/references/{reference:path}/view`: the greedy `:path` +# converter on the existing GET /references/{reference:path} (Phase 3) would otherwise shadow a +# `/view` suffix, and a real reference ending in "/view" would collide. See spec §17.4. +@router.get("/projection/references/{reference:path}", response_model=ProjectionView) +async def get_reference_projection( + *, + reference: str, + db: Annotated[AsyncSession, Depends(get_async_db)], + current_user: Annotated[User, Security(auth.get_current_user)], + workspace_id: Annotated[UUID, Query(description="Workspace to scope the view (required)")], +): + await authorize(current_user, SchemaPolicy.list(workspace_id)) + return await projection_ctx.build_reference_view( + db, workspace_id=workspace_id, reference=reference, user=current_user + ) diff --git a/extralit-server/src/extralit_server/contexts/v2/projection.py b/extralit-server/src/extralit_server/contexts/v2/projection.py new file mode 100644 index 000000000..0b37de044 --- /dev/null +++ b/extralit-server/src/extralit_server/contexts/v2/projection.py @@ -0,0 +1,70 @@ +"""Projection view (spec §17.4): resolve each reviewable cell as +submitted-response(requesting user) -> suggestion, grouped by reference. Query-time, +Postgres-only. A future OLAP materialization can replace this without changing the API.""" + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from extralit_server.api.schemas.v2.projection import ProjectionCell, ProjectionRecord, ProjectionView +from extralit_server.contexts.v2 import records as records_ctx +from extralit_server.enums import ResponseStatus +from extralit_server.models.v2 import V2Question, V2Response, V2Suggestion + + +async def build_reference_view(db: AsyncSession, *, workspace_id: UUID, reference: str, user) -> ProjectionView: + records = await records_ctx.list_records_by_reference(db, workspace_id=workspace_id, reference=reference) + if not records: + return ProjectionView(reference=reference, records=[], total_records=0) + + schema_ids = {r.schema_id for r in records} + record_ids = [r.id for r in records] + + questions_by_schema: dict[UUID, list[V2Question]] = {} + q_rows = (await db.execute(select(V2Question).where(V2Question.schema_id.in_(schema_ids)))).scalars().all() + for q in q_rows: + questions_by_schema.setdefault(q.schema_id, []).append(q) + + # (record_id, question_id) -> suggestion value + sugg_rows = (await db.execute(select(V2Suggestion).where(V2Suggestion.record_id.in_(record_ids)))).scalars().all() + suggestions = {(s.record_id, s.question_id): s.value for s in sugg_rows} + + # requesting user's submitted responses only: record_id -> {question_name: value} + resp_rows = ( + ( + await db.execute( + select(V2Response).where( + V2Response.record_id.in_(record_ids), + V2Response.user_id == user.id, + V2Response.status == ResponseStatus.submitted, + ) + ) + ) + .scalars() + .all() + ) + responses = {r.record_id: (r.values or {}) for r in resp_rows} + + projection_records: list[ProjectionRecord] = [] + for record in records: + cells: list[ProjectionCell] = [] + for question in questions_by_schema.get(record.schema_id, []): + wrapped = responses.get(record.id, {}).get(question.name) + if wrapped is not None: + cells.append(ProjectionCell(question_name=question.name, value=wrapped.get("value"), source="response")) + elif (record.id, question.id) in suggestions: + cells.append( + ProjectionCell( + question_name=question.name, + value=suggestions[(record.id, question.id)], + source="suggestion", + ) + ) + else: + cells.append(ProjectionCell(question_name=question.name, value=None, source=None)) + projection_records.append( + ProjectionRecord(record_id=record.id, schema_id=record.schema_id, reference=record.reference, cells=cells) + ) + + return ProjectionView(reference=reference, records=projection_records, total_records=len(records)) diff --git a/extralit-server/tests/integration/api/v2/test_projection.py b/extralit-server/tests/integration/api/v2/test_projection.py new file mode 100644 index 000000000..9d18b9f36 --- /dev/null +++ b/extralit-server/tests/integration/api/v2/test_projection.py @@ -0,0 +1,58 @@ +import pytest + +from extralit_server.enums import QuestionType, SchemaStatus +from tests.factories import ( + SchemaFactory, + SchemaVersionFactory, + V2QuestionFactory, + V2RecordFactory, + V2SuggestionFactory, + WorkspaceFactory, +) + +pytestmark = pytest.mark.asyncio + + +async def _schema_with_question(workspace): + schema = await SchemaFactory.create(status=SchemaStatus.published, workspace=workspace) + version = await SchemaVersionFactory.create( + schema=schema, columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + ) + q = await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + return schema, version, q + + +async def test_projection_view_resolves_suggestion_cell(async_client, owner_auth_header): + workspace = await WorkspaceFactory.create() + schema, version, q = await _schema_with_question(workspace) + record = await V2RecordFactory.create(version=version, reference="doc-1") + await V2SuggestionFactory.create(record=record, question=q, value="flu") + + resp = await async_client.get( + f"/api/v2/projection/references/doc-1?workspace_id={workspace.id}", headers=owner_auth_header + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["reference"] == "doc-1" + assert body["total_records"] == 1 + proj_record = body["records"][0] + assert proj_record["record_id"] == str(record.id) + assert proj_record["schema_id"] == str(schema.id) + cell = proj_record["cells"][0] + assert cell["question_name"] == "dx" + assert cell["value"] == "flu" + assert cell["source"] == "suggestion" + + +async def test_projection_view_unknown_reference_returns_empty(async_client, owner_auth_header): + workspace = await WorkspaceFactory.create() + resp = await async_client.get( + f"/api/v2/projection/references/doc-nope?workspace_id={workspace.id}", headers=owner_auth_header + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["reference"] == "doc-nope" + assert body["total_records"] == 0 + assert body["records"] == [] diff --git a/extralit-server/tests/integration/contexts/v2/test_projection.py b/extralit-server/tests/integration/contexts/v2/test_projection.py new file mode 100644 index 000000000..8b7995cd9 --- /dev/null +++ b/extralit-server/tests/integration/contexts/v2/test_projection.py @@ -0,0 +1,53 @@ +import pytest + +from extralit_server.contexts.v2 import projection as projection_ctx +from extralit_server.enums import QuestionType, ResponseStatus, SchemaStatus +from tests.factories import ( + SchemaFactory, + SchemaVersionFactory, + UserFactory, + V2QuestionFactory, + V2RecordFactory, + V2ResponseFactory, + V2SuggestionFactory, +) + +pytestmark = pytest.mark.asyncio + + +async def _schema_with_question(db): + schema = await SchemaFactory.create(status=SchemaStatus.published, workspace__name="wsp") + version = await SchemaVersionFactory.create( + schema=schema, columns_cache=[{"name": "disease", "dtype": "str", "nullable": True, "review": None}] + ) + schema.current_version_id = version.id + q = await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + await db.commit() + return schema, version, q + + +async def test_cell_resolves_to_suggestion_when_no_response(db): + schema, version, q = await _schema_with_question(db) + record = await V2RecordFactory.create(version=version, reference="doc-1") + await V2SuggestionFactory.create(record=record, question=q, value="flu") + user = await UserFactory.create() + + view = await projection_ctx.build_reference_view(db, workspace_id=schema.workspace_id, reference="doc-1", user=user) + cell = view.records[0].cells[0] + assert cell.value == "flu" and cell.source == "suggestion" + + +async def test_cell_resolves_to_response_over_suggestion(db): + schema, version, q = await _schema_with_question(db) + record = await V2RecordFactory.create(version=version, reference="doc-2") + await V2SuggestionFactory.create(record=record, question=q, value="flu") + user = await UserFactory.create() + await V2ResponseFactory.create( + record=record, user=user, status=ResponseStatus.submitted, values={"dx": {"value": "covid"}} + ) + + view = await projection_ctx.build_reference_view(db, workspace_id=schema.workspace_id, reference="doc-2", user=user) + cell = view.records[0].cells[0] + assert cell.value == "covid" and cell.source == "response" From 418dc8fa587e9b2b3a234b91a554ee32d91fe742 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:21:15 +0000 Subject: [PATCH 18/23] test(v2): guard annotation against index-engine imports; suite green --- .../unit/test_annotation_no_index_import.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 extralit-server/tests/unit/test_annotation_no_index_import.py diff --git a/extralit-server/tests/unit/test_annotation_no_index_import.py b/extralit-server/tests/unit/test_annotation_no_index_import.py new file mode 100644 index 000000000..74a933eac --- /dev/null +++ b/extralit-server/tests/unit/test_annotation_no_index_import.py @@ -0,0 +1,25 @@ +import ast +from pathlib import Path + +import extralit_server + +ROOT = Path(extralit_server.__file__).parent +GUARDED = [ + ROOT / "contexts" / "v2" / "annotation.py", + ROOT / "contexts" / "v2" / "projection.py", + ROOT / "api" / "v2" / "annotation.py", + ROOT / "api" / "v2" / "questions.py", +] + + +def test_annotation_modules_do_not_import_index_engine(): + for path in GUARDED: + tree = ast.parse(path.read_text()) + imported = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + elif isinstance(node, ast.Import): + imported.extend(alias.name for alias in node.names) + assert not any("index" in m and "extralit_server" in m for m in imported), f"{path} imports the index engine" + assert not any(m.endswith("index_sync") for m in imported), f"{path} imports index_sync" From 974dc7dc764a5ffb4bd43793489db33a92e637d5 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:28:28 +0000 Subject: [PATCH 19/23] fix(v2): harden no-index-import guard to catch 'from ...v2 import index_sync' idiom The AST guard only inspected ImportFrom.module, so `from extralit_server.contexts.v2 import index_sync` (the exact idiom used in api/v2/records.py and api/v2/schemas.py) slipped through undetected since neither assertion looked at the imported alias names. Refactor detection into _imports_index_engine(), checking bare and qualified alias names plus relative-import forms, and add a self-test that exercises every realistic violating and innocent import form against synthetic source snippets. --- .../unit/test_annotation_no_index_import.py | 85 +++++++++++++++++-- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/extralit-server/tests/unit/test_annotation_no_index_import.py b/extralit-server/tests/unit/test_annotation_no_index_import.py index 74a933eac..2c9723ed0 100644 --- a/extralit-server/tests/unit/test_annotation_no_index_import.py +++ b/extralit-server/tests/unit/test_annotation_no_index_import.py @@ -1,6 +1,8 @@ import ast from pathlib import Path +import pytest + import extralit_server ROOT = Path(extralit_server.__file__).parent @@ -12,14 +14,79 @@ ] +def _imports_index_engine(source: str) -> bool: + """Return True if `source` imports extralit_server's Lance index engine (spec §17.5). + + Catches every realistic violating form, including: + - `import extralit_server.index.lancedb_engine` + - `import extralit_server.contexts.v2.index_sync` + - `from extralit_server.index import ...` + - `from extralit_server import index` + - `from extralit_server.contexts.v2.index_sync import sync_upserted_records` + - `from extralit_server.contexts.v2 import index_sync` (bare name, no "index" + substring in `node.module` — the idiom actually used in + api/v2/records.py and api/v2/schemas.py) + - relative forms `from . import index_sync` / `from .. import index_sync` + (where `node.module` is None) + """ + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.name + if "extralit_server" in name and "index" in name: + return True + if name.endswith("index_sync"): + return True + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + # Relative imports (module is None/"") are necessarily within the + # extralit_server package tree, since the guarded files live there. + is_extralit_scoped = module == "" or "extralit_server" in module + for alias in node.names: + name = alias.name + qualified = f"{module}.{name}" if module else name + if name.endswith("index_sync"): + return True + if "extralit_server" in qualified and "index" in qualified: + return True + if is_extralit_scoped and name == "index": + return True + return False + + def test_annotation_modules_do_not_import_index_engine(): for path in GUARDED: - tree = ast.parse(path.read_text()) - imported = [] - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom) and node.module: - imported.append(node.module) - elif isinstance(node, ast.Import): - imported.extend(alias.name for alias in node.names) - assert not any("index" in m and "extralit_server" in m for m in imported), f"{path} imports the index engine" - assert not any(m.endswith("index_sync") for m in imported), f"{path} imports index_sync" + source = path.read_text() + assert not _imports_index_engine(source), f"{path} imports the index engine" + + +VIOLATING_SNIPPETS = { + "import extralit_server.index.lancedb_engine": "import extralit_server.index.lancedb_engine\n", + "import extralit_server.contexts.v2.index_sync": "import extralit_server.contexts.v2.index_sync\n", + "from extralit_server.index import ...": "from extralit_server.index import lancedb_engine\n", + "from extralit_server import index": "from extralit_server import index\n", + "from extralit_server.contexts.v2.index_sync import sync_upserted_records": ( + "from extralit_server.contexts.v2.index_sync import sync_upserted_records\n" + ), + "from extralit_server.contexts.v2 import index_sync": ("from extralit_server.contexts.v2 import index_sync\n"), + "relative: from . import index_sync": "from . import index_sync\n", + "relative: from .. import index_sync": "from .. import index_sync\n", +} + +INNOCENT_SNIPPETS = { + "from extralit_server.models.v2 import V2Record": "from extralit_server.models.v2 import V2Record\n", + "from extralit_server.contexts.v2 import annotation": "from extralit_server.contexts.v2 import annotation\n", + "import extralit_server.contexts.v2.annotation": "import extralit_server.contexts.v2.annotation\n", + "from extralit_server.database import get_async_db": "from extralit_server.database import get_async_db\n", +} + + +@pytest.mark.parametrize("source", VIOLATING_SNIPPETS.values(), ids=VIOLATING_SNIPPETS.keys()) +def test_detector_flags_every_violating_import_form(source): + assert _imports_index_engine(source), f"detector failed to flag violating source: {source!r}" + + +@pytest.mark.parametrize("source", INNOCENT_SNIPPETS.values(), ids=INNOCENT_SNIPPETS.keys()) +def test_detector_does_not_flag_innocent_imports(source): + assert not _imports_index_engine(source), f"detector incorrectly flagged innocent source: {source!r}" From c680ef742b0ed4b98aeb2d7303a20f6d0352a05f Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:42:04 +0000 Subject: [PATCH 20/23] test(v2): close no-index guard gap + cover response/projection edge branches; polish validator messages --- .../validators/v2/questions.py | 2 +- .../extralit_server/validators/v2/values.py | 2 - .../contexts/v2/test_annotation_context.py | 54 +++++++++++++++++++ .../contexts/v2/test_projection.py | 15 ++++++ .../unit/test_annotation_no_index_import.py | 1 + 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/extralit-server/src/extralit_server/validators/v2/questions.py b/extralit-server/src/extralit_server/validators/v2/questions.py index 177b4eb6b..3cba43b5a 100644 --- a/extralit-server/src/extralit_server/validators/v2/questions.py +++ b/extralit-server/src/extralit_server/validators/v2/questions.py @@ -13,7 +13,7 @@ class QuestionBindingValidator: def validate(cls, *, type: QuestionType, columns: list[str], columns_cache: list[dict]) -> None: if type in DEFERRED_TYPES: raise UnprocessableEntityError( - f"question type {type.value!r} (span) is not supported in this release; " + f"question type {type.value!r} is not supported in this release; " "it is deferred to the PDF-chunk annotation design" ) if not columns: diff --git a/extralit-server/src/extralit_server/validators/v2/values.py b/extralit-server/src/extralit_server/validators/v2/values.py index 3ccb74476..912497de8 100644 --- a/extralit-server/src/extralit_server/validators/v2/values.py +++ b/extralit-server/src/extralit_server/validators/v2/values.py @@ -39,8 +39,6 @@ def validate(cls, value, *, type: QuestionType, settings: dict, columns: list[st RankingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) elif type == QuestionType.table: cls._validate_table(value, columns) - else: - raise UnprocessableEntityError(f"unknown question type {type!r}") @staticmethod def _validate_table(value, columns: list[str]) -> None: diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index e7a7bfb50..2f7f8530c 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -170,3 +170,57 @@ async def test_submitted_response_requires_required_question(db): user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"notes": {"value": "n"}}), ) + + +async def test_submitted_response_rejects_empty_values(db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + user = await UserFactory.create() + + with pytest.raises(UnprocessableEntityError, match="missing response values"): + await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={}) + ) + + with pytest.raises(UnprocessableEntityError, match="missing response values"): + await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values=None) + ) + + +async def test_response_rejects_non_configured_question(db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + user = await UserFactory.create() + + with pytest.raises(UnprocessableEntityError, match="non-configured"): + await annotation_ctx.upsert_response( + db, + record, + user, + upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"not_a_question": {"value": "x"}}), + ) + + +async def test_upsert_response_is_idempotent_per_record_user(db): + schema = await _published_schema(db) + await V2QuestionFactory.create( + schema=schema, name="dx", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version__schema=schema) + user = await UserFactory.create() + + r1 = await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"dx": {"value": "flu"}}) + ) + r2 = await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"dx": {"value": "covid"}}) + ) + assert r1.id == r2.id + assert r2.values == {"dx": {"value": "covid"}} diff --git a/extralit-server/tests/integration/contexts/v2/test_projection.py b/extralit-server/tests/integration/contexts/v2/test_projection.py index 8b7995cd9..b833172b5 100644 --- a/extralit-server/tests/integration/contexts/v2/test_projection.py +++ b/extralit-server/tests/integration/contexts/v2/test_projection.py @@ -51,3 +51,18 @@ async def test_cell_resolves_to_response_over_suggestion(db): view = await projection_ctx.build_reference_view(db, workspace_id=schema.workspace_id, reference="doc-2", user=user) cell = view.records[0].cells[0] assert cell.value == "covid" and cell.source == "response" + + +async def test_cell_is_none_when_no_response_or_suggestion(db): + schema, version, q1 = await _schema_with_question(db) + await V2QuestionFactory.create( + schema=schema, name="notes", type=QuestionType.text, columns=["disease"], settings={"type": "text"} + ) + record = await V2RecordFactory.create(version=version, reference="doc-3") + await V2SuggestionFactory.create(record=record, question=q1, value="flu") + user = await UserFactory.create() + + view = await projection_ctx.build_reference_view(db, workspace_id=schema.workspace_id, reference="doc-3", user=user) + cells = {c.question_name: c for c in view.records[0].cells} + assert cells["dx"].value == "flu" and cells["dx"].source == "suggestion" + assert cells["notes"].value is None and cells["notes"].source is None diff --git a/extralit-server/tests/unit/test_annotation_no_index_import.py b/extralit-server/tests/unit/test_annotation_no_index_import.py index 2c9723ed0..1a624d84d 100644 --- a/extralit-server/tests/unit/test_annotation_no_index_import.py +++ b/extralit-server/tests/unit/test_annotation_no_index_import.py @@ -11,6 +11,7 @@ ROOT / "contexts" / "v2" / "projection.py", ROOT / "api" / "v2" / "annotation.py", ROOT / "api" / "v2" / "questions.py", + ROOT / "api" / "v2" / "projection.py", ] From 313b21f34887372c0906104f22f511fbd8417fef Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 02:53:32 +0000 Subject: [PATCH 21/23] fix(v2): validate question settings against type at create/update; restore fail-closed value dispatch (roborev job 120) - Add QuestionSettingsValidator (validators/v2/questions.py), reusing v1's QuestionSettings TypeAdapter, and call it in create_question/update_question after binding validation so settings-driven questions (rating/label_selection/ multi_label_selection/ranking) with empty or type-mismatched settings fail loudly at create/update time instead of persisting unusable. - Restore the terminal fail-closed `else: raise` in V2ResponseValueValidator.validate so an unmapped future QuestionType rejects rather than silently accepting an unvalidated value. --- .../extralit_server/contexts/v2/annotation.py | 7 ++- .../validators/v2/questions.py | 32 ++++++++++ .../extralit_server/validators/v2/values.py | 5 ++ .../contexts/v2/test_annotation_context.py | 62 ++++++++++++++++++- .../tests/unit/validators/v2/test_values.py | 7 +++ 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/extralit-server/src/extralit_server/contexts/v2/annotation.py b/extralit-server/src/extralit_server/contexts/v2/annotation.py index a989b3738..b7c4a00c6 100644 --- a/extralit-server/src/extralit_server/contexts/v2/annotation.py +++ b/extralit-server/src/extralit_server/contexts/v2/annotation.py @@ -13,7 +13,7 @@ from extralit_server.enums import ResponseStatus from extralit_server.errors.future import UnprocessableEntityError from extralit_server.models.v2 import Schema, SchemaVersion, V2Question, V2Record, V2Response, V2Suggestion -from extralit_server.validators.v2.questions import QuestionBindingValidator +from extralit_server.validators.v2.questions import QuestionBindingValidator, QuestionSettingsValidator from extralit_server.validators.v2.values import V2ResponseValueValidator, V2SuggestionValidator @@ -29,6 +29,7 @@ async def _current_columns_cache(db: AsyncSession, schema: Schema) -> list[dict] async def create_question(db: AsyncSession, schema: Schema, *, create: QuestionCreate) -> V2Question: columns_cache = await _current_columns_cache(db, schema) QuestionBindingValidator.validate(type=create.type, columns=create.columns, columns_cache=columns_cache) + QuestionSettingsValidator.validate(type=create.type, settings=create.settings) question = V2Question( schema_id=schema.id, name=create.name, @@ -61,6 +62,10 @@ async def update_question(db: AsyncSession, question: V2Question, *, update: Que columns_cache = await _current_columns_cache(db, schema) QuestionBindingValidator.validate(type=question.type, columns=update.columns, columns_cache=columns_cache) question.columns = list(update.columns) + + effective_settings = update.settings if update.settings is not None else question.settings + QuestionSettingsValidator.validate(type=question.type, settings=effective_settings) + for attr in ("title", "description", "settings", "required"): value = getattr(update, attr) if value is not None: diff --git a/extralit-server/src/extralit_server/validators/v2/questions.py b/extralit-server/src/extralit_server/validators/v2/questions.py index 3cba43b5a..e5dfd3cff 100644 --- a/extralit-server/src/extralit_server/validators/v2/questions.py +++ b/extralit-server/src/extralit_server/validators/v2/questions.py @@ -1,9 +1,24 @@ +from pydantic import TypeAdapter, ValidationError + +from extralit_server.api.schemas.v1.questions import QuestionSettings from extralit_server.enums import QuestionType from extralit_server.errors.future import UnprocessableEntityError # span is reserved in the enum but deferred to the PDF-chunk design session (spec §17.3). DEFERRED_TYPES = {QuestionType.span} +# Settings-driven types: their `settings` blob must structurally match v1's QuestionSettings +# union for that type — the SAME shape values.py::_parsed feeds a response/suggestion value +# through at annotation time. `text` has no required settings; `table` is structure-only +# (bound columns, no Pandera re-run); `span` is deferred and already rejected by +# QuestionBindingValidator before settings validation ever runs. +SETTINGS_VALIDATED_TYPES = { + QuestionType.label_selection, + QuestionType.multi_label_selection, + QuestionType.rating, + QuestionType.ranking, +} + class QuestionBindingValidator: """Validate a question's column binding against the schema's current columns_cache @@ -29,3 +44,20 @@ def validate(cls, *, type: QuestionType, columns: list[str], columns_cache: list raise UnprocessableEntityError( f"unknown column(s) {unknown!r} for question binding; available columns: {sorted(known)!r}" ) + + +class QuestionSettingsValidator: + """Validate a question's `settings` blob against its `type` at create/update time + (spec §17.3). Without this, a settings-driven question (rating/label_selection/ + multi_label_selection/ranking) with empty or malformed settings persists successfully + but is permanently unusable: every later suggestion/response raises an opaque pydantic + ValidationError at annotation time instead of failing loudly at create time.""" + + @classmethod + def validate(cls, *, type: QuestionType, settings: dict) -> None: + if type not in SETTINGS_VALIDATED_TYPES: + return + try: + TypeAdapter(QuestionSettings).validate_python({**settings, "type": type.value}) + except ValidationError as e: + raise UnprocessableEntityError(f"invalid settings for question type {type.value!r}: {e}") from e diff --git a/extralit-server/src/extralit_server/validators/v2/values.py b/extralit-server/src/extralit_server/validators/v2/values.py index 912497de8..762479802 100644 --- a/extralit-server/src/extralit_server/validators/v2/values.py +++ b/extralit-server/src/extralit_server/validators/v2/values.py @@ -39,6 +39,11 @@ def validate(cls, value, *, type: QuestionType, settings: dict, columns: list[st RankingQuestionResponseValueValidator(value).validate_for(_parsed(settings)) elif type == QuestionType.table: cls._validate_table(value, columns) + else: + # Defensive: this dispatch is exhaustive for today's QuestionType, but a future + # enum member wired through without a branch here must fail closed (reject), + # not silently accept an unvalidated value. + raise UnprocessableEntityError(f"unknown question type {type!r}; cannot validate value") @staticmethod def _validate_table(value, columns: list[str]) -> None: diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index 2f7f8530c..d023198ee 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -31,7 +31,7 @@ async def test_create_question_validates_binding(db): title="Diagnosis", type=QuestionType.label_selection, columns=["disease"], - settings={"type": "label_selection", "options": [{"value": "x"}]}, + settings={"type": "label_selection", "options": [{"value": "x", "text": "X"}]}, ), ) assert q.id is not None and q.columns == ["disease"] @@ -47,6 +47,66 @@ async def test_create_question_rejects_unknown_column(db): ) +async def test_create_question_rejects_empty_settings_for_settings_driven_type(db): + schema = await _published_schema(db) + with pytest.raises(UnprocessableEntityError, match="invalid settings for question type 'rating'"): + await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="score", + title="Score", + type=QuestionType.rating, + columns=["disease"], + settings={}, + ), + ) + + +async def test_create_question_accepts_valid_settings_for_settings_driven_type(db): + schema = await _published_schema(db) + q = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="score", + title="Score", + type=QuestionType.rating, + columns=["disease"], + settings={"type": "rating", "options": [{"value": 1}, {"value": 2}]}, + ), + ) + assert q.id is not None and q.settings["options"] == [{"value": 1}, {"value": 2}] + + +async def test_create_question_text_type_allows_empty_settings(db): + schema = await _published_schema(db) + q = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate(name="notes", title="Notes", type=QuestionType.text, columns=["disease"], settings={}), + ) + assert q.id is not None + + +async def test_update_question_rejects_settings_that_no_longer_match_type(db): + schema = await _published_schema(db) + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="score", + title="Score", + type=QuestionType.rating, + columns=["disease"], + settings={"type": "rating", "options": [{"value": 1}, {"value": 2}]}, + ), + ) + + with pytest.raises(UnprocessableEntityError, match="invalid settings for question type 'rating'"): + await annotation_ctx.update_question(db, question, update=QuestionUpdate(settings={"type": "rating"})) + + async def test_create_question_requires_published_schema(db): schema = await SchemaFactory.create(status=SchemaStatus.draft) # current_version_id is None with pytest.raises(UnprocessableEntityError, match="published"): diff --git a/extralit-server/tests/unit/validators/v2/test_values.py b/extralit-server/tests/unit/validators/v2/test_values.py index 62b1a7ac9..d16686279 100644 --- a/extralit-server/tests/unit/validators/v2/test_values.py +++ b/extralit-server/tests/unit/validators/v2/test_values.py @@ -39,6 +39,13 @@ def test_span_value_is_rejected(): V2ResponseValueValidator.validate([], type=QuestionType.span, settings={"type": "span"}, columns=["c"]) +def test_unknown_question_type_fails_closed(): + # Guards against a future QuestionType member being wired through without a branch in + # V2ResponseValueValidator.validate — the dispatch must reject, not silently accept. + with pytest.raises(UnprocessableEntityError, match="unknown question type"): + V2ResponseValueValidator.validate("x", type="bogus_type", settings={}, columns=["c"]) + + MULTI_LABEL_SETTINGS = { "type": "multi_label_selection", "options": [{"value": "yes", "text": "Yes"}], From 1ca23aa39fdb148d036694546671a52f1c210a60 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 03:01:07 +0000 Subject: [PATCH 22/23] fix(v2): normalize question settings discriminator on store; reject contradictory type (roborev job 121) QuestionSettingsValidator injected the "type" discriminator only for validation, while create_question/update_question persisted raw settings and annotation-time _parsed() never injected it. A settings-driven question created with valid settings but no "type" key thus passed create-time validation yet was permanently unusable at annotation time (opaque 422 on every suggestion/response). - create_question/update_question now normalize the *stored* settings with the question's own type, so create.type/question.type is always the source of truth for the persisted discriminator. - QuestionSettingsValidator.validate rejects an explicit "type" in the incoming settings that contradicts the question's real type, instead of silently overwriting it. --- .../extralit_server/contexts/v2/annotation.py | 10 ++- .../validators/v2/questions.py | 6 ++ .../contexts/v2/test_annotation_context.py | 68 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/extralit-server/src/extralit_server/contexts/v2/annotation.py b/extralit-server/src/extralit_server/contexts/v2/annotation.py index b7c4a00c6..beff06d02 100644 --- a/extralit-server/src/extralit_server/contexts/v2/annotation.py +++ b/extralit-server/src/extralit_server/contexts/v2/annotation.py @@ -37,7 +37,10 @@ async def create_question(db: AsyncSession, schema: Schema, *, create: QuestionC description=create.description, type=create.type, columns=list(create.columns), - settings=dict(create.settings), + # Normalize the discriminator on store so stored settings always agree with `type` — + # QuestionSettingsValidator injects it for validation only; annotation-time `_parsed` + # (validators/v2/values.py) requires it to already be present on the persisted blob. + settings={**create.settings, "type": create.type.value}, required=create.required, ) db.add(question) @@ -65,8 +68,11 @@ async def update_question(db: AsyncSession, question: V2Question, *, update: Que effective_settings = update.settings if update.settings is not None else question.settings QuestionSettingsValidator.validate(type=question.type, settings=effective_settings) + if update.settings is not None: + # Normalize with the question's own (immutable) type, never a client-supplied one. + question.settings = {**effective_settings, "type": question.type.value} - for attr in ("title", "description", "settings", "required"): + for attr in ("title", "description", "required"): value = getattr(update, attr) if value is not None: setattr(question, attr, value) diff --git a/extralit-server/src/extralit_server/validators/v2/questions.py b/extralit-server/src/extralit_server/validators/v2/questions.py index e5dfd3cff..a5900741c 100644 --- a/extralit-server/src/extralit_server/validators/v2/questions.py +++ b/extralit-server/src/extralit_server/validators/v2/questions.py @@ -55,6 +55,12 @@ class QuestionSettingsValidator: @classmethod def validate(cls, *, type: QuestionType, settings: dict) -> None: + explicit_type = settings.get("type") + if explicit_type is not None and explicit_type != type.value: + raise UnprocessableEntityError( + f"settings 'type' {explicit_type!r} does not match question type {type.value!r}" + ) + if type not in SETTINGS_VALIDATED_TYPES: return try: diff --git a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py index d023198ee..dc603c45d 100644 --- a/extralit-server/tests/integration/contexts/v2/test_annotation_context.py +++ b/extralit-server/tests/integration/contexts/v2/test_annotation_context.py @@ -89,6 +89,74 @@ async def test_create_question_text_type_allows_empty_settings(db): assert q.id is not None +async def test_create_question_without_type_key_normalizes_settings_and_is_usable_end_to_end(db): + # Regression test (roborev job 121): settings-driven questions created with valid settings + # that OMIT the "type" discriminator must still be usable at annotation time — create_question + # must normalize the discriminator into the stored settings, not just inject it transiently + # for validation. + schema = await _published_schema(db) + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="dx", + title="Diagnosis", + type=QuestionType.label_selection, + columns=["disease"], + settings={"options": [{"value": "yes", "text": "Yes"}]}, # no "type" key + ), + ) + assert question.settings["type"] == QuestionType.label_selection.value + + record = await V2RecordFactory.create(version__schema=schema) + suggestion = await annotation_ctx.upsert_suggestion( + db, record, question, upsert=SuggestionUpsert(question_id=question.id, value="yes") + ) + assert suggestion.value == "yes" + + user = await UserFactory.create() + response = await annotation_ctx.upsert_response( + db, record, user, upsert=ResponseUpsert(status=ResponseStatus.submitted, values={"dx": {"value": "yes"}}) + ) + assert response.values == {"dx": {"value": "yes"}} + + +async def test_create_question_rejects_contradictory_type_in_settings(db): + schema = await _published_schema(db) + with pytest.raises(UnprocessableEntityError, match="does not match question type"): + await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="score", + title="Score", + type=QuestionType.rating, + columns=["disease"], + settings={"type": "text", "options": [{"value": 1}, {"value": 2}]}, + ), + ) + + +async def test_update_question_rejects_contradictory_type_in_settings(db): + schema = await _published_schema(db) + question = await annotation_ctx.create_question( + db, + schema, + create=QuestionCreate( + name="score", + title="Score", + type=QuestionType.rating, + columns=["disease"], + settings={"type": "rating", "options": [{"value": 1}, {"value": 2}]}, + ), + ) + + with pytest.raises(UnprocessableEntityError, match="does not match question type"): + await annotation_ctx.update_question( + db, question, update=QuestionUpdate(settings={"type": "label_selection", "options": []}) + ) + + async def test_update_question_rejects_settings_that_no_longer_match_type(db): schema = await _published_schema(db) question = await annotation_ctx.create_question( From b63a251c5dd4c12c43ba5bd4b3747516f2c1c19f Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 8 Jul 2026 03:03:04 +0000 Subject: [PATCH 23/23] =?UTF-8?q?docs(spec):=20=C2=A718=20resolved=20durin?= =?UTF-8?q?g=20Phase=204=20(annotation)=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-27-schema-centric-data-model-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md index 17d576d4b..49539b122 100644 --- a/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md +++ b/docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md @@ -610,3 +610,83 @@ three tables. projection-view endpoint. - **Migration (§8):** v1 questions→`v2_questions` column bindings, suggestions, responses parity; v1 span questions handled per the migrator decision. + +## 18. Resolved during Phase 4 implementation (2026-07-08) + +Phase 4 built the annotation layer per §17 with no design reversals. As-built outcomes +and the deviations/decisions added during implementation and review (per-task +spec+quality reviews, a whole-branch review, and roborev jobs 101–122): + +- **As-built surface.** ORM: `V2Question`/`V2Suggestion`/`V2Response` on `v2_questions`/ + `v2_suggestions`/`v2_responses` with PG enums `v2_question_type_enum`/ + `v2_suggestion_type_enum`/`v2_response_status_enum` (reusing v1 `QuestionType`/ + `SuggestionType`/`ResponseStatus` *values*), one-directional `relationship()`s only (no + backrefs → v1's `User`/`Schema` mappers untouched, no registry collision). Validators in + `validators/v2/` (`QuestionBindingValidator`, `QuestionSettingsValidator`, + `V2ResponseValueValidator`, `V2SuggestionValidator`). Business logic in + `contexts/v2/annotation.py` (questions/suggestions/responses) + `contexts/v2/projection.py`. + Policies `V2QuestionPolicy`/`V2SuggestionPolicy`/`V2ResponsePolicy` in + `api/policies/v1/v2_annotation_policy.py`. Routers `api/v2/questions.py`, + `api/v2/annotation.py` (suggestions + responses), `api/v2/projection.py`, plus the + single-version route added to `api/v2/schemas.py`. +- **`schemas.kind` dropped (§14).** Migration `6393b1a01aa0` removes the column + enum; + the emergent kind is now implied by bindings. Zero residual `SchemaKind`/`schema.kind` + references across server/SDK/frontend. +- **Migrations are SQLite-safe (deviation from the plan's draft snippets).** The plan drafts + showed `op.execute("DROP TYPE IF EXISTS …")`, which errors on SQLite (the test suite runs + Alembic against SQLite). Both migrations (`6393b1a01aa0` drop-kind, `c1510e93882a` create + annotation tables) instead drop PG enums via `sa.Enum(name=…).drop(op.get_bind(), + checkfirst=True)` and guard Postgres-only DDL with `!= "sqlite"`, matching the canonical + `9f3010c649c8` pattern. Both round-trip (upgrade→downgrade→upgrade) cleanly; the create + migration was stripped of unrelated autogenerate reflection drift so it creates only the + three annotation tables. +- **Reuse of v1 value validation as-built.** `V2ResponseValueValidator` dispatches to v1's + per-type `…ResponseValueValidator` classes via `TypeAdapter(QuestionSettings)`; `table` is + structure-only (dict + keys ⊆ bound columns, no Pandera); `span` is rejected (deferred). + The dispatch is **fail-closed** (terminal `else: raise`) so a future unmapped + `QuestionType` cannot silently accept a value (restored after a final-review cleanup had + removed it — roborev jobs 119/120). +- **Settings validated at question create/update (added during review, roborev job 120).** + `QuestionBindingValidator` (existence/arity/span) runs first, then `QuestionSettingsValidator` + validates `settings` against `type` for the settings-driven types (`label_selection`/ + `multi_label_selection`/`rating`/`ranking`) using v1's `QuestionSettings`, so a question that + could never accept a value fails loudly at create time instead of being persisted-but-unusable. + Stored settings are **normalized to carry the correct `"type"` discriminator** and an explicit + contradictory `settings["type"]` is rejected — keeping create-validation, storage, and + annotation-time `_parsed()` in agreement (roborev job 121). +- **Response semantics hold.** `values` keyed by question **name** on both write validation and + projection read; `(record, user)` idempotent upsert; **no `record.status` transition** on + submit (§17.3, tested); required-question enforcement on submit (empty values / non-configured + name / missing-required all 422). Own-response authz: any workspace member (incl. annotators) + writes only their own row via `current_user`. +- **Submitted-ranking completeness NOT enforced (deliberate §17.3 scope).** v2 calls v1's + ranking `validate_for` without `response_status`, so a submitted ranking that omits options + passes where v1 would reject it — matching §17.3's enumerated "values valid + unique" checks + (settings-level only). The values that ARE present are still validated. Recorded as a Phase-5 + tracking item, not a fix. +- **Projection view as-built (§17.4).** `build_reference_view` resolves each cell as + requesting-user's **submitted** response (by question name) → suggestion (by + `(record_id, question_id)`) → `None`; batched (one records query + three `IN (…)` queries, + no N+1); Postgres-only. Exposed at a **distinct** `GET /projection/references/{reference:path}` + prefix (NOT `/references/{…}/view`) so the Phase-3 greedy `:path` route on + `/references/{reference:path}` cannot shadow it; `workspace_id` is a required query param, + authorized via `SchemaPolicy.list`. +- **No Lance sync for annotation (§17.5), durably guarded.** No annotation context/handler + imports `extralit_server.index`/`contexts.v2.index_sync`; a response-upsert API test asserts + `sync_upserted_records` is never called. The AST guard test (`tests/unit/ + test_annotation_no_index_import.py`) was hardened to (a) catch every real import idiom — + including `from extralit_server.contexts.v2 import index_sync` (a blind spot in the first + draft, found by review + roborev job 117) — via a shared detector self-tested against 8 + violating + 4 innocent forms, and (b) cover `api/v2/projection.py` in addition to the context + modules and the other two handlers. +- **Async eager-load discipline.** Every path where a policy reads `*.schema.workspace_id` + eager-loads the relationship (`selectinload(V2Question.schema)`/`selectinload(V2Record.schema)`) + so no lazy-load crosses the greenlet boundary at runtime — mirroring v1's handler precedent. +- **Review outcome.** All 11 tasks passed per-task spec+quality review; the whole-branch review + returned no Critical/Important (verdict: mergeable) and its recommended items were applied + (guard-file completeness + edge-branch tests + validator-message polish). The roborev branch + review (job 120) surfaced two Low findings (settings-vs-type at create; fail-open dispatch), + both fixed; the follow-up fix's residual discriminator gap (job 121) was fixed; the final + commit's review (job 122) returned **no issues**. Deferred as non-blocking follow-ups: the + guard's substring `"index"` match would over-block a hypothetical future `reindex`-named module + (direction-safe); submitted-ranking completeness (above) is a Phase-5 item.