From c740dc1ec70ba91503a7c82213a45e8a82e00475 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 15:10:45 +0200 Subject: [PATCH 1/7] feat(requests): allow the request source 'sample' for prepared showcase cases Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/db/schema/app.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index 85e6304..756794f 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -24,6 +24,8 @@ export const tenantPolicy = (table: string) => export const REQUEST_STATUSES = ["NEW", "PROCESSING", "REVIEW", "APPROVED", "EXPORTED", "REJECTED", "ERROR"] as const; export type RequestStatus = (typeof REQUEST_STATUSES)[number]; +export const REQUEST_SOURCES = ["upload", "sample"] as const; +export type RequestSource = (typeof REQUEST_SOURCES)[number]; // Minimal request aggregate – the first tenant table (#4). #5 and #7 extend it additively. export const requests = appSchema @@ -37,7 +39,8 @@ export const requests = appSchema status: text("status").$type().default("NEW").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), // Intake (#5): who uploaded, what arrived, duplicate fingerprint (ADR-0001 D9). - source: text("source").$type<"upload">().default("upload").notNull(), + // `sample` (#71): a prepared showcase case whose extraction replays a recorded model answer. + source: text("source").$type().default("upload").notNull(), createdBy: uuid("created_by"), subject: text("subject"), messageId: text("message_id"), @@ -69,6 +72,7 @@ export const requests = appSchema }), check("requests_status_check", sql.raw(`status in (${REQUEST_STATUSES.map((s) => `'${s}'`).join(", ")})`)), check("requests_duplicate_decision_check", sql`duplicate_decision is null or duplicate_decision in ('distinct', 'duplicate')`), + check("requests_source_check", sql.raw(`source in (${REQUEST_SOURCES.map((s) => `'${s}'`).join(", ")})`)), tenantPolicy("requests"), ], ) From 1065a93a03f4ff05293a8aef696fac8ae18d8f1f Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 15:11:27 +0200 Subject: [PATCH 2/7] feat(db): check the request source values (migration 0019) Additive: every existing row is 'upload'. Defence in depth for the new 'sample' source below the TypeScript type. Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migrations/0019_request_source_check.sql | 1 + src/db/migrations/meta/0019_snapshot.json | 2010 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + 3 files changed, 2018 insertions(+) create mode 100644 src/db/migrations/0019_request_source_check.sql create mode 100644 src/db/migrations/meta/0019_snapshot.json diff --git a/src/db/migrations/0019_request_source_check.sql b/src/db/migrations/0019_request_source_check.sql new file mode 100644 index 0000000..4cf2a51 --- /dev/null +++ b/src/db/migrations/0019_request_source_check.sql @@ -0,0 +1 @@ +ALTER TABLE "app"."requests" ADD CONSTRAINT "requests_source_check" CHECK (source in ('upload', 'sample')); \ No newline at end of file diff --git a/src/db/migrations/meta/0019_snapshot.json b/src/db/migrations/meta/0019_snapshot.json new file mode 100644 index 0000000..c183da8 --- /dev/null +++ b/src/db/migrations/meta/0019_snapshot.json @@ -0,0 +1,2010 @@ +{ + "id": "322de3a5-c7e0-4919-98bf-4986d30467a7", + "prevId": "8c8a14b9-dd3c-4858-bdf7-9127405111b6", + "version": "7", + "dialect": "postgresql", + "tables": { + "identity.account": { + "name": "account", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.invitation": { + "name": "invitation", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.member": { + "name": "member", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.organization": { + "name": "organization", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.rate_limit": { + "name": "rate_limit", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.session": { + "name": "session", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "identity", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.user": { + "name": "user", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "identity.verification": { + "name": "verification", + "schema": "identity", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.audit_events": { + "name": "audit_events", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_entity_idx": { + "name": "audit_events_entity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_company_id_organization_id_fk": { + "name": "audit_events_company_id_organization_id_fk", + "tableFrom": "audit_events", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "audit_events_tenant_isolation": { + "name": "audit_events_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.documents": { + "name": "documents", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_id_idx": { + "name": "documents_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_request_id_idx": { + "name": "documents_request_id_idx", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_organization_id_fk": { + "name": "documents_company_id_organization_id_fk", + "tableFrom": "documents", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "documents_request_same_company_fk": { + "name": "documents_request_same_company_fk", + "tableFrom": "documents", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "documents_tenant_isolation": { + "name": "documents_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "documents_kind_check": { + "name": "documents_kind_check", + "value": "kind in ('eml', 'msg', 'pdf', 'xlsx', 'docx')" + } + }, + "isRLSEnabled": true + }, + "app.extracted_fields": { + "name": "extracted_fields", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_key": { + "name": "field_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_status": { + "name": "model_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quote": { + "name": "quote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "item_index": { + "name": "item_index", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "extracted_fields_request_idx": { + "name": "extracted_fields_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extracted_fields_run_same_company_fk": { + "name": "extracted_fields_run_same_company_fk", + "tableFrom": "extracted_fields", + "tableTo": "extraction_runs", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extracted_fields_request_same_company_fk": { + "name": "extracted_fields_request_same_company_fk", + "tableFrom": "extracted_fields", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extracted_fields_evidence_segment_fk": { + "name": "extracted_fields_evidence_segment_fk", + "tableFrom": "extracted_fields", + "tableTo": "extraction_segments", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "document_id", + "segment_id" + ], + "columnsTo": [ + "run_id", + "document_id", + "segment_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extracted_fields_run_field_item_unique": { + "name": "extracted_fields_run_field_item_unique", + "nullsNotDistinct": true, + "columns": [ + "run_id", + "field_key", + "item_index" + ] + } + }, + "policies": { + "extracted_fields_tenant_isolation": { + "name": "extracted_fields_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "extracted_fields_item_index_check": { + "name": "extracted_fields_item_index_check", + "value": "item_index is null or item_index >= 0" + }, + "extracted_fields_status_check": { + "name": "extracted_fields_status_check", + "value": "status in ('found', 'uncertain', 'missing', 'unverified')" + }, + "extracted_fields_found_has_evidence": { + "name": "extracted_fields_found_has_evidence", + "value": "status <> 'found' or (quote is not null and segment_id is not null)" + } + }, + "isRLSEnabled": true + }, + "app.extraction_runs": { + "name": "extraction_runs", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "documents": { + "name": "documents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "extraction_runs_request_idx": { + "name": "extraction_runs_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extraction_runs_company_id_organization_id_fk": { + "name": "extraction_runs_company_id_organization_id_fk", + "tableFrom": "extraction_runs", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "extraction_runs_request_same_company_fk": { + "name": "extraction_runs_request_same_company_fk", + "tableFrom": "extraction_runs", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extraction_runs_job_id_unique": { + "name": "extraction_runs_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "job_id" + ] + }, + "extraction_runs_id_company_unique": { + "name": "extraction_runs_id_company_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "policies": { + "extraction_runs_tenant_isolation": { + "name": "extraction_runs_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.extraction_segments": { + "name": "extraction_segments", + "schema": "app", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator": { + "name": "locator", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "extraction_segments_run_same_company_fk": { + "name": "extraction_segments_run_same_company_fk", + "tableFrom": "extraction_segments", + "tableTo": "extraction_runs", + "schemaTo": "app", + "columnsFrom": [ + "run_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "extraction_segments_pk": { + "name": "extraction_segments_pk", + "columns": [ + "run_id", + "document_id", + "segment_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "extraction_segments_tenant_isolation": { + "name": "extraction_segments_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "app.field_corrections": { + "name": "field_corrections", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "field_key": { + "name": "field_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_index": { + "name": "item_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "corrected_by": { + "name": "corrected_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "field_corrections_request_idx": { + "name": "field_corrections_request_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "field_corrections_request_same_company_fk": { + "name": "field_corrections_request_same_company_fk", + "tableFrom": "field_corrections", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "field_corrections_tenant_isolation": { + "name": "field_corrections_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "field_corrections_item_index_check": { + "name": "field_corrections_item_index_check", + "value": "item_index is null or item_index >= 0" + } + }, + "isRLSEnabled": true + }, + "app.request_exports": { + "name": "request_exports", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "erp_reference": { + "name": "erp_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "request_exports_company_id_organization_id_fk": { + "name": "request_exports_company_id_organization_id_fk", + "tableFrom": "request_exports", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "request_exports_request_same_company_fk": { + "name": "request_exports_request_same_company_fk", + "tableFrom": "request_exports", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "request_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "request_exports_request_id_unique": { + "name": "request_exports_request_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_id" + ] + } + }, + "policies": { + "request_exports_tenant_isolation": { + "name": "request_exports_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "request_exports_status_check": { + "name": "request_exports_status_check", + "value": "status in ('pending', 'succeeded')" + }, + "request_exports_succeeded_has_reference": { + "name": "request_exports_succeeded_has_reference", + "value": "status <> 'succeeded' or (erp_reference is not null and exported_at is not null)" + } + }, + "isRLSEnabled": true + }, + "app.requests": { + "name": "requests", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "possible_duplicate": { + "name": "possible_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_stage": { + "name": "error_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duplicate_decision": { + "name": "duplicate_decision", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "requests_company_created_idx": { + "name": "requests_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_message_id_idx": { + "name": "requests_company_message_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "requests_company_fingerprint_idx": { + "name": "requests_company_fingerprint_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "requests_company_id_organization_id_fk": { + "name": "requests_company_id_organization_id_fk", + "tableFrom": "requests", + "tableTo": "organization", + "schemaTo": "identity", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "requests_duplicate_same_company_fk": { + "name": "requests_duplicate_same_company_fk", + "tableFrom": "requests", + "tableTo": "requests", + "schemaTo": "app", + "columnsFrom": [ + "duplicate_of_id", + "company_id" + ], + "columnsTo": [ + "id", + "company_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "requests_id_company_unique": { + "name": "requests_id_company_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "company_id" + ] + } + }, + "policies": { + "requests_tenant_isolation": { + "name": "requests_tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "company_id = nullif(current_setting('app.company_id', true), '')::uuid", + "withCheck": "company_id = nullif(current_setting('app.company_id', true), '')::uuid" + } + }, + "checkConstraints": { + "requests_status_check": { + "name": "requests_status_check", + "value": "status in ('NEW', 'PROCESSING', 'REVIEW', 'APPROVED', 'EXPORTED', 'REJECTED', 'ERROR')" + }, + "requests_duplicate_decision_check": { + "name": "requests_duplicate_decision_check", + "value": "duplicate_decision is null or duplicate_decision in ('distinct', 'duplicate')" + }, + "requests_source_check": { + "name": "requests_source_check", + "value": "source in ('upload', 'sample')" + } + }, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": { + "identity": "identity", + "app": "app" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 853cdfe..a2b882a 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1790256343255, "tag": "0018_identity_schema", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1790514651317, + "tag": "0019_request_source_check", + "breakpoints": true } ] } \ No newline at end of file From 18eab97bdb5deb3e84a0e3eccdc2cf6f818f87e3 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 15:49:37 +0200 Subject: [PATCH 3/7] feat(demo): seed prepared samples from recorded AI answers A visitor of the showcase should see a whole case - original, sources, decision and export receipt - even while the model provider is overloaded. `pnpm seed:samples` creates per demo company one sample to review and one approved sample, through the real intake, processing and approval paths; only the extraction replays an answer recorded once with `pnpm samples:record`, and it runs inline instead of through the queue so no drain can ever send a sample to the live model. List and detail label them as prepared samples. Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- eslint.config.mjs | 2 +- package.json | 2 + src/app/requests/[id]/page.tsx | 6 + src/app/requests/page.tsx | 9 + src/app/requests/sample-label.ts | 4 + src/features/requests/index.ts | 2 + src/features/requests/repository.ts | 18 +- src/features/samples/data/pumpe-p204.eml | 21 + src/features/samples/data/werk-ost.eml | 21 + .../samples/data/werk-ost.recording.json | 363 ++++++++++++++++++ src/features/samples/index.ts | 3 + src/features/samples/samples.ts | 57 +++ src/features/samples/seed.ts | 53 +++ src/samples-record.ts | 49 +++ src/seed-samples.ts | 44 +++ tests/integration/samples.test.ts | 83 ++++ 16 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 src/app/requests/sample-label.ts create mode 100644 src/features/samples/data/pumpe-p204.eml create mode 100644 src/features/samples/data/werk-ost.eml create mode 100644 src/features/samples/data/werk-ost.recording.json create mode 100644 src/features/samples/index.ts create mode 100644 src/features/samples/samples.ts create mode 100644 src/features/samples/seed.ts create mode 100644 src/samples-record.ts create mode 100644 src/seed-samples.ts create mode 100644 tests/integration/samples.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 24e63f4..5bdc29f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ const config = [ // Logs are a data-leak path (#28): application code logs through `logEvent` (fixed key set, IDs and // codes only). Exempt: the operator's seed script and deploy step (`setup.ts`) – no tenant data, // their messages explain role/connection problems with URLs masked. - { files: ["src/**/*.{ts,tsx}"], ignores: ["src/seed.ts", "src/setup.ts"], rules: { "no-console": "error" } }, + { files: ["src/**/*.{ts,tsx}"], ignores: ["src/seed.ts", "src/seed-samples.ts", "src/samples-record.ts", "src/setup.ts"], rules: { "no-console": "error" } }, ]; export default config; diff --git a/package.json b/package.json index 25eb039..ad0f015 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "verify:full": "pnpm verify && pnpm e2e && pnpm evals", "setup:deploy": "tsx src/setup.ts", "seed:demo": "tsx src/seed.ts", + "seed:samples": "tsx src/seed-samples.ts", + "samples:record": "tsx src/samples-record.ts", "contract:types": "openapi-typescript contracts/ai-service.openapi.yaml -o src/features/extraction/ai-service.contract.ts && openapi-typescript contracts/erp-export.openapi.yaml -o src/features/export/erp-export.contract.ts", "e2e": "playwright test", "evals": "uv run --project services/ai --directory services/ai python -m requestflow_ai.evals --replay" diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index 62689aa..1b3f5f4 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -5,6 +5,7 @@ import { getRuntime, requestActor } from "@/app/_server/runtime"; import { duplicateDecidable, loadReview, REJECTION_REASON_MAX, type ReviewField } from "@/features/review"; import { processingNotice } from "../processing-notice"; import { requestRowView } from "../row-view"; +import { SAMPLE_EXPLANATION, SAMPLE_LABEL } from "../sample-label"; import { StatusPill } from "../status-pill"; import { DONE_MESSAGES, ERROR_MESSAGES, messageFor } from "./messages"; import { approveAction, confirmNotDuplicateAction, correctFieldAction, rejectAction, rejectAsDuplicateAction } from "./actions"; @@ -83,6 +84,11 @@ export default async function RequestPage({ Eingang {dateFormat.format(request.createdAt)} + {request.source === "sample" && ( +

+ {SAMPLE_LABEL}. {SAMPLE_EXPLANATION} +

+ )} {done &&

{done}

} {error &&

{error}

} {request.status === "ERROR" && request.errorMessage && ( diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index cb66dfd..1300f7b 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -6,6 +6,7 @@ import { listExportRecords } from "@/features/export"; import { listRequests, parseCursor, type RequestFilter } from "@/features/requests"; import { reprocessAction } from "./actions"; import { requestRowView } from "./row-view"; +import { SAMPLE_LABEL } from "./sample-label"; import { REQUEST_STATUS_LABEL } from "./status-labels"; import { StatusPill } from "./status-pill"; import { UploadForm } from "./upload-form"; @@ -125,6 +126,14 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro {dateFormat.format(request.createdAt)} {request.subject ?? "(ohne Betreff)"} + {request.source === "sample" && ( + <> + {" "} + + {SAMPLE_LABEL} + + + )}
diff --git a/src/app/requests/sample-label.ts b/src/app/requests/sample-label.ts new file mode 100644 index 0000000..8418e29 --- /dev/null +++ b/src/app/requests/sample-label.ts @@ -0,0 +1,4 @@ +// Label of a prepared showcase sample (#71) – the same words in list and detail. +export const SAMPLE_LABEL = "Vorbereitetes Beispiel – aufgezeichnete KI-Antwort"; +export const SAMPLE_EXPLANATION = + "Diese synthetische Anfrage ist vorbereitet: Ihre KI-Auswertung wurde einmal aufgezeichnet und wird hier wiederverwendet. Eigene Uploads wertet die KI live aus."; diff --git a/src/features/requests/index.ts b/src/features/requests/index.ts index 4e284ef..c808d90 100644 --- a/src/features/requests/index.ts +++ b/src/features/requests/index.ts @@ -2,6 +2,7 @@ export { countRequestsByStatus, countRequestsCreatedBy, + countSamples, createRequest, findDuplicate, getRequest, @@ -10,6 +11,7 @@ export { type RequestPage, lockDuplicateDetection, lockRequest, + markSample, recordDuplicateDecision, recordExportRetry, recordProcessingFailure, diff --git a/src/features/requests/repository.ts b/src/features/requests/repository.ts index 06cf89f..077f2f6 100644 --- a/src/features/requests/repository.ts +++ b/src/features/requests/repository.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gte, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, or, sql, type SQL } from "drizzle-orm"; import { requests, type RequestStatus } from "@/db/schema"; import { tenantOf, type TenantTx } from "@/features/tenancy"; import { parseCursor, REQUEST_PAGE_SIZE } from "./cursor"; @@ -154,6 +154,22 @@ export async function recordProcessingFailure(tx: TenantTx, id: string, failure: .where(and(eq(requests.id, id), eq(requests.status, "PROCESSING"))); } +/** Marks a request as a prepared showcase sample (#71) – its label in list and detail comes from here. */ +export async function markSample(tx: TenantTx, id: string): Promise { + tenantOf(tx); + await tx.update(requests).set({ source: "sample" }).where(eq(requests.id, id)); +} + +/** Samples of the tenant in one of `statuses` (#71: the seed creates a sample only when none is open). */ +export async function countSamples(tx: TenantTx, statuses: readonly RequestStatus[]): Promise { + tenantOf(tx); + const [row] = await tx + .select({ count: sql`count(*)::int` }) + .from(requests) + .where(and(eq(requests.source, "sample"), inArray(requests.status, [...statuses]))); + return row?.count ?? 0; +} + /** Requests a user created since `since` (upload rate limit, #59 review) – within the tenant. */ export async function countRequestsCreatedBy(tx: TenantTx, userId: string, since: Date): Promise { tenantOf(tx); diff --git a/src/features/samples/data/pumpe-p204.eml b/src/features/samples/data/pumpe-p204.eml new file mode 100644 index 0000000..e17fec5 --- /dev/null +++ b/src/features/samples/data/pumpe-p204.eml @@ -0,0 +1,21 @@ +From: Instandhaltung +To: Vertrieb +Subject: Anfrage Dichtungssatz fuer Pumpe P-204 +Date: Tue, 22 Sep 2026 14:02:00 +0200 +Message-ID: +Content-Type: text/plain; charset=utf-8 + +Sehr geehrte Damen und Herren, + +bitte senden Sie uns ein Angebot fuer: + +1) 2 Stk. Gleitringdichtung 45 mm, Werkstoff SiC/SiC +2) 4 Stk. O-Ring 120 x 4 mm, Werkstoff FKM + +Liefertermin: 20.11.2026 +Bitte legen Sie ein Abnahmepruefzeugnis 3.1 nach EN 10204 bei. + +Mit freundlichen Gruessen +Tom Beispiel +Muster Chemie GmbH +Tel. +49 221 5550199 diff --git a/src/features/samples/data/werk-ost.eml b/src/features/samples/data/werk-ost.eml new file mode 100644 index 0000000..91752cc --- /dev/null +++ b/src/features/samples/data/werk-ost.eml @@ -0,0 +1,21 @@ +From: Einkauf +To: Vertrieb +Subject: Anfrage Rohrbogen und Flansche fuer Werk Ost +Date: Mon, 21 Sep 2026 09:14:00 +0200 +Message-ID: +Content-Type: text/plain; charset=utf-8 + +Guten Tag, + +wir bitten um ein Angebot fuer folgende Positionen: + +1) 8 Stk. Rohrbogen 90 Grad DN 80, Werkstoff P235GH +2) 16 Stk. Vorschweissflansch DN 80 PN 40, Werkstoff 1.0460 +3) 16 Stk. Flachdichtung DN 80 PN 40, Werkstoff Graphit + +Gewuenschter Liefertermin: KW 48 + +Mit freundlichen Gruessen +Jana Beispiel +Beispiel Anlagenbau GmbH +Tel. +49 40 5550123 diff --git a/src/features/samples/data/werk-ost.recording.json b/src/features/samples/data/werk-ost.recording.json new file mode 100644 index 0000000..74dd65d --- /dev/null +++ b/src/features/samples/data/werk-ost.recording.json @@ -0,0 +1,363 @@ +{ + "documentId": "sample-werk-ost", + "documentKind": "eml", + "segments": [ + { + "id": "eml-h-from", + "text": "From: Einkauf ", + "locator": { + "kind": "email", + "part": "header", + "line": 1, + "header": "From" + } + }, + { + "id": "eml-h-subject", + "text": "Subject: Anfrage Rohrbogen und Flansche fuer Werk Ost", + "locator": { + "kind": "email", + "part": "header", + "line": 2, + "header": "Subject" + } + }, + { + "id": "eml-l1", + "text": "Guten Tag,", + "locator": { + "kind": "email", + "part": "body", + "line": 1, + "header": null + } + }, + { + "id": "eml-l3", + "text": "wir bitten um ein Angebot fuer folgende Positionen:", + "locator": { + "kind": "email", + "part": "body", + "line": 3, + "header": null + } + }, + { + "id": "eml-l5", + "text": "1) 8 Stk. Rohrbogen 90 Grad DN 80, Werkstoff P235GH", + "locator": { + "kind": "email", + "part": "body", + "line": 5, + "header": null + } + }, + { + "id": "eml-l6", + "text": "2) 16 Stk. Vorschweissflansch DN 80 PN 40, Werkstoff 1.0460", + "locator": { + "kind": "email", + "part": "body", + "line": 6, + "header": null + } + }, + { + "id": "eml-l7", + "text": "3) 16 Stk. Flachdichtung DN 80 PN 40, Werkstoff Graphit", + "locator": { + "kind": "email", + "part": "body", + "line": 7, + "header": null + } + }, + { + "id": "eml-l9", + "text": "Gewuenschter Liefertermin: KW 48", + "locator": { + "kind": "email", + "part": "body", + "line": 9, + "header": null + } + }, + { + "id": "eml-l11", + "text": "Mit freundlichen Gruessen", + "locator": { + "kind": "email", + "part": "body", + "line": 11, + "header": null + } + }, + { + "id": "eml-l12", + "text": "Jana Beispiel", + "locator": { + "kind": "email", + "part": "body", + "line": 12, + "header": null + } + }, + { + "id": "eml-l13", + "text": "Beispiel Anlagenbau GmbH", + "locator": { + "kind": "email", + "part": "body", + "line": 13, + "header": null + } + }, + { + "id": "eml-l14", + "text": "Tel. +49 40 5550123", + "locator": { + "kind": "email", + "part": "body", + "line": 14, + "header": null + } + } + ], + "fields": { + "company": { + "value": "Beispiel Anlagenbau GmbH", + "status": "found", + "evidence": { + "segmentId": "eml-l13", + "quote": "Beispiel Anlagenbau GmbH" + }, + "modelStatus": "found", + "reason": null + }, + "contact_person": { + "value": "Jana Beispiel", + "status": "found", + "evidence": { + "segmentId": "eml-l12", + "quote": "Jana Beispiel" + }, + "modelStatus": "found", + "reason": null + }, + "email": { + "value": "einkauf@beispiel-anlagenbau.example.com", + "status": "found", + "evidence": { + "segmentId": "eml-h-from", + "quote": "einkauf@beispiel-anlagenbau.example.com" + }, + "modelStatus": "found", + "reason": null + }, + "phone": { + "value": "+49 40 5550123", + "status": "found", + "evidence": { + "segmentId": "eml-l14", + "quote": "+49 40 5550123" + }, + "modelStatus": "found", + "reason": null + }, + "requested_delivery_date": { + "value": "KW 48", + "status": "uncertain", + "evidence": { + "segmentId": "eml-l9", + "quote": "KW 48" + }, + "modelStatus": "uncertain", + "reason": "calendar_week_only" + }, + "additional_requirements": { + "value": null, + "status": "missing", + "evidence": null, + "modelStatus": "missing", + "reason": null + } + }, + "lineItems": [ + { + "index": 0, + "description": { + "value": "Rohrbogen 90 Grad", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "Rohrbogen 90 Grad" + }, + "modelStatus": "found", + "reason": null + }, + "quantity": { + "value": "8", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "8" + }, + "modelStatus": "found", + "reason": null + }, + "unit": { + "value": "pcs", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "Stk." + }, + "modelStatus": "found", + "reason": null + }, + "material": { + "value": "P235GH", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "P235GH" + }, + "modelStatus": "found", + "reason": null + }, + "dimensions": { + "value": "DN 80", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "DN 80" + }, + "modelStatus": "found", + "reason": null + } + }, + { + "index": 1, + "description": { + "value": "Vorschweissflansch", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "Vorschweissflansch" + }, + "modelStatus": "found", + "reason": null + }, + "quantity": { + "value": "16", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "16" + }, + "modelStatus": "found", + "reason": null + }, + "unit": { + "value": "pcs", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "Stk." + }, + "modelStatus": "found", + "reason": null + }, + "material": { + "value": "1.0460", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "1.0460" + }, + "modelStatus": "found", + "reason": null + }, + "dimensions": { + "value": "DN 80 PN 40", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "DN 80 PN 40" + }, + "modelStatus": "found", + "reason": null + } + }, + { + "index": 2, + "description": { + "value": "Flachdichtung", + "status": "found", + "evidence": { + "segmentId": "eml-l7", + "quote": "Flachdichtung" + }, + "modelStatus": "found", + "reason": null + }, + "quantity": { + "value": "16", + "status": "found", + "evidence": { + "segmentId": "eml-l7", + "quote": "16" + }, + "modelStatus": "found", + "reason": null + }, + "unit": { + "value": "pcs", + "status": "found", + "evidence": { + "segmentId": "eml-l7", + "quote": "Stk." + }, + "modelStatus": "found", + "reason": null + }, + "material": { + "value": "Graphit", + "status": "found", + "evidence": { + "segmentId": "eml-l7", + "quote": "Graphit" + }, + "modelStatus": "found", + "reason": null + }, + "dimensions": { + "value": "DN 80 PN 40", + "status": "found", + "evidence": { + "segmentId": "eml-l7", + "quote": "DN 80 PN 40" + }, + "modelStatus": "found", + "reason": null + } + } + ], + "run": { + "modelId": "gemini-3.5-flash", + "promptVersion": "extract_v2", + "schemaVersion": "2", + "latencyMs": 24741, + "modelVersion": "gemini-3.5-flash", + "pdfPipeline": null, + "tokens": { + "inputTokens": 1281, + "outputTokens": 749, + "totalTokens": 3558 + }, + "modelLatencyMs": 24737 + }, + "warnings": [], + "requestId": "cb7fbdce-e1ce-4c21-812c-29f8e4a4e7a2", + "attachments": [] +} diff --git a/src/features/samples/index.ts b/src/features/samples/index.ts new file mode 100644 index 0000000..7ee3db0 --- /dev/null +++ b/src/features/samples/index.ts @@ -0,0 +1,3 @@ +// Public API of the `samples` module (#71): prepared showcase cases from recorded AI answers. +export { freshSampleMail, RECORDED_MODEL_PREFIX, recordedAiClient, recordingFile, SAMPLES, sampleMail, sampleRecording, type Sample } from "./samples"; +export { seedSamples, type SeededSample } from "./seed"; diff --git a/src/features/samples/samples.ts b/src/features/samples/samples.ts new file mode 100644 index 0000000..2ee71d2 --- /dev/null +++ b/src/features/samples/samples.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { AiServiceClient, ExtractResponse } from "@/features/extraction"; + +// Prepared showcase cases (#71): synthetic mails whose AI answer was recorded once +// (`pnpm samples:record`) and is replayed when a sample is seeded – no live model call. +export interface Sample { + key: string; + filename: string; + /** `review`: stays in review for visitors to try; `exported`: approved and exported through the normal path. */ + purpose: "review" | "exported"; +} + +export const SAMPLES: readonly Sample[] = [ + { key: "werk-ost", filename: "werk-ost.eml", purpose: "review" }, + { key: "pumpe-p204", filename: "pumpe-p204.eml", purpose: "exported" }, +]; + +const dataFile = (name: string) => new URL(`./data/${name}`, import.meta.url); + +export const recordingFile = (sample: Sample) => dataFile(`${sample.key}.recording.json`); + +/** The committed mail. */ +export function sampleMail(sample: Sample): Uint8Array { + return new Uint8Array(readFileSync(dataFile(sample.filename))); +} + +/** + * The mail as a new arrival: a fresh Message-ID, so a sample seeded again is not flagged as a duplicate of + * an earlier one. The header is not a segment of the recording, so the recorded evidence still matches. + */ +export function freshSampleMail(sample: Sample, messageId: string = ``): Uint8Array { + const text = new TextDecoder().decode(sampleMail(sample)); + const replaced = text.replace(/^Message-ID: .*$/m, `Message-ID: ${messageId}`); + if (replaced === text) throw new Error(`sample ${sample.key} has no Message-ID header`); + return new TextEncoder().encode(replaced); +} + +export function sampleRecording(sample: Sample): ExtractResponse { + return JSON.parse(readFileSync(recordingFile(sample), "utf8")) as ExtractResponse; +} + +/** Marks a replayed run in its stored model id, so a recorded answer is never mistaken for a live one. */ +export const RECORDED_MODEL_PREFIX = "recorded:"; + +/** An AI client that answers with the recording – for the document it is given, without any network call. */ +export function recordedAiClient(recording: ExtractResponse): AiServiceClient { + return { + async extract(input) { + return { + ...recording, + documentId: input.documentId, + run: { ...recording.run, modelId: `${RECORDED_MODEL_PREFIX}${recording.run.modelId}` }, + }; + }, + }; +} diff --git a/src/features/samples/seed.ts b/src/features/samples/seed.ts new file mode 100644 index 0000000..0a4a209 --- /dev/null +++ b/src/features/samples/seed.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import type { Actor } from "@/features/identity"; +import { submitUpload, type IntakeDeps } from "@/features/intake"; +import { processRequestJob, type JobSender } from "@/features/jobs"; +import { countSamples, markSample, type RequestRow } from "@/features/requests"; +import { approveRequest } from "@/features/review"; +import { freshSampleMail, recordedAiClient, SAMPLES, sampleRecording, type Sample } from "./samples"; + +// While a sample of a purpose is in one of these statuses it still serves that purpose – no new one. +const STILL_SERVING: Record = { + review: ["NEW", "PROCESSING", "REVIEW"], + exported: ["APPROVED", "EXPORTED"], +}; + +export interface SeededSample { + key: string; + requestId: string; + status: "REVIEW" | "APPROVED"; +} + +/** + * Seeds the prepared samples of the actor's company (#71), idempotently: a sample is created only when no + * sample of its purpose still serves. Every step runs the real path – intake (original stored, request, + * documents, audit, duplicate check), processing (run, segments, fields, REVIEW) and, for the exported + * sample, the approval that queues the export. One deliberate difference: processing runs inline with the + * recorded answer instead of through the queue, so no drain can ever send a sample to the live model. + */ +export async function seedSamples(deps: IntakeDeps, actor: Actor): Promise { + const seeded: SeededSample[] = []; + for (const sample of SAMPLES) { + const serving = await deps.tenancy.withTenant(actor.companyId, (tx) => countSamples(tx, STILL_SERVING[sample.purpose])); + if (serving > 0) continue; + const requestId = await createSample(deps, actor, sample); + if (sample.purpose === "exported") await approveRequest({ tenancy: deps.tenancy, boss: deps.boss }, actor, requestId); + seeded.push({ key: sample.key, requestId, status: sample.purpose === "exported" ? "APPROVED" : "REVIEW" }); + } + return seeded; +} + +async function createSample(deps: IntakeDeps, actor: Actor, sample: Sample): Promise { + // Takes the processing job the intake would queue and hands out its id for the inline run instead. + let jobId: string | undefined; + const inline = { send: async () => (jobId = randomUUID()) } as unknown as JobSender; + const { requestId } = await submitUpload({ ...deps, boss: inline }, actor, [{ name: sample.filename, bytes: freshSampleMail(sample) }]); + if (!jobId) throw new Error(`sample ${sample.key}: the intake queued no processing job`); + await deps.tenancy.withTenant(actor.companyId, (tx) => markSample(tx, requestId)); + const outcome = await processRequestJob( + { tenancy: deps.tenancy, storage: deps.storage, ai: recordedAiClient(sampleRecording(sample)) }, + { id: jobId, data: { requestId, companyId: actor.companyId } }, + ); + if (outcome !== "processed") throw new Error(`sample ${sample.key} was not processed`); + return requestId; +} diff --git a/src/samples-record.ts b/src/samples-record.ts new file mode 100644 index 0000000..49f864a --- /dev/null +++ b/src/samples-record.ts @@ -0,0 +1,49 @@ +// Records the AI service's answer for every sample mail (`pnpm samples:record`, #71). Run once against a +// deployed AI service; the answers are committed next to the mails and replayed by `pnpm seed:samples`. +// Synthetic mails only. Needs AI_SERVICE_URL and AI_SERVICE_TOKEN (never printed). Optional arguments: sample keys. +import { randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { AiServiceError, createAiServiceClient } from "@/features/extraction"; +import { recordingFile, SAMPLES, sampleMail } from "@/features/samples"; + +const ATTEMPTS = 8; +const WAIT_MS = 30_000; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +async function main(): Promise { + const client = createAiServiceClient({ baseUrl: required("AI_SERVICE_URL"), token: required("AI_SERVICE_TOKEN"), timeoutMs: 120_000 }); + // `pnpm samples:record …` records only these samples and keeps the other recordings. + const only = process.argv.slice(2); + for (const sample of SAMPLES.filter(({ key }) => only.length === 0 || only.includes(key))) { + for (let attempt = 1; ; attempt++) { + try { + const response = await client.extract({ + bytes: sampleMail(sample), + filename: sample.filename, + mediaType: "message/rfc822", + documentId: `sample-${sample.key}`, + correlationId: randomUUID(), + }); + writeFileSync(recordingFile(sample), `${JSON.stringify(response, null, 2)}\n`); + const statuses = Object.entries(response.fields).map(([key, field]) => `${key}=${field.status}`).join(" "); + console.log(`recorded ${sample.key}: ${statuses}; ${response.lineItems.length} line items; model ${response.run.modelId}`); + break; + } catch (error) { + // Overload of the model provider is common on the free tier: wait and try again. + if (!(error instanceof AiServiceError) || !error.retryable || attempt >= ATTEMPTS) throw error; + console.log(`${sample.key}: ${error.code}, attempt ${attempt} of ${ATTEMPTS} – retrying`); + await new Promise((resolve) => setTimeout(resolve, WAIT_MS)); + } + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "recording failed"); + process.exit(1); +}); diff --git a/src/seed-samples.ts b/src/seed-samples.ts new file mode 100644 index 0000000..5f17097 --- /dev/null +++ b/src/seed-samples.ts @@ -0,0 +1,44 @@ +// Prepared samples (`pnpm seed:samples`, #71): per demo company one sample to review and one approved +// sample whose export runs through the normal queue. Idempotent – safe to repeat, e.g. after visitors +// decided the samples. The extraction replays recorded answers (src/features/samples); no model call. +// Needs the demo companies (`pnpm seed:demo`) and the same shell variables (runbook §6). +import { loadConfig } from "@/config/env"; +import { createDatabase } from "@/db"; +import { createJobQueue } from "@/db/job-queue-client"; +import { createAuth, getActor } from "@/features/identity"; +import { seedSamples } from "@/features/samples"; +import { S3BlobStore } from "@/features/storage"; +import { createTenancy } from "@/features/tenancy"; + +const ADMINS = ["admin@musterbau.example.com", "admin@beispielwerk.example.com"]; + +async function main(): Promise { + const password = process.env.SEED_PASSWORD; + if (!password) throw new Error("SEED_PASSWORD is required (the demo admins sign in to seed their company)"); + const config = loadConfig(); + const database = createDatabase(config.databaseUrl, { max: 2 }); + const auth = createAuth(database.db, config.auth); + const storage = new S3BlobStore(config.storage); + const boss = await createJobQueue(config.databaseUrl); + // No hourly upload cap: seeding is an operator step, and a sample never calls the model. + const deps = { tenancy: createTenancy(database.db), storage, boss, limits: { ...config.upload, maxPerHour: undefined } }; + try { + for (const email of ADMINS) { + const login = await auth.api.signInEmail({ body: { email, password }, returnHeaders: true }); + const cookie = login.headers.getSetCookie().map((line) => line.split(";")[0]).join("; "); + const actor = await getActor(auth, database.db, new Headers({ cookie })); + if (!actor) throw new Error(`${email} has no company – run pnpm seed:demo first`); + const seeded = await seedSamples(deps, actor); + console.log(seeded.length === 0 ? `${email}: samples still in place` : `${email}: ${seeded.map((sample) => `${sample.key} → ${sample.status}`).join(", ")}`); + } + } finally { + await boss.stop({ graceful: true }); + storage.destroy(); + await database.pool.end(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "seeding samples failed"); + process.exit(1); +}); diff --git a/tests/integration/samples.test.ts b/tests/integration/samples.test.ts new file mode 100644 index 0000000..f6b424d --- /dev/null +++ b/tests/integration/samples.test.ts @@ -0,0 +1,83 @@ +import { sql } from "drizzle-orm"; +import type { PgBoss } from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { loadConfig } from "@/config/env"; +import { createJobQueue } from "@/db/job-queue-client"; +import { getActor, type Actor } from "@/features/identity"; +import { QUEUES } from "@/features/jobs"; +import { getRequest } from "@/features/requests"; +import { loadReview } from "@/features/review"; +import { RECORDED_MODEL_PREFIX, seedSamples } from "@/features/samples"; +import { S3BlobStore } from "@/features/storage"; +import { createTenancy, type Tenancy } from "@/features/tenancy"; +import { companyWithAdmin, createStack, type Stack } from "./helpers/stack"; + +// Prepared samples (#71) against real Postgres, pg-boss and S3: the real intake, processing and approval +// paths, with the extraction replayed from the committed recordings – no AI service is running here. +describe("prepared samples", () => { + let stack: Stack; + let tenancy: Tenancy; + let storage: S3BlobStore; + let boss: PgBoss; + let admin: Actor; + const deps = () => ({ tenancy, storage, boss, limits: { ...loadConfig().upload, maxPerHour: undefined } }); + const jobsFor = async (queue: string, requestId: string) => + (await stack.database.pool.query("select count(*)::int as n from pgboss.job where name = $1 and singleton_key = $2", [queue, requestId])).rows[0].n as number; + + beforeAll(async () => { + stack = createStack(); + tenancy = createTenancy(stack.database.db); + storage = new S3BlobStore(loadConfig().storage); + boss = await createJobQueue(loadConfig().databaseUrl); + admin = (await getActor(stack.auth, stack.database.db, new Headers({ cookie: (await companyWithAdmin(stack)).cookie })))!; + }); + + afterAll(async () => { + await boss.stop({ graceful: false }); + storage.destroy(); + await stack.close(); + }); + + it("seeds one sample in review and one approved sample whose export is queued – without a processing job", async () => { + const seeded = await seedSamples(deps(), admin); + + expect(seeded.map(({ key, status }) => [key, status])).toEqual([ + ["werk-ost", "REVIEW"], + ["pumpe-p204", "APPROVED"], + ]); + const [review, exported] = seeded.map((sample) => sample.requestId) as [string, string]; + + const view = await loadReview(tenancy, admin, review); + expect(view?.request).toMatchObject({ status: "REVIEW", source: "sample", possibleDuplicate: false }); + const statuses = view!.fields.map((field) => field.status); + expect(statuses).toContain("uncertain"); + expect(statuses).toContain("missing"); + expect(view!.lineItems.length).toBeGreaterThan(0); + // The original is stored like any upload, so "Original öffnen" works. + expect(new TextDecoder().decode(await storage.get(view!.documents[0]!.storageKey))).toContain("Subject: Anfrage Rohrbogen und Flansche fuer Werk Ost"); + + const runs = await stack.database.pool.query("select model_id from app.extraction_runs where request_id = any($1::uuid[])", [[review, exported]]); + expect(runs.rows.map((row) => row.model_id as string).every((id) => id.startsWith(RECORDED_MODEL_PREFIX))).toBe(true); + + expect((await tenancy.withTenant(admin.companyId, (tx) => getRequest(tx, exported)))).toMatchObject({ status: "APPROVED", source: "sample" }); + expect(await jobsFor(QUEUES.exportRequest, exported)).toBe(1); + // Processing ran inline with the recording: nothing a drain could send to the live model. + expect(await jobsFor(QUEUES.processRequest, review)).toBe(0); + expect(await jobsFor(QUEUES.processRequest, exported)).toBe(0); + }); + + it("is idempotent: a second run creates nothing while the samples still serve", async () => { + const before = await tenancy.withTenant(admin.companyId, (tx) => tx.execute(sql`select count(*)::int as n from app.requests where source = 'sample'`)); + + expect(await seedSamples(deps(), admin)).toEqual([]); + + const after = await tenancy.withTenant(admin.companyId, (tx) => tx.execute(sql`select count(*)::int as n from app.requests where source = 'sample'`)); + expect(after.rows[0]).toEqual(before.rows[0]); + }); + + it("the database refuses an unknown request source (migration 0019)", async () => { + await expect( + tenancy.withTenant(admin.companyId, (tx) => tx.execute(sql`update app.requests set source = 'mailbox' where company_id = ${admin.companyId}`)), + ).rejects.toThrow(); + }); +}); From 6b9b6ea2b4936682caac60c6bf0d2261002c78b7 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 15:50:30 +0200 Subject: [PATCH 4/7] docs(samples): module in the architecture map, runbook step and changelog Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++++ docs/technical/architecture.md | 1 + docs/technical/deployment-vercel.md | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f3cba..3a67eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added +- Prepared samples for the showcase (`pnpm seed:samples`): per demo company one synthetic request to review + (with an uncertain and a missing value) and one already approved and exported through the normal path. Their + extraction replays a recorded AI answer, so they work even while the model provider is overloaded; list and + detail label them "Vorbereitetes Beispiel – aufgezeichnete KI-Antwort" (#71). - ERP export sends the reviewed positions (description, quantity, unit, material, dimensions) with each approved request – ERP contract 1.1.0, additive and optional. A position value the ERP would refuse blocks the approval so the clerk can still correct it. diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index a97e7bc..c6d2387 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -30,6 +30,7 @@ Every new file belongs to one of these modules – otherwise add the module here | `review` | `src/features/review/` | review UI, corrections, approve/reject | authenticated UI | confidential + personal | session, role check, audit | built: review page (fields + status badges + source view for mail, PDF incl. OCR label, XLSX cells, DOCX paragraphs/tables, attachments; line items as a table with per-field status and audited corrections), corrections with history, approve (→ export job) / reject with reason, duplicate decision (confirm or reject as duplicate) | | `export` | `src/features/export/` | ERP port + REST adapter, idempotency | outbound HTTP | confidential | idempotency key, unique export, timeout | built: REST adapter (timeout, error classes, contract validation), export handler under row lock, `drainExports()`, `request_exports` | | `erp-mock` | `src/features/erp-mock/` | simulated ERP REST API | route behind flag | synthetic | disabled unless `ERP_MOCK_ENABLED` | built: idempotent receiver (replay → same reference, 409 on a different body), fault injection, bounded in-memory store, route `/api/erp-mock/v1/quote-requests` | +| `samples` | `src/features/samples/`, entrypoints `src/seed-samples.ts` (`pnpm seed:samples`) and `src/samples-record.ts` (`pnpm samples:record`) | prepared showcase cases: synthetic mails with a recorded AI answer, seeded through intake → processing → approval | operator scripts only | synthetic | no model call when seeding (recording replayed inline, no queued processing job); requests marked `source = 'sample'` (CHECK, migration 0019) | built: two samples per demo company – one in review (uncertain + missing value), one approved with its export queued (#71) | | `identity` | `src/features/identity/` | Better Auth, users, companies, roles | public login route | personal (staff) | rate limit, invite-only | built: Better Auth (invite-only, organization + admin plugins), `authorize()`, audited invite, user management (`/users`: roles, deactivate/reactivate, last-admin rule), seed | | `tenancy` | `src/features/tenancy/` | `withTenant()`, RLS policies | internal | – | forced RLS, `app_rw` without BYPASSRLS | built: `withTenant()`, forced RLS on `app.*`, guard test (every `app` table: `company_id`, forced RLS, only company policies; allow-list empty) | | `audit` | `src/features/audit/` | append-only audit events | internal | personal (staff) | INSERT/SELECT only | partial: `recordAudit()` (append-only enforced by grants) | diff --git a/docs/technical/deployment-vercel.md b/docs/technical/deployment-vercel.md index fd9414f..4395417 100644 --- a/docs/technical/deployment-vercel.md +++ b/docs/technical/deployment-vercel.md @@ -118,6 +118,12 @@ With the same shell variables and a strong `SEED_PASSWORD` (≥ 12 chars, passwo creates the two synthetic companies with their admins (invite-only; see `src/seed.ts`). Hand the accounts out only to people who may see the demo. +Then `pnpm seed:samples` (same variables and `SEED_PASSWORD`) prepares per company one sample to review and one +approved sample whose export the next drain sends to the ERP mock (#71). Their extraction replays answers recorded +once with `pnpm samples:record` (`src/features/samples/data/`) – no model call, so the samples work even while the +model provider is overloaded. List and detail label them „Vorbereitetes Beispiel – aufgezeichnete KI-Antwort“. The +step is idempotent: repeat it whenever visitors have decided the samples, and it adds only what is missing. + ## 7. Jobs without a worker - `after()`: with `JOB_DRAIN_INLINE=true`, upload, approval and "Erneut verarbeiten" drain once after the From d51ad56e829b6ce2dce68dc9b54734ce98f956f0 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 21:35:12 +0200 Subject: [PATCH 5/7] feat(samples): recorded AI answer for the exported sample Recorded 2026-09-27 against the showcase AI service (gemini-3.5-flash): all six fields found, two line items - the clean counterpart to the review sample with its uncertain and missing values. Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../samples/data/pumpe-p204.recording.json | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 src/features/samples/data/pumpe-p204.recording.json diff --git a/src/features/samples/data/pumpe-p204.recording.json b/src/features/samples/data/pumpe-p204.recording.json new file mode 100644 index 0000000..be44055 --- /dev/null +++ b/src/features/samples/data/pumpe-p204.recording.json @@ -0,0 +1,313 @@ +{ + "documentId": "sample-pumpe-p204", + "documentKind": "eml", + "segments": [ + { + "id": "eml-h-from", + "text": "From: Instandhaltung ", + "locator": { + "kind": "email", + "part": "header", + "line": 1, + "header": "From" + } + }, + { + "id": "eml-h-subject", + "text": "Subject: Anfrage Dichtungssatz fuer Pumpe P-204", + "locator": { + "kind": "email", + "part": "header", + "line": 2, + "header": "Subject" + } + }, + { + "id": "eml-l1", + "text": "Sehr geehrte Damen und Herren,", + "locator": { + "kind": "email", + "part": "body", + "line": 1, + "header": null + } + }, + { + "id": "eml-l3", + "text": "bitte senden Sie uns ein Angebot fuer:", + "locator": { + "kind": "email", + "part": "body", + "line": 3, + "header": null + } + }, + { + "id": "eml-l5", + "text": "1) 2 Stk. Gleitringdichtung 45 mm, Werkstoff SiC/SiC", + "locator": { + "kind": "email", + "part": "body", + "line": 5, + "header": null + } + }, + { + "id": "eml-l6", + "text": "2) 4 Stk. O-Ring 120 x 4 mm, Werkstoff FKM", + "locator": { + "kind": "email", + "part": "body", + "line": 6, + "header": null + } + }, + { + "id": "eml-l8", + "text": "Liefertermin: 20.11.2026", + "locator": { + "kind": "email", + "part": "body", + "line": 8, + "header": null + } + }, + { + "id": "eml-l9", + "text": "Bitte legen Sie ein Abnahmepruefzeugnis 3.1 nach EN 10204 bei.", + "locator": { + "kind": "email", + "part": "body", + "line": 9, + "header": null + } + }, + { + "id": "eml-l11", + "text": "Mit freundlichen Gruessen", + "locator": { + "kind": "email", + "part": "body", + "line": 11, + "header": null + } + }, + { + "id": "eml-l12", + "text": "Tom Beispiel", + "locator": { + "kind": "email", + "part": "body", + "line": 12, + "header": null + } + }, + { + "id": "eml-l13", + "text": "Muster Chemie GmbH", + "locator": { + "kind": "email", + "part": "body", + "line": 13, + "header": null + } + }, + { + "id": "eml-l14", + "text": "Tel. +49 221 5550199", + "locator": { + "kind": "email", + "part": "body", + "line": 14, + "header": null + } + } + ], + "fields": { + "company": { + "value": "Muster Chemie GmbH", + "status": "found", + "evidence": { + "segmentId": "eml-l13", + "quote": "Muster Chemie GmbH" + }, + "modelStatus": "found", + "reason": null + }, + "contact_person": { + "value": "Tom Beispiel", + "status": "found", + "evidence": { + "segmentId": "eml-l12", + "quote": "Tom Beispiel" + }, + "modelStatus": "found", + "reason": null + }, + "email": { + "value": "instandhaltung@muster-chemie.example.com", + "status": "found", + "evidence": { + "segmentId": "eml-h-from", + "quote": "instandhaltung@muster-chemie.example.com" + }, + "modelStatus": "found", + "reason": null + }, + "phone": { + "value": "+49 221 5550199", + "status": "found", + "evidence": { + "segmentId": "eml-l14", + "quote": "+49 221 5550199" + }, + "modelStatus": "found", + "reason": null + }, + "requested_delivery_date": { + "value": "2026-11-20", + "status": "found", + "evidence": { + "segmentId": "eml-l8", + "quote": "20.11.2026" + }, + "modelStatus": "found", + "reason": null + }, + "additional_requirements": { + "value": "Bitte legen Sie ein Abnahmepruefzeugnis 3.1 nach EN 10204 bei.", + "status": "found", + "evidence": { + "segmentId": "eml-l9", + "quote": "Bitte legen Sie ein Abnahmepruefzeugnis 3.1 nach EN 10204 bei." + }, + "modelStatus": "found", + "reason": null + } + }, + "lineItems": [ + { + "index": 0, + "description": { + "value": "Gleitringdichtung", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "Gleitringdichtung" + }, + "modelStatus": "found", + "reason": null + }, + "quantity": { + "value": "2", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "2" + }, + "modelStatus": "found", + "reason": null + }, + "unit": { + "value": "pcs", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "Stk." + }, + "modelStatus": "found", + "reason": null + }, + "material": { + "value": "SiC/SiC", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "SiC/SiC" + }, + "modelStatus": "found", + "reason": null + }, + "dimensions": { + "value": "45 mm", + "status": "found", + "evidence": { + "segmentId": "eml-l5", + "quote": "45 mm" + }, + "modelStatus": "found", + "reason": null + } + }, + { + "index": 1, + "description": { + "value": "O-Ring", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "O-Ring" + }, + "modelStatus": "found", + "reason": null + }, + "quantity": { + "value": "4", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "4" + }, + "modelStatus": "found", + "reason": null + }, + "unit": { + "value": "pcs", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "Stk." + }, + "modelStatus": "found", + "reason": null + }, + "material": { + "value": "FKM", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "FKM" + }, + "modelStatus": "found", + "reason": null + }, + "dimensions": { + "value": "120 x 4 mm", + "status": "found", + "evidence": { + "segmentId": "eml-l6", + "quote": "120 x 4 mm" + }, + "modelStatus": "found", + "reason": null + } + } + ], + "run": { + "modelId": "gemini-3.5-flash", + "promptVersion": "extract_v2", + "schemaVersion": "2", + "latencyMs": 13539, + "modelVersion": "gemini-3.5-flash", + "pdfPipeline": null, + "tokens": { + "inputTokens": 1280, + "outputTokens": 1096, + "totalTokens": 4522 + }, + "modelLatencyMs": 13534 + }, + "warnings": [], + "requestId": "c072709f-679f-4c22-95c5-829a6ff6c909", + "attachments": [] +} From 3033f21fcbfdd0f8cce246dee8245aeb59d9d887 Mon Sep 17 00:00:00 2001 From: Fluory Date: Sun, 27 Sep 2026 22:05:26 +0200 Subject: [PATCH 6/7] fix(samples): export the sample right away and never leave one stuck The fresh review of #83 found that the "exported" sample stopped at APPROVED until some drain ran, and that an aborted seed left a sample in NEW/PROCESSING that blocked every later run. The seed now runs the export job inline through the normal export path (ERP reference, export row, audit) and falls back to the queue when the ERP is down; the source is set in the intake transaction, only settled states count as serving, and a failed processing step ends in a visible ERROR that cannot be reprocessed live. Recordings are validated with the contract schema and their replay counts no tokens or model time. New: unit tests for the recorded client, integration tests for export, ERP outage and abort, and an E2E smoke for the labels. Refs #71 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/technical/deployment-vercel.md | 3 +- eslint.config.mjs | 5 +- src/app/requests/page.tsx | 3 +- src/features/extraction/ai-client.ts | 14 ++-- src/features/extraction/index.ts | 2 +- src/features/intake/submit.ts | 9 ++- src/features/jobs/drain.ts | 2 +- src/features/jobs/index.ts | 2 +- src/features/jobs/reprocess.ts | 2 + src/features/requests/index.ts | 1 - src/features/requests/repository.ts | 8 +- src/features/samples/index.ts | 2 +- src/features/samples/samples.test.ts | 41 ++++++++++ src/features/samples/samples.ts | 19 ++++- src/features/samples/seed.ts | 84 +++++++++++++++----- src/seed-samples.ts | 16 +++- tests/e2e/global-setup.ts | 4 +- tests/e2e/sample-smoke.spec.ts | 30 +++++++ tests/integration/samples.test.ts | 112 ++++++++++++++++++++++----- 19 files changed, 288 insertions(+), 71 deletions(-) create mode 100644 src/features/samples/samples.test.ts create mode 100644 tests/e2e/sample-smoke.spec.ts diff --git a/docs/technical/deployment-vercel.md b/docs/technical/deployment-vercel.md index 4395417..e7483ce 100644 --- a/docs/technical/deployment-vercel.md +++ b/docs/technical/deployment-vercel.md @@ -119,7 +119,8 @@ creates the two synthetic companies with their admins (invite-only; see `src/see accounts out only to people who may see the demo. Then `pnpm seed:samples` (same variables and `SEED_PASSWORD`) prepares per company one sample to review and one -approved sample whose export the next drain sends to the ERP mock (#71). Their extraction replays answers recorded +approved sample that it exports right away through the normal export path to the configured ERP (the ERP mock +route; if it is not reachable, the export is queued and the next drain retries it) (#71). Their extraction replays answers recorded once with `pnpm samples:record` (`src/features/samples/data/`) – no model call, so the samples work even while the model provider is overloaded. List and detail label them „Vorbereitetes Beispiel – aufgezeichnete KI-Antwort“. The step is idempotent: repeat it whenever visitors have decided the samples, and it adds only what is missing. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5bdc29f..bb89191 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,8 +12,9 @@ const config = [ }, }, // Logs are a data-leak path (#28): application code logs through `logEvent` (fixed key set, IDs and - // codes only). Exempt: the operator's seed script and deploy step (`setup.ts`) – no tenant data, - // their messages explain role/connection problems with URLs masked. + // codes only). Exempt: the operator's seed scripts (`seed.ts`, `seed-samples.ts`), the sample recorder + // (`samples-record.ts`, #71) and the deploy step (`setup.ts`) – they print sample keys, statuses and + // field statuses of synthetic mails, never tenant data or secrets; setup masks URLs. { files: ["src/**/*.{ts,tsx}"], ignores: ["src/seed.ts", "src/seed-samples.ts", "src/samples-record.ts", "src/setup.ts"], rules: { "no-console": "error" } }, ]; diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 1300f7b..b24acd7 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -153,7 +153,8 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro )} - {request.status === "ERROR" && ( + {/* A sample in ERROR from processing is not reprocessed: that would call the live model (#71). */} + {request.status === "ERROR" && !(request.source === "sample" && request.errorStage === "processing") && (