c2cc3878dd
- confirm-loop sparar nu även action=accept som positivt exempel i ai_corrections - imageS3Key sparas vid image_training-samtycke, annars null - ai_corrections.proposal lagrar det specifika AI-förslaget per item - BUILD_TRAINING_SAMPLE bankar lokalt till ai_training_bank (ej externt runTask) - Jobbet kastar aldrig i AAMOS_MODE=gemini - Migration 0020: ai_corrections.image_s3_key/proposal + ai_training_bank - docs/28-lärande-loop.md: datakontrakt, samtycke, retention, GDPR-radering - Tester: accept + bildref (ja/nej) + lokal bank i gemini-läge - REQUIRE_REAL=1 är AI-fokuserat i gemini-läge; staging mail/S3 får vara mock
114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
import {
|
||
doublePrecision,
|
||
index,
|
||
integer,
|
||
jsonb,
|
||
pgTable,
|
||
text,
|
||
timestamp,
|
||
uniqueIndex,
|
||
uuid,
|
||
} from "drizzle-orm/pg-core";
|
||
import { createdAt, jobStatusEnum, jobTypeEnum, scanTypeEnum, updatedAt } from "./_shared.js";
|
||
import { households } from "./households.js";
|
||
import { users } from "./users.js";
|
||
|
||
/**
|
||
* Asynkrona skannings-/AI-jobb (spec §50, §54):
|
||
* App → signed S3 upload → API job → worker → AAMOS → result → app.
|
||
*/
|
||
export const scanJobs = pgTable(
|
||
"scan_jobs",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
userId: uuid("user_id")
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
|
||
scanType: scanTypeEnum("scan_type").notNull(),
|
||
jobType: jobTypeEnum("job_type").notNull(),
|
||
status: jobStatusEnum("status").notNull().default("queued"),
|
||
s3Keys: text("s3_keys").array().notNull().default([]),
|
||
context: jsonb("context"),
|
||
/** Strukturerat AI-resultat validerat mot ai-contracts innan lagring. */
|
||
result: jsonb("result"),
|
||
error: text("error"),
|
||
modelVersion: text("model_version"),
|
||
promptVersion: text("prompt_version"),
|
||
latencyMs: integer("latency_ms"),
|
||
costUsd: doublePrecision("cost_usd"),
|
||
attempts: integer("attempts").notNull().default(0),
|
||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||
createdAt: createdAt(),
|
||
updatedAt: updatedAt(),
|
||
},
|
||
(t) => [
|
||
index("scan_jobs_user_idx").on(t.userId, t.createdAt),
|
||
index("scan_jobs_status_idx").on(t.status),
|
||
],
|
||
);
|
||
|
||
/**
|
||
* Verifierade korrigeringar (spec §33): AI sa X, användaren sa Y.
|
||
* Grund för träningsdata – används ENDAST enligt samtycke.
|
||
*/
|
||
export const aiCorrections = pgTable(
|
||
"ai_corrections",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
scanJobId: uuid("scan_job_id").references(() => scanJobs.id, { onDelete: "set null" }),
|
||
userId: uuid("user_id")
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
taskType: text("task_type").notNull(),
|
||
aiOutput: jsonb("ai_output").notNull(),
|
||
userCorrection: jsonb("user_correction").notNull(),
|
||
/** The specific AI proposal item this correction refers to. */
|
||
proposal: jsonb("proposal"),
|
||
/** First stored image key when image_training consent granted, otherwise null. */
|
||
imageS3Key: text("image_s3_key"),
|
||
modelVersion: text("model_version"),
|
||
promptVersion: text("prompt_version"),
|
||
/** Snapshot av samtyckesläget när korrigeringen skapades. */
|
||
consentSnapshot: jsonb("consent_snapshot").notNull(),
|
||
exportedToTraining: timestamp("exported_to_training", { withTimezone: true }),
|
||
trainingBatchId: text("training_batch_id"),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [index("ai_corrections_task_idx").on(t.taskType, t.createdAt)],
|
||
);
|
||
|
||
/**
|
||
* App-owned, versioned training dataset (spec §33 + Skiva 1 fixrunda).
|
||
* BUILD_TRAINING_SAMPLE banks eligible ai_corrections here instead of
|
||
* calling external AAMOS/Gemini runTask. The bank is the consented place
|
||
* for richer (image, proposal, correction) data.
|
||
*/
|
||
export const aiTrainingBank = pgTable(
|
||
"ai_training_bank",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
correctionId: uuid("correction_id")
|
||
.notNull()
|
||
.references(() => aiCorrections.id, { onDelete: "cascade" }),
|
||
scanJobId: uuid("scan_job_id").references(() => scanJobs.id, { onDelete: "set null" }),
|
||
version: text("version").notNull().default("v1"),
|
||
taskType: text("task_type").notNull(),
|
||
imageS3Key: text("image_s3_key"),
|
||
proposal: jsonb("proposal").notNull(),
|
||
action: text("action").notNull(),
|
||
corrected: jsonb("corrected"),
|
||
modelVersion: text("model_version"),
|
||
promptVersion: text("prompt_version"),
|
||
consentSnapshot: jsonb("consent_snapshot").notNull().default({}),
|
||
exportedAt: timestamp("exported_at", { withTimezone: true }).notNull().defaultNow(),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [
|
||
uniqueIndex("ai_training_bank_correction_unique").on(t.correctionId),
|
||
index("ai_training_bank_version_idx").on(t.version, t.taskType),
|
||
index("ai_training_bank_scan_job_idx").on(t.scanJobId),
|
||
index("ai_training_bank_created_at_idx").on(t.createdAt),
|
||
],
|
||
);
|