fix(skiva-1): spara accept, bildref, lokal träningsbank, lärande-loop-dok
- 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
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Hermetic test environment for worker integration tests.
|
||||
* Must run BEFORE any application module is imported.
|
||||
*/
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.AAMOS_MODE = "mock";
|
||||
process.env.EMAIL_MODE = "log";
|
||||
process.env.S3_MODE = "mock";
|
||||
process.env.LOG_LEVEL = "error";
|
||||
|
||||
process.env.TEST_DATABASE_URL ||= "postgres://app_user:app_dev_password@localhost:5432/cibello_test";
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
@@ -0,0 +1,100 @@
|
||||
import "./setup-env.js";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||
import { processTrainingExport } from "../src/processors/maintenance.js";
|
||||
|
||||
describe("BUILD_TRAINING_SAMPLE banks locally", () => {
|
||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||
const email = "training-export-test@example.invalid";
|
||||
|
||||
async function cleanup() {
|
||||
const existing = await testDb.db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.email, email));
|
||||
for (const u of existing) {
|
||||
await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id));
|
||||
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
|
||||
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
|
||||
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
it("banks eligible corrections locally and does not throw in gemini mode", async () => {
|
||||
const [user] = await testDb.db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
email: "training-export-test@example.invalid",
|
||||
passwordHash: "not-used",
|
||||
displayName: "Training Export Test",
|
||||
})
|
||||
.returning();
|
||||
const userId = user!.id;
|
||||
|
||||
await testDb.db.insert(schema.userConsents).values([
|
||||
{ userId, kind: "anonymized_improvement", status: "granted" },
|
||||
{ userId, kind: "image_training", status: "granted" },
|
||||
]);
|
||||
|
||||
const [job] = await testDb.db
|
||||
.insert(schema.scanJobs)
|
||||
.values({
|
||||
userId,
|
||||
scanType: "fridge",
|
||||
jobType: "ANALYZE_FRIDGE_IMAGE",
|
||||
status: "completed",
|
||||
s3Keys: ["fridge-scans/train.jpg"],
|
||||
result: { items: [] },
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [correction] = await testDb.db
|
||||
.insert(schema.aiCorrections)
|
||||
.values({
|
||||
scanJobId: job!.id,
|
||||
userId,
|
||||
taskType: "ANALYZE_FRIDGE_IMAGE",
|
||||
aiOutput: { raw: { items: [] } },
|
||||
proposal: { detectedName: "Mellanmjölk", confidence: 0.98 },
|
||||
userCorrection: { action: "accept", corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" } },
|
||||
imageS3Key: "fridge-scans/train.jpg",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
promptVersion: "gemini-fridge-v1",
|
||||
consentSnapshot: { anonymized_improvement: "granted", image_training: "granted" },
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Force gemini mode in environment so we prove the job does not call AAMOS.
|
||||
const previousMode = process.env.AAMOS_MODE;
|
||||
process.env.AAMOS_MODE = "gemini";
|
||||
|
||||
const exported = await processTrainingExport({ db: testDb.db } as never);
|
||||
|
||||
process.env.AAMOS_MODE = previousMode;
|
||||
|
||||
expect(exported).toBe(1);
|
||||
|
||||
const banked = await testDb.db
|
||||
.select()
|
||||
.from(schema.aiTrainingBank)
|
||||
.where(eq(schema.aiTrainingBank.correctionId, correction!.id));
|
||||
expect(banked).toHaveLength(1);
|
||||
expect(banked[0]!.version).toBe("v1");
|
||||
expect(banked[0]!.imageS3Key).toBe("fridge-scans/train.jpg");
|
||||
expect(banked[0]!.action).toBe("accept");
|
||||
|
||||
const updated = await testDb.db
|
||||
.select()
|
||||
.from(schema.aiCorrections)
|
||||
.where(eq(schema.aiCorrections.id, correction!.id));
|
||||
expect(updated[0]!.exportedToTraining).not.toBeNull();
|
||||
expect(updated[0]!.trainingBatchId).toMatch(/^cibello-local-v1-/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user