feat(ops): /ops/v1/summary med ai/aktivering/engagemang/betalning/jobb/sakerhet + canary

- Nya toppnivåblock i /ops/v1/summary: ai_scan, aktivering, engagemang,
  betalning, jobb, sakerhet. Alla värden läses från DB/cache; null där data
  saknas, inga påhittade värden.
- AI-kostnad i USD mikrocent (native); intäkter fortsatt SEK-öre.
- product_analytics_events är källa för volym, latens, lyckandegrad,
  felfrekvens, aktivering, retention och engagemang.
- Nya händelser: scan_started (API) och scan_failed/scan_completed med
  latencyMs + felkod (worker). latencyMs flödar nu in i scan_completed.
- Safety canary-jobb varje timme: re-härleder allergener för alla recept,
  räknar överifierade publika recept och food-safety-lint; skriver EN rad till
  ops_safety_canary. Endpointen läser endast sista raden.
- Cache-refresh-jobb var 60 s skriver hela summariet till Redis; endpointen
  serverar cachen med 503 vid cache-miss.
- Bearer-token-skydd med OPS_TOKEN; HTTPS-tvång i produktion; ingen PII.
- Tester för endpoint, auth, cache-miss och safety canary.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 05:47:43 +07:00
parent f4a603e977
commit 8f20b2ef1b
27 changed files with 1169 additions and 41 deletions
+60 -3
View File
@@ -1,5 +1,6 @@
import { eq, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { schema, trackProductAnalytics } from "@app/database";
import { scanCompleted, scanFailed } from "@app/analytics";
import type { AamosResult, AamosTaskType, DetectedItem } from "@app/ai-contracts";
import type { WorkerContext } from "../context.js";
import { getLocaleContext } from "../locale.js";
@@ -43,15 +44,17 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
);
if (result.status === "failed" || result.output == null) {
const latencyMs = Date.now() - started;
await ctx.db
.update(schema.scanJobs)
.set({
status: "failed",
error: result.error ?? "AI-analysen misslyckades. Försök igen eller registrera manuellt.",
latencyMs: Date.now() - started,
latencyMs,
updatedAt: new Date(),
})
.where(eq(schema.scanJobs.id, scanJobId));
await recordScanFailed(ctx, job, { ...result, latencyMs });
return;
}
@@ -78,6 +81,8 @@ export async function processScanJob(ctx: WorkerContext, scanJobId: string): Pro
})
.where(eq(schema.scanJobs.id, scanJobId));
await recordScanCompleted(ctx, job, result);
// Bokför verklig AI-kostnad/tokens utan PII (spec §45).
await recordAiUsage(ctx, job.userId, result);
@@ -293,7 +298,11 @@ function tokenize(text: string): string[] {
// AI-kostnadsbokföring utan PII.
// ---------------------------------------------------------------------------
async function recordAiUsage(ctx: WorkerContext, userId: string, result: AamosResult<AamosTaskType>): Promise<void> {
async function recordAiUsage(
ctx: WorkerContext,
userId: string,
result: AamosResult<AamosTaskType>,
): Promise<void> {
const costUsd = result.costUsd ?? 0;
const tokensIn = result.inputTokens ?? 0;
const tokensOut = result.outputTokens ?? 0;
@@ -320,3 +329,51 @@ async function recordAiUsage(ctx: WorkerContext, userId: string, result: AamosRe
},
});
}
function classifyScanError(error?: string | null): string {
if (!error) return "unknown";
const lower = error.toLowerCase();
if (lower.includes("budget")) return "budget_exhausted";
if (lower.includes("timeout")) return "timeout";
if (lower.includes("kunde inte hämta bild")) return "image_fetch_failed";
if (lower.includes("matchar inte schema") || lower.includes("inte giltig json"))
return "parse_error";
if (lower.includes("inga items") || lower.includes("no items")) return "no_items_detected";
return "ai_provider_error";
}
async function recordScanCompleted(
ctx: WorkerContext,
job: { userId: string; householdId: string | null; scanType: string; jobType: string },
result: AamosResult<AamosTaskType>,
): Promise<void> {
await trackProductAnalytics(ctx.db, job.userId, {
...scanCompleted(),
householdId: job.householdId ?? undefined,
properties: {
scanType: job.scanType,
jobType: job.jobType,
latencyMs: result.latencyMs ?? null,
costUsd: result.costUsd ?? null,
modelVersion: result.modelVersion ?? null,
promptVersion: result.promptVersion ?? null,
},
});
}
async function recordScanFailed(
ctx: WorkerContext,
job: { userId: string; householdId: string | null; scanType: string; jobType: string },
result: AamosResult<AamosTaskType>,
): Promise<void> {
await trackProductAnalytics(ctx.db, job.userId, {
...scanFailed(),
householdId: job.householdId ?? undefined,
properties: {
scanType: job.scanType,
jobType: job.jobType,
errorCode: classifyScanError(result.error),
latencyMs: result.latencyMs ?? null,
},
});
}