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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user