fix(insights): tighten session fingerprint so duplicate UX issues dedupe #5
@@ -251,6 +251,11 @@ model Insight {
|
|||||||
severity String
|
severity String
|
||||||
status String @default("new")
|
status String @default("new")
|
||||||
fingerprint String
|
fingerprint String
|
||||||
|
// Additional fingerprints semantically merged into this insight by the dedup
|
||||||
|
// gate: a new session whose own fingerprint differs but which the LLM judged
|
||||||
|
// to be the SAME underlying problem gets its fingerprint aliased here, so the
|
||||||
|
// next identical session fast-paths (no re-analysis, no duplicate row).
|
||||||
|
aliasFingerprints String[] @default([])
|
||||||
title String
|
title String
|
||||||
body Json
|
body Json
|
||||||
relatedSessionIds String[]
|
relatedSessionIds String[]
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "tsx src/lib/dedup.smoke.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@panel/web": "workspace:*",
|
"@panel/web": "workspace:*",
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { prisma } from "../db";
|
import { prisma } from "../db";
|
||||||
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
|
import { callDeepSeek, extractJson, type Tier, type CallResult, DeepSeekError } from "../lib/deepseek";
|
||||||
import { checkBudget } from "../lib/budget";
|
import { checkBudget } from "../lib/budget";
|
||||||
import { pickPromptTag } from "../lib/prompts";
|
import { pickPromptTag } from "../lib/prompts";
|
||||||
import { validate } from "../lib/json-validate";
|
import { validate } from "../lib/json-validate";
|
||||||
import { getText } from "../lib/minio";
|
import { getText } from "../lib/minio";
|
||||||
import { alertP0Insight, alertBudgetCap } from "../lib/telegram";
|
import { alertP0Insight, alertBudgetCap } from "../lib/telegram";
|
||||||
|
import {
|
||||||
|
buildInsightCatalog,
|
||||||
|
findSemanticMatch,
|
||||||
|
classifyExistingAction,
|
||||||
|
shouldAcceptMatch,
|
||||||
|
DEDUP_ENABLED,
|
||||||
|
DEDUP_THRESHOLD,
|
||||||
|
} from "../lib/dedup";
|
||||||
|
|
||||||
const PANEL_URL = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
|
const PANEL_URL = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
|
||||||
|
|
||||||
@@ -22,6 +30,88 @@ export type AnalyzeResult = {
|
|||||||
budgetState: string;
|
budgetState: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GroupSession = { id: string; startedAt: Date };
|
||||||
|
|
||||||
|
type ExistingInsight = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
relatedSessionIds: string[];
|
||||||
|
lastSeenAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
aliasFingerprints: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function markAnalyzed(ids: string[]): Promise<void> {
|
||||||
|
await prisma.sessionMeta.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { status: "analyzed", processedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a session group to an existing insight WITHOUT re-running the LLM.
|
||||||
|
* Returns true when fully handled (caller should `continue`). Returns false
|
||||||
|
* only for an active-but-stale insight when the caller allows a refresh
|
||||||
|
* re-analysis (Layer 1, same fingerprint) — the caller then falls through to
|
||||||
|
* the normal analysis/update path.
|
||||||
|
*
|
||||||
|
* - dismissed / duplicate → suppress silently (respect the triage decision)
|
||||||
|
* - validated / shipped → flag regression (cheap, no re-analysis)
|
||||||
|
* - active & recent → attach occurrence
|
||||||
|
*/
|
||||||
|
async function attachToExisting(
|
||||||
|
existing: ExistingInsight,
|
||||||
|
group: GroupSession[],
|
||||||
|
opts: { allowStaleReanalyze: boolean; cutoff: Date },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const action = classifyExistingAction(
|
||||||
|
existing.status,
|
||||||
|
existing.updatedAt <= opts.cutoff,
|
||||||
|
opts.allowStaleReanalyze,
|
||||||
|
);
|
||||||
|
if (action === "reanalyze") return false; // active & stale → caller refreshes
|
||||||
|
|
||||||
|
const rel = Array.from(new Set([...existing.relatedSessionIds, ...group.map((g) => g.id)]));
|
||||||
|
const groupLast = group.reduce((a, b) => (a.startedAt > b.startedAt ? a : b)).startedAt;
|
||||||
|
const lastSeenAt = groupLast > existing.lastSeenAt ? groupLast : existing.lastSeenAt;
|
||||||
|
|
||||||
|
await prisma.insight.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
relatedSessionIds: rel,
|
||||||
|
occurrenceCount: rel.length,
|
||||||
|
lastSeenAt,
|
||||||
|
...(action === "regress" ? { status: "regressed", regressionDetected: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await markAnalyzed(group.map((g) => g.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logDedupCost(sessionId: string, projectKey: string, raw: CallResult): Promise<void> {
|
||||||
|
await prisma.costLedger.create({
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
projectKey,
|
||||||
|
promptTag: "dedup_gate",
|
||||||
|
promptVersion: 0,
|
||||||
|
provider: "deepseek",
|
||||||
|
model: raw.model,
|
||||||
|
tier: "flash",
|
||||||
|
tokensInputCacheMiss: raw.usage.inputTokensMiss,
|
||||||
|
tokensInputCacheHit: raw.usage.inputTokensHit,
|
||||||
|
tokensOutput: raw.usage.outputTokens,
|
||||||
|
costInputCacheMissUsd: raw.cost.inputMissUsd,
|
||||||
|
costInputCacheHitUsd: raw.cost.inputHitUsd,
|
||||||
|
costOutputUsd: raw.cost.outputUsd,
|
||||||
|
costTotalUsd: raw.cost.totalUsd,
|
||||||
|
cacheHitRatio: raw.cost.cacheHitRatio,
|
||||||
|
callDurationMs: raw.durationMs,
|
||||||
|
errorCode: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAnalyze(): Promise<AnalyzeResult> {
|
export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||||
const budget = await checkBudget();
|
const budget = await checkBudget();
|
||||||
if (!budget.allow) {
|
if (!budget.allow) {
|
||||||
@@ -72,31 +162,73 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Application-level fingerprint cache: if an active insight with same fingerprint
|
// ─── Layer 1: exact fingerprint (or aliased fingerprint) fast-path ───
|
||||||
// exists and is younger than CACHE_TTL_HOURS, just attach this session to it.
|
// A session whose fingerprint already maps to an insight is attached without
|
||||||
|
// re-running the LLM: dismissed/duplicate SUPPRESS it (respect triage — no
|
||||||
|
// new row, no cost), validated/shipped FLAG a regression, active+recent just
|
||||||
|
// accumulate. Only active+stale falls through to a refresh re-analysis.
|
||||||
const cutoff = new Date(Date.now() - CACHE_TTL_HOURS * 3600_000);
|
const cutoff = new Date(Date.now() - CACHE_TTL_HOURS * 3600_000);
|
||||||
const existing = await prisma.insight.findUnique({
|
const existing = await prisma.insight.findFirst({
|
||||||
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint: s.fingerprint } },
|
where: {
|
||||||
|
projectKey: PROJECT_KEY,
|
||||||
|
OR: [{ fingerprint: s.fingerprint }, { aliasFingerprints: { has: s.fingerprint } }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (existing && existing.updatedAt > cutoff && !["dismissed", "validated"].includes(existing.status)) {
|
if (existing) {
|
||||||
// Aggregate this session into the existing insight
|
const handled = await attachToExisting(existing, group, { allowStaleReanalyze: true, cutoff });
|
||||||
const rel = Array.from(new Set([...existing.relatedSessionIds, s.id]));
|
if (handled) {
|
||||||
await prisma.insight.update({
|
skipped++;
|
||||||
where: { id: existing.id },
|
continue;
|
||||||
data: {
|
}
|
||||||
relatedSessionIds: rel,
|
// active & stale → fall through to full re-analysis (existing stays set).
|
||||||
occurrenceCount: rel.length,
|
}
|
||||||
lastSeenAt: s.startedAt > existing.lastSeenAt ? s.startedAt : existing.lastSeenAt,
|
|
||||||
},
|
// Fetch timeline from MinIO — needed by both the dedup gate and the analysis.
|
||||||
});
|
let timeline: string;
|
||||||
await prisma.sessionMeta.update({
|
try {
|
||||||
where: { id: s.id },
|
timeline = await getText(COMPRESSION_BUCKET, s.compressed.semanticTimelineMinioKey);
|
||||||
data: { status: "analyzed", processedAt: new Date() },
|
} catch (e) {
|
||||||
});
|
console.warn(`[analyze] timeline fetch failed ${s.id}: ${(e as Error).message}`);
|
||||||
skipped++;
|
failed++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Layer 2: semantic dedup gate (only for genuinely new fingerprints) ───
|
||||||
|
// Before spending the expensive analysis call, ask the flash model whether
|
||||||
|
// this session is the SAME underlying problem as an existing insight —
|
||||||
|
// including dismissed/duplicate ones we must NOT recreate. On a confident
|
||||||
|
// match we alias this fingerprint onto that insight and attach/suppress, so
|
||||||
|
// future identical sessions fast-path with no LLM and no duplicate row.
|
||||||
|
if (!existing && DEDUP_ENABLED) {
|
||||||
|
const catalog = await buildInsightCatalog(PROJECT_KEY);
|
||||||
|
const match = await findSemanticMatch({ timeline, tags: s.tags, catalog });
|
||||||
|
if (match.raw) {
|
||||||
|
costUsd += match.raw.cost.totalUsd;
|
||||||
|
await logDedupCost(s.id, PROJECT_KEY, match.raw);
|
||||||
|
}
|
||||||
|
const catalogIds = new Set(catalog.map((c) => c.id));
|
||||||
|
if (match.matchId && shouldAcceptMatch(match.matchId, match.confidence, catalogIds, DEDUP_THRESHOLD)) {
|
||||||
|
const matchId = match.matchId;
|
||||||
|
const matched = await prisma.insight.findUnique({ where: { id: matchId } });
|
||||||
|
if (matched) {
|
||||||
|
await prisma.insight.update({
|
||||||
|
where: { id: matched.id },
|
||||||
|
data: {
|
||||||
|
aliasFingerprints: Array.from(new Set([...matched.aliasFingerprints, s.fingerprint])),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await attachToExisting(matched, group, { allowStaleReanalyze: false, cutoff });
|
||||||
|
console.log(
|
||||||
|
`[analyze] dedup-gate matched ${s.fingerprint} → ${matched.id} ` +
|
||||||
|
`(${matched.status}) conf=${match.confidence.toFixed(2)} :: ${match.reason}`,
|
||||||
|
);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no confident match → fall through and create a new insight below.
|
||||||
|
}
|
||||||
|
|
||||||
const promptTag = isBundle ? "pattern_bundle" : pickPromptTag(s.tags);
|
const promptTag = isBundle ? "pattern_bundle" : pickPromptTag(s.tags);
|
||||||
const template = await prisma.promptTemplate.findFirst({
|
const template = await prisma.promptTemplate.findFirst({
|
||||||
where: { tag: promptTag, active: true },
|
where: { tag: promptTag, active: true },
|
||||||
@@ -114,16 +246,6 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
|||||||
else if (s.severity === "P2" || s.severity === "P3" || s.severity === "INFO") tier = "flash";
|
else if (s.severity === "P2" || s.severity === "P3" || s.severity === "INFO") tier = "flash";
|
||||||
if (budget.forceTier) tier = budget.forceTier;
|
if (budget.forceTier) tier = budget.forceTier;
|
||||||
|
|
||||||
// Fetch timeline from MinIO (primary)
|
|
||||||
let timeline: string;
|
|
||||||
try {
|
|
||||||
timeline = await getText(COMPRESSION_BUCKET, s.compressed.semanticTimelineMinioKey);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[analyze] timeline fetch failed ${s.id}: ${(e as Error).message}`);
|
|
||||||
failed++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For bundles, append a summary of the other sessions in the group.
|
// For bundles, append a summary of the other sessions in the group.
|
||||||
if (isBundle) {
|
if (isBundle) {
|
||||||
const otherSessionIds = group.slice(1).map((x) => x.id);
|
const otherSessionIds = group.slice(1).map((x) => x.id);
|
||||||
|
|||||||
@@ -145,11 +145,17 @@ export function compressSnapshots(
|
|||||||
|
|
||||||
const formatCustom = (ev: CanonicalEvent): string => {
|
const formatCustom = (ev: CanonicalEvent): string => {
|
||||||
// Inline a tiny subset of important properties to keep tokens bounded.
|
// Inline a tiny subset of important properties to keep tokens bounded.
|
||||||
|
// `error`, `source`, `vin` are essential for vin_decode_failed disambiguation
|
||||||
|
// (client-side validation reject vs. upstream provider failure) — without
|
||||||
|
// them the LLM cannot tell whether a failure actually reached a provider.
|
||||||
const p = ev.properties ?? {};
|
const p = ev.properties ?? {};
|
||||||
const keys = [
|
const keys = [
|
||||||
"provider",
|
"provider",
|
||||||
"provider_attempted",
|
"provider_attempted",
|
||||||
"error_code",
|
"error_code",
|
||||||
|
"error",
|
||||||
|
"source",
|
||||||
|
"vin",
|
||||||
"result",
|
"result",
|
||||||
"plan",
|
"plan",
|
||||||
"amount",
|
"amount",
|
||||||
@@ -391,11 +397,39 @@ export function compressSnapshots(
|
|||||||
hypotheses.push("Fresh trial activated and decoded a VIN successfully — onboarding succeeded");
|
hypotheses.push("Fresh trial activated and decoded a VIN successfully — onboarding succeeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pattern 3: VIN decode failure or provider fallback — investigate upstream.
|
// Pattern 3: VIN decode failure or provider fallback — but separate the
|
||||||
const vinFailures = count("vin_decode_failed") + count("vin_decode_error");
|
// client-side validation rejects (input never reached a provider) from real
|
||||||
if (vinFailures >= 2 || count("provider_fallback_triggered") >= 1) {
|
// upstream failures. Lumping them together biases the LLM toward "provider
|
||||||
|
// issue" verdicts when the actual signal is user input affordance.
|
||||||
|
const vinFailEvents = customEvents.filter(
|
||||||
|
(c) => c.name === "vin_decode_failed" || c.name === "vin_decode_error",
|
||||||
|
);
|
||||||
|
const clientRejects = vinFailEvents.filter((c) => {
|
||||||
|
const p = (c.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const err = String(p.error ?? "");
|
||||||
|
const vin = String(p.vin ?? "");
|
||||||
|
if (/(Geçersiz şase|17 karakter|I, O, Q|invalid VIN|must be 17|format)/i.test(err)) return true;
|
||||||
|
if (vin && vin.replace(/\s/g, "").length !== 17) return true;
|
||||||
|
if (vin && /[IOQ]/i.test(vin)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const realProviderFails = vinFailEvents.length - clientRejects.length;
|
||||||
|
const fallbacks = count("provider_fallback_triggered");
|
||||||
|
|
||||||
|
if (clientRejects.length >= 1 && realProviderFails === 0) {
|
||||||
|
const sample = String(
|
||||||
|
((clientRejects[0].properties ?? {}) as Record<string, unknown>).vin ?? "",
|
||||||
|
).slice(0, 24);
|
||||||
hypotheses.push(
|
hypotheses.push(
|
||||||
`VIN decode failure pattern (failures=${vinFailures}, fallbacks=${count("provider_fallback_triggered")}) — check provider health`,
|
`VIN inputuna geçersiz format girildi (${clientRejects.length}x client-side validation reddi${sample ? `, örn. "${sample}"` : ""}) — provider çağrılmadı, input affordance / yanlış alan kullanımı problemi`,
|
||||||
|
);
|
||||||
|
} else if (realProviderFails >= 2 || fallbacks >= 1) {
|
||||||
|
hypotheses.push(
|
||||||
|
`VIN decode failure pattern (provider failures=${realProviderFails}, fallbacks=${fallbacks}) — check provider health`,
|
||||||
|
);
|
||||||
|
} else if (realProviderFails === 1) {
|
||||||
|
hypotheses.push(
|
||||||
|
`Single VIN decode failure reached a provider — likely transient, watch for repeat`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,12 +474,23 @@ export function compressSnapshots(
|
|||||||
...(hypotheses.length ? hypotheses.map((h) => `- ${h}`) : ["- (none)"]),
|
...(hypotheses.length ? hypotheses.map((h) => `- ${h}`) : ["- (none)"]),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
// Fingerprint inputs are intentionally coarse so that recurring problems
|
||||||
|
// cluster into a single Insight row instead of producing a new row per
|
||||||
|
// user/vehicle/category. Two prior issues this guards against:
|
||||||
|
// 1. URL carries vehicleId/categoryId UUIDs — every user produces a unique
|
||||||
|
// URL, so identical kategori-bouncing sessions never deduplicate.
|
||||||
|
// normalizePath collapses UUIDs/ULIDs/CUIDs/numeric ids to ":id".
|
||||||
|
// 2. header.severity is derived from rage-click count and varies between
|
||||||
|
// P1/P2/P3 for the same root cause — it should be a property of the
|
||||||
|
// Insight, not part of its identity. Dropped from the hash.
|
||||||
|
// Tag set + normalized path + first-error + first-failed-endpoint give
|
||||||
|
// enough discrimination because distinct UX failures already carry distinct
|
||||||
|
// tagger tags (vin_decode_*, payment_*, search_validation_*, etc.).
|
||||||
const fingerprint = fingerprintHash([
|
const fingerprint = fingerprintHash([
|
||||||
[...header.tags].sort().join(","),
|
[...header.tags].sort().join(","),
|
||||||
url,
|
normalizePath(url),
|
||||||
errors[0] ? normalizeError(errors[0]) : null,
|
errors[0] ? normalizeError(errors[0]) : null,
|
||||||
failedEndpoints[0] ?? null,
|
failedEndpoints[0] ? normalizePath(failedEndpoints[0]) : null,
|
||||||
header.severity,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -477,6 +522,47 @@ function stripQuery(u: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse ID-like path segments to `:id` so URLs with embedded
|
||||||
|
* UUID/ULID/CUID/numeric identifiers fingerprint identically across users.
|
||||||
|
*
|
||||||
|
* Conservative on purpose — only matches segments whose entire content is a
|
||||||
|
* recognised id format, leaving meaningful path words alone. Used both for
|
||||||
|
* the page url and for failed-endpoint paths.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* /dashboard/vehicles/6a3e7c44-d64b-4245-abc7-adb692a90fff/categories/29ee50e7-887f-4124-b1fc-bbeea5e358d1
|
||||||
|
* → /dashboard/vehicles/:id/categories/:id
|
||||||
|
* /api/orders/12345/items → /api/orders/:id/items
|
||||||
|
* /insights/i/cmpwrb82s002f14fza8lbc7f4 → /insights/i/:id
|
||||||
|
*/
|
||||||
|
function normalizePath(u: string | null | undefined): string | null {
|
||||||
|
if (!u) return u ?? null;
|
||||||
|
let path: string;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(u, "https://x");
|
||||||
|
path = parsed.pathname || u;
|
||||||
|
} catch {
|
||||||
|
const q = u.indexOf("?");
|
||||||
|
path = q === -1 ? u : u.slice(0, q);
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => {
|
||||||
|
if (!seg) return seg;
|
||||||
|
// UUID v1–v8 canonical form
|
||||||
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return ":id";
|
||||||
|
// ULID — Crockford base32, exactly 26 chars
|
||||||
|
if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(seg)) return ":id";
|
||||||
|
// CUID2 / similar — lowercase alphanumeric, length >= 20 starting with a letter
|
||||||
|
if (/^[a-z][a-z0-9]{19,}$/.test(seg)) return ":id";
|
||||||
|
// Pure numeric id
|
||||||
|
if (/^\d+$/.test(seg)) return ":id";
|
||||||
|
return seg;
|
||||||
|
})
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
function truncate(s: string, n: number): string {
|
||||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||||
}
|
}
|
||||||
|
|||||||
57
apps/worker/src/lib/dedup.smoke.ts
Normal file
57
apps/worker/src/lib/dedup.smoke.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test for the insight dedup decision logic (pure helpers, no I/O).
|
||||||
|
* Run: pnpm --filter worker exec tsx src/lib/dedup.smoke.ts
|
||||||
|
*
|
||||||
|
* Covers the two new guarantees:
|
||||||
|
* 1. No duplicate insights — same-fingerprint / aliased sessions never create
|
||||||
|
* a new row (attach/reanalyze), and a confident semantic match attaches.
|
||||||
|
* 2. No insights similar to dismissed ones — dismissed/duplicate fingerprints
|
||||||
|
* are SUPPRESSED, never re-opened.
|
||||||
|
*/
|
||||||
|
import { classifyExistingAction, shouldAcceptMatch, type ExistingAction } from "./dedup";
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function eq<T>(label: string, got: T, want: T): void {
|
||||||
|
if (got === want) {
|
||||||
|
pass++;
|
||||||
|
} else {
|
||||||
|
fail++;
|
||||||
|
console.error(`✗ ${label}: got ${JSON.stringify(got)} want ${JSON.stringify(want)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── classifyExistingAction(status, isStale, allowStaleReanalyze) ───
|
||||||
|
const cea = (s: string, stale: boolean, allow: boolean): ExistingAction =>
|
||||||
|
classifyExistingAction(s, stale, allow);
|
||||||
|
|
||||||
|
// Suppressed themes are never re-opened, regardless of staleness/allow flag.
|
||||||
|
eq("dismissed → suppress (fresh)", cea("dismissed", false, true), "suppress");
|
||||||
|
eq("dismissed → suppress (stale)", cea("dismissed", true, true), "suppress");
|
||||||
|
eq("dismissed → suppress (gate path)", cea("dismissed", true, false), "suppress");
|
||||||
|
eq("duplicate → suppress", cea("duplicate", true, true), "suppress");
|
||||||
|
|
||||||
|
// Fixed themes that recur are flagged as regressions.
|
||||||
|
eq("validated → regress", cea("validated", false, true), "regress");
|
||||||
|
eq("shipped → regress (stale)", cea("shipped", true, true), "regress");
|
||||||
|
|
||||||
|
// Active themes accumulate; stale ones refresh only on the Layer-1 path.
|
||||||
|
eq("new fresh → attach", cea("new", false, true), "attach");
|
||||||
|
eq("new stale (Layer1) → reanalyze", cea("new", true, true), "reanalyze");
|
||||||
|
eq("new stale (gate, no-reanalyze) → attach", cea("new", true, false), "attach");
|
||||||
|
eq("in_backlog fresh → attach", cea("in_backlog", false, true), "attach");
|
||||||
|
eq("in_progress stale (Layer1) → reanalyze", cea("in_progress", true, true), "reanalyze");
|
||||||
|
eq("regressed fresh → attach", cea("regressed", false, true), "attach");
|
||||||
|
eq("triaged stale (gate) → attach", cea("triaged", true, false), "attach");
|
||||||
|
|
||||||
|
// ─── shouldAcceptMatch(matchId, confidence, catalogIds, threshold) ───
|
||||||
|
const cat = new Set(["cm_a", "cm_b", "cm_c"]);
|
||||||
|
const T = 0.72;
|
||||||
|
eq("null match → reject", shouldAcceptMatch(null, 0.99, cat, T), false);
|
||||||
|
eq("hallucinated id → reject", shouldAcceptMatch("cm_zzz", 0.99, cat, T), false);
|
||||||
|
eq("in-catalog + above threshold → accept", shouldAcceptMatch("cm_a", 0.8, cat, T), true);
|
||||||
|
eq("in-catalog + at threshold → accept", shouldAcceptMatch("cm_b", 0.72, cat, T), true);
|
||||||
|
eq("in-catalog + below threshold → reject", shouldAcceptMatch("cm_c", 0.71, cat, T), false);
|
||||||
|
|
||||||
|
console.log(`\ndedup smoke: ${pass} passed, ${fail} failed`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
160
apps/worker/src/lib/dedup.ts
Normal file
160
apps/worker/src/lib/dedup.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { prisma } from "../db";
|
||||||
|
import { callDeepSeek, extractJson, type CallResult } from "./deepseek";
|
||||||
|
|
||||||
|
// ─── Status families (kept in sync with analyze.ts) ───
|
||||||
|
export const ACTIVE_STATUSES = ["new", "triaged", "in_backlog", "in_progress", "regressed"];
|
||||||
|
export const SUPPRESSED_STATUSES = ["dismissed", "duplicate"];
|
||||||
|
export const FIXED_STATUSES = ["validated", "shipped"];
|
||||||
|
|
||||||
|
// ─── Tunables ───
|
||||||
|
export const DEDUP_ENABLED = (process.env.INSIGHT_DEDUP_GATE ?? "true") !== "false";
|
||||||
|
export const DEDUP_THRESHOLD = Number(process.env.INSIGHT_DEDUP_THRESHOLD ?? "0.72");
|
||||||
|
const SUPPRESS_LOOKBACK_DAYS = Number(process.env.INSIGHT_DEDUP_SUPPRESS_DAYS ?? "90");
|
||||||
|
const CATALOG_MAX = Number(process.env.INSIGHT_DEDUP_CATALOG_MAX ?? "120");
|
||||||
|
|
||||||
|
export type CatalogEntry = { id: string; title: string; type: string; status: string };
|
||||||
|
|
||||||
|
// ─── Pure decision helpers (unit-testable, no I/O) ───
|
||||||
|
|
||||||
|
export type ExistingAction = "suppress" | "regress" | "attach" | "reanalyze";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what to do when a session's fingerprint already maps to an insight.
|
||||||
|
* suppress — dismissed/duplicate: bump counters, keep status (no LLM)
|
||||||
|
* regress — validated/shipped: flag regression (no LLM)
|
||||||
|
* reanalyze — active but stale: caller re-runs the LLM to refresh
|
||||||
|
* attach — active and fresh: bump counters (no LLM)
|
||||||
|
*/
|
||||||
|
export function classifyExistingAction(
|
||||||
|
status: string,
|
||||||
|
isStale: boolean,
|
||||||
|
allowStaleReanalyze: boolean,
|
||||||
|
): ExistingAction {
|
||||||
|
if (SUPPRESSED_STATUSES.includes(status)) return "suppress";
|
||||||
|
if (FIXED_STATUSES.includes(status)) return "regress";
|
||||||
|
if (allowStaleReanalyze && isStale) return "reanalyze";
|
||||||
|
return "attach";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether to accept a dedup-gate match: the id must exist in the catalog
|
||||||
|
* (anti-hallucination) and confidence must clear the threshold.
|
||||||
|
*/
|
||||||
|
export function shouldAcceptMatch(
|
||||||
|
matchId: string | null,
|
||||||
|
confidence: number,
|
||||||
|
catalogIds: Set<string>,
|
||||||
|
threshold: number,
|
||||||
|
): boolean {
|
||||||
|
return !!matchId && catalogIds.has(matchId) && confidence >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the dedup catalog the LLM gate compares a new session against:
|
||||||
|
* - ALL currently-active insights (dedup target → attach).
|
||||||
|
* - Recently-touched suppressed/fixed insights (dismissed / duplicate /
|
||||||
|
* validated / shipped) within the lookback window (suppression target →
|
||||||
|
* don't recreate a known non-issue / flag regression).
|
||||||
|
* Active entries are prioritised, then most-recent others, capped at CATALOG_MAX
|
||||||
|
* so the prompt stays bounded as the dismissed pile grows over time.
|
||||||
|
*/
|
||||||
|
export async function buildInsightCatalog(projectKey: string): Promise<CatalogEntry[]> {
|
||||||
|
const since = new Date(Date.now() - SUPPRESS_LOOKBACK_DAYS * 86_400_000);
|
||||||
|
const rows = await prisma.insight.findMany({
|
||||||
|
where: {
|
||||||
|
projectKey,
|
||||||
|
OR: [
|
||||||
|
{ status: { in: ACTIVE_STATUSES } },
|
||||||
|
{ status: { in: [...SUPPRESSED_STATUSES, ...FIXED_STATUSES] }, updatedAt: { gte: since } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, type: true, status: true },
|
||||||
|
orderBy: [{ updatedAt: "desc" }],
|
||||||
|
});
|
||||||
|
const active = rows.filter((r) => ACTIVE_STATUSES.includes(r.status));
|
||||||
|
const rest = rows.filter((r) => !ACTIVE_STATUSES.includes(r.status));
|
||||||
|
return [...active, ...rest].slice(0, CATALOG_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a strict deduplication classifier for a product-analytics insight pipeline (Sase.tr — a Turkish car-parts catalog SaaS where users decode a VIN, browse a category tree / parts panel, and subscribe via Stripe).
|
||||||
|
|
||||||
|
You receive ONE new user-session signal and a CATALOG of existing insights, each tagged with a status. Decide whether the new session describes the SAME underlying product problem as exactly one catalog entry.
|
||||||
|
|
||||||
|
"Same problem" means: same root cause AND same user-facing failure on the same surface/flow — e.g. both are "category-tree navigation produces rage-clicks", or both are "VIN decode upstream provider timeout".
|
||||||
|
NOT the same: merely sharing a page, a severity, or the generic signal "ux_friction". Distinct sub-problems on the same page are DIFFERENT (e.g. a broken schema IMAGE vs. a slow category LIST vs. a parts panel that won't SELECT are three different insights).
|
||||||
|
|
||||||
|
Matching an entry whose status is "dismissed" or "duplicate" is expected and valuable — it means this is a known non-issue we must NOT recreate. Matching "validated"/"shipped" means a previously-fixed problem may have regressed.
|
||||||
|
|
||||||
|
Return ONLY JSON: {"match_insight_id": "<id from catalog, or null>", "confidence": <0..1>, "reason": "<one short sentence>"}.
|
||||||
|
Be conservative: when in doubt, return null with low confidence. Never invent an id that is not in the catalog.`;
|
||||||
|
|
||||||
|
export type DedupResult = {
|
||||||
|
matchId: string | null;
|
||||||
|
confidence: number;
|
||||||
|
reason: string;
|
||||||
|
raw: CallResult | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the flash model whether this session matches an existing insight.
|
||||||
|
* Returns matchId=null if the catalog is empty, parsing fails, or the model
|
||||||
|
* returns an id that is not actually in the catalog (anti-hallucination guard).
|
||||||
|
*/
|
||||||
|
export async function findSemanticMatch(input: {
|
||||||
|
timeline: string;
|
||||||
|
tags: string[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}): Promise<DedupResult> {
|
||||||
|
if (input.catalog.length === 0) {
|
||||||
|
return { matchId: null, confidence: 0, reason: "empty catalog", raw: null };
|
||||||
|
}
|
||||||
|
const catalogText = input.catalog
|
||||||
|
.map((c) => `- [${c.id}] (status=${c.status}, type=${c.type}) ${c.title}`)
|
||||||
|
.join("\n");
|
||||||
|
const snippet =
|
||||||
|
input.timeline.length > 2800 ? `${input.timeline.slice(0, 2800)}\n…(truncated)` : input.timeline;
|
||||||
|
const userPrompt = [
|
||||||
|
"=== NEW SESSION ===",
|
||||||
|
`tags: [${input.tags.join(", ")}]`,
|
||||||
|
"",
|
||||||
|
snippet,
|
||||||
|
"",
|
||||||
|
"=== EXISTING INSIGHTS CATALOG ===",
|
||||||
|
catalogText,
|
||||||
|
"",
|
||||||
|
'Return JSON only: {"match_insight_id": <id|null>, "confidence": 0..1, "reason": "..."}',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
let raw: CallResult;
|
||||||
|
try {
|
||||||
|
raw = await callDeepSeek({
|
||||||
|
tier: "flash",
|
||||||
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
|
userPrompt,
|
||||||
|
maxOutputTokens: 200,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { matchId: null, confidence: 0, reason: `dedup call failed: ${(e as Error).message}`, raw: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(extractJson(raw.text)) as {
|
||||||
|
match_insight_id?: string | null;
|
||||||
|
confidence?: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
let matchId = p.match_insight_id ?? null;
|
||||||
|
let confidence = typeof p.confidence === "number" ? p.confidence : 0;
|
||||||
|
let reason = String(p.reason ?? "");
|
||||||
|
// Anti-hallucination: only accept ids that are actually in the catalog.
|
||||||
|
if (matchId && !input.catalog.some((c) => c.id === matchId)) {
|
||||||
|
reason = `rejected non-catalog id ${matchId}; ${reason}`;
|
||||||
|
matchId = null;
|
||||||
|
confidence = 0;
|
||||||
|
}
|
||||||
|
return { matchId, confidence, reason, raw };
|
||||||
|
} catch (e) {
|
||||||
|
return { matchId: null, confidence: 0, reason: `parse error: ${(e as Error).message}`, raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,17 +264,25 @@ Return JSON per the schema. Use occurrence patterns from the bundle summary to e
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "provider_quality",
|
tag: "provider_quality",
|
||||||
version: 3,
|
version: 4,
|
||||||
name: "Provider Quality v3 (TR, ext maxLen)",
|
name: "Provider Quality v4 (TR, client-validation guardrail)",
|
||||||
systemPrompt: `You analyze upstream provider failures (PL24/PCAT/RMEX/TecDoc) impacting Sase.tr users. Identify which provider failed and propose action.
|
systemPrompt: `You analyze upstream provider failures (PL24/PCAT/RMEX/TecDoc) impacting Sase.tr users. Identify which provider failed and propose action.
|
||||||
|
|
||||||
${SASE_CONTEXT}
|
${SASE_CONTEXT}
|
||||||
|
|
||||||
|
CRITICAL GUARDRAIL — distinguish client-side input rejection from upstream provider failure:
|
||||||
|
- A \`vin_decode_failed\` event is ONLY a provider issue when the input actually reached a provider. Check the inlined event properties:
|
||||||
|
- If \`error\` contains "Geçersiz şase numarası", "17 karakter olmalı", "I, O, Q", "invalid VIN", or any 17-character / format complaint → this was a **client-side validation reject**, the server was NEVER called.
|
||||||
|
- If \`vin\` is shorter than 17 characters, contains spaces (e.g. "5Q0 907 521" — that's a VW PART NUMBER, not a VIN), or contains I/O/Q → same: client-side rejection.
|
||||||
|
- If \`source\` is a UI location ("landing", "dashboard") and no \`provider_attempted\` field is present → did not reach a provider.
|
||||||
|
- In all of the above cases this is **NOT a provider_quality issue**. Return \`confidence: 0.15\`, \`failure_mode: "unknown"\`, \`affected_provider: "multi"\`, and in the \`hypothesis\` explicitly state: "Bu provider hatası değil — kullanıcı VIN alanına geçersiz format girdi (client-side reddi). Doğru kategori ux_friction / input affordance." This low-confidence output is preferable to inventing a provider issue.
|
||||||
|
- Only return a high-confidence provider_quality verdict when at least 2 events show \`provider_attempted\` set OR the timeline shows network 5xx/timeout patterns from upstream endpoints (e.g. \`/api/vin/decode\`, \`/api/parts\`, requests to PL24/PCAT/RMEX/TecDoc paths).
|
||||||
|
|
||||||
Schema:
|
Schema:
|
||||||
${JSON.stringify(PROVIDER_SCHEMA)}`,
|
${JSON.stringify(PROVIDER_SCHEMA)}`,
|
||||||
userPromptTemplate: `{{timeline}}
|
userPromptTemplate: `{{timeline}}
|
||||||
|
|
||||||
Return JSON per the schema.`,
|
Return JSON per the schema. Before classifying as provider issue, verify the guardrail above by checking inlined event properties (error, source, vin).`,
|
||||||
outputSchemaJson: PROVIDER_SCHEMA,
|
outputSchemaJson: PROVIDER_SCHEMA,
|
||||||
modelTier: "flash",
|
modelTier: "flash",
|
||||||
maxOutputTokens: 800,
|
maxOutputTokens: 800,
|
||||||
@@ -299,6 +307,12 @@ export function pickPromptTag(tags: string[]): string {
|
|||||||
// Upgrade hesitation — pricing page concerns
|
// Upgrade hesitation — pricing page concerns
|
||||||
if (set.has("upgrade_hesitation")) return "upgrade_hesitation";
|
if (set.has("upgrade_hesitation")) return "upgrade_hesitation";
|
||||||
|
|
||||||
|
// Client-side VIN validation rejects must route to ux_friction — these are
|
||||||
|
// input affordance problems (user typed a part number / short string into the
|
||||||
|
// VIN field), not upstream provider failures. Checked *before* provider tags
|
||||||
|
// so that the more specific signal wins.
|
||||||
|
if (set.has("vin_decode_client_validation_fail")) return "ux_friction";
|
||||||
|
|
||||||
// Provider issues
|
// Provider issues
|
||||||
if (
|
if (
|
||||||
set.has("provider_reliability_issue") ||
|
set.has("provider_reliability_issue") ||
|
||||||
|
|||||||
@@ -71,23 +71,59 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── VIN decode failure pattern ───
|
// ─── VIN decode failure pattern ───
|
||||||
|
// Distinguish *client-side validation rejects* (input too short / wrong format
|
||||||
|
// / forbidden chars I,O,Q — server never called) from *upstream provider
|
||||||
|
// failures* (PL24/PCAT/RMEX/TecDoc timeout/error). Bundling them together
|
||||||
|
// produces false-positive "provider issue" insights (e.g. user typing a VW
|
||||||
|
// part number "5Q0 907 521" into the VIN field hits client-side regex; no
|
||||||
|
// provider was contacted, so it isn't a provider quality signal).
|
||||||
const vinFails = events.filter((e) => e.name === "vin_decode_failed");
|
const vinFails = events.filter((e) => e.name === "vin_decode_failed");
|
||||||
if (vinFails.length >= 2) {
|
if (vinFails.length >= 1) {
|
||||||
const providers = new Set(
|
const isClientValidationReject = (e: CanonicalEvent): boolean => {
|
||||||
vinFails.map((e) => String(e.properties.provider_attempted ?? e.properties.source ?? "")),
|
const p = e.properties ?? {};
|
||||||
);
|
const err = String(p.error ?? "");
|
||||||
if (providers.size === 1 && [...providers][0]) {
|
const vin = String(p.vin ?? "");
|
||||||
tags.push("vin_decode_fail_pattern");
|
// Frontend Zod/regex messages we ship — keep in sync with web VIN validator.
|
||||||
severity = bump(severity, "P1");
|
const clientMsgRe = /(Geçersiz şase|17 karakter|I, O, Q|invalid VIN|must be 17|format)/i;
|
||||||
} else {
|
if (clientMsgRe.test(err)) return true;
|
||||||
// Different providers failing → still notable
|
// Length / forbidden-char heuristic — if the user typed something that
|
||||||
tags.push("vin_decode_repeated_failure");
|
// couldn't possibly reach the upstream, treat as client-side rejection.
|
||||||
severity = bump(severity, "P2");
|
if (vin && vin.replace(/\s/g, "").length !== 17) return true;
|
||||||
|
if (vin && /[IOQ]/i.test(vin)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const clientRejects = vinFails.filter(isClientValidationReject);
|
||||||
|
const realFailures = vinFails.filter((e) => !isClientValidationReject(e));
|
||||||
|
|
||||||
|
if (clientRejects.length >= 1 && realFailures.length === 0) {
|
||||||
|
// Pure client-side input affordance problem — route through ux_friction,
|
||||||
|
// not provider_quality. Severity is low (no service impact).
|
||||||
|
tags.push("vin_decode_client_validation_fail");
|
||||||
|
severity = bump(severity, "P3");
|
||||||
|
} else if (realFailures.length >= 2) {
|
||||||
|
// True upstream failures: group by *provider* (not the UI source field —
|
||||||
|
// "landing"/"dashboard" are page locations, not providers).
|
||||||
|
const providers = new Set(
|
||||||
|
realFailures
|
||||||
|
.map((e) => String(e.properties.provider_attempted ?? ""))
|
||||||
|
.filter((v) => v.length > 0),
|
||||||
|
);
|
||||||
|
if (providers.size === 1) {
|
||||||
|
tags.push("vin_decode_fail_pattern");
|
||||||
|
severity = bump(severity, "P1");
|
||||||
|
} else if (providers.size > 1) {
|
||||||
|
tags.push("vin_decode_repeated_failure");
|
||||||
|
severity = bump(severity, "P2");
|
||||||
|
} else {
|
||||||
|
// Unknown provider attribution but server-side failure shape — still
|
||||||
|
// worth surfacing but as a softer signal.
|
||||||
|
tags.push("vin_decode_repeated_failure");
|
||||||
|
severity = bump(severity, "P2");
|
||||||
|
}
|
||||||
|
} else if (realFailures.length === 1) {
|
||||||
|
tags.push("vin_decode_failed_single");
|
||||||
|
severity = bump(severity, "P3");
|
||||||
}
|
}
|
||||||
} else if (vinFails.length === 1) {
|
|
||||||
// Single failure is still a quality signal, less severe
|
|
||||||
tags.push("vin_decode_failed_single");
|
|
||||||
severity = bump(severity, "P3");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has(events, "provider_fallback_triggered")) {
|
if (has(events, "provider_fallback_triggered")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user