Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f616529
docs(spec): extraction table design — workspace projection, Perspecti…
JonnyTran Jul 21, 2026
8179d65
docs(spec): add Perspective/Vue3/Nuxt4 integration refs + single-row …
JonnyTran Jul 21, 2026
7675fbb
plan
JonnyTran Jul 21, 2026
f133812
docs(plan): update extraction table plan with DuckDB integration and …
JonnyTran Jul 21, 2026
046a306
feat(server): accept list[dict] table values additively (spec §3.4)
JonnyTran Jul 21, 2026
5191fcc
feat(server): enrich ProjectionCell with record_id/agent/score proven…
JonnyTran Jul 21, 2026
15c4e4b
feat(server): workspace projection denormalized via in-process DuckDB
JonnyTran Jul 21, 2026
4225c7b
fix(server): escape JSON paths in workspace projection denormalization
JonnyTran Jul 21, 2026
de18a6f
fix(server): join sub-column bindings instead of interpolating JSON p…
JonnyTran Jul 21, 2026
d9a12e0
feat(server): GET /api/v2/projection workspace endpoint
JonnyTran Jul 21, 2026
6f013f9
test(server): pin projection authz and query-bounds validation
JonnyTran Jul 21, 2026
7bb8e23
feat(v2-ui): workspace projection contract, domain types, repository …
JonnyTran Jul 21, 2026
6d56eda
feat(v2-ui): pure grid adapter — flat rows, banding, click-URL contract
JonnyTran Jul 21, 2026
9af4e71
test(v2-ui): pin server column/cell ordering in getWorkspaceProjection
JonnyTran Jul 21, 2026
86c47d0
test(v2-ui): pin grid-adapter key order and encode annotation URL sch…
JonnyTran Jul 21, 2026
2ec8275
feat(v2-ui): workspace-projection use-case, extractions storage, DI w…
JonnyTran Jul 21, 2026
989190c
test(v2-ui): pin manifest aggregation and zero-progress guard
JonnyTran Jul 21, 2026
2a1ac44
fix(v2): exempt table values from suggestion score cardinality; close…
JonnyTran Jul 21, 2026
06f7e68
chore: update VSCode settings for cursorpyright analysis paths and cl…
JonnyTran Jul 21, 2026
37b77c7
fix(server): narrow table score exemption to scalar; tighten review t…
JonnyTran Jul 21, 2026
0ee7999
fix(v2-ui): regenerate OpenAPI snapshot under py3.12 to match CI (422…
JonnyTran Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,9 @@
"python.analysis.extraPaths": [
"${workspaceFolder}/extralit-server/src",
"${workspaceFolder}/extralit/src"
],
"cursorpyright.analysis.extraPaths": [
"${workspaceFolder}/extralit-server/src",
"${workspaceFolder}/extralit/src"
]
}
1,748 changes: 1,748 additions & 0 deletions docs/superpowers/plans/2026-07-20-extraction-table.md

Large diffs are not rendered by default.

270 changes: 270 additions & 0 deletions docs/superpowers/specs/2026-07-20-extraction-table-design.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions extralit-frontend/v2/di/di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRep
import { AnnotationRepository } from "~/v2/infrastructure/repositories/AnnotationRepository";
import { ProjectionRepository } from "~/v2/infrastructure/repositories/ProjectionRepository";
import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case";
import { GetWorkspaceProjectionUseCase } from "~/v2/domain/usecases/get-workspace-projection-use-case";
import { useExtractions } from "~/v2/infrastructure/storage/ExtractionsStorage";
import { SubmitReferenceReviewUseCase } from "~/v2/domain/usecases/submit-reference-review-use-case";
import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case";
import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case";
Expand Down Expand Up @@ -42,6 +44,7 @@ export const loadV2DependencyContainer = (nuxtApp: NuxtAppLike) => {

register(AnnotationRepository).withDependency(useAxios).build(),
register(ProjectionRepository).withDependency(useAxios).build(),
register(GetWorkspaceProjectionUseCase).withDependencies(ProjectionRepository, useExtractions).build(),

register(GetReferenceReviewUseCase)
.withDependencies(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface ProjectionColumn {
name: string; // flat "Schema.question" / "Schema.question.subcol"
schemaId: string;
schemaName: string;
questionName: string;
subColumn: string | null;
dtype: string;
}

export interface ProjectionGridCell {
value: unknown;
source: "response" | "suggestion";
recordId: string;
agent: string | null;
score: number | number[] | null;
}

export interface ProjectionGridRow {
reference: string;
rowIndex: number;
cells: Record<string, ProjectionGridCell>;
}

export class WorkspaceProjection {
constructor(
public readonly columns: ProjectionColumn[],
public readonly rows: ProjectionGridRow[],
public readonly totalReferences: number
) {}
}
145 changes: 145 additions & 0 deletions extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
ANNOTATION_CELL_LINKS_ENABLED,
bandParity,
buildAnnotationUrl,
cellAt,
REFERENCE_COLUMN,
toPerspectiveData,
} from "./grid-adapter";
import { WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection";

const cell = (value: unknown): ProjectionGridCell => ({
value,
source: "suggestion",
recordId: "r-1",
agent: "gpt-x",
score: 0.9,
});

const COLUMNS = [
{ name: "Design.type", schemaId: "s-1", schemaName: "Design", questionName: "type", subColumn: null, dtype: "text" },
{
name: "Outcomes.results.value",
schemaId: "s-2",
schemaName: "Outcomes",
questionName: "results",
subColumn: "value",
dtype: "table",
},
];

const PROJECTION = new WorkspaceProjection(
COLUMNS,
[
{ reference: "10.1/a", rowIndex: 0, cells: { "Design.type": cell("RCT") } },
{ reference: "10.1/a", rowIndex: 1, cells: { "Outcomes.results.value": cell("12%") } },
{ reference: "10.1/b", rowIndex: 0, cells: {} },
],
2
);

describe("toPerspectiveData", () => {
it("emits one flat record per row with every manifest column (null when absent)", () => {
expect(toPerspectiveData(PROJECTION)).toEqual([
{ [REFERENCE_COLUMN]: "10.1/a", "Design.type": "RCT", "Outcomes.results.value": null },
{ [REFERENCE_COLUMN]: "10.1/a", "Design.type": null, "Outcomes.results.value": "12%" },
{ [REFERENCE_COLUMN]: "10.1/b", "Design.type": null, "Outcomes.results.value": null },
]);
});

it("preserves manifest column order (not alphabetical), reference first", () => {
// Deliberately reverse-alphabetical ("Zebra" before "Apple") so this test fails if
// anyone re-sorts columns instead of following `projection.columns` order.
const orderColumns = [
{
name: "Zebra.value",
schemaId: "s-2",
schemaName: "Zebra",
questionName: "value",
subColumn: null,
dtype: "text",
},
{
name: "Apple.value",
schemaId: "s-1",
schemaName: "Apple",
questionName: "value",
subColumn: null,
dtype: "text",
},
];
const orderProjection = new WorkspaceProjection(orderColumns, [{ reference: "10.1/a", rowIndex: 0, cells: {} }], 1);
const rows = toPerspectiveData(orderProjection);
expect(Object.keys(rows[0])).toEqual([REFERENCE_COLUMN, "Zebra.value", "Apple.value"]);
});

it("excludes cell keys that are not present in the column manifest", () => {
const extraCellProjection = new WorkspaceProjection(
COLUMNS,
[{ reference: "10.1/a", rowIndex: 0, cells: { "Unlisted.column": cell("x") } }],
1
);
expect(toPerspectiveData(extraCellProjection)).toEqual([
{ [REFERENCE_COLUMN]: "10.1/a", "Design.type": null, "Outcomes.results.value": null },
]);
});

it("returns an empty array for a projection with no rows", () => {
const empty = new WorkspaceProjection(COLUMNS, [], 0);
expect(toPerspectiveData(empty)).toEqual([]);
});
});

describe("cellAt", () => {
it("returns the enriched cell for the loaded row order", () => {
expect(cellAt(PROJECTION, 0, "Design.type")?.recordId).toBe("r-1");
});

it("returns null for empty cells and out-of-range rows", () => {
expect(cellAt(PROJECTION, 2, "Design.type")).toBeNull();
expect(cellAt(PROJECTION, 99, "Design.type")).toBeNull();
});
});

describe("bandParity", () => {
it("flips parity when the reference changes, not per row", () => {
expect(bandParity(PROJECTION)).toEqual([0, 0, 1]);
});

it("does not merge a reference that recurs after a gap", () => {
const gapped = new WorkspaceProjection(
COLUMNS,
[
{ reference: "10.1/a", rowIndex: 0, cells: {} },
{ reference: "10.1/b", rowIndex: 0, cells: {} },
{ reference: "10.1/a", rowIndex: 0, cells: {} },
],
2
);
expect(bandParity(gapped)).toEqual([0, 1, 0]);
});

it("returns an empty array for a projection with no rows", () => {
const empty = new WorkspaceProjection(COLUMNS, [], 0);
expect(bandParity(empty)).toEqual([]);
});
});

describe("annotation URL contract (spec §3.3)", () => {
it("stays guarded off in this build", () => {
expect(ANNOTATION_CELL_LINKS_ENABLED).toBe(false);
});

it("puts the schema id in the dataset slot and encodes the reference", () => {
expect(buildAnnotationUrl("s-1", "10.1/a b")).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b");
});

it("encodes reserved query characters (&, #) in the reference", () => {
expect(buildAnnotationUrl("s-1", "a&b#c")).toBe("/dataset/s-1/annotation-mode?_search=a%26b%23c");
});

it("encodes reserved characters in the schema id path segment too", () => {
expect(buildAnnotationUrl("s/1", "10.1/a")).toBe("/dataset/s%2F1/annotation-mode?_search=10.1%2Fa");
});
});
86 changes: 86 additions & 0 deletions extralit-frontend/v2/domain/entities/projection/grid-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { type WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection";

/**
* Flat-row column name carrying the reference (DOI/identifier) for each row.
* Kept out of `projection.columns` since it is a manifest-independent, always-present key.
*/
export const REFERENCE_COLUMN = "reference";

/**
* Maps a `WorkspaceProjection` into the flat-row shape Perspective ingests.
*
* `projection.columns` order is the single source of truth for column order (server-side
* question order is definition order, not alphabetical). We iterate `columns` to build each
* record rather than relying on the emitted plain object's own key insertion order for
* anything downstream: today every column name is `Schema.question[.subcol]` and always
* contains a `.`, so it can never collide with JS's canonical-integer-key hoisting (e.g. a
* bare `"2024"` key would sort before string keys) — but that safety is a property of the
* naming convention, not of this function's return type. A plain `Record<string, unknown>`
* has no ordering guarantee Perspective is contractually bound to respect. Handoff note for
* Phase 2: prefer passing `projection.columns` as Perspective's explicit column/schema
* config rather than relying on inferred key order from the objects returned here.
* `reference` is emitted first so every row shares an identical, stably-ordered key set —
* every manifest column is present on every row (`null` when the cell is absent) so
* Perspective infers one stable schema instead of a per-row-varying one.
*/
export function toPerspectiveData(projection: WorkspaceProjection): Record<string, unknown>[] {
return projection.rows.map((row) => {
const record: Record<string, unknown> = { [REFERENCE_COLUMN]: row.reference };
for (const column of projection.columns) {
const cell = row.cells[column.name];
record[column.name] = cell ? cell.value : null;
}
return record;
});
}

/**
* Looks up the enriched cell (value + provenance) for a grid position.
*
* Relies on the static-grid invariant: the projection is loaded once, unsorted and
* unfiltered, so a datagrid row index maps 1:1 onto `projection.rows` by array position.
* Returns null for an absent cell or an out-of-range row index.
*/
export function cellAt(
projection: WorkspaceProjection,
rowIndex: number,
columnName: string
): ProjectionGridCell | null {
const row = projection.rows[rowIndex];
if (!row) {
return null;
}
return row.cells[columnName] ?? null;
}

/**
* Returns a 0/1 parity value per row that flips whenever `reference` changes from the
* previous row (not per row). Perspective has no notion of merged/grouped cells, so this
* alternating band is the §3.1 reference-grouping affordance rendered as a background tint.
*/
export function bandParity(projection: WorkspaceProjection): number[] {
let parity = 0;
let previousReference: string | null = null;
return projection.rows.map((row) => {
if (previousReference !== null && row.reference !== previousReference) {
parity = parity === 0 ? 1 : 0;
}
previousReference = row.reference;
return parity;
});
}

/**
* Guards the §3.3 click-to-annotate affordance off until annotation-mode resolves v2 schema
* ids (see ledger §5). Flip to true once that lands.
*/
export const ANNOTATION_CELL_LINKS_ENABLED = false;

/**
* Builds the annotation-mode deep link for a cell's reference, percent-encoding both the
* schema id (path segment) and the reference (query value) so slashes, `&`, `#`, and other
* reserved characters can't reshape the URL.
*/
export function buildAnnotationUrl(schemaId: string, reference: string): string {
return `/dataset/${encodeURIComponent(schemaId)}/annotation-mode?_search=${encodeURIComponent(reference)}`;
}
Loading
Loading