Skip to content

feat(v2): workspace projection endpoint + extraction-table data layer#233

Merged
JonnyTran merged 21 commits into
developfrom
feat/v2-ui-extraction-table
Jul 22, 2026
Merged

feat(v2): workspace projection endpoint + extraction-table data layer#233
JonnyTran merged 21 commits into
developfrom
feat/v2-ui-extraction-table

Conversation

@JonnyTran

Copy link
Copy Markdown
Member

Summary

Phase 1 of the extraction-table build (plan: docs/superpowers/plans/2026-07-20-extraction-table.md, spec §3–§4). Everything here is additive and independently shippable: server contract + workspace projection endpoint, plus the frontend data layer up to DI registration. No UI, no deletions, no new frontend dependencies.

Phase 2 (Perspective grid, /extractions page, review-page retirement) is a separate stacked PR.

What's included

Server

  • list[dict] table values accepted additively — bare dict remains the 1-row case (spec §3.4)
  • ProjectionCell enriched with record_id / agent / score; optional, so SDK feat(sdk): v2 SDK vertical slice — generated DTOs, async transport, resources, agentic CLI #231 stays backward-compatible
  • build_workspace_view — workspace-wide denormalization: effective-record dedup, submitted-response ?? suggestion coalesce, table fan-out with independent stacking and scalar repetition
  • GET /api/v2/projection?workspace_id=&offset=&limit= (limit ∈ [1,100] default 50)

Frontend (data layer only)

  • Regenerated contract (openapi.json + v2-api.ts); repository types against components["schemas"]
  • WorkspaceProjection domain types + ProjectionRepository.getWorkspaceProjection
  • Pure grid adapter (flat rows, band parity, annotation-URL contract behind a disabled feature guard)
  • GetWorkspaceProjectionUseCase + ExtractionsStorage + DI wiring

Architecture note — DuckDB

The denormalization runs as one SQL statement in an in-process, in-memory DuckDB connection, fed by batched Postgres slices from the existing AsyncSession and offloaded via to_thread. DuckDB never ATTACHes Postgres (integration tests run in a rolled-back transaction a second connection can't see). This keeps the transform declarative over arbitrary schema-defined JSON and pre-builds the Arrow path for later streaming.

duckdb>=1.5.4 is the only dependency added anywhere in this PR.

Verification

  • Server: 131 passed (tests/unit/validators/v2, tests/integration/contexts/v2, tests/integration/api/v2)
  • Contract drift gate: npm run gen:api && git diff --exit-code v2/infrastructure/apiclean
  • Frontend: 869 passed, typecheck clean, lint clean
  • Scope audit: no package.json/lockfile/nuxt.config.ts/vitest.config.ts changes, no deletions, no pages//components//e2e/ changes

Known flaky suite (not introduced here)

components/features/dataset-creation/configuration/DatasetConfiguration.spec.js fails in the full run with a @nuxt/test-utils setupNuxt hook timeout (10s) — a timing symptom, not an assertion failure. It passes 21/21 in isolation. It's a v1 dataset-creation component with zero references to v2 DI, extractions, or projection, and both the new DI registration and the new Pinia store are lazy.

I was unable to run a develop baseline to prove it pre-existing (the command was blocked), so this is strong evidence but not proof. Worth a second opinion from someone who knows whether this suite is already known-flaky in CI.

Review notes

Two findings surfaced and were fixed during review, both worth knowing about:

  1. JSON-path injection (server). The denormalization originally interpolated question/sub-column names into JSON paths. QuestionCreate.columns constrains list length, not element content, so a " or "" was storable via the public API and made DuckDB abort the entire statement — 500ing the whole grid for every reference, not just one cell. Fixed structurally: no data-derived text reaches a JSON path anymore (join on qc.sub_column = e.entry_key). Hostile names (sub"col, back\slash, "", *) are covered by tests.
  2. Untested authorization. The endpoint returns an entire workspace's extracted values, but every test used an owner header, and owner short-circuits the policy before the membership check. A non-member 403 test was added and mutation-verified: removing authorize() leaks the full workspace.

Recorded decision: response coalescing selects the latest submitted response envelope per record (any user), so a question absent from that envelope falls back to its suggestion even if an earlier submitted response contained it. Record-level per spec §3.2 — documented in the code, deliberately not changed.

Column ordering (server definition order, not alphabetical) is now pinned by tests at both the repository and adapter layers.

🤖 Generated with Claude Code

JonnyTran added 17 commits July 20, 2026 18:23
…architectural details

- Enhanced the architecture section to detail the use of DuckDB for in-memory denormalization, replacing previous methods.
- Clarified the tech stack to include DuckDB as a denormalization engine.
- Updated Phase 1 description to specify the addition of DuckDB as a new dependency.
- Added new integration tests for the workspace projection functionality.
…ema id

- toPerspectiveData: add a reverse-alphabetical fixture asserting
  Object.keys() equals [reference, ...manifest order] so a stray
  alphabetical sort regresses the test, not just the comment.
- Cover empty-rows for toPerspectiveData/bandParity, a cells key absent
  from the manifest, and a reference recurring after a gap ([0,1,0]).
- buildAnnotationUrl: encodeURIComponent the schemaId path segment too
  (not just the reference), plus tests for &/# and a slashed schema id.
- Correct the toPerspectiveData comment: the integer-like-key hazard is
  avoided today because column names always contain a dot, not because
  of anything the function does — and note Phase 2 should pass
  projection.columns as Perspective's explicit column config rather than
  rely on inferred key order.
Pins the two hazards the paging fix relies on but that were previously
unasserted: (1) columns are captured once from the first page, in
server order, never sorted or duplicated across pages; (2) an empty
page with a huge totalReferences stops after one call instead of
hammering the endpoint. Also captures totalReferences from the first
page only (matching columns), and documents the server spine-CTE
invariant the zero-progress guard depends on.
@JonnyTran
JonnyTran requested review from a team as code owners July 21, 2026 19:24
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
extralit-frontend Ready Ready Preview, Comment Jul 21, 2026 11:50pm

… review gaps

Addresses medium-severity roborev findings on the Phase 1 branch.

Behavior fix:
- V2SuggestionValidator._validate_score now takes the question type and skips the
  list-cardinality rules for table questions. Those rules assume a list value is a
  list of answer choices (multi_label_selection, ranking); a table value's list is
  N rows (spec 3.4), and a suggestion's score applies to the suggestion as a whole.
  Before this, a multi-row table suggestion with one confidence score -- the exact
  shape Task 3's fan-out consumes -- was rejected by upsert_suggestion.

Hardening:
- Serialize DuckDB inputs with ensure_ascii=False. The projection joins Python
  strings against keys DuckDB parses from JSON text; emitting non-ASCII names as
  \uXXXX made that join depend on DuckDB decoding them back. It does decode them
  correctly today, so this removes a dependency rather than fixing a live break.

Test gaps closed:
- table suggestions: single score over multi-row value, mismatched score list, and
  score list over a bare dict; plus non-table types still enforcing cardinality
- multi-question response envelope: pins the positional key/value zip-unnest that
  every other test exercised with only one key
- non-ASCII schema/question/sub-column names resolve end to end
- response-over-suggestion cell: seed agent/score on the losing suggestion so the
  `is None` assertions prove suppression instead of passing vacuously
- repository fixture: suggestion-sourced table sub-column with agent/score and a
  list-valued score, so a transposition or dropped sub_column now fails
- use-case: first-page totalReferences capture (growing total mid-sweep) and the
  empty-workspace path; manifest test pages now return distinct manifests

Server 138 passed; frontend 871 passed, typecheck/lint/gen:api drift all clean.
…ean up SQL denormalization logic

- Added extraPaths for cursorpyright analysis to match Python analysis settings.
- Simplified the SQL denormalization logic by replacing the detailed SQL statement with a placeholder, indicating that the exact SQL is to be defined by the implementer.
…ests

Addresses roborev job #228 (auto-review of 2a1ac44).

Medium — the table score exemption was total, accepting arbitrary list
scores. That contradicts its own rationale (a table suggestion's score is
whole-suggestion confidence) and is not inert: the fan-out repeats the whole
list onto every cell, surfacing a multi-value score no consumer can read.
Narrowed to `score is None or isinstance(score, (int, float))`; a per-row
score is a distinct future feature needing indexed fan-out. Flipped the two
list-accepting tests to assert rejection; mutation-verified they fail without
the guard.

Low — the two non-table negative tests paired type=multi_label_selection
with LABEL_SETTINGS (settings type label_selection), a combination the API
cannot persist that only worked because both settings models expose
`.options`. Added "no" to MULTI_LABEL_SETTINGS and use it, so the type/settings
pair matches what QuestionSettingsValidator enforces.

Low — test_non_ascii_names_and_bindings_resolve keyed its response envelope
on ASCII "pays", so the responses-side ensure_ascii=False was untested.
Renamed the scalar question to "país" and key the envelope with it, exercising
the response-envelope join (rc.question_name = q.question_name) against a
non-ASCII key.

Server 138 passed; ruff clean.
… phrase)

The v2 OpenAPI drift gate dumps the schema under CI's Python (<=3.12), where
HTTPStatus(422).phrase is 'Unprocessable Entity'. The committed snapshot was
generated under Python 3.13, which renamed the phrase to 'Unprocessable Content',
so the full-file diff -u failed across all 28 occurrences. Regenerated
openapi.json + v2-api.ts under Python 3.12; no schema/type changes beyond the phrase.
@JonnyTran
JonnyTran merged commit a23c8a4 into develop Jul 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant