Compare commits
19 Commits
fix/insigh
...
feat/insig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5daf2e5c | ||
|
|
7a56a72037 | ||
|
|
556cfd6ba0 | ||
|
|
b61d955256 | ||
|
|
a7fe80f0c5 | ||
|
|
9a479f9a5b | ||
| 59cb1f63ec | |||
|
|
8a81cb77bf | ||
| 7eca679ab8 | |||
|
|
928728dc66 | ||
|
|
ab86dbdf59 | ||
|
|
3941b7018f | ||
|
|
f663c0aa09 | ||
|
|
fb33692452 | ||
|
|
09438cf513 | ||
|
|
985592aa73 | ||
|
|
f05e0bc808 | ||
|
|
0419cc271c | ||
|
|
fdce3f6bd0 |
@@ -142,6 +142,10 @@ model SessionMeta {
|
||||
processedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Raw rrweb recording preservation (archived to MinIO before PostHog's ~30-day deletion).
|
||||
rrwebArchivedAt DateTime?
|
||||
rrwebArchiveKey String?
|
||||
|
||||
compressed CompressedSession?
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@ -247,6 +251,11 @@ model Insight {
|
||||
severity String
|
||||
status String @default("new")
|
||||
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
|
||||
body Json
|
||||
relatedSessionIds String[]
|
||||
@@ -364,6 +373,71 @@ model EvalRun {
|
||||
@@map("eval_runs")
|
||||
}
|
||||
|
||||
// ---------- Phase 8a: Content generation ----------
|
||||
|
||||
// A topic/brief for content generation. Either auto-generated by the
|
||||
// content-topics job (LLM idea generation) or entered manually. One topic
|
||||
// fans out into one ContentDraft per selected channel.
|
||||
model ContentTopic {
|
||||
id String @id @default(cuid())
|
||||
projectKey String
|
||||
title String
|
||||
brief String @db.Text
|
||||
angle String? @db.Text
|
||||
channels String[] // ["blog","linkedin","x","instagram"]
|
||||
keywords String[]
|
||||
/// queued | generating | drafted | archived
|
||||
status String @default("queued")
|
||||
/// auto | manual
|
||||
source String @default("auto")
|
||||
fingerprint String? // dedupe near-identical auto topics
|
||||
sourcePromptTag String?
|
||||
sourcePromptVersion Int?
|
||||
sourceModel String?
|
||||
sourceCostUsd Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
drafts ContentDraft[]
|
||||
|
||||
@@index([projectKey, status])
|
||||
@@index([fingerprint])
|
||||
@@map("content_topics")
|
||||
}
|
||||
|
||||
// One generated piece of content for a single channel. Lives as a draft in
|
||||
// the panel; founder reviews/edits, then publishes via the n8n webhook
|
||||
// (Phase 8c). bodyJson holds the channel-specific generated structure;
|
||||
// founderEdits holds any human overrides applied before publish.
|
||||
model ContentDraft {
|
||||
id String @id @default(cuid())
|
||||
topicId String
|
||||
projectKey String
|
||||
/// blog | linkedin | x | instagram
|
||||
channel String
|
||||
/// draft | approved | publishing | published | failed | rejected
|
||||
status String @default("draft")
|
||||
bodyJson Json
|
||||
founderEdits Json?
|
||||
publishedUrl String?
|
||||
n8nExecutionId String?
|
||||
publishError String?
|
||||
sourcePromptTag String
|
||||
sourcePromptVersion Int
|
||||
sourceModel String
|
||||
sourceCostUsd Float @default(0)
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
topic ContentTopic @relation(fields: [topicId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectKey, status])
|
||||
@@index([topicId])
|
||||
@@index([channel, status])
|
||||
@@map("content_drafts")
|
||||
}
|
||||
|
||||
// ---------- Phase 7a: Sase user-management ----------
|
||||
|
||||
// Founder-only notes pinned to a Sase user. Stored panel-side (KVKK minimize:
|
||||
@@ -380,3 +454,114 @@ model SaseUserNote {
|
||||
@@index([saseUserId, pinned, createdAt])
|
||||
@@map("sase_user_notes")
|
||||
}
|
||||
|
||||
// ---------- Phase 9: External Observability Archive ----------
|
||||
|
||||
// PostHog raw event stream mirrored to our DB permanently (free tier deletes
|
||||
// events on a rolling window). Lossless: full properties kept as JSONB, with
|
||||
// hot fields promoted to indexed columns. Also cold-dumped to MinIO as gzip JSONL.
|
||||
model PosthogEvent {
|
||||
uuid String @id // PostHog event uuid → natural dedup key
|
||||
projectKey String // "sase"
|
||||
event String
|
||||
distinctId String
|
||||
personId String?
|
||||
sessionId String? // $session_id — joins to SessionMeta.id
|
||||
timestamp DateTime // PostHog event time
|
||||
properties Json // full properties — lossless
|
||||
ingestedAt DateTime @default(now())
|
||||
dumpedAt DateTime? // set once written to MinIO cold partition
|
||||
|
||||
@@index([projectKey, timestamp])
|
||||
@@index([event, timestamp])
|
||||
@@index([distinctId, timestamp])
|
||||
@@index([dumpedAt])
|
||||
@@map("posthog_events")
|
||||
}
|
||||
|
||||
// Append-only history of PostHog person properties. Free tier overwrites the
|
||||
// live person; a new row is written only when properties change (propertiesHash),
|
||||
// so this is a compact timeline of how each person evolved.
|
||||
model PosthogPersonSnapshot {
|
||||
id String @id @default(cuid())
|
||||
projectKey String
|
||||
personId String // PostHog person uuid (persons.id) — joins PosthogEvent.personId
|
||||
distinctIds String[] // all distinct_ids merged into this person
|
||||
propertiesHash String // change-detection key
|
||||
properties Json
|
||||
capturedAt DateTime @default(now())
|
||||
|
||||
@@index([personId, capturedAt])
|
||||
@@index([projectKey, capturedAt])
|
||||
@@map("posthog_person_snapshots")
|
||||
}
|
||||
|
||||
// Append-only history of cohort definitions + membership counts. New row only
|
||||
// when count or filters change (stateHash).
|
||||
model PosthogCohortSnapshot {
|
||||
id String @id @default(cuid())
|
||||
projectKey String
|
||||
cohortId Int
|
||||
name String
|
||||
count Int?
|
||||
isStatic Boolean @default(false)
|
||||
stateHash String // hash of count + filters → change-detection
|
||||
filters Json
|
||||
capturedAt DateTime @default(now())
|
||||
|
||||
@@index([cohortId, capturedAt])
|
||||
@@index([projectKey, capturedAt])
|
||||
@@map("posthog_cohort_snapshots")
|
||||
}
|
||||
|
||||
// ---------- Phase C: Sentry archive ----------
|
||||
|
||||
// Sentry issue (group) aggregate state. Upserted to the latest snapshot — the
|
||||
// raw history lives in SentryEvent; the issue row is the current grouping/metadata.
|
||||
model SentryIssue {
|
||||
id String @id // Sentry issue/group id
|
||||
projectKey String // "sase" — panel-side project key
|
||||
sentrySourceProject String? // Sentry project slug (e.g. "python", "sase-web") — distinguishes browser vs server errors when one panel project has multiple Sentry projects
|
||||
shortId String?
|
||||
title String?
|
||||
culprit String?
|
||||
level String?
|
||||
status String?
|
||||
type String?
|
||||
count Int? // total events as of lastSyncedAt
|
||||
userCount Int?
|
||||
firstSeen DateTime?
|
||||
lastSeen DateTime?
|
||||
permalink String?
|
||||
metadata Json?
|
||||
ingestedAt DateTime @default(now())
|
||||
lastSyncedAt DateTime @default(now())
|
||||
|
||||
@@index([projectKey, lastSeen])
|
||||
@@index([projectKey, sentrySourceProject, lastSeen])
|
||||
@@map("sentry_issues")
|
||||
}
|
||||
|
||||
// Individual Sentry events (the at-risk raw occurrences; free tier prunes ~30d).
|
||||
// Lossless full payload as JSONB; also cold-dumped to MinIO.
|
||||
model SentryEvent {
|
||||
eventId String @id // Sentry event id (32 hex)
|
||||
issueId String? // parent issue/group id
|
||||
projectKey String // panel-side project key (e.g. "sase")
|
||||
sentrySourceProject String? // Sentry project slug the event came from ("python", "sase-web")
|
||||
title String?
|
||||
message String?
|
||||
level String?
|
||||
platform String?
|
||||
timestamp DateTime // event occurrence time
|
||||
tags Json?
|
||||
payload Json // full event payload — lossless
|
||||
ingestedAt DateTime @default(now())
|
||||
dumpedAt DateTime?
|
||||
|
||||
@@index([projectKey, timestamp])
|
||||
@@index([projectKey, sentrySourceProject, timestamp])
|
||||
@@index([issueId, timestamp])
|
||||
@@index([dumpedAt])
|
||||
@@map("sentry_events")
|
||||
}
|
||||
|
||||
27
apps/web/src/app/api/content/generate/route.ts
Normal file
27
apps/web/src/app/api/content/generate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Manually trigger one content-generate run (queued topics → channel drafts).
|
||||
// Also scheduled every 10min; this is for on-demand kicks after queueing topics.
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
|
||||
const job = await contentQueue().add(
|
||||
"content-generate",
|
||||
{},
|
||||
{ removeOnComplete: 50, removeOnFail: 25 },
|
||||
);
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: "/api/content/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
return NextResponse.json({ ok: true, jobId: job.id });
|
||||
}
|
||||
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal file
27
apps/web/src/app/api/content/topics/generate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Manually trigger one content-topics run (auto topic-idea generation).
|
||||
// The job itself is also scheduled (every 8h) — this is for on-demand kicks.
|
||||
export async function POST() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
|
||||
const job = await contentQueue().add(
|
||||
"content-topics",
|
||||
{},
|
||||
{ removeOnComplete: 30, removeOnFail: 15 },
|
||||
);
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: "/api/content/topics/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
return NextResponse.json({ ok: true, jobId: job.id });
|
||||
}
|
||||
29
apps/web/src/app/api/internal/catalog-gap-check/route.ts
Normal file
29
apps/web/src/app/api/internal/catalog-gap-check/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { detectCatalogCoverageGaps } from "@/lib/sase/catalog-coverage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const INTERNAL_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
|
||||
|
||||
function validToken(req: Request): boolean {
|
||||
if (!INTERNAL_TOKEN) return false;
|
||||
const provided = req.headers.get("x-internal-worker-token") ?? "";
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(INTERNAL_TOKEN);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
if (!validToken(req)) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const gaps = await detectCatalogCoverageGaps({ windowDays: 7, minFailures: 3 });
|
||||
return NextResponse.json({ ok: true, gaps });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
36
apps/web/src/app/api/sase/query-log/[id]/route.ts
Normal file
36
apps/web/src/app/api/sase/query-log/[id]/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getVinDecodeDetail, getVinUserBehavior } from "@/lib/sase/vin-detail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await ctx.params;
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ ok: false, error: "bad_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const detail = await getVinDecodeDetail(id);
|
||||
if (!detail) {
|
||||
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Behaviour is best-effort: if the panel DB query fails, still return the
|
||||
// decode detail rather than 500 the whole modal.
|
||||
let behavior = null;
|
||||
try {
|
||||
behavior = await getVinUserBehavior(detail.userId, detail.vehicleId);
|
||||
} catch {
|
||||
behavior = null;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, detail, behavior });
|
||||
}
|
||||
214
apps/web/src/app/content/_actions.ts
Normal file
214
apps/web/src/app/content/_actions.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { contentQueue } from "@/lib/queue";
|
||||
import { publishToN8n } from "@/lib/n8n";
|
||||
|
||||
const PROJECT_KEY = "sase";
|
||||
const VALID_CHANNELS = ["blog", "linkedin", "x", "instagram"];
|
||||
// Draft lifecycle reachable from the UI. publishing/published/failed are set
|
||||
// by the publish action + n8n callback (Phase 8c), not directly here.
|
||||
const DRAFT_STATUS = new Set(["draft", "approved", "rejected"]);
|
||||
|
||||
async function requireSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("unauthenticated");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createTopic(input: {
|
||||
title: string;
|
||||
brief: string;
|
||||
angle?: string;
|
||||
channels: string[];
|
||||
keywords: string[];
|
||||
}) {
|
||||
await requireSession();
|
||||
const title = input.title.trim().slice(0, 250);
|
||||
if (!title) throw new Error("title required");
|
||||
const channels = input.channels.filter((c) => VALID_CHANNELS.includes(c));
|
||||
if (channels.length === 0) throw new Error("pick at least one channel");
|
||||
|
||||
const topic = await prisma.contentTopic.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
title,
|
||||
brief: input.brief.trim().slice(0, 2000),
|
||||
angle: input.angle?.trim().slice(0, 1000) || null,
|
||||
channels,
|
||||
keywords: input.keywords.map((k) => k.trim()).filter(Boolean).slice(0, 12),
|
||||
status: "queued",
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/topics",
|
||||
method: "POST",
|
||||
requestPayload: { title, channels },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return topic.id;
|
||||
}
|
||||
|
||||
export async function archiveTopic(topicId: string) {
|
||||
await requireSession();
|
||||
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "archived" } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/topics/${topicId}/archive`,
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
revalidatePath(`/content/t/${topicId}`);
|
||||
}
|
||||
|
||||
export async function requeueTopic(topicId: string) {
|
||||
await requireSession();
|
||||
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "queued" } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/topics/${topicId}/requeue`,
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
revalidatePath(`/content/t/${topicId}`);
|
||||
}
|
||||
|
||||
export async function enqueueTopicGeneration() {
|
||||
await requireSession();
|
||||
const job = await contentQueue().add("content-topics", {}, { removeOnComplete: 30, removeOnFail: 15 });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/topics/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return job.id;
|
||||
}
|
||||
|
||||
export async function enqueueContentGeneration() {
|
||||
await requireSession();
|
||||
const job = await contentQueue().add("content-generate", {}, { removeOnComplete: 50, removeOnFail: 25 });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: "/content/generate",
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath("/content");
|
||||
return job.id;
|
||||
}
|
||||
|
||||
export async function saveDraftEdits(draftId: string, editsJson: string) {
|
||||
await requireSession();
|
||||
let parsed: unknown = null;
|
||||
const trimmed = editsJson.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
throw new Error(`invalid JSON: ${(e as Error).message}`);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("edits must be a JSON object");
|
||||
}
|
||||
}
|
||||
const draft = await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: { founderEdits: parsed === null ? undefined : (parsed as object) },
|
||||
});
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/edits`,
|
||||
method: "POST",
|
||||
requestPayload: { length: trimmed.length },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
}
|
||||
|
||||
export async function setDraftStatus(draftId: string, status: string) {
|
||||
await requireSession();
|
||||
if (!DRAFT_STATUS.has(status)) throw new Error(`bad status: ${status}`);
|
||||
const draft = await prisma.contentDraft.update({ where: { id: draftId }, data: { status } });
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/status`,
|
||||
method: "POST",
|
||||
requestPayload: { status },
|
||||
responseStatus: 200,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
revalidatePath("/content");
|
||||
}
|
||||
|
||||
// Publish an approved (or previously-failed) draft via the n8n publish webhook.
|
||||
// Synchronous: we await n8n's response and persist the final status here.
|
||||
export async function publishDraft(draftId: string) {
|
||||
await requireSession();
|
||||
const draft = await prisma.contentDraft.findUnique({
|
||||
where: { id: draftId },
|
||||
include: { topic: { select: { title: true } } },
|
||||
});
|
||||
if (!draft) throw new Error("draft not found");
|
||||
if (!["approved", "failed"].includes(draft.status)) {
|
||||
throw new Error("only approved (or failed) drafts can be published");
|
||||
}
|
||||
|
||||
// Effective content = generated body with founder edits layered on top.
|
||||
const base = (draft.bodyJson && typeof draft.bodyJson === "object" && !Array.isArray(draft.bodyJson)
|
||||
? (draft.bodyJson as Record<string, unknown>)
|
||||
: {});
|
||||
const edits = (draft.founderEdits && typeof draft.founderEdits === "object" && !Array.isArray(draft.founderEdits)
|
||||
? (draft.founderEdits as Record<string, unknown>)
|
||||
: {});
|
||||
const content = { ...base, ...edits };
|
||||
|
||||
await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: { status: "publishing", publishError: null },
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
|
||||
const result = await publishToN8n({
|
||||
draftId,
|
||||
channel: draft.channel,
|
||||
projectKey: PROJECT_KEY,
|
||||
topicTitle: draft.topic.title,
|
||||
content,
|
||||
});
|
||||
|
||||
await prisma.contentDraft.update({
|
||||
where: { id: draftId },
|
||||
data: result.ok
|
||||
? {
|
||||
status: "published",
|
||||
publishedUrl: result.publishedUrl ?? null,
|
||||
publishedAt: new Date(),
|
||||
publishError: null,
|
||||
}
|
||||
: { status: "failed", publishError: (result.error ?? "unknown").slice(0, 500) },
|
||||
});
|
||||
|
||||
await writeAudit({
|
||||
projectKey: PROJECT_KEY,
|
||||
endpoint: `/content/drafts/${draftId}/publish`,
|
||||
method: "POST",
|
||||
requestPayload: { channel: draft.channel, ok: result.ok },
|
||||
responseStatus: result.ok ? 200 : 502,
|
||||
});
|
||||
revalidatePath(`/content/t/${draft.topicId}`);
|
||||
revalidatePath("/content");
|
||||
|
||||
if (!result.ok) throw new Error(result.error ?? "publish failed");
|
||||
return result.publishedUrl ?? null;
|
||||
}
|
||||
134
apps/web/src/app/content/_controls.tsx
Normal file
134
apps/web/src/app/content/_controls.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
createTopic,
|
||||
enqueueTopicGeneration,
|
||||
enqueueContentGeneration,
|
||||
} from "./_actions";
|
||||
|
||||
const CHANNELS = [
|
||||
{ value: "blog", label: "Blog" },
|
||||
{ value: "linkedin", label: "LinkedIn" },
|
||||
{ value: "x", label: "X" },
|
||||
{ value: "instagram", label: "Instagram" },
|
||||
];
|
||||
|
||||
export function ContentControls({ queuedCount }: { queuedCount: number }) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// manual topic form state
|
||||
const [title, setTitle] = useState("");
|
||||
const [brief, setBrief] = useState("");
|
||||
const [angle, setAngle] = useState("");
|
||||
const [keywords, setKeywords] = useState("");
|
||||
const [channels, setChannels] = useState<string[]>(["blog", "linkedin", "x", "instagram"]);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleChannel = (c: string) =>
|
||||
setChannels((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
|
||||
|
||||
const submitTopic = () =>
|
||||
fire(async () => {
|
||||
await createTopic({
|
||||
title,
|
||||
brief,
|
||||
angle,
|
||||
channels,
|
||||
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
|
||||
});
|
||||
setTitle("");
|
||||
setBrief("");
|
||||
setAngle("");
|
||||
setKeywords("");
|
||||
setShowForm(false);
|
||||
}, "konu eklendi");
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => fire(() => enqueueTopicGeneration(), "konu üretimi kuyruğa alındı")}
|
||||
>
|
||||
Otomatik konu üret
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}
|
||||
>
|
||||
Taslakları üret ({queuedCount} kuyrukta)
|
||||
</Button>
|
||||
<Button size="sm" variant={showForm ? "default" : "outline"} onClick={() => setShowForm((v) => !v)}>
|
||||
{showForm ? "Formu kapat" : "Elle konu ekle"}
|
||||
</Button>
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Başlık"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="w-full rounded border bg-background p-2 text-sm"
|
||||
rows={2}
|
||||
placeholder="Brief — içerik ne anlatacak?"
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Açı (opsiyonel)"
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded border bg-background px-2 py-1 text-sm"
|
||||
placeholder="Anahtar kelimeler (virgülle)"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{CHANNELS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channels.includes(c.value)}
|
||||
onChange={() => toggleChannel(c.value)}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
<Button size="sm" disabled={pending || !title.trim()} onClick={submitTopic}>
|
||||
Ekle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/app/content/costs/page.tsx
Normal file
174
apps/web/src/app/content/costs/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONTENT_FILTER = { promptTag: { startsWith: "content_" } };
|
||||
|
||||
function startOfDayUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
export default async function ContentCostDashboard() {
|
||||
const now = new Date();
|
||||
|
||||
const [todayAgg, monthAgg, byPrompt, recentErrors, budgetSettings, draftCount, topicCount] =
|
||||
await Promise.all([
|
||||
prisma.costLedger.aggregate({
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfDayUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.aggregate({
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.groupBy({
|
||||
by: ["promptTag"],
|
||||
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
|
||||
_sum: { costTotalUsd: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.costLedger.findMany({
|
||||
where: { ...CONTENT_FILTER, errorCode: { not: null } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.budgetSetting.findMany({ where: { projectKey: null } }),
|
||||
prisma.contentDraft.count({ where: { projectKey: "sase" } }),
|
||||
prisma.contentTopic.count({ where: { projectKey: "sase" } }),
|
||||
]);
|
||||
|
||||
const limits: Record<string, number> = {};
|
||||
for (const b of budgetSettings) {
|
||||
if (typeof b.settingValue === "number") limits[b.settingKey] = b.settingValue;
|
||||
}
|
||||
const monthlyCap = limits.content_monthly_hard_cap_usd ?? 15;
|
||||
const dailyHardCap = limits.content_daily_hard_cap_usd ?? 2;
|
||||
|
||||
const today = Number(todayAgg._sum.costTotalUsd ?? 0);
|
||||
const month = Number(monthAgg._sum.costTotalUsd ?? 0);
|
||||
const monthCalls = Number(monthAgg._count ?? 0);
|
||||
const avgPerCall = monthCalls > 0 ? month / monthCalls : 0;
|
||||
const todayPct = Math.min(100, (today / dailyHardCap) * 100);
|
||||
const monthPct = Math.min(100, (month / monthlyCap) * 100);
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · maliyet">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
İçerik üretimi DeepSeek harcaması (insight bütçesinden ayrı).{" "}
|
||||
<a href="/content" className="underline">Kuyruk</a> ·{" "}
|
||||
<a href="/insights/settings/budgets" className="underline">Bütçe ayarları</a>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Bugün</CardDescription>
|
||||
<CardTitle className="text-2xl">${today.toFixed(4)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{todayPct.toFixed(0)}% / günlük sert sınır ${dailyHardCap}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Bu ay</CardDescription>
|
||||
<CardTitle className="text-2xl">${month.toFixed(2)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">
|
||||
{monthPct.toFixed(0)}% / aylık sınır ${monthlyCap}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Çağrı başı ort. (ay)</CardDescription>
|
||||
<CardTitle className="text-2xl">${avgPerCall.toFixed(4)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">{monthCalls} çağrı</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Üretilen</CardDescription>
|
||||
<CardTitle className="text-2xl">{draftCount}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-xs text-muted-foreground">{topicCount} konu · taslak</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Prompt başına (bu ay)</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead className="w-[120px]">Çağrı</TableHead>
|
||||
<TableHead className="w-[120px]">Maliyet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{byPrompt.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-xs text-muted-foreground">
|
||||
Henüz içerik harcaması yok.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
byPrompt.map((p) => (
|
||||
<TableRow key={p.promptTag ?? "—"}>
|
||||
<TableCell className="text-xs">{p.promptTag ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{p._count}</TableCell>
|
||||
<TableCell className="font-mono text-xs">${Number(p._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{recentErrors.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Son hatalar</h2>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zaman</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Prompt</TableHead>
|
||||
<TableHead>Hata</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentErrors.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{r.model}</TableCell>
|
||||
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
|
||||
<TableCell><Badge variant="destructive">{r.errorCode}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/app/content/page.tsx
Normal file
113
apps/web/src/app/content/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContentControls } from "./_controls";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PROJECT_KEY = "sase";
|
||||
|
||||
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
|
||||
switch (status) {
|
||||
case "drafted":
|
||||
return "default";
|
||||
case "generating":
|
||||
return "secondary";
|
||||
case "archived":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ContentInbox() {
|
||||
const [topics, paused, queuedCount] = await Promise.all([
|
||||
prisma.contentTopic.findMany({
|
||||
where: { projectKey: PROJECT_KEY, status: { not: "archived" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { drafts: { select: { status: true, channel: true } } },
|
||||
}),
|
||||
prisma.budgetSetting.findFirst({ where: { projectKey: null, settingKey: "content_paused" } }),
|
||||
prisma.contentTopic.count({
|
||||
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const isPaused = paused?.settingValue === true;
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · konu kuyruğu">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sase.tr için otomatik içerik üretimi. Konular LLM ile üretilir, kanal taslakları panelde birikir, sen
|
||||
incele/düzenle/onayla.{" "}
|
||||
<a href="/content/costs" className="underline">Maliyet</a>
|
||||
{isPaused && (
|
||||
<>
|
||||
{" · "}
|
||||
<Badge variant="destructive">content_paused</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ContentControls queuedCount={queuedCount} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Konu</TableHead>
|
||||
<TableHead className="w-[110px]">Durum</TableHead>
|
||||
<TableHead className="w-[90px]">Kaynak</TableHead>
|
||||
<TableHead className="w-[160px]">Kanallar</TableHead>
|
||||
<TableHead className="w-[90px]">Taslak</TableHead>
|
||||
<TableHead className="w-[110px]">Oluşturma</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topics.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-xs text-muted-foreground">
|
||||
Henüz konu yok. Yukarıdan otomatik üret ya da elle ekle.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
topics.map((t) => {
|
||||
const approved = t.drafts.filter((d) => d.status === "approved").length;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<a href={`/content/t/${t.id}`} className="font-medium underline-offset-2 hover:underline">
|
||||
{t.title}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(t.status)}>{t.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{t.source}</TableCell>
|
||||
<TableCell className="text-xs">{(t.channels as string[]).join(", ")}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{t.drafts.length}
|
||||
{approved > 0 && <span className="text-muted-foreground"> ({approved}✓)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{t.createdAt.toISOString().slice(0, 10)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
209
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal file
209
apps/web/src/app/content/t/[id]/_draft-card.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { saveDraftEdits, setDraftStatus, publishDraft } from "../../_actions";
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
type Props = {
|
||||
draftId: string;
|
||||
channel: string;
|
||||
status: string;
|
||||
bodyJson: Json;
|
||||
founderEdits: Json;
|
||||
model: string | null;
|
||||
costUsd: number;
|
||||
publishedUrl: string | null;
|
||||
publishError: string | null;
|
||||
};
|
||||
|
||||
function asRecord(v: Json): Record<string, unknown> {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : String(v);
|
||||
}
|
||||
function arr(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.map(str) : [];
|
||||
}
|
||||
|
||||
function ChannelPreview({ channel, content }: { channel: string; content: Record<string, unknown> }) {
|
||||
if (channel === "blog") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-base font-semibold">{str(content.title)}</div>
|
||||
<div className="text-xs text-muted-foreground">/{str(content.slug)}</div>
|
||||
<div className="text-xs italic text-muted-foreground">{str(content.meta_description)}</div>
|
||||
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{str(content.body_markdown)}</pre>
|
||||
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.tags).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (channel === "x") {
|
||||
const tweets = arr(content.tweets);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tweets.map((t, i) => (
|
||||
<div key={i} className="rounded border p-2 text-sm">
|
||||
<span className="mr-2 text-xs text-muted-foreground">{i + 1}/{tweets.length}</span>
|
||||
{t}
|
||||
<span className="ml-2 text-xs text-muted-foreground">({t.length})</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.hashtags).map((h) => (
|
||||
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// linkedin / instagram — body or caption + hashtags
|
||||
const text = channel === "instagram" ? str(content.caption) : str(content.body);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{text}</pre>
|
||||
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
|
||||
{content.image_prompt != null && (
|
||||
<div className="text-xs text-muted-foreground">image_prompt: {str(content.image_prompt)}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{arr(content.hashtags).map((h) => (
|
||||
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
approved: "default",
|
||||
draft: "outline",
|
||||
rejected: "destructive",
|
||||
published: "default",
|
||||
publishing: "secondary",
|
||||
failed: "destructive",
|
||||
};
|
||||
|
||||
export function DraftCard(props: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editsText, setEditsText] = useState(
|
||||
props.founderEdits ? JSON.stringify(props.founderEdits, null, 2) : "",
|
||||
);
|
||||
|
||||
const base = asRecord(props.bodyJson);
|
||||
const edits = asRecord(props.founderEdits);
|
||||
const effective = { ...base, ...edits };
|
||||
const locked = ["published", "publishing"].includes(props.status);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium uppercase">{props.channel}</span>
|
||||
<Badge variant={STATUS_VARIANT[props.status] ?? "outline"}>{props.status}</Badge>
|
||||
{Object.keys(edits).length > 0 && <Badge variant="secondary" className="text-xs">düzenlendi</Badge>}
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{props.model ?? "—"} · ${props.costUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{props.publishedUrl && (
|
||||
<div className="text-xs">
|
||||
Yayınlandı: <a href={props.publishedUrl} className="underline" target="_blank" rel="noreferrer">{props.publishedUrl}</a>
|
||||
</div>
|
||||
)}
|
||||
{props.publishError && <div className="text-xs text-destructive">Yayın hatası: {props.publishError}</div>}
|
||||
|
||||
<ChannelPreview channel={props.channel} content={effective} />
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
founder edits (JSON object — sadece değiştirmek istediğin alanları yaz, üzerine biner)
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded border bg-background p-2 font-mono text-xs"
|
||||
rows={8}
|
||||
value={editsText}
|
||||
onChange={(e) => setEditsText(e.target.value)}
|
||||
placeholder={'{\n "title": "..."\n}'}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => saveDraftEdits(props.draftId, editsText), "düzenleme kaydedildi")}>
|
||||
Düzenlemeyi kaydet
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!locked && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>
|
||||
{editing ? "Düzenlemeyi gizle" : "Düzenle"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending || props.status === "approved"}
|
||||
onClick={() => fire(() => setDraftStatus(props.draftId, "approved"), "onaylandı")}
|
||||
>
|
||||
Onayla
|
||||
</Button>
|
||||
{props.status !== "draft" && (
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => setDraftStatus(props.draftId, "draft"), "taslağa alındı")}>
|
||||
Taslağa al
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending || props.status === "rejected"}
|
||||
onClick={() => fire(() => setDraftStatus(props.draftId, "rejected"), "reddedildi")}
|
||||
>
|
||||
Reddet
|
||||
</Button>
|
||||
{(props.status === "approved" || props.status === "failed") && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
fire(
|
||||
() => publishDraft(props.draftId),
|
||||
props.status === "failed" ? "yeniden yayınlanıyor" : "yayınlanıyor",
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.status === "failed" ? "Yeniden yayınla" : "Yayınla"}
|
||||
</Button>
|
||||
)}
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.status === "publishing" && (
|
||||
<div className="border-t pt-2 text-xs text-muted-foreground">n8n'e gönderildi, yayınlanıyor…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal file
43
apps/web/src/app/content/t/[id]/_topic-actions.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { archiveTopic, requeueTopic, enqueueContentGeneration } from "../../_actions";
|
||||
|
||||
export function TopicActions({ topicId, status }: { topicId: string; status: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, start] = useTransition();
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
|
||||
const fire = (fn: () => Promise<unknown>, label: string) =>
|
||||
start(async () => {
|
||||
try {
|
||||
await fn();
|
||||
setFlash(label);
|
||||
setTimeout(() => setFlash(null), 2500);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setFlash(`hata: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{status !== "queued" && (
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => requeueTopic(topicId), "kuyruğa alındı")}>
|
||||
Yeniden kuyruğa al
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}>
|
||||
Taslakları üret
|
||||
</Button>
|
||||
{status !== "archived" && (
|
||||
<Button size="sm" variant="destructive" disabled={pending} onClick={() => fire(() => archiveTopic(topicId), "arşivlendi")}>
|
||||
Arşivle
|
||||
</Button>
|
||||
)}
|
||||
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/app/content/t/[id]/page.tsx
Normal file
76
apps/web/src/app/content/t/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DraftCard } from "./_draft-card";
|
||||
import { TopicActions } from "./_topic-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CHANNEL_ORDER = ["blog", "linkedin", "x", "instagram"];
|
||||
|
||||
export default async function TopicDetail({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const topic = await prisma.contentTopic.findUnique({
|
||||
where: { id },
|
||||
include: { drafts: true },
|
||||
});
|
||||
if (!topic) notFound();
|
||||
|
||||
const drafts = [...topic.drafts].sort(
|
||||
(a, b) => CHANNEL_ORDER.indexOf(a.channel) - CHANNEL_ORDER.indexOf(b.channel),
|
||||
);
|
||||
const totalCost = topic.drafts.reduce((s, d) => s + d.sourceCostUsd, topic.sourceCostUsd);
|
||||
|
||||
return (
|
||||
<PanelShell title="Content · konu">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<a href="/content" className="underline">← Kuyruk</a>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">{topic.title}</h1>
|
||||
<Badge variant="outline">{topic.status}</Badge>
|
||||
<Badge variant="secondary">{topic.source}</Badge>
|
||||
</div>
|
||||
{topic.brief && <p className="text-sm">{topic.brief}</p>}
|
||||
{topic.angle && <p className="text-xs text-muted-foreground">Açı: {topic.angle}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(topic.keywords as string[]).map((k) => (
|
||||
<Badge key={k} variant="outline" className="text-xs">{k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Kanallar: {(topic.channels as string[]).join(", ")} · Maliyet: ${totalCost.toFixed(4)} ·{" "}
|
||||
{topic.sourceModel ?? "—"}
|
||||
</div>
|
||||
<TopicActions topicId={topic.id} status={topic.status} />
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Henüz taslak üretilmemiş. Konu kuyruktaysa <span className="font-mono">content-generate</span> job'u
|
||||
(≤10dk) ya da kuyruk sayfasındaki “Taslakları üret” bunu işler.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{drafts.map((d) => (
|
||||
<DraftCard
|
||||
key={d.id}
|
||||
draftId={d.id}
|
||||
channel={d.channel}
|
||||
status={d.status}
|
||||
bodyJson={d.bodyJson}
|
||||
founderEdits={d.founderEdits}
|
||||
model={d.sourceModel}
|
||||
costUsd={d.sourceCostUsd}
|
||||
publishedUrl={d.publishedUrl}
|
||||
publishError={d.publishError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
@@ -318,6 +318,12 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
|
||||
"min_score_for_analysis",
|
||||
"cache_ttl_hours",
|
||||
"analysis_paused",
|
||||
// Content generation (separate envelope) — see lib/content-budget.ts
|
||||
"content_monthly_hard_cap_usd",
|
||||
"content_daily_soft_cap_usd",
|
||||
"content_daily_hard_cap_usd",
|
||||
"content_per_call_max_usd",
|
||||
"content_paused",
|
||||
]);
|
||||
if (!allowed.has(key)) throw new Error("bad setting key");
|
||||
const existing = await prisma.budgetSetting.findFirst({
|
||||
@@ -342,4 +348,8 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
|
||||
revalidatePath("/insights/settings/budgets");
|
||||
revalidatePath("/insights/costs");
|
||||
revalidatePath("/insights");
|
||||
if (key.startsWith("content_")) {
|
||||
revalidatePath("/content/costs");
|
||||
revalidatePath("/content");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,41 @@ const SCHEMA: Array<{ key: string; label: string; unit?: string; help?: string;
|
||||
help: "Kill switch — no LLM calls until cleared. Pipeline still ingests/tags/compresses.",
|
||||
default: false,
|
||||
},
|
||||
// ---- Content generation (separate envelope from insight analysis) ----
|
||||
{
|
||||
key: "content_monthly_hard_cap_usd",
|
||||
label: "Content · monthly hard cap",
|
||||
unit: "USD/month",
|
||||
help: "Content generation halts when its month-to-date spend reaches this.",
|
||||
default: 15,
|
||||
},
|
||||
{
|
||||
key: "content_daily_soft_cap_usd",
|
||||
label: "Content · daily soft cap",
|
||||
unit: "USD/day",
|
||||
help: "Above this, content pro tier downgrades to flash for the rest of today.",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
key: "content_daily_hard_cap_usd",
|
||||
label: "Content · daily hard cap",
|
||||
unit: "USD/day",
|
||||
help: "Content generation paused above this until UTC midnight.",
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
key: "content_per_call_max_usd",
|
||||
label: "Content · per-call max",
|
||||
unit: "USD",
|
||||
help: "Single content LLM call ceiling. Currently warn-only.",
|
||||
default: 0.3,
|
||||
},
|
||||
{
|
||||
key: "content_paused",
|
||||
label: "Pause content generation",
|
||||
help: "Kill switch for content topics + drafts. Independent of analysis_paused.",
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function BudgetsSettingsPage() {
|
||||
|
||||
@@ -121,6 +121,13 @@ export default async function VinDecodePage({
|
||||
>
|
||||
Trends →
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/sase/vin-decode/trends#wmi"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
title="Sıradaki eklenecek WMI adayları (talebe göre)"
|
||||
>
|
||||
WMI fırsatları →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +215,7 @@ export default async function VinDecodePage({
|
||||
label: a.provider,
|
||||
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(a.provider === "pcat" ? "parts-catalogs" : a.provider === "vin_api" ? "vin-api" : a.provider)}`,
|
||||
count: a.attemptCount,
|
||||
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
|
||||
meta: `P50 ${ms(a.p50Ms)} · P95 ${ms(a.p95Ms)}`,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -109,16 +109,16 @@ export default async function ProviderDeepPage({
|
||||
value={`${(stats.wins.successRate * 100).toFixed(1)}%`}
|
||||
/>
|
||||
<Kpi
|
||||
label="Avg RT"
|
||||
value={stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}
|
||||
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"}`}
|
||||
label="P50 / P95 RT"
|
||||
value={stats.wins.p50 != null ? `${stats.wins.p50}ms` : "—"}
|
||||
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"} · Ø ${stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}`}
|
||||
/>
|
||||
<Kpi
|
||||
label="Chain'de görüldü"
|
||||
value={stats.attempts.total.toLocaleString("tr-TR")}
|
||||
hint={
|
||||
stats.attempts.total > 0
|
||||
? `Ø ${stats.attempts.avgMs}ms · P95 ${stats.attempts.p95 ?? "—"}ms`
|
||||
? `P50 ${stats.attempts.p50 ?? "—"}ms · P95 ${stats.attempts.p95 ?? "—"}ms`
|
||||
: "chain key'ı yok"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getWeeklyGrowth,
|
||||
getProviderShareTrend,
|
||||
getUnderSupportedBrands,
|
||||
getWmiOpportunities,
|
||||
getPeakHeatmap,
|
||||
getCacheHitTrend,
|
||||
getEfficiencyTrend,
|
||||
@@ -42,12 +43,13 @@ const PROVIDER_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default async function TrendsPage() {
|
||||
const [volume, growth, shareTrend, underSupported, heatmap, cache, efficiency] =
|
||||
const [volume, growth, shareTrend, underSupported, wmiOpps, heatmap, cache, efficiency] =
|
||||
await Promise.all([
|
||||
getLongTermVolume(90),
|
||||
getWeeklyGrowth(12),
|
||||
getProviderShareTrend(30),
|
||||
getUnderSupportedBrands(30, 20, 0.75),
|
||||
getWmiOpportunities(30, 15, 0.7),
|
||||
getPeakHeatmap(30),
|
||||
getCacheHitTrend(30),
|
||||
getEfficiencyTrend(30),
|
||||
@@ -252,6 +254,82 @@ export default async function TrendsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4b. WMI opportunity radar — data-driven Faz 4 */}
|
||||
<Card id="wmi">
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
4b. WMI fırsat radarı (son 30g, hacim ≥15, başarı ≤70%) — sıradaki
|
||||
ekleme adayları, talebe göre sıralı
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{wmiOpps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Düşük başarılı WMI yok — talep edilen üretici kodları yeterince
|
||||
karşılanıyor.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>WMI</TableHead>
|
||||
<TableHead>Durum / marka</TableHead>
|
||||
<TableHead className="text-right">Talep (kullanıcı)</TableHead>
|
||||
<TableHead className="text-right">Toplam</TableHead>
|
||||
<TableHead className="text-right">Fail</TableHead>
|
||||
<TableHead className="text-right">Başarı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{wmiOpps.map((w) => (
|
||||
<TableRow key={w.wmi}>
|
||||
<TableCell className="font-mono text-xs">{w.wmi}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{w.brandMatched === 0 ? (
|
||||
<Badge variant="destructive">eşleşmesiz WMI</Badge>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono">{w.topBrandSlug ?? "?"}</span>
|
||||
{w.topBrandName && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{w.topBrandName}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-muted-foreground">· decode zayıf</span>
|
||||
</>
|
||||
)}
|
||||
{w.topResultKind && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({w.topResultKind})
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium tabular-nums">
|
||||
{w.uniqueUsers}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{w.total.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-destructive">
|
||||
{w.failed.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-destructive">
|
||||
{(w.successRate * 100).toFixed(1)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
"eşleşmesiz WMI" = hiç markaya bağlanamayan üretici kodu →
|
||||
eklenince tamamen yeni kapsama. Talep (farklı kullanıcı) sütununa
|
||||
göre sıralı: en çok kişinin isteyip alamadığı WMI en üstte. Faz 4'ün
|
||||
el yordamı yerine veri-güdümlü hali.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 5. Peak heatmap */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
307
apps/web/src/app/projects/sase/vin-decode/vins/_detail-modal.tsx
Normal file
307
apps/web/src/app/projects/sase/vin-decode/vins/_detail-modal.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Timings = {
|
||||
pcat?: number;
|
||||
emex?: number;
|
||||
pl24?: number;
|
||||
vin_api?: number;
|
||||
lock_wait?: number;
|
||||
cache_source?: string;
|
||||
cache_neg_hit?: boolean;
|
||||
pcat_car_count?: number;
|
||||
emex_candidate_count?: number;
|
||||
candidate_pick?: string;
|
||||
pl24_circuit_open?: boolean;
|
||||
aborted?: boolean;
|
||||
result_kind?: string;
|
||||
wmi?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
type Detail = {
|
||||
id: string;
|
||||
vin: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
responseTimeMs: number | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
vehicleId: string | null;
|
||||
timings: Timings | null;
|
||||
};
|
||||
|
||||
type Behavior = {
|
||||
oemCopiedCount: number;
|
||||
oemCodes: string[];
|
||||
categoriesOpened: number;
|
||||
partsPanelViews: number;
|
||||
firstAt: string | null;
|
||||
lastAt: string | null;
|
||||
} | null;
|
||||
|
||||
type ApiResponse =
|
||||
| { ok: true; detail: Detail; behavior: Behavior }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function ms(v: number | null | undefined): string {
|
||||
if (v == null) return "—";
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)}s`;
|
||||
return `${v}ms`;
|
||||
}
|
||||
|
||||
const PROVIDER_KEYS: Array<[keyof Timings, string]> = [
|
||||
["pcat", "parts-catalogs"],
|
||||
["emex", "emex"],
|
||||
["pl24", "pl24"],
|
||||
["vin_api", "vin-api"],
|
||||
];
|
||||
|
||||
export function VinDetailButton({ id, vin }: { id: string; vin: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [behavior, setBehavior] = useState<Behavior>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || detail || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/sase/query-log/${id}`, { cache: "no-store" })
|
||||
.then(async (r) => (await r.json()) as ApiResponse)
|
||||
.then((data) => {
|
||||
if (data.ok) {
|
||||
setDetail(data.detail);
|
||||
setBehavior(data.behavior);
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, id, detail, loading]);
|
||||
|
||||
const t = detail?.timings ?? null;
|
||||
const proxies = t ? PROVIDER_KEYS.filter(([k]) => typeof t[k] === "number") : [];
|
||||
const raced = t ? (typeof t.lock_wait === "number" || t.cache_source === "lock_wait") : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="cursor-pointer font-mono text-xs hover:underline"
|
||||
title={`${vin} — detay`}
|
||||
>
|
||||
{vin}
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono text-base">{vin}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detail
|
||||
? new Date(detail.createdAt).toISOString().slice(0, 19).replace("T", " ")
|
||||
: "Decode detayı"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Yükleniyor…</p>}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">Hata: {error}</p>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Decode özet */}
|
||||
<Section title="Decode">
|
||||
<Row label="Durum">
|
||||
{detail.success ? (
|
||||
<Badge variant="default">ok</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">fail</Badge>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Kazanan provider">
|
||||
{detail.source ? (
|
||||
<Badge variant="outline" className="font-mono">{detail.source}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Toplam yanıt">{ms(detail.responseTimeMs)}</Row>
|
||||
<Row label="Marka">
|
||||
{detail.brandSlug ? (
|
||||
<span className="font-mono text-xs">
|
||||
{detail.brandSlug}
|
||||
{detail.brandName && (
|
||||
<span className="ml-1 text-muted-foreground">{detail.brandName}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Row>
|
||||
<Row label="Kullanıcı">
|
||||
<span className="font-mono text-xs">{detail.userEmail ?? detail.userId.slice(0, 8)}</span>
|
||||
</Row>
|
||||
{detail.errorMessage && (
|
||||
<Row label="Hata mesajı">
|
||||
<span className="text-xs text-destructive">{detail.errorMessage}</span>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Proxy süreleri */}
|
||||
<Section title="Proxy süreleri">
|
||||
{proxies.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provider denemesi yok (cache'ten döndü).
|
||||
</p>
|
||||
) : (
|
||||
proxies.map(([k, label]) => (
|
||||
<Row key={String(k)} label={label}>
|
||||
<span className="tabular-nums">{ms(t?.[k] as number)}</span>
|
||||
</Row>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Race / kilit */}
|
||||
<Section title="Race / kilit durumu">
|
||||
{raced ? (
|
||||
<Row label="Eşzamanlı istek">
|
||||
<span className="text-yellow-600">
|
||||
Aynı VIN paralelde çözülüyordu — {ms(t?.lock_wait)} kilit beklendi
|
||||
</span>
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Eşzamanlı istek">
|
||||
<span className="text-muted-foreground">Race yok (kilit beklenmedi)</span>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="cache_source">
|
||||
<span className="font-mono text-xs">{t?.cache_source ?? "—"}</span>
|
||||
</Row>
|
||||
<Row label="candidate_pick">
|
||||
<span className="font-mono text-xs">{t?.candidate_pick ?? "—"}</span>
|
||||
</Row>
|
||||
{t?.pl24_circuit_open && (
|
||||
<Row label="PL24 circuit">
|
||||
<Badge variant="destructive">açık (circuit open)</Badge>
|
||||
</Row>
|
||||
)}
|
||||
{t?.aborted && (
|
||||
<Row label="Abort">
|
||||
<Badge variant="destructive">bütçe/timeout ile iptal</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Aday / sonuç */}
|
||||
<Section title="Aday & sonuç">
|
||||
<Row label="result_kind">
|
||||
<span className="font-mono text-xs">{t?.result_kind ?? "—"}</span>
|
||||
</Row>
|
||||
<Row label="PCAT araç adayı">
|
||||
<span className="tabular-nums">
|
||||
{typeof t?.pcat_car_count === "number" ? t.pcat_car_count : "—"}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="EMEX adayı">
|
||||
<span className="tabular-nums">
|
||||
{typeof t?.emex_candidate_count === "number" ? t.emex_candidate_count : "—"}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="WMI">
|
||||
<span className="font-mono text-xs">{t?.wmi ?? "—"}</span>
|
||||
</Row>
|
||||
{t?.cache_neg_hit && (
|
||||
<Row label="Negatif cache">
|
||||
<Badge variant="outline">negatif cache hit</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Kullanıcı davranışı (bu araç) */}
|
||||
<Section title="Kullanıcı davranışı (bu araç)">
|
||||
{!detail.vehicleId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Decode bir araç kaydı üretmedi — kategori/OEM davranışı yok.
|
||||
</p>
|
||||
) : behavior == null ? (
|
||||
<p className="text-xs text-muted-foreground">Davranış verisi alınamadı.</p>
|
||||
) : (
|
||||
<>
|
||||
<Row label="Kategori açıldı">
|
||||
<span className="tabular-nums">{behavior.categoriesOpened}</span>
|
||||
{behavior.partsPanelViews > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({behavior.partsPanelViews} görüntüleme)
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="OEM kopyalandı">
|
||||
{behavior.oemCopiedCount > 0 ? (
|
||||
<span className="text-emerald-600">
|
||||
Evet · {behavior.oemCopiedCount} kez ({behavior.oemCodes.length} farklı kod)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Hayır</span>
|
||||
)}
|
||||
</Row>
|
||||
{behavior.oemCodes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{behavior.oemCodes.map((c) => (
|
||||
<Badge key={c} variant="secondary" className="font-mono text-xs">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-right">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { listVinDecodes } from "@/lib/sase/vin-list";
|
||||
import { saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
import { VinsFilterBar } from "./_filter-bar";
|
||||
import { VinActions } from "./_actions";
|
||||
import { VinDetailButton } from "./_detail-modal";
|
||||
import { Pager } from "./_pager";
|
||||
import { buildHref, type VinsSearchParams } from "./_query";
|
||||
|
||||
@@ -115,8 +116,8 @@ export default async function VinsListPage({
|
||||
<TableCell className="font-mono text-xs">
|
||||
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs" title={r.vin}>
|
||||
{r.vin}
|
||||
<TableCell className="font-mono text-xs">
|
||||
<VinDetailButton id={r.id} vin={r.vin} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.brandSlug ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderIcon,
|
||||
LayoutDashboardIcon,
|
||||
LightbulbIcon,
|
||||
PenLineIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
TerminalIcon,
|
||||
@@ -30,6 +31,7 @@ const navMain = [
|
||||
{ title: "Projects", url: "/projects", icon: <FolderIcon /> },
|
||||
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> },
|
||||
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
|
||||
{ title: "Content", url: "/content", icon: <PenLineIcon /> },
|
||||
{ title: "Events", url: "/events", icon: <ActivityIcon /> },
|
||||
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
LightbulbIcon,
|
||||
LogOutIcon,
|
||||
PenLineIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SlidersHorizontalIcon,
|
||||
@@ -94,6 +95,17 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Content">
|
||||
<CommandItem keywords={["content","icerik","konu","kuyruk","queue"]} onSelect={() => go("/content")}>
|
||||
<PenLineIcon /> İçerik kuyruğu
|
||||
</CommandItem>
|
||||
<CommandItem keywords={["content","cost","maliyet","spend"]} onSelect={() => go("/content/costs")}>
|
||||
<CoinsIcon /> İçerik maliyeti
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.map((p) => (
|
||||
<CommandItem
|
||||
|
||||
68
apps/web/src/lib/n8n.ts
Normal file
68
apps/web/src/lib/n8n.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// n8n publish client (Phase 8c). The panel POSTs an approved draft to the
|
||||
// n8n `publish-content` webhook and waits for a synchronous response (n8n's
|
||||
// "Respond to Webhook" node returns the publish result). Synchronous by
|
||||
// design: sp.semih.ai is Tailscale-only, so an n8n→panel callback would need
|
||||
// cross-network reachability we'd rather avoid. n8n routes by `channel`.
|
||||
|
||||
const WEBHOOK_URL = process.env.N8N_PUBLISH_WEBHOOK_URL ?? "";
|
||||
const SECRET = process.env.N8N_WEBHOOK_SECRET ?? "";
|
||||
const TIMEOUT_MS = Number(process.env.N8N_PUBLISH_TIMEOUT_MS ?? "30000");
|
||||
|
||||
export type PublishRequest = {
|
||||
draftId: string;
|
||||
channel: string;
|
||||
projectKey: string;
|
||||
topicTitle: string;
|
||||
content: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PublishResult = {
|
||||
ok: boolean;
|
||||
publishedUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function n8nConfigured(): boolean {
|
||||
return Boolean(WEBHOOK_URL);
|
||||
}
|
||||
|
||||
export async function publishToN8n(req: PublishRequest): Promise<PublishResult> {
|
||||
if (!WEBHOOK_URL) return { ok: false, error: "N8N_PUBLISH_WEBHOOK_URL not set" };
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(SECRET ? { "x-content-secret": SECRET } : {}),
|
||||
},
|
||||
body: JSON.stringify(req),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: `n8n unreachable: ${(e as Error).message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) return { ok: false, error: `n8n ${res.status}: ${text.slice(0, 200)}` };
|
||||
|
||||
// n8n may wrap the Respond-to-Webhook body or return it bare.
|
||||
try {
|
||||
const parsed = JSON.parse(text || "{}");
|
||||
const ok = parsed.ok === true || parsed.success === true;
|
||||
const publishedUrl =
|
||||
parsed.publishedUrl ?? parsed.url ?? parsed.postUrl ?? parsed.permalink ?? undefined;
|
||||
if (!ok && !publishedUrl) {
|
||||
return { ok: false, error: String(parsed.error ?? parsed.message ?? text.slice(0, 200)) };
|
||||
}
|
||||
return { ok: true, publishedUrl };
|
||||
} catch {
|
||||
// Non-JSON 2xx — treat as success but without a URL.
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import IORedis from "ioredis";
|
||||
|
||||
const url = process.env.REDIS_URL;
|
||||
let _queue: Queue | null = null;
|
||||
let _contentQueue: Queue | null = null;
|
||||
|
||||
export function pipelineQueue(): Queue {
|
||||
if (_queue) return _queue;
|
||||
@@ -11,3 +12,11 @@ export function pipelineQueue(): Queue {
|
||||
_queue = new Queue("insight-pipeline", { connection });
|
||||
return _queue;
|
||||
}
|
||||
|
||||
export function contentQueue(): Queue {
|
||||
if (_contentQueue) return _contentQueue;
|
||||
if (!url) throw new Error("REDIS_URL not set");
|
||||
const connection = new IORedis(url, { maxRetriesPerRequest: null });
|
||||
_contentQueue = new Queue("content-pipeline", { connection });
|
||||
return _contentQueue;
|
||||
}
|
||||
|
||||
@@ -312,19 +312,24 @@ export async function getTrialFunnel(days = 90): Promise<TrialFunnelRow[]> {
|
||||
AND q.created_at <= coalesce(t.trial_end, now())
|
||||
GROUP BY t.user_id, t.converted
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN decode_count = 0 THEN '0'
|
||||
WHEN decode_count BETWEEN 1 AND 2 THEN '1-2'
|
||||
WHEN decode_count BETWEEN 3 AND 5 THEN '3-5'
|
||||
WHEN decode_count BETWEEN 6 AND 10 THEN '6-10'
|
||||
WHEN decode_count BETWEEN 11 AND 25 THEN '11-25'
|
||||
ELSE '26+'
|
||||
END AS bucket,
|
||||
count(*) AS trial_users,
|
||||
count(*) FILTER (WHERE converted = true) AS converted
|
||||
FROM trial_decode_counts
|
||||
GROUP BY bucket
|
||||
SELECT bucket, trial_users, converted
|
||||
FROM (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN decode_count = 0 THEN '0'
|
||||
WHEN decode_count BETWEEN 1 AND 2 THEN '1-2'
|
||||
WHEN decode_count BETWEEN 3 AND 5 THEN '3-5'
|
||||
WHEN decode_count BETWEEN 6 AND 10 THEN '6-10'
|
||||
WHEN decode_count BETWEEN 11 AND 25 THEN '11-25'
|
||||
ELSE '26+'
|
||||
END AS bucket,
|
||||
count(*) AS trial_users,
|
||||
count(*) FILTER (WHERE converted = true) AS converted
|
||||
FROM trial_decode_counts
|
||||
GROUP BY 1
|
||||
) b
|
||||
-- ORDER BY references the real subquery column bucket; Postgres won't
|
||||
-- resolve a SELECT-list alias inside an expression in ORDER BY (42703).
|
||||
ORDER BY
|
||||
CASE bucket
|
||||
WHEN '0' THEN 0
|
||||
|
||||
77
apps/web/src/lib/sase/catalog-coverage.ts
Normal file
77
apps/web/src/lib/sase/catalog-coverage.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
|
||||
// ─── Catalog coverage gaps ────────────────────────────────────────────────
|
||||
// The single biggest VIN-decode failure mode is "No catalog — identified as X":
|
||||
// the decoder *recognises* the vehicle (brand/year) but Sase has no parts
|
||||
// catalog mapped for it, so the user sees their car but no parts. These rows
|
||||
// land in query_logs with success=false and error_message starting
|
||||
// "No catalog — identified as <Brand> [<Year>]". We aggregate them by brand so
|
||||
// the worker can raise ONE insight per brand-gap instead of per failed query.
|
||||
export type CatalogGap = {
|
||||
brand: string;
|
||||
failures: number;
|
||||
uniqueUsers: number;
|
||||
labels: string[]; // distinct "<Brand> <Year>" identities seen, e.g. ["Renault 2004","Renault 2006"]
|
||||
years: number[]; // parsed model years (sorted)
|
||||
firstSeen: string; // ISO
|
||||
lastSeen: string; // ISO
|
||||
windowDays: number;
|
||||
};
|
||||
|
||||
export async function detectCatalogCoverageGaps(
|
||||
opts: { windowDays?: number; minFailures?: number } = {},
|
||||
): Promise<CatalogGap[]> {
|
||||
const windowDays = opts.windowDays ?? 7;
|
||||
const minFailures = opts.minFailures ?? 3;
|
||||
|
||||
// Brand is the first token of the identified label; initcap() folds HONDA/Honda
|
||||
// into one group. array_agg collects the distinct "<Brand> <Year>" identities so
|
||||
// the worker can show the affected model years.
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
brand: string;
|
||||
failures: number;
|
||||
unique_users: number;
|
||||
first_seen: Date;
|
||||
last_seen: Date;
|
||||
labels: string[];
|
||||
}>
|
||||
>`
|
||||
WITH nc AS (
|
||||
SELECT user_id, created_at,
|
||||
trim(substring(error_message FROM 'identified as (.*)$')) AS label
|
||||
FROM query_logs
|
||||
WHERE success = false
|
||||
AND created_at > now() - make_interval(days => ${windowDays})
|
||||
AND error_message LIKE 'No catalog%'
|
||||
)
|
||||
SELECT
|
||||
initcap(split_part(label, ' ', 1)) AS brand,
|
||||
count(*)::int AS failures,
|
||||
count(distinct user_id)::int AS unique_users,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen,
|
||||
array_agg(DISTINCT label ORDER BY label) AS labels
|
||||
FROM nc
|
||||
WHERE label IS NOT NULL AND label <> ''
|
||||
GROUP BY 1
|
||||
HAVING count(*) >= ${minFailures}
|
||||
ORDER BY failures DESC
|
||||
`;
|
||||
|
||||
return rows.map((r) => {
|
||||
const years = Array.from(
|
||||
new Set(r.labels.flatMap((l) => (l.match(/\b(?:19|20)\d{2}\b/g) ?? []).map(Number))),
|
||||
).sort((a, b) => a - b);
|
||||
return {
|
||||
brand: r.brand,
|
||||
failures: r.failures,
|
||||
uniqueUsers: r.unique_users,
|
||||
labels: r.labels,
|
||||
years,
|
||||
firstSeen: r.first_seen.toISOString(),
|
||||
lastSeen: r.last_seen.toISOString(),
|
||||
windowDays,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,8 @@ export type AnomalyType =
|
||||
| "p95_latency_spike"
|
||||
| "volume_drop"
|
||||
| "volume_spike"
|
||||
| "timeout_dominance";
|
||||
| "timeout_dominance"
|
||||
| "unknown_vin_spike";
|
||||
|
||||
export type AnomalyHit = {
|
||||
type: AnomalyType;
|
||||
@@ -36,6 +37,7 @@ type WindowStats = {
|
||||
p95: number | null;
|
||||
avgMs: number | null;
|
||||
timeouts: number;
|
||||
unknownVins: number;
|
||||
};
|
||||
|
||||
async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
@@ -47,6 +49,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
p95: number | null;
|
||||
avg_ms: number | null;
|
||||
timeouts: bigint;
|
||||
unknown_vins: bigint;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
@@ -63,7 +66,16 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
OR error_message ILIKE '%timeout%'
|
||||
OR (timings->>'aborted')::boolean = true
|
||||
)
|
||||
) AS timeouts
|
||||
) AS timeouts,
|
||||
count(*) FILTER (
|
||||
WHERE success = false
|
||||
AND (
|
||||
error_message ILIKE '%unknown vin%'
|
||||
OR error_message ILIKE '%tanınamad%'
|
||||
OR error_message ILIKE '%destekl%'
|
||||
OR (timings->>'result_kind') = 'unknown'
|
||||
)
|
||||
) AS unknown_vins
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start} AND created_at < ${end}
|
||||
`;
|
||||
@@ -74,6 +86,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
p95: null,
|
||||
avg_ms: null,
|
||||
timeouts: 0n,
|
||||
unknown_vins: 0n,
|
||||
};
|
||||
const total = Number(r.total);
|
||||
const succeeded = Number(r.succeeded);
|
||||
@@ -87,6 +100,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
|
||||
p95: r.p95,
|
||||
avgMs: r.avg_ms,
|
||||
timeouts: Number(r.timeouts),
|
||||
unknownVins: Number(r.unknown_vins),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +128,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
|
||||
p95: null,
|
||||
avgMs: null,
|
||||
timeouts: 0,
|
||||
unknownVins: 0,
|
||||
};
|
||||
}
|
||||
const total = samples.reduce((a, b) => a + b.total, 0);
|
||||
@@ -122,6 +137,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
|
||||
const p95Values = samples.map((s) => s.p95).filter((v): v is number => v != null);
|
||||
const avgValues = samples.map((s) => s.avgMs).filter((v): v is number => v != null);
|
||||
const timeouts = samples.reduce((a, b) => a + b.timeouts, 0);
|
||||
const unknownVins = samples.reduce((a, b) => a + b.unknownVins, 0);
|
||||
return {
|
||||
total,
|
||||
succeeded,
|
||||
@@ -131,9 +147,37 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
|
||||
p95: p95Values.length ? Math.round(p95Values.reduce((a, b) => a + b, 0) / p95Values.length) : null,
|
||||
avgMs: avgValues.length ? Math.round(avgValues.reduce((a, b) => a + b, 0) / avgValues.length) : null,
|
||||
timeouts,
|
||||
unknownVins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution for an unknown-VIN / failure spike: which `source` dominates the
|
||||
* failures in the window, and how many of those were aborted (budget/timeout).
|
||||
* A proxy/decoder outage shows up here as a surge of source='none'/'aborted'
|
||||
* or one provider's failures — turns "success dropped" into "EMEX is down".
|
||||
*/
|
||||
async function dominantFailingSource(start: Date, end: Date): Promise<string | null> {
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{ source: string | null; cnt: bigint; aborted: bigint }>
|
||||
>`
|
||||
SELECT
|
||||
coalesce(source, 'none') AS source,
|
||||
count(*) AS cnt,
|
||||
count(*) FILTER (WHERE (timings->>'aborted')::boolean = true) AS aborted
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start} AND created_at < ${end} AND success = false
|
||||
GROUP BY coalesce(source, 'none')
|
||||
ORDER BY count(*) DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
const cnt = Number(r.cnt);
|
||||
const aborted = Number(r.aborted);
|
||||
return aborted > 0 ? `${r.source} (${cnt}, ${aborted} aborted)` : `${r.source} (${cnt})`;
|
||||
}
|
||||
|
||||
export async function detectVinAnomalies(): Promise<{
|
||||
current: WindowStats;
|
||||
baseline: WindowStats;
|
||||
@@ -243,6 +287,35 @@ export async function detectVinAnomalies(): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Unknown-VIN spike ("tanınamadı" patlaması) — proxy/decoder outage. The
|
||||
// generic success-rate-drop catches this too, but this names it explicitly
|
||||
// and attributes the dominant failing source. Fire when the unknown-VIN
|
||||
// share jumps ≥2× baseline, or surges from a near-zero baseline.
|
||||
if (current.total >= MIN_CURRENT_VOLUME) {
|
||||
const curRate = current.unknownVins / current.total;
|
||||
const baseRate = baseline.total > 0 ? baseline.unknownVins / baseline.total : 0;
|
||||
const spiked =
|
||||
curRate >= 0.15 &&
|
||||
(baseRate < 0.02 ? current.unknownVins >= 5 : curRate >= baseRate * 2);
|
||||
if (spiked) {
|
||||
const attribution = await dominantFailingSource(currentStart, now);
|
||||
hits.push({
|
||||
type: "unknown_vin_spike",
|
||||
severity: curRate >= 0.4 ? "critical" : "high",
|
||||
message:
|
||||
`"Tanınamadı" oranı ${pct(baseRate)} → ${pct(curRate)} ` +
|
||||
`(${current.unknownVins}/${current.total}, son ${CURRENT_WINDOW_MIN}dk)` +
|
||||
(attribution ? ` · baskın source: ${attribution}` : ""),
|
||||
baseline: baseRate,
|
||||
observed: curRate,
|
||||
current_volume: current.total,
|
||||
baseline_volume: baseline.total,
|
||||
detected_at: ts,
|
||||
dedupe_key: `vin:unknown_vin_spike:${bucket}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { current, baseline, anomalies: hits };
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ export type ProviderAttemptRow = {
|
||||
provider: string;
|
||||
attemptCount: number;
|
||||
avgMs: number;
|
||||
p50Ms: number | null;
|
||||
p95Ms: number | null;
|
||||
};
|
||||
|
||||
@@ -175,12 +176,19 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
|
||||
const start = rangeStart(range);
|
||||
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{ provider: string; cnt: bigint; avg_ms: number | null; p95_ms: number | null }>
|
||||
Array<{
|
||||
provider: string;
|
||||
cnt: bigint;
|
||||
avg_ms: number | null;
|
||||
p50_ms: number | null;
|
||||
p95_ms: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
key AS provider,
|
||||
count(*) AS cnt,
|
||||
avg((value::text)::numeric)::int AS avg_ms,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p50_ms,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p95_ms
|
||||
FROM query_logs, jsonb_each(timings) AS j(key, value)
|
||||
WHERE created_at >= ${start}
|
||||
@@ -194,6 +202,7 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
|
||||
provider: r.provider,
|
||||
attemptCount: Number(r.cnt),
|
||||
avgMs: r.avg_ms ?? 0,
|
||||
p50Ms: r.p50_ms,
|
||||
p95Ms: r.p95_ms,
|
||||
}));
|
||||
}
|
||||
@@ -251,6 +260,7 @@ export type ErrorBucketRow = {
|
||||
|
||||
export const ERROR_BUCKET_KEYS = [
|
||||
"BUDGET_EXCEEDED",
|
||||
"NO_CATALOG",
|
||||
"UNKNOWN_VIN",
|
||||
"TIMEOUT",
|
||||
"INVALID_VIN",
|
||||
@@ -264,6 +274,9 @@ export type ErrorBucketKey = (typeof ERROR_BUCKET_KEYS)[number];
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: ErrorBucketKey; matchers: RegExp[] }> = [
|
||||
{ key: "BUDGET_EXCEEDED", matchers: [/budget/i, /aborted/i] },
|
||||
// Vehicle identified but no parts catalog mapped — the #1 "OTHER" failure and
|
||||
// the server-side twin of the catalog_empty_result insight signal.
|
||||
{ key: "NO_CATALOG", matchers: [/no catalog/i] },
|
||||
{ key: "UNKNOWN_VIN", matchers: [/unknown vin/i, /tanınamadı/i, /destekl/i] },
|
||||
{ key: "TIMEOUT", matchers: [/timeout/i, /timed out/i] },
|
||||
{ key: "INVALID_VIN", matchers: [/geçersiz/i, /invalid vin/i] },
|
||||
|
||||
161
apps/web/src/lib/sase/vin-detail.ts
Normal file
161
apps/web/src/lib/sase/vin-detail.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Single-decode drill-down for the VIN list modal. Pulls the full query_logs
|
||||
// row + parsed timings from the Sase RO DB, resolves the vehicle_id (so we can
|
||||
// correlate frontend behaviour), then joins PostHog custom events from the
|
||||
// PANEL DB (cross-database — two clients, not one SQL join).
|
||||
|
||||
export type DecodeTimings = {
|
||||
// provider proxy times (ms) — present only when that provider was attempted
|
||||
pcat?: number;
|
||||
emex?: number;
|
||||
pl24?: number;
|
||||
vin_api?: number;
|
||||
// race / lock: ms waited because another request was decoding the same VIN
|
||||
lock_wait?: number;
|
||||
// 'miss' | 'db_hit' | 'redis_positive' | 'lock_wait' | 'redis_negative' ...
|
||||
cache_source?: string;
|
||||
cache_neg_hit?: boolean;
|
||||
// candidate disambiguation counts returned by each provider
|
||||
pcat_car_count?: number;
|
||||
emex_candidate_count?: number;
|
||||
candidate_pick?: string; // 'none' | 'auto' | 'single' ...
|
||||
pl24_circuit_open?: boolean;
|
||||
aborted?: boolean;
|
||||
result_kind?: string; // 'vehicle' | 'unknown' ...
|
||||
wmi?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
export type VinDecodeDetail = {
|
||||
id: string;
|
||||
vin: string;
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
brandSlug: string | null;
|
||||
brandName: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
responseTimeMs: number | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: Date;
|
||||
vehicleId: string | null;
|
||||
timings: DecodeTimings | null;
|
||||
};
|
||||
|
||||
export async function getVinDecodeDetail(id: string): Promise<VinDecodeDetail | null> {
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
vin: string;
|
||||
user_id: string;
|
||||
user_email: string | null;
|
||||
user_name: string | null;
|
||||
brand_slug: string | null;
|
||||
brand_name: string | null;
|
||||
source: string | null;
|
||||
success: boolean;
|
||||
response_time_ms: number | null;
|
||||
error_message: string | null;
|
||||
timings: DecodeTimings | null;
|
||||
created_at: Date;
|
||||
vehicle_id: string | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
q.id, q.vin, q.user_id,
|
||||
u.email AS user_email, u.name AS user_name,
|
||||
b.slug AS brand_slug, b.name AS brand_name,
|
||||
q.source, q.success, q.response_time_ms, q.error_message,
|
||||
q.timings, q.created_at,
|
||||
v.id AS vehicle_id
|
||||
FROM query_logs q
|
||||
LEFT JOIN users u ON u.id = q.user_id
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
LEFT JOIN vehicles v ON v.vin = q.vin
|
||||
WHERE q.id = ${id}::uuid
|
||||
LIMIT 1
|
||||
`;
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
return {
|
||||
id: r.id,
|
||||
vin: r.vin,
|
||||
userId: r.user_id,
|
||||
userEmail: r.user_email,
|
||||
userName: r.user_name,
|
||||
brandSlug: r.brand_slug,
|
||||
brandName: r.brand_name,
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
responseTimeMs: r.response_time_ms,
|
||||
errorMessage: r.error_message,
|
||||
createdAt: r.created_at,
|
||||
vehicleId: r.vehicle_id,
|
||||
timings: r.timings,
|
||||
};
|
||||
}
|
||||
|
||||
export type VinUserBehavior = {
|
||||
oemCopiedCount: number;
|
||||
oemCodes: string[];
|
||||
categoriesOpened: number;
|
||||
partsPanelViews: number;
|
||||
firstAt: Date | null;
|
||||
lastAt: Date | null;
|
||||
};
|
||||
|
||||
// Correlate post-decode frontend behaviour for THIS vehicle. PostHog custom
|
||||
// events live in the panel DB (session_custom_events). They carry the Sase
|
||||
// user uuid (distinct_id / $user_id) and the vehicle_id, so we scope to this
|
||||
// user + vehicle. Returns null when the decode produced no vehicle row.
|
||||
export async function getVinUserBehavior(
|
||||
userId: string,
|
||||
vehicleId: string | null,
|
||||
): Promise<VinUserBehavior | null> {
|
||||
if (!vehicleId) return null;
|
||||
|
||||
const [agg] = await prisma.$queryRaw<
|
||||
Array<{
|
||||
oem_copied: bigint;
|
||||
categories_opened: bigint;
|
||||
parts_views: bigint;
|
||||
first_at: Date | null;
|
||||
last_at: Date | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE "eventName" = 'oem_code_copied') AS oem_copied,
|
||||
count(DISTINCT (properties->>'category_id'))
|
||||
FILTER (WHERE "eventName" = 'parts_panel_viewed'
|
||||
AND properties->>'category_id' IS NOT NULL) AS categories_opened,
|
||||
count(*) FILTER (WHERE "eventName" = 'parts_panel_viewed') AS parts_views,
|
||||
min("timestamp") AS first_at,
|
||||
max("timestamp") AS last_at
|
||||
FROM session_custom_events
|
||||
WHERE properties->>'vehicle_id' = ${vehicleId}
|
||||
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
|
||||
AND "eventName" IN ('oem_code_copied', 'parts_panel_viewed')
|
||||
`;
|
||||
|
||||
const codeRows = await prisma.$queryRaw<Array<{ code: string }>>`
|
||||
SELECT DISTINCT properties->>'oem_code' AS code
|
||||
FROM session_custom_events
|
||||
WHERE "eventName" = 'oem_code_copied'
|
||||
AND properties->>'vehicle_id' = ${vehicleId}
|
||||
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
|
||||
AND properties->>'oem_code' IS NOT NULL
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
return {
|
||||
oemCopiedCount: Number(agg?.oem_copied ?? 0n),
|
||||
oemCodes: codeRows.map((r) => r.code),
|
||||
categoriesOpened: Number(agg?.categories_opened ?? 0n),
|
||||
partsPanelViews: Number(agg?.parts_views ?? 0n),
|
||||
firstAt: agg?.first_at ?? null,
|
||||
lastAt: agg?.last_at ?? null,
|
||||
};
|
||||
}
|
||||
@@ -164,6 +164,87 @@ export async function getUnderSupportedBrands(
|
||||
});
|
||||
}
|
||||
|
||||
// ─── WMI opportunity radar — systematizes "which WMI to add next" (Faz 4) ──
|
||||
// WMI = World Manufacturer Identifier = first 3 chars of the VIN (ISO 3779).
|
||||
// Grouping by substring(vin,1,3) works on 100% of historical rows regardless
|
||||
// of whether timings.wmi has been backfilled. We surface WMIs with meaningful
|
||||
// demand but low decode success — ranked by *distinct users* (real demand,
|
||||
// not one user retrying). `brandMatched = 0` means the WMI never resolved to
|
||||
// any brand at all → a fully-uncovered manufacturer code (strongest "add this"
|
||||
// signal). result_kind (when present in timings) labels the dominant outcome.
|
||||
export type WmiOpportunity = {
|
||||
wmi: string;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
uniqueUsers: number;
|
||||
brandMatched: number; // how many of these requests carried a brand_id
|
||||
topBrandSlug: string | null;
|
||||
topBrandName: string | null;
|
||||
topResultKind: string | null;
|
||||
};
|
||||
|
||||
export async function getWmiOpportunities(
|
||||
days = 30,
|
||||
minVolume = 15,
|
||||
maxSuccessRate = 0.7,
|
||||
): Promise<WmiOpportunity[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
wmi: string;
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
failed: bigint;
|
||||
users: bigint;
|
||||
brand_matched: bigint;
|
||||
top_brand_slug: string | null;
|
||||
top_brand_name: string | null;
|
||||
top_result_kind: string | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
substring(q.vin from 1 for 3) AS wmi,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE q.success = true) AS succeeded,
|
||||
count(*) FILTER (WHERE q.success = false) AS failed,
|
||||
count(DISTINCT q.user_id) AS users,
|
||||
count(*) FILTER (WHERE q.brand_id IS NOT NULL) AS brand_matched,
|
||||
mode() WITHIN GROUP (ORDER BY b.slug)
|
||||
FILTER (WHERE b.slug IS NOT NULL) AS top_brand_slug,
|
||||
mode() WITHIN GROUP (ORDER BY b.name)
|
||||
FILTER (WHERE b.name IS NOT NULL) AS top_brand_name,
|
||||
mode() WITHIN GROUP (ORDER BY (q.timings->>'result_kind'))
|
||||
FILTER (WHERE (q.timings->>'result_kind') IS NOT NULL) AS top_result_kind
|
||||
FROM query_logs q
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
WHERE q.created_at >= ${start}
|
||||
AND char_length(q.vin) >= 3
|
||||
GROUP BY substring(q.vin from 1 for 3)
|
||||
HAVING count(*) >= ${minVolume}
|
||||
AND count(*) FILTER (WHERE q.success = true)::float / count(*) <= ${maxSuccessRate}
|
||||
ORDER BY count(DISTINCT q.user_id) DESC, count(*) FILTER (WHERE q.success = false) DESC
|
||||
LIMIT 30
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const succeeded = Number(r.succeeded);
|
||||
return {
|
||||
wmi: r.wmi,
|
||||
total,
|
||||
succeeded,
|
||||
failed: Number(r.failed),
|
||||
successRate: total > 0 ? succeeded / total : 0,
|
||||
uniqueUsers: Number(r.users),
|
||||
brandMatched: Number(r.brand_matched),
|
||||
topBrandSlug: r.top_brand_slug,
|
||||
topBrandName: r.top_brand_name,
|
||||
topResultKind: r.top_result_kind,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Peak heatmap (hour-of-day × day-of-week, last 30d) ───────────────────
|
||||
export type HeatmapCell = {
|
||||
dayOfWeek: number; // 0 = Mon, 6 = Sun (ISO style)
|
||||
@@ -318,9 +399,9 @@ export type ProviderDeepStats = {
|
||||
provider: string;
|
||||
range: "7d" | "30d";
|
||||
// Won-by-this-provider window
|
||||
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p95: number | null };
|
||||
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p50: number | null; p95: number | null };
|
||||
// Attempted-in-chain window (timings key present)
|
||||
attempts: { total: number; avgMs: number; p95: number | null };
|
||||
attempts: { total: number; avgMs: number; p50: number | null; p95: number | null };
|
||||
// Daily win volume for the window
|
||||
dailyVolume: Array<{ date: string; count: number }>;
|
||||
topBrands: Array<{ brandSlug: string | null; brandName: string | null; count: number }>;
|
||||
@@ -360,6 +441,7 @@ export async function getProviderDeepStats(
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
avg_ms: number | null;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
}>
|
||||
>`
|
||||
@@ -367,6 +449,7 @@ export async function getProviderDeepStats(
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE success = true) AS succeeded,
|
||||
avg(response_time_ms)::int AS avg_ms,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY response_time_ms)::int AS p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)::int AS p95
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start} AND source = ${provider}
|
||||
@@ -381,14 +464,15 @@ export async function getProviderDeepStats(
|
||||
// Attempts: timings key present (provider showed up in the chain even if it
|
||||
// didn't win). Some providers have no timings key (e.g. 'cache'), so this
|
||||
// can be empty.
|
||||
let attempts = { total: 0, avgMs: 0, p95: null as number | null };
|
||||
let attempts = { total: 0, avgMs: 0, p50: null as number | null, p95: null as number | null };
|
||||
if (timingsKey) {
|
||||
const [attemptsRow] = await saseDb.$queryRaw<
|
||||
Array<{ total: bigint; avg_ms: number | null; p95_ms: number | null }>
|
||||
Array<{ total: bigint; avg_ms: number | null; p50_ms: number | null; p95_ms: number | null }>
|
||||
>`
|
||||
SELECT
|
||||
count(*) AS total,
|
||||
avg((timings->>${timingsKey})::numeric)::int AS avg_ms,
|
||||
percentile_cont(0.50) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p50_ms,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p95_ms
|
||||
FROM query_logs
|
||||
WHERE created_at >= ${start}
|
||||
@@ -399,6 +483,7 @@ export async function getProviderDeepStats(
|
||||
attempts = {
|
||||
total: Number(attemptsRow.total),
|
||||
avgMs: attemptsRow.avg_ms ?? 0,
|
||||
p50: attemptsRow.p50_ms,
|
||||
p95: attemptsRow.p95_ms,
|
||||
};
|
||||
}
|
||||
@@ -514,6 +599,7 @@ export async function getProviderDeepStats(
|
||||
succeeded: winsSucceeded,
|
||||
successRate: winsTotal > 0 ? winsSucceeded / winsTotal : 0,
|
||||
avgMs: winsRow?.avg_ms ?? null,
|
||||
p50: winsRow?.p50 ?? null,
|
||||
p95: winsRow?.p95 ?? null,
|
||||
},
|
||||
attempts,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx src/lib/dedup.smoke.ts && tsx src/lib/tagger.smoke.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@panel/web": "workspace:*",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Worker } from "bullmq";
|
||||
import { startEventBus } from "./consumers/event-bus";
|
||||
import { startScheduledJobs } from "./schedulers/nightly";
|
||||
import { startInsightPipeline } from "./schedulers/pipeline";
|
||||
import { startContentPipeline } from "./schedulers/content";
|
||||
import { upsertSeedData } from "./lib/seed-runtime";
|
||||
import { redis } from "./redis";
|
||||
import { prisma } from "./db";
|
||||
|
||||
const workers: Worker[] = [];
|
||||
|
||||
async function main() {
|
||||
console.log("[worker] starting…");
|
||||
await redis.ping();
|
||||
@@ -14,15 +18,27 @@ async function main() {
|
||||
|
||||
await upsertSeedData().catch((e) => console.warn("[seed] failed:", e.message));
|
||||
|
||||
await startScheduledJobs();
|
||||
await startInsightPipeline();
|
||||
workers.push(await startScheduledJobs());
|
||||
workers.push(await startInsightPipeline());
|
||||
workers.push(await startContentPipeline());
|
||||
await startEventBus();
|
||||
|
||||
console.log("[worker] up.");
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (sig: string) => {
|
||||
console.log(`[worker] ${sig} — shutting down`);
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[worker] ${sig} — closing ${workers.length} workers gracefully`);
|
||||
// Close workers FIRST: this stops them taking new jobs and releases held job
|
||||
// locks, so the next container doesn't inherit half-finished jobs as "stalled"
|
||||
// and spam "Missing lock … moveToFinished". Bounded so an in-flight job can't
|
||||
// block the shutdown past the orchestrator's stop grace period.
|
||||
await Promise.race([
|
||||
Promise.allSettled(workers.map((w) => w.close())),
|
||||
new Promise((resolve) => setTimeout(resolve, 15_000)),
|
||||
]);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
await redis.quit().catch(() => {});
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 { pickPromptTag } from "../lib/prompts";
|
||||
import { validate } from "../lib/json-validate";
|
||||
import { getText } from "../lib/minio";
|
||||
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";
|
||||
|
||||
@@ -22,6 +30,88 @@ export type AnalyzeResult = {
|
||||
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> {
|
||||
const budget = await checkBudget();
|
||||
if (!budget.allow) {
|
||||
@@ -72,31 +162,73 @@ export async function runAnalyze(): Promise<AnalyzeResult> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Application-level fingerprint cache: if an active insight with same fingerprint
|
||||
// exists and is younger than CACHE_TTL_HOURS, just attach this session to it.
|
||||
// ─── Layer 1: exact fingerprint (or aliased fingerprint) fast-path ───
|
||||
// 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 existing = await prisma.insight.findUnique({
|
||||
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint: s.fingerprint } },
|
||||
const existing = await prisma.insight.findFirst({
|
||||
where: {
|
||||
projectKey: PROJECT_KEY,
|
||||
OR: [{ fingerprint: s.fingerprint }, { aliasFingerprints: { has: s.fingerprint } }],
|
||||
},
|
||||
});
|
||||
if (existing && existing.updatedAt > cutoff && !["dismissed", "validated"].includes(existing.status)) {
|
||||
// Aggregate this session into the existing insight
|
||||
const rel = Array.from(new Set([...existing.relatedSessionIds, s.id]));
|
||||
await prisma.insight.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
relatedSessionIds: rel,
|
||||
occurrenceCount: rel.length,
|
||||
lastSeenAt: s.startedAt > existing.lastSeenAt ? s.startedAt : existing.lastSeenAt,
|
||||
},
|
||||
});
|
||||
await prisma.sessionMeta.update({
|
||||
where: { id: s.id },
|
||||
data: { status: "analyzed", processedAt: new Date() },
|
||||
});
|
||||
skipped++;
|
||||
if (existing) {
|
||||
const handled = await attachToExisting(existing, group, { allowStaleReanalyze: true, cutoff });
|
||||
if (handled) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// active & stale → fall through to full re-analysis (existing stays set).
|
||||
}
|
||||
|
||||
// Fetch timeline from MinIO — needed by both the dedup gate and the analysis.
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 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 template = await prisma.promptTemplate.findFirst({
|
||||
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";
|
||||
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.
|
||||
if (isBundle) {
|
||||
const otherSessionIds = group.slice(1).map((x) => x.id);
|
||||
|
||||
114
apps/worker/src/jobs/archive-recordings.ts
Normal file
114
apps/worker/src/jobs/archive-recordings.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { prisma } from "../db";
|
||||
import { getSnapshotSources, getSnapshotBlob, isConfigured } from "../lib/posthog";
|
||||
import { putBuffer } from "../lib/minio";
|
||||
|
||||
// Archive every recording's raw rrweb before PostHog's ~30-day deletion. Runs over
|
||||
// ALL recordings (incl. discarded ones) at full fidelity — unlike compress, which
|
||||
// only handles scored sessions and caps blobs for token budget.
|
||||
const RRWEB_BUCKET = process.env.RRWEB_ARCHIVE_BUCKET ?? "rrweb-archive";
|
||||
const BATCH = Number(process.env.INSIGHT_RECORDING_ARCHIVE_BATCH ?? "60");
|
||||
const MAX_AGE_DAYS = Number(process.env.INSIGHT_RECORDING_ARCHIVE_MAX_AGE_DAYS ?? "25");
|
||||
// PostHog's snapshot API is aggressively rate-limited. Pace requests and bail out
|
||||
// of the run on sustained 429s — unarchived rows keep rrwebArchivedAt=null and are
|
||||
// retried next cycle (the 25d window vs */6h cadence gives ample slack).
|
||||
const RATE_DELAY_MS = Number(process.env.INSIGHT_RECORDING_ARCHIVE_DELAY_MS ?? "500");
|
||||
// blob_v2 rejects wide blob-key ranges (a 23-key span 400s, a 5-key span is fine),
|
||||
// so fetch in small contiguous chunks and concatenate.
|
||||
const BLOB_CHUNK = Number(process.env.INSIGHT_RECORDING_BLOB_CHUNK ?? "10");
|
||||
const CHUNK_DELAY_MS = Number(process.env.INSIGHT_RECORDING_CHUNK_DELAY_MS ?? "150");
|
||||
|
||||
export type RecordingArchiveResult = {
|
||||
scanned: number;
|
||||
archived: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
rateLimited: boolean;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const isRateLimit = (e: unknown) => (e as Error)?.message?.includes(" 429");
|
||||
|
||||
export async function runArchiveRecordings(): Promise<RecordingArchiveResult> {
|
||||
if (!isConfigured()) return { scanned: 0, archived: 0, failed: 0, skipped: 0, rateLimited: false };
|
||||
|
||||
const floor = new Date(Date.now() - MAX_AGE_DAYS * 24 * 3600_000);
|
||||
const pending = await prisma.sessionMeta.findMany({
|
||||
where: { rrwebArchivedAt: null, startedAt: { gt: floor } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
take: BATCH,
|
||||
});
|
||||
if (pending.length === 0) return { scanned: 0, archived: 0, failed: 0, skipped: 0, rateLimited: false };
|
||||
|
||||
let archived = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
let rateLimited = false;
|
||||
let processed = 0;
|
||||
|
||||
for (const s of pending) {
|
||||
if (processed > 0) await sleep(RATE_DELAY_MS);
|
||||
processed++;
|
||||
try {
|
||||
const sources = await getSnapshotSources(s.id);
|
||||
if (sources.length === 0) {
|
||||
// No snapshots (too short / already purged) — mark done so we stop retrying.
|
||||
await prisma.sessionMeta.update({ where: { id: s.id }, data: { rrwebArchivedAt: new Date() } });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch ALL blobs per source (no 12-cap), chunked to dodge blob_v2's range limit.
|
||||
const bySource = new Map<string, string[]>();
|
||||
for (const src of sources) {
|
||||
if (!bySource.has(src.source)) bySource.set(src.source, []);
|
||||
bySource.get(src.source)!.push(src.blob_key);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const [source, keys] of bySource) {
|
||||
const sorted = [...keys].sort((a, b) => Number(a) - Number(b));
|
||||
for (let i = 0; i < sorted.length; i += BLOB_CHUNK) {
|
||||
if (i > 0) await sleep(CHUNK_DELAY_MS);
|
||||
const lo = sorted[i];
|
||||
const hi = sorted[Math.min(i + BLOB_CHUNK - 1, sorted.length - 1)];
|
||||
const blob = await getSnapshotBlob(s.id, source, lo, hi);
|
||||
if (blob) parts.push(blob);
|
||||
}
|
||||
}
|
||||
const raw = parts.join("\n").trim();
|
||||
if (!raw) {
|
||||
await prisma.sessionMeta.update({ where: { id: s.id }, data: { rrwebArchivedAt: new Date() } });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${s.projectKey}/${dateFolder(s.startedAt)}/${s.id}.jsonl.gz`;
|
||||
const gz = gzipSync(Buffer.from(raw, "utf-8"));
|
||||
await putBuffer(RRWEB_BUCKET, key, gz, "application/gzip");
|
||||
|
||||
await prisma.sessionMeta.update({
|
||||
where: { id: s.id },
|
||||
data: { rrwebArchivedAt: new Date(), rrwebArchiveKey: key },
|
||||
});
|
||||
archived++;
|
||||
} catch (e) {
|
||||
if (isRateLimit(e)) {
|
||||
// Rate limited — stop hammering. Remaining rows retry next cycle.
|
||||
rateLimited = true;
|
||||
break;
|
||||
}
|
||||
// Other transient errors — leave rrwebArchivedAt null so it retries next run.
|
||||
console.warn("[archive-recordings] failed", s.id, (e as Error).message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned: pending.length, archived, failed, skipped, rateLimited };
|
||||
}
|
||||
|
||||
function dateFolder(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}/${m}/${day}`;
|
||||
}
|
||||
196
apps/worker/src/jobs/catalog-gap-detect.ts
Normal file
196
apps/worker/src/jobs/catalog-gap-detect.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { alertCatalogGap, isTelegramConfigured } from "../lib/telegram";
|
||||
|
||||
// Server-side companion to the session-driven insight pipeline. The behavioural
|
||||
// pipeline (PostHog → tagger → analyze) catches "user resolved a vehicle but saw
|
||||
// no parts" from client events (catalog_empty_result / parts_render_blocked).
|
||||
// This job catches the SAME failure from the server side — query_logs rows where
|
||||
// decode succeeded at identifying the car but no parts catalog exists
|
||||
// ("No catalog — identified as X") — and raises one Insight per brand-gap into
|
||||
// the founder's existing inbox. Panel owns the Sase DB; we fetch the aggregate
|
||||
// over the internal API (same trust model as vin-anomaly-detect) and write rows.
|
||||
|
||||
const PANEL_BASE =
|
||||
process.env.PANEL_INTERNAL_URL ?? process.env.PANEL_PUBLIC_URL ?? "http://panel-web:3000";
|
||||
const PANEL_PUBLIC = process.env.PANEL_PUBLIC_URL ?? "https://sp.semih.ai";
|
||||
const WORKER_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
|
||||
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
|
||||
|
||||
type CatalogGap = {
|
||||
brand: string;
|
||||
failures: number;
|
||||
uniqueUsers: number;
|
||||
labels: string[];
|
||||
years: number[];
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
windowDays: number;
|
||||
};
|
||||
type CheckResponse = { ok: boolean; error?: string; gaps?: CatalogGap[] };
|
||||
|
||||
type Summary = {
|
||||
ok: boolean;
|
||||
gaps: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
alertsFired: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
function brandSlug(b: string): string {
|
||||
return b.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
// P1 once a gap bites real breadth (≥4 distinct users or ≥15 failed lookups in
|
||||
// the window) — a core-value failure for a whole brand; otherwise P2.
|
||||
function severityFor(g: CatalogGap): "P1" | "P2" {
|
||||
return g.uniqueUsers >= 4 || g.failures >= 15 ? "P1" : "P2";
|
||||
}
|
||||
function priorityFor(sev: string, users: number): number {
|
||||
return (sev === "P1" ? 80 : 55) + Math.min(15, users);
|
||||
}
|
||||
|
||||
// Shape the body around keys the insight detail page already renders
|
||||
// (hypothesis / affected_route / user_impact_estimate / suggested_investigation /
|
||||
// reproduce_steps); extra keys still show in the Raw JSON panel.
|
||||
function buildBody(g: CatalogGap): Prisma.InputJsonValue {
|
||||
const yrs = g.years.length ? ` (${g.years[0]}–${g.years[g.years.length - 1]})` : "";
|
||||
const labelList = g.labels.slice(0, 12).join(", ");
|
||||
return {
|
||||
summary: `${g.brand}${yrs}: araç decode'da tanınıyor ama parça kataloğu bulunamıyor ("No catalog"). Son ${g.windowDays} günde ${g.failures} başarısız sorgu / ${g.uniqueUsers} kullanıcı.`,
|
||||
hypothesis: `Decode aracı doğru tanıyor ama "tanınan araç → parça kataloğu" eşlemesi ${g.brand} için boş dönüyor. Hatalar belirli model yıllarında kümeleniyor (${labelList}) → muhtemelen katalog MAPPING eksikliği (EMEX/PCAT kaynağında veri var ama brand/yıl eşleşmiyor) ya da o segment için kaynak verisi hiç yok.`,
|
||||
affected_route: `/dashboard/catalog/${g.brand}, /dashboard/vehicles/:id (decode → "No catalog")`,
|
||||
user_impact_estimate: `${g.uniqueUsers} kullanıcı son ${g.windowDays} günde ${g.brand} için parça göremedi → doğrudan churn sinyali. ${g.brand} TR pazarında yaygın bir marka.`,
|
||||
suggested_investigation: [
|
||||
`vehicles.service'teki katalog lookup zincirinde "No catalog" branch'ini incele (apps/api .../vehicles/vehicles.service.ts)`,
|
||||
`sase-catalog-src-emex / sase-catalog-src-pcat DB'lerinde ${g.brand} (${labelList}) var mı — veri mi eksik, mapping mi bozuk?`,
|
||||
`Tanınan brand/model_year → catalog brand/subcatalog eşlemesini kontrol et`,
|
||||
],
|
||||
reproduce_steps: [
|
||||
`Katalogdan ${g.brand} seç (ya da bu markaya ait bir VIN decode et)`,
|
||||
`Bir model/yıl seç (${g.labels[0] ?? g.brand})`,
|
||||
`Parça/kategori yerine boş sonuç / "No catalog" gözlenir`,
|
||||
],
|
||||
suggested_fix_effort: "M",
|
||||
affected_labels: g.labels,
|
||||
total_failures: g.failures,
|
||||
unique_users: g.uniqueUsers,
|
||||
window_days: g.windowDays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCatalogGapDetect(): Promise<Summary> {
|
||||
if (!WORKER_TOKEN) {
|
||||
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: "INTERNAL_WORKER_TOKEN not set" };
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${PANEL_BASE}/api/internal/catalog-gap-check`, {
|
||||
headers: { "x-internal-worker-token": WORKER_TOKEN, "cache-control": "no-store" },
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `fetch failed: ${(e as Error).message}` };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: `panel returned ${res.status}` };
|
||||
}
|
||||
const data = (await res.json()) as CheckResponse;
|
||||
if (!data.ok) {
|
||||
return { ok: false, gaps: 0, created: 0, updated: 0, alertsFired: 0, reason: data.error };
|
||||
}
|
||||
|
||||
const gaps = data.gaps ?? [];
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let alertsFired = 0;
|
||||
|
||||
for (const g of gaps) {
|
||||
const fingerprint = `catalog_gap:${brandSlug(g.brand)}`;
|
||||
const sev = severityFor(g);
|
||||
const body = buildBody(g);
|
||||
const title = `Katalog yok: ${g.brand} — ${g.uniqueUsers} kullanıcı parça göremiyor (son ${g.windowDays}g)`;
|
||||
|
||||
const existing = await prisma.insight.findUnique({
|
||||
where: { projectKey_fingerprint: { projectKey: PROJECT_KEY, fingerprint } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Refresh counts/severity but RESPECT founder triage: a dismissed/duplicate
|
||||
// gap stays dismissed (no resurrection). A gap the founder had already
|
||||
// validated/shipped that is failing again flips to "regressed" + alerts.
|
||||
const regressed = ["validated", "shipped"].includes(existing.status);
|
||||
await prisma.insight.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
severity: sev,
|
||||
occurrenceCount: g.failures,
|
||||
uniqueUserCount: g.uniqueUsers,
|
||||
lastSeenAt: new Date(g.lastSeen),
|
||||
priorityScore: priorityFor(sev, g.uniqueUsers),
|
||||
...(regressed ? { status: "regressed", regressionDetected: true } : {}),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
if (regressed && isTelegramConfigured()) {
|
||||
const r = await alertCatalogGap({
|
||||
brand: g.brand,
|
||||
failures: g.failures,
|
||||
uniqueUsers: g.uniqueUsers,
|
||||
windowDays: g.windowDays,
|
||||
severity: sev,
|
||||
insightId: existing.id,
|
||||
panelUrl: PANEL_PUBLIC,
|
||||
day,
|
||||
regressed: true,
|
||||
});
|
||||
if (r.ok && !r.deduped) alertsFired++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = await prisma.insight.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
type: "catalog_coverage_gap",
|
||||
severity: sev,
|
||||
status: "new",
|
||||
fingerprint,
|
||||
title,
|
||||
body,
|
||||
relatedSessionIds: [],
|
||||
occurrenceCount: g.failures,
|
||||
uniqueUserCount: g.uniqueUsers,
|
||||
firstSeenAt: new Date(g.firstSeen),
|
||||
lastSeenAt: new Date(g.lastSeen),
|
||||
confidence: 1.0,
|
||||
priorityScore: priorityFor(sev, g.uniqueUsers),
|
||||
sourcePromptTag: "catalog_gap_detector",
|
||||
sourcePromptVersion: 0,
|
||||
sourceModel: "rule:catalog-gap",
|
||||
sourceCostUsd: 0,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
if (isTelegramConfigured()) {
|
||||
const r = await alertCatalogGap({
|
||||
brand: g.brand,
|
||||
failures: g.failures,
|
||||
uniqueUsers: g.uniqueUsers,
|
||||
windowDays: g.windowDays,
|
||||
severity: sev,
|
||||
insightId: row.id,
|
||||
panelUrl: PANEL_PUBLIC,
|
||||
day,
|
||||
regressed: false,
|
||||
});
|
||||
if (r.ok && !r.deduped) alertsFired++;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, gaps: gaps.length, created, updated, alertsFired };
|
||||
}
|
||||
180
apps/worker/src/jobs/content-generate.ts
Normal file
180
apps/worker/src/jobs/content-generate.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// content-generate job (Phase 8a): turns queued ContentTopic rows into one
|
||||
// ContentDraft per selected channel. Mirrors the insight analyze job:
|
||||
// budget guard → per-call DeepSeek → JSON validate → persist + cost ledger.
|
||||
// Drafts land in `draft` status; the founder reviews/edits and (Phase 8c)
|
||||
// publishes via the n8n webhook. Nothing is published from here.
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
|
||||
import { checkContentBudget } from "../lib/content-budget";
|
||||
import { channelPromptTag, type ContentChannel } from "../lib/content-prompts";
|
||||
import { validate } from "../lib/json-validate";
|
||||
|
||||
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
|
||||
const GENERATE_BATCH = Number(process.env.CONTENT_GENERATE_BATCH ?? "3");
|
||||
|
||||
export type ContentGenerateResult = {
|
||||
topics: number;
|
||||
draftsCreated: number;
|
||||
failed: number;
|
||||
costUsd: number;
|
||||
budgetState: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runContentGenerate(): Promise<ContentGenerateResult> {
|
||||
const budget = await checkContentBudget();
|
||||
if (!budget.allow) {
|
||||
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
|
||||
}
|
||||
|
||||
const topics = await prisma.contentTopic.findMany({
|
||||
where: { projectKey: PROJECT_KEY, status: "queued" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: GENERATE_BATCH,
|
||||
});
|
||||
if (topics.length === 0) {
|
||||
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state };
|
||||
}
|
||||
|
||||
let draftsCreated = 0;
|
||||
let failed = 0;
|
||||
let costUsd = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "generating" } });
|
||||
|
||||
const channels = (topic.channels as string[]).filter((c) =>
|
||||
["blog", "linkedin", "x", "instagram"].includes(c),
|
||||
) as ContentChannel[];
|
||||
|
||||
for (const channel of channels) {
|
||||
// Skip if a draft for this topic+channel already exists (idempotent reruns).
|
||||
const existing = await prisma.contentDraft.findFirst({
|
||||
where: { topicId: topic.id, channel },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const tag = channelPromptTag(channel);
|
||||
const template = await prisma.promptTemplate.findFirst({
|
||||
where: { tag, active: true },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (!template) {
|
||||
console.warn(`[content-generate] no prompt for ${tag}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
|
||||
const userPrompt = template.userPromptTemplate
|
||||
.replace("{{title}}", topic.title)
|
||||
.replace("{{brief}}", topic.brief ?? "")
|
||||
.replace("{{angle}}", topic.angle ?? "(belirtilmedi)")
|
||||
.replace("{{keywords}}", (topic.keywords as string[]).join(", ") || "(yok)");
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await callDeepSeek({
|
||||
tier,
|
||||
systemPrompt: template.systemPrompt,
|
||||
userPrompt,
|
||||
maxOutputTokens: template.maxOutputTokens,
|
||||
temperature: template.temperature,
|
||||
});
|
||||
} catch (e) {
|
||||
const status = e instanceof DeepSeekError ? e.status : 0;
|
||||
console.warn(`[content-generate] deepseek error topic=${topic.id} ${channel}: ${status}`);
|
||||
await logCost({ tag, tier, promptVersion: template.version, errorCode: `${status}` });
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
costUsd += result.cost.totalUsd;
|
||||
|
||||
let parsed: any;
|
||||
let validationErrors = "";
|
||||
try {
|
||||
parsed = JSON.parse(extractJson(result.text));
|
||||
const errs = validate(parsed, template.outputSchemaJson as any);
|
||||
if (errs.length) validationErrors = errs.map((e) => `${e.path}: ${e.message}`).join("; ");
|
||||
} catch (e) {
|
||||
validationErrors = `json parse: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
await logCost({
|
||||
tag,
|
||||
tier,
|
||||
promptVersion: template.version,
|
||||
usage: result.usage,
|
||||
cost: result.cost,
|
||||
model: result.model,
|
||||
durationMs: result.durationMs,
|
||||
errorCode: validationErrors ? "validation_failed" : undefined,
|
||||
});
|
||||
|
||||
if (validationErrors) {
|
||||
console.warn(`[content-generate] validation failed topic=${topic.id} ${channel}: ${validationErrors.slice(0, 160)}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.contentDraft.create({
|
||||
data: {
|
||||
topicId: topic.id,
|
||||
projectKey: PROJECT_KEY,
|
||||
channel,
|
||||
status: "draft",
|
||||
bodyJson: parsed,
|
||||
sourcePromptTag: tag,
|
||||
sourcePromptVersion: template.version,
|
||||
sourceModel: result.model,
|
||||
sourceCostUsd: result.cost.totalUsd,
|
||||
},
|
||||
});
|
||||
draftsCreated++;
|
||||
}
|
||||
|
||||
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "drafted" } });
|
||||
|
||||
const recheck = await checkContentBudget();
|
||||
if (!recheck.allow) {
|
||||
console.log(`[content-generate] budget exhausted mid-batch (${recheck.state})`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { topics: topics.length, draftsCreated, failed, costUsd, budgetState: budget.state };
|
||||
}
|
||||
|
||||
async function logCost(opts: {
|
||||
tag: string;
|
||||
tier: Tier;
|
||||
promptVersion: number;
|
||||
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
|
||||
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
|
||||
model?: string;
|
||||
durationMs?: number;
|
||||
errorCode?: string;
|
||||
}): Promise<void> {
|
||||
await prisma.costLedger.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
promptTag: opts.tag,
|
||||
promptVersion: opts.promptVersion,
|
||||
provider: "deepseek",
|
||||
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
|
||||
tier: opts.tier,
|
||||
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
|
||||
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
|
||||
tokensOutput: opts.usage?.outputTokens ?? 0,
|
||||
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
|
||||
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
|
||||
costOutputUsd: opts.cost?.outputUsd ?? 0,
|
||||
costTotalUsd: opts.cost?.totalUsd ?? 0,
|
||||
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
|
||||
callDurationMs: opts.durationMs ?? null,
|
||||
errorCode: opts.errorCode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
204
apps/worker/src/jobs/content-topics.ts
Normal file
204
apps/worker/src/jobs/content-topics.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// content-topics job (Phase 8a): auto-generates content topic ideas via the
|
||||
// LLM and queues them as ContentTopic rows. Conservative by design — it only
|
||||
// tops the backlog up to a target, dedupes near-identical titles, and is
|
||||
// gated by the separate content budget.
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
|
||||
import { checkContentBudget } from "../lib/content-budget";
|
||||
import { validate } from "../lib/json-validate";
|
||||
import { fingerprintHash } from "../lib/hash";
|
||||
|
||||
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
|
||||
// Stop generating new ideas once this many topics are already waiting.
|
||||
const BACKLOG_TARGET = Number(process.env.CONTENT_BACKLOG_TARGET ?? "12");
|
||||
const DEFAULT_CHANNELS = (process.env.CONTENT_DEFAULT_CHANNELS ?? "blog,linkedin,x,instagram")
|
||||
.split(",")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
type TopicIdea = {
|
||||
title: string;
|
||||
brief: string;
|
||||
angle?: string;
|
||||
channels?: string[];
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
export type ContentTopicsResult = {
|
||||
generated: number;
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
costUsd: number;
|
||||
budgetState: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runContentTopics(): Promise<ContentTopicsResult> {
|
||||
const budget = await checkContentBudget();
|
||||
if (!budget.allow) {
|
||||
return { generated: 0, inserted: 0, skipped: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
|
||||
}
|
||||
|
||||
// Only top up the backlog — don't generate endlessly.
|
||||
const queuedCount = await prisma.contentTopic.count({
|
||||
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
|
||||
});
|
||||
if (queuedCount >= BACKLOG_TARGET) {
|
||||
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "backlog_full" };
|
||||
}
|
||||
const want = Math.min(6, BACKLOG_TARGET - queuedCount);
|
||||
|
||||
const template = await prisma.promptTemplate.findFirst({
|
||||
where: { tag: "content_topic_ideas", active: true },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (!template) {
|
||||
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "no_prompt" };
|
||||
}
|
||||
|
||||
// Recent topics so the model avoids repeating itself.
|
||||
const recent = await prisma.contentTopic.findMany({
|
||||
where: { projectKey: PROJECT_KEY },
|
||||
select: { title: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 40,
|
||||
});
|
||||
const context =
|
||||
recent.length > 0
|
||||
? `Son üretilen konular (BUNLARA BENZER üretme):\n${recent.map((r) => `- ${r.title}`).join("\n")}`
|
||||
: "Henüz üretilmiş konu yok.";
|
||||
|
||||
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
|
||||
const userPrompt = template.userPromptTemplate
|
||||
.replace("{{context}}", context)
|
||||
.replace("{{count}}", String(want));
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await callDeepSeek({
|
||||
tier,
|
||||
systemPrompt: template.systemPrompt,
|
||||
userPrompt,
|
||||
maxOutputTokens: template.maxOutputTokens,
|
||||
temperature: template.temperature,
|
||||
});
|
||||
} catch (e) {
|
||||
const status = e instanceof DeepSeekError ? e.status : 0;
|
||||
await logCost({ tier, promptVersion: template.version, errorCode: `${status}` });
|
||||
return {
|
||||
generated: 0,
|
||||
inserted: 0,
|
||||
skipped: 0,
|
||||
budgetState: budget.state,
|
||||
costUsd: 0,
|
||||
reason: `deepseek ${status}: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
await logCost({
|
||||
tier,
|
||||
promptVersion: template.version,
|
||||
usage: result.usage,
|
||||
cost: result.cost,
|
||||
model: result.model,
|
||||
durationMs: result.durationMs,
|
||||
});
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(extractJson(result.text));
|
||||
} catch (e) {
|
||||
return {
|
||||
generated: 0,
|
||||
inserted: 0,
|
||||
skipped: 0,
|
||||
budgetState: budget.state,
|
||||
costUsd: result.cost.totalUsd,
|
||||
reason: `json parse: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
const errs = validate(parsed, template.outputSchemaJson as any);
|
||||
if (errs.length) {
|
||||
return {
|
||||
generated: 0,
|
||||
inserted: 0,
|
||||
skipped: 0,
|
||||
budgetState: budget.state,
|
||||
costUsd: result.cost.totalUsd,
|
||||
reason: `validation: ${errs.map((e) => e.path).join(",").slice(0, 120)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ideas: TopicIdea[] = Array.isArray(parsed.topics) ? parsed.topics : [];
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const idea of ideas) {
|
||||
const fp = fingerprintHash([PROJECT_KEY, idea.title]);
|
||||
const dupe = await prisma.contentTopic.findFirst({ where: { projectKey: PROJECT_KEY, fingerprint: fp } });
|
||||
if (dupe) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const channels = (idea.channels && idea.channels.length ? idea.channels : DEFAULT_CHANNELS).filter((c) =>
|
||||
["blog", "linkedin", "x", "instagram"].includes(c),
|
||||
);
|
||||
await prisma.contentTopic.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
title: idea.title.slice(0, 250),
|
||||
brief: idea.brief ?? "",
|
||||
angle: idea.angle ?? null,
|
||||
channels: channels.length ? channels : DEFAULT_CHANNELS,
|
||||
keywords: Array.isArray(idea.keywords) ? idea.keywords.slice(0, 12) : [],
|
||||
status: "queued",
|
||||
source: "auto",
|
||||
fingerprint: fp,
|
||||
sourcePromptTag: "content_topic_ideas",
|
||||
sourcePromptVersion: template.version,
|
||||
sourceModel: result.model,
|
||||
sourceCostUsd: result.cost.totalUsd / Math.max(1, ideas.length),
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
|
||||
return {
|
||||
generated: ideas.length,
|
||||
inserted,
|
||||
skipped,
|
||||
budgetState: budget.state,
|
||||
costUsd: result.cost.totalUsd,
|
||||
};
|
||||
}
|
||||
|
||||
async function logCost(opts: {
|
||||
tier: Tier;
|
||||
promptVersion: number;
|
||||
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
|
||||
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
|
||||
model?: string;
|
||||
durationMs?: number;
|
||||
errorCode?: string;
|
||||
}): Promise<void> {
|
||||
await prisma.costLedger.create({
|
||||
data: {
|
||||
projectKey: PROJECT_KEY,
|
||||
promptTag: "content_topic_ideas",
|
||||
promptVersion: opts.promptVersion,
|
||||
provider: "deepseek",
|
||||
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
|
||||
tier: opts.tier,
|
||||
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
|
||||
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
|
||||
tokensOutput: opts.usage?.outputTokens ?? 0,
|
||||
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
|
||||
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
|
||||
costOutputUsd: opts.cost?.outputUsd ?? 0,
|
||||
costTotalUsd: opts.cost?.totalUsd ?? 0,
|
||||
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
|
||||
callDurationMs: opts.durationMs ?? null,
|
||||
errorCode: opts.errorCode ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
202
apps/worker/src/jobs/posthog-event-archive.ts
Normal file
202
apps/worker/src/jobs/posthog-event-archive.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { prisma } from "../db";
|
||||
import { hogqlQuery, isConfigured } from "../lib/posthog";
|
||||
import { putBuffer } from "../lib/minio";
|
||||
|
||||
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
|
||||
// Separate watermark stream so it never collides with the recording-ingest watermark (PROJECT_KEY).
|
||||
const WM_KEY = `${PROJECT_KEY}:events`;
|
||||
const BACKFILL_DAYS = Number(process.env.INSIGHT_EVENT_BACKFILL_DAYS ?? "365");
|
||||
const BATCH_SIZE = Number(process.env.INSIGHT_EVENT_BATCH ?? "500");
|
||||
const MAX_PAGES = Number(process.env.INSIGHT_EVENT_MAX_PAGES ?? "20");
|
||||
const MAX_DUMP_DAYS = Number(process.env.INSIGHT_EVENT_MAX_DUMP_DAYS ?? "120");
|
||||
const ARCHIVE_BUCKET = process.env.INSIGHT_ARCHIVE_BUCKET ?? "posthog-archive";
|
||||
|
||||
export type EventArchiveResult = {
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
pages: number;
|
||||
cursorAdvancedTo: string | null;
|
||||
dumped: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
uuid: string;
|
||||
projectKey: string;
|
||||
event: string;
|
||||
distinctId: string;
|
||||
personId: string | null;
|
||||
sessionId: string | null;
|
||||
timestamp: Date;
|
||||
properties: unknown;
|
||||
};
|
||||
|
||||
// Coerce HogQL's properties cell (object or JSON string) into a plain object.
|
||||
function parseProps(v: unknown): unknown {
|
||||
if (v && typeof v === "object") return v;
|
||||
if (typeof v === "string" && v) {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function emptyOrNull(v: unknown): string | null {
|
||||
const s = v == null ? "" : String(v);
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export async function runArchivePosthogEvents(): Promise<EventArchiveResult> {
|
||||
if (!isConfigured()) {
|
||||
return { fetched: 0, inserted: 0, duplicates: 0, pages: 0, cursorAdvancedTo: null, dumped: 0, error: "posthog_not_configured" };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const wm = await prisma.ingestionWatermark.findUnique({ where: { projectKey: WM_KEY } });
|
||||
let cursor = wm?.posthogCursor
|
||||
? new Date(wm.posthogCursor)
|
||||
: new Date(now.getTime() - BACKFILL_DAYS * 24 * 3600_000);
|
||||
|
||||
let fetched = 0;
|
||||
let inserted = 0;
|
||||
let duplicates = 0;
|
||||
let pages = 0;
|
||||
let error: string | undefined;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
let resp;
|
||||
try {
|
||||
// properties.$session_id is the 5th column; full properties is the 7th.
|
||||
// parseDateTimeBestEffort is required — ClickHouse can't compare timestamp
|
||||
// against a raw ISO8601 string literal (the trailing Z / ms 500s the query).
|
||||
resp = await hogqlQuery(
|
||||
`SELECT uuid, event, distinct_id, person_id, properties.$session_id, timestamp, properties
|
||||
FROM events
|
||||
WHERE timestamp >= parseDateTimeBestEffort('${cursor.toISOString()}')
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ${BATCH_SIZE}`,
|
||||
);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
break;
|
||||
}
|
||||
pages++;
|
||||
const rows = resp.results ?? [];
|
||||
if (rows.length === 0) break;
|
||||
|
||||
const batch: EventRow[] = [];
|
||||
let maxTs = cursor;
|
||||
for (const r of rows) {
|
||||
const uuid = String(r[0] ?? "");
|
||||
if (!uuid) continue;
|
||||
const ts = new Date(String(r[5]));
|
||||
if (ts > maxTs) maxTs = ts;
|
||||
batch.push({
|
||||
uuid,
|
||||
projectKey: PROJECT_KEY,
|
||||
event: String(r[1] ?? ""),
|
||||
distinctId: String(r[2] ?? ""),
|
||||
personId: emptyOrNull(r[3]),
|
||||
sessionId: emptyOrNull(r[4]),
|
||||
timestamp: ts,
|
||||
properties: parseProps(r[6]) as object,
|
||||
});
|
||||
}
|
||||
fetched += batch.length;
|
||||
|
||||
if (batch.length) {
|
||||
const res = await prisma.posthogEvent.createMany({
|
||||
data: batch as never,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
inserted += res.count;
|
||||
duplicates += batch.length - res.count;
|
||||
}
|
||||
|
||||
// Advance cursor to the batch's max timestamp. Using >= on the next query
|
||||
// re-reads same-second events, but uuid PK + skipDuplicates absorbs them.
|
||||
if (maxTs > cursor) {
|
||||
cursor = maxTs;
|
||||
} else {
|
||||
// Safety valve: a full all-duplicate batch with no time progress would
|
||||
// otherwise loop forever. Nudge cursor past it.
|
||||
cursor = new Date(cursor.getTime() + 1);
|
||||
}
|
||||
|
||||
if (rows.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
// Persist cursor.
|
||||
await prisma.ingestionWatermark.upsert({
|
||||
where: { projectKey: WM_KEY },
|
||||
create: { projectKey: WM_KEY, lastPolledAt: now, posthogCursor: cursor.toISOString() },
|
||||
update: { lastPolledAt: now, posthogCursor: cursor.toISOString() },
|
||||
});
|
||||
|
||||
// Cold dump: write closed (before-today, UTC) days to MinIO as gzip JSONL.
|
||||
let dumped = 0;
|
||||
try {
|
||||
dumped = await dumpClosedDays(now);
|
||||
} catch (e) {
|
||||
error = error ?? `dump_failed: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
return { fetched, inserted, duplicates, pages, cursorAdvancedTo: cursor.toISOString(), dumped, error };
|
||||
}
|
||||
|
||||
// Groups undumped rows (timestamp < start-of-today-UTC) by UTC day, writes the
|
||||
// full day as one gzip JSONL object, then marks those rows dumped. Re-running is
|
||||
// deterministic: each closed day maps to exactly one object that is overwritten.
|
||||
async function dumpClosedDays(now: Date): Promise<number> {
|
||||
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
|
||||
const undumped = await prisma.posthogEvent.findMany({
|
||||
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { lt: todayStart } },
|
||||
select: { timestamp: true },
|
||||
orderBy: { timestamp: "asc" },
|
||||
});
|
||||
if (undumped.length === 0) return 0;
|
||||
|
||||
const days = Array.from(new Set(undumped.map((r) => r.timestamp.toISOString().slice(0, 10)))).slice(0, MAX_DUMP_DAYS);
|
||||
|
||||
let dumped = 0;
|
||||
for (const day of days) {
|
||||
const dayStart = new Date(`${day}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 3600_000);
|
||||
const rows = await prisma.posthogEvent.findMany({
|
||||
where: { projectKey: PROJECT_KEY, timestamp: { gte: dayStart, lt: dayEnd } },
|
||||
orderBy: { timestamp: "asc" },
|
||||
});
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const jsonl = rows
|
||||
.map((r) =>
|
||||
JSON.stringify({
|
||||
uuid: r.uuid,
|
||||
event: r.event,
|
||||
distinctId: r.distinctId,
|
||||
personId: r.personId,
|
||||
sessionId: r.sessionId,
|
||||
timestamp: r.timestamp.toISOString(),
|
||||
properties: r.properties,
|
||||
}),
|
||||
)
|
||||
.join("\n");
|
||||
const gz = gzipSync(Buffer.from(jsonl, "utf-8"));
|
||||
const [y, m, d] = day.split("-");
|
||||
const key = `${PROJECT_KEY}/${y}/${m}/${d}.jsonl.gz`;
|
||||
await putBuffer(ARCHIVE_BUCKET, key, gz, "application/gzip");
|
||||
|
||||
const marked = await prisma.posthogEvent.updateMany({
|
||||
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { gte: dayStart, lt: dayEnd } },
|
||||
data: { dumpedAt: new Date() },
|
||||
});
|
||||
dumped += marked.count;
|
||||
}
|
||||
return dumped;
|
||||
}
|
||||
97
apps/worker/src/jobs/posthog-identity-archive.ts
Normal file
97
apps/worker/src/jobs/posthog-identity-archive.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { prisma } from "../db";
|
||||
import { listPersons, listCohorts, isConfigured } from "../lib/posthog";
|
||||
import { stableHash } from "../lib/hash";
|
||||
|
||||
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
|
||||
|
||||
export type IdentityArchiveResult = {
|
||||
personsScanned: number;
|
||||
personSnapshots: number;
|
||||
cohortsScanned: number;
|
||||
cohortSnapshots: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Daily append-only snapshot of PostHog persons + cohorts. A new row is written
|
||||
// only when the payload changed since the last snapshot (hash compare), so the
|
||||
// table stays a compact change history rather than a daily full copy.
|
||||
export async function runArchiveIdentity(): Promise<IdentityArchiveResult> {
|
||||
if (!isConfigured()) {
|
||||
return { personsScanned: 0, personSnapshots: 0, cohortsScanned: 0, cohortSnapshots: 0, error: "posthog_not_configured" };
|
||||
}
|
||||
|
||||
let personsScanned = 0;
|
||||
let personSnapshots = 0;
|
||||
let cohortsScanned = 0;
|
||||
let cohortSnapshots = 0;
|
||||
let error: string | undefined;
|
||||
|
||||
// ── Persons ──
|
||||
try {
|
||||
const persons = await listPersons();
|
||||
personsScanned = persons.length;
|
||||
|
||||
// Latest stored hash per person (one query via DISTINCT ON).
|
||||
const latest = await prisma.$queryRaw<Array<{ personId: string; propertiesHash: string }>>`
|
||||
SELECT DISTINCT ON ("personId") "personId", "propertiesHash"
|
||||
FROM posthog_person_snapshots
|
||||
WHERE "projectKey" = ${PROJECT_KEY}
|
||||
ORDER BY "personId", "capturedAt" DESC
|
||||
`;
|
||||
const latestHash = new Map(latest.map((r) => [r.personId, r.propertiesHash]));
|
||||
|
||||
const toInsert = persons
|
||||
.filter((p) => p.id)
|
||||
.map((p) => ({
|
||||
projectKey: PROJECT_KEY,
|
||||
personId: p.id,
|
||||
distinctIds: p.distinct_ids ?? [],
|
||||
propertiesHash: stableHash(p.properties ?? {}),
|
||||
properties: (p.properties ?? {}) as object,
|
||||
}))
|
||||
.filter((row) => latestHash.get(row.personId) !== row.propertiesHash);
|
||||
|
||||
if (toInsert.length) {
|
||||
const res = await prisma.posthogPersonSnapshot.createMany({ data: toInsert as never });
|
||||
personSnapshots = res.count;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `persons_failed: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
// ── Cohorts ──
|
||||
try {
|
||||
const cohorts = await listCohorts();
|
||||
cohortsScanned = cohorts.length;
|
||||
|
||||
const latest = await prisma.$queryRaw<Array<{ cohortId: number; stateHash: string }>>`
|
||||
SELECT DISTINCT ON ("cohortId") "cohortId", "stateHash"
|
||||
FROM posthog_cohort_snapshots
|
||||
WHERE "projectKey" = ${PROJECT_KEY}
|
||||
ORDER BY "cohortId", "capturedAt" DESC
|
||||
`;
|
||||
const latestHash = new Map(latest.map((r) => [r.cohortId, r.stateHash]));
|
||||
|
||||
const toInsert = cohorts.map((c) => {
|
||||
const filters = c.filters ?? c.groups ?? {};
|
||||
return {
|
||||
projectKey: PROJECT_KEY,
|
||||
cohortId: c.id,
|
||||
name: c.name ?? "",
|
||||
count: c.count ?? null,
|
||||
isStatic: Boolean(c.is_static),
|
||||
stateHash: stableHash({ count: c.count ?? null, filters }),
|
||||
filters: filters as object,
|
||||
};
|
||||
}).filter((row) => latestHash.get(row.cohortId) !== row.stateHash);
|
||||
|
||||
if (toInsert.length) {
|
||||
const res = await prisma.posthogCohortSnapshot.createMany({ data: toInsert as never });
|
||||
cohortSnapshots = res.count;
|
||||
}
|
||||
} catch (e) {
|
||||
error = error ?? `cohorts_failed: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
return { personsScanned, personSnapshots, cohortsScanned, cohortSnapshots, error };
|
||||
}
|
||||
215
apps/worker/src/jobs/sentry-archive.ts
Normal file
215
apps/worker/src/jobs/sentry-archive.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { prisma } from "../db";
|
||||
import {
|
||||
getProjects,
|
||||
isConfigured,
|
||||
listEventsPage,
|
||||
listIssuesPage,
|
||||
type SentryEventRaw,
|
||||
} from "../lib/sentry";
|
||||
import { putBuffer } from "../lib/minio";
|
||||
|
||||
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
|
||||
const ISSUE_MAX_PAGES = Number(process.env.SENTRY_ISSUE_MAX_PAGES ?? "20");
|
||||
const EVENT_MAX_PAGES = Number(process.env.SENTRY_EVENT_MAX_PAGES ?? "40");
|
||||
const MAX_DUMP_DAYS = Number(process.env.SENTRY_MAX_DUMP_DAYS ?? "120");
|
||||
const ARCHIVE_BUCKET = process.env.SENTRY_ARCHIVE_BUCKET ?? "sentry-archive";
|
||||
|
||||
export type SentryArchiveResult = {
|
||||
issuesSynced: number;
|
||||
eventsFetched: number;
|
||||
eventsInserted: number;
|
||||
eventsDuplicate: number;
|
||||
dumped: number;
|
||||
perProject: Record<string, { fetched: number; inserted: number; duplicate: number }>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function toDate(v: unknown): Date | null {
|
||||
if (!v) return null;
|
||||
const d = new Date(String(v));
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function runArchiveSentry(): Promise<SentryArchiveResult> {
|
||||
const perProject: SentryArchiveResult["perProject"] = {};
|
||||
if (!isConfigured()) {
|
||||
return {
|
||||
issuesSynced: 0,
|
||||
eventsFetched: 0,
|
||||
eventsInserted: 0,
|
||||
eventsDuplicate: 0,
|
||||
dumped: 0,
|
||||
perProject,
|
||||
error: "sentry_not_configured",
|
||||
};
|
||||
}
|
||||
|
||||
const projects = getProjects();
|
||||
let issuesSynced = 0;
|
||||
let eventsFetched = 0;
|
||||
let eventsInserted = 0;
|
||||
let eventsDuplicate = 0;
|
||||
let error: string | undefined;
|
||||
|
||||
// ── Issues: single org-wide loop covers all projects. ───────────────────
|
||||
// Each issue carries `project.slug` so rows preserve which Sentry project
|
||||
// raised them.
|
||||
try {
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < ISSUE_MAX_PAGES; page++) {
|
||||
const { data, nextCursor } = await listIssuesPage(cursor);
|
||||
if (!data.length) break;
|
||||
for (const i of data) {
|
||||
if (!i.id) continue;
|
||||
const sentrySourceProject = i.project?.slug ?? null;
|
||||
const row = {
|
||||
projectKey: PROJECT_KEY,
|
||||
sentrySourceProject,
|
||||
shortId: i.shortId ?? null,
|
||||
title: i.title ?? null,
|
||||
culprit: i.culprit ?? null,
|
||||
level: i.level ?? null,
|
||||
status: i.status ?? null,
|
||||
type: i.type ?? null,
|
||||
count: i.count != null ? Number(i.count) : null,
|
||||
userCount: i.userCount ?? null,
|
||||
firstSeen: toDate(i.firstSeen),
|
||||
lastSeen: toDate(i.lastSeen),
|
||||
permalink: i.permalink ?? null,
|
||||
metadata: (i.metadata ?? {}) as object,
|
||||
lastSyncedAt: new Date(),
|
||||
};
|
||||
await prisma.sentryIssue.upsert({
|
||||
where: { id: i.id },
|
||||
create: { id: i.id, ...row },
|
||||
update: row,
|
||||
});
|
||||
issuesSynced++;
|
||||
}
|
||||
if (!nextCursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `issues_failed: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
// ── Events: per Sentry project, newest-first, stop once caught up. ──────
|
||||
for (const slug of projects) {
|
||||
const counters = { fetched: 0, inserted: 0, duplicate: 0 };
|
||||
perProject[slug] = counters;
|
||||
try {
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < EVENT_MAX_PAGES; page++) {
|
||||
const { data, nextCursor } = await listEventsPage(slug, cursor);
|
||||
if (!data.length) break;
|
||||
|
||||
const batch = data
|
||||
.map((e: SentryEventRaw) => {
|
||||
const eventId = String(e.eventID ?? e.id ?? "");
|
||||
const ts = toDate(e.dateCreated ?? e.dateReceived);
|
||||
if (!eventId || !ts) return null;
|
||||
return {
|
||||
eventId,
|
||||
issueId: e.groupID ? String(e.groupID) : null,
|
||||
projectKey: PROJECT_KEY,
|
||||
sentrySourceProject: slug,
|
||||
title: e.title ?? null,
|
||||
message: typeof e.message === "string" ? e.message : null,
|
||||
level: typeof e.level === "string" ? (e.level as string) : null,
|
||||
platform: e.platform ?? null,
|
||||
timestamp: ts,
|
||||
tags: (e.tags ?? []) as object,
|
||||
payload: e as object,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
counters.fetched += batch.length;
|
||||
eventsFetched += batch.length;
|
||||
if (batch.length) {
|
||||
const res = await prisma.sentryEvent.createMany({
|
||||
data: batch as never,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
counters.inserted += res.count;
|
||||
counters.duplicate += batch.length - res.count;
|
||||
eventsInserted += res.count;
|
||||
eventsDuplicate += batch.length - res.count;
|
||||
// A full page with no new rows means we've reached already-archived events.
|
||||
if (res.count === 0) break;
|
||||
}
|
||||
if (!nextCursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
} catch (e) {
|
||||
// Project-level failure doesn't stop the other projects from being archived.
|
||||
error = error ?? `events_failed[${slug}]: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cold dump closed days to MinIO ──
|
||||
let dumped = 0;
|
||||
try {
|
||||
dumped = await dumpClosedDays(new Date());
|
||||
} catch (e) {
|
||||
error = error ?? `dump_failed: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
return {
|
||||
issuesSynced,
|
||||
eventsFetched,
|
||||
eventsInserted,
|
||||
eventsDuplicate,
|
||||
dumped,
|
||||
perProject,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async function dumpClosedDays(now: Date): Promise<number> {
|
||||
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const undumped = await prisma.sentryEvent.findMany({
|
||||
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { lt: todayStart } },
|
||||
select: { timestamp: true },
|
||||
orderBy: { timestamp: "asc" },
|
||||
});
|
||||
if (undumped.length === 0) return 0;
|
||||
|
||||
const days = Array.from(new Set(undumped.map((r) => r.timestamp.toISOString().slice(0, 10)))).slice(0, MAX_DUMP_DAYS);
|
||||
let dumped = 0;
|
||||
for (const day of days) {
|
||||
const dayStart = new Date(`${day}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 3600_000);
|
||||
const rows = await prisma.sentryEvent.findMany({
|
||||
where: { projectKey: PROJECT_KEY, timestamp: { gte: dayStart, lt: dayEnd } },
|
||||
orderBy: { timestamp: "asc" },
|
||||
});
|
||||
if (rows.length === 0) continue;
|
||||
const jsonl = rows
|
||||
.map((r) =>
|
||||
JSON.stringify({
|
||||
eventId: r.eventId,
|
||||
issueId: r.issueId,
|
||||
sentrySourceProject: r.sentrySourceProject,
|
||||
title: r.title,
|
||||
message: r.message,
|
||||
level: r.level,
|
||||
platform: r.platform,
|
||||
timestamp: r.timestamp.toISOString(),
|
||||
tags: r.tags,
|
||||
payload: r.payload,
|
||||
}),
|
||||
)
|
||||
.join("\n");
|
||||
const gz = gzipSync(Buffer.from(jsonl, "utf-8"));
|
||||
const [y, m, d] = day.split("-");
|
||||
await putBuffer(ARCHIVE_BUCKET, `${PROJECT_KEY}/${y}/${m}/${d}.jsonl.gz`, gz, "application/gzip");
|
||||
const marked = await prisma.sentryEvent.updateMany({
|
||||
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { gte: dayStart, lt: dayEnd } },
|
||||
data: { dumpedAt: new Date() },
|
||||
});
|
||||
dumped += marked.count;
|
||||
}
|
||||
return dumped;
|
||||
}
|
||||
@@ -145,11 +145,17 @@ export function compressSnapshots(
|
||||
|
||||
const formatCustom = (ev: CanonicalEvent): string => {
|
||||
// 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 keys = [
|
||||
"provider",
|
||||
"provider_attempted",
|
||||
"error_code",
|
||||
"error",
|
||||
"source",
|
||||
"vin",
|
||||
"result",
|
||||
"plan",
|
||||
"amount",
|
||||
@@ -391,11 +397,39 @@ export function compressSnapshots(
|
||||
hypotheses.push("Fresh trial activated and decoded a VIN successfully — onboarding succeeded");
|
||||
}
|
||||
|
||||
// Pattern 3: VIN decode failure or provider fallback — investigate upstream.
|
||||
const vinFailures = count("vin_decode_failed") + count("vin_decode_error");
|
||||
if (vinFailures >= 2 || count("provider_fallback_triggered") >= 1) {
|
||||
// Pattern 3: VIN decode failure or provider fallback — but separate the
|
||||
// client-side validation rejects (input never reached a provider) from real
|
||||
// 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(
|
||||
`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)"]),
|
||||
].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([
|
||||
[...header.tags].sort().join(","),
|
||||
url,
|
||||
normalizePath(url),
|
||||
errors[0] ? normalizeError(errors[0]) : null,
|
||||
failedEndpoints[0] ?? null,
|
||||
header.severity,
|
||||
failedEndpoints[0] ? normalizePath(failedEndpoints[0]) : null,
|
||||
]);
|
||||
|
||||
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 {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
116
apps/worker/src/lib/content-budget.ts
Normal file
116
apps/worker/src/lib/content-budget.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// Content-generation budget guard. Mirrors lib/budget.ts but keeps a separate
|
||||
// envelope from the insight pipeline: spend is summed only over cost_ledger
|
||||
// rows whose promptTag starts with "content_", and the caps come from the
|
||||
// `content_*` budget settings. This way content generation can never exhaust
|
||||
// the insight analysis budget (or vice-versa).
|
||||
|
||||
import { prisma } from "../db";
|
||||
import type { BudgetState } from "./budget";
|
||||
|
||||
export type ContentBudgetDecision = {
|
||||
allow: boolean;
|
||||
state: BudgetState;
|
||||
reason: string;
|
||||
forceTier?: "flash";
|
||||
todayUsd: number;
|
||||
monthUsd: number;
|
||||
limits: {
|
||||
monthlyHardCap: number;
|
||||
dailySoftCap: number;
|
||||
dailyHardCap: number;
|
||||
perCallMax: number;
|
||||
};
|
||||
};
|
||||
|
||||
async function getNumber(key: string, fallback: number): Promise<number> {
|
||||
const row = await prisma.budgetSetting.findFirst({
|
||||
where: { projectKey: null, settingKey: key },
|
||||
});
|
||||
const v = row?.settingValue;
|
||||
return typeof v === "number" ? v : fallback;
|
||||
}
|
||||
|
||||
async function getBool(key: string, fallback: boolean): Promise<boolean> {
|
||||
const row = await prisma.budgetSetting.findFirst({
|
||||
where: { projectKey: null, settingKey: key },
|
||||
});
|
||||
const v = row?.settingValue;
|
||||
return typeof v === "boolean" ? v : fallback;
|
||||
}
|
||||
|
||||
function startOfDayUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
function startOfMonthUtc(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
// Sums only content_* spend.
|
||||
async function contentSpend(since: Date): Promise<number> {
|
||||
const agg = await prisma.costLedger.aggregate({
|
||||
where: { createdAt: { gte: since }, promptTag: { startsWith: "content_" } },
|
||||
_sum: { costTotalUsd: true },
|
||||
});
|
||||
return Number(agg._sum.costTotalUsd ?? 0);
|
||||
}
|
||||
|
||||
export async function checkContentBudget(): Promise<ContentBudgetDecision> {
|
||||
const monthlyHardCap = await getNumber("content_monthly_hard_cap_usd", 15);
|
||||
const dailySoftCap = await getNumber("content_daily_soft_cap_usd", 1);
|
||||
const dailyHardCap = await getNumber("content_daily_hard_cap_usd", 2);
|
||||
const perCallMax = await getNumber("content_per_call_max_usd", 0.3);
|
||||
const paused = await getBool("content_paused", false);
|
||||
|
||||
const limits = { monthlyHardCap, dailySoftCap, dailyHardCap, perCallMax };
|
||||
|
||||
if (paused) {
|
||||
return {
|
||||
allow: false,
|
||||
state: "hard_paused",
|
||||
reason: "content_paused setting is true",
|
||||
todayUsd: 0,
|
||||
monthUsd: 0,
|
||||
limits,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [todayUsd, monthUsd] = await Promise.all([
|
||||
contentSpend(startOfDayUtc(now)),
|
||||
contentSpend(startOfMonthUtc(now)),
|
||||
]);
|
||||
|
||||
if (monthUsd >= monthlyHardCap) {
|
||||
return {
|
||||
allow: false,
|
||||
state: "monthly_paused",
|
||||
reason: `content month spend $${monthUsd.toFixed(4)} >= monthly cap $${monthlyHardCap}`,
|
||||
todayUsd,
|
||||
monthUsd,
|
||||
limits,
|
||||
};
|
||||
}
|
||||
if (todayUsd >= dailyHardCap) {
|
||||
return {
|
||||
allow: false,
|
||||
state: "hard_paused",
|
||||
reason: `content today spend $${todayUsd.toFixed(4)} >= daily hard cap $${dailyHardCap}`,
|
||||
todayUsd,
|
||||
monthUsd,
|
||||
limits,
|
||||
};
|
||||
}
|
||||
if (todayUsd >= dailySoftCap) {
|
||||
return {
|
||||
allow: true,
|
||||
state: "soft_throttled",
|
||||
reason: `content today spend $${todayUsd.toFixed(4)} >= daily soft cap $${dailySoftCap}, force flash`,
|
||||
forceTier: "flash",
|
||||
todayUsd,
|
||||
monthUsd,
|
||||
limits,
|
||||
};
|
||||
}
|
||||
|
||||
return { allow: true, state: "active", reason: "ok", todayUsd, monthUsd, limits };
|
||||
}
|
||||
216
apps/worker/src/lib/content-prompts.ts
Normal file
216
apps/worker/src/lib/content-prompts.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Content-generation prompt templates (Phase 8a). Same shape & DB table as
|
||||
// the insight prompts (PromptTemplate) — distinguished by the `content_*` tag
|
||||
// prefix, which is also how content spend is separated in the cost ledger.
|
||||
//
|
||||
// One topic-idea prompt (auto topic generation) + one prompt per channel.
|
||||
// All natural-language output is Turkish (B2B automotive marketing tone).
|
||||
|
||||
import type { PromptTemplate } from "./prompts";
|
||||
|
||||
export const CONTENT_TAGS = [
|
||||
"content_topic_ideas",
|
||||
"content_blog",
|
||||
"content_linkedin",
|
||||
"content_x",
|
||||
"content_instagram",
|
||||
] as const;
|
||||
|
||||
export type ContentChannel = "blog" | "linkedin" | "x" | "instagram";
|
||||
|
||||
// Maps a channel to its generation prompt tag.
|
||||
export function channelPromptTag(channel: ContentChannel): string {
|
||||
return `content_${channel}`;
|
||||
}
|
||||
|
||||
const SASE_BRAND = `Sase.tr hakkında:
|
||||
- B2B SaaS: VIN/şasi sorgulama + OEM yedek parça uyumluluğu. Hedef kitle: Türkiye'deki yedek parçacılar, oto servisleri, tamirhaneler, parça ithalatçıları.
|
||||
- Değer önerisi: doğru parçayı VIN'den hızlı bul, yanlış parça iadesini azalt, 4 upstream katalog (PL24/Partslink24, PCAT, RMEX, TecDoc) tek arayüzde.
|
||||
- Abonelik: starter / brand_specific / full. Deneme akışı var.
|
||||
|
||||
Marka tonu:
|
||||
- Profesyonel, net, sektörün dilini bilen. Esnaf/teknisyen okuyucuya saygılı, abartısız.
|
||||
- Otomotiv terimlerini doğru kullan: VIN, şasi no, OEM, OE/eşdeğer parça, OBD, motor kodu, donanım kodu, katalog, çapraz referans.
|
||||
- Satış baskısı değil; gerçek bir sorunu çözerek güven kur. CTA yumuşak ama net (örn. "Sase.tr'de VIN ile parça aramayı ücretsiz deneyin").
|
||||
- Yanlış/uydurma teknik iddia YOK. Emin değilsen genel konuş, spesifik sayı/iddia uydurma.
|
||||
|
||||
Çıktı kuralları:
|
||||
- SADECE şemaya uyan geçerli JSON döndür. Markdown yok, kod bloğu yok, açıklama yok.
|
||||
- Tüm doğal dil alanları (başlık, gövde, caption, brief, CTA, vb.) TÜRKÇE. Hashtag'ler Türkçe veya sektör-standart İngilizce olabilir (örn. #yedekparça #OEM).
|
||||
- Alan uzunluk sınırlarına (maxLength) uy; aşma, gerekirse kısalt.`;
|
||||
|
||||
// ---- Topic idea generation ----
|
||||
const TOPIC_IDEAS_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["topics"],
|
||||
properties: {
|
||||
topics: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 8,
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["title", "brief", "channels", "keywords"],
|
||||
properties: {
|
||||
title: { type: "string", maxLength: 160 },
|
||||
brief: { type: "string", maxLength: 600 },
|
||||
angle: { type: "string", maxLength: 300 },
|
||||
channels: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 4,
|
||||
items: { enum: ["blog", "linkedin", "x", "instagram"] },
|
||||
},
|
||||
keywords: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Per-channel content schemas ----
|
||||
const BLOG_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["title", "slug", "meta_description", "body_markdown", "tags"],
|
||||
properties: {
|
||||
title: { type: "string", maxLength: 160 },
|
||||
slug: { type: "string", maxLength: 120 },
|
||||
meta_description: { type: "string", maxLength: 300 },
|
||||
body_markdown: { type: "string", maxLength: 14000 },
|
||||
tags: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
|
||||
cta: { type: "string", maxLength: 300 },
|
||||
},
|
||||
};
|
||||
|
||||
const LINKEDIN_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["body", "hashtags"],
|
||||
properties: {
|
||||
body: { type: "string", maxLength: 2600 },
|
||||
hashtags: { type: "array", minItems: 0, maxItems: 10, items: { type: "string" } },
|
||||
cta: { type: "string", maxLength: 200 },
|
||||
},
|
||||
};
|
||||
|
||||
const X_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["tweets"],
|
||||
properties: {
|
||||
tweets: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", maxLength: 280 } },
|
||||
hashtags: { type: "array", minItems: 0, maxItems: 6, items: { type: "string" } },
|
||||
},
|
||||
};
|
||||
|
||||
const INSTAGRAM_SCHEMA = {
|
||||
type: "object",
|
||||
required: ["caption", "hashtags"],
|
||||
properties: {
|
||||
caption: { type: "string", maxLength: 2200 },
|
||||
hashtags: { type: "array", minItems: 0, maxItems: 30, items: { type: "string" } },
|
||||
image_prompt: { type: "string", maxLength: 400 },
|
||||
},
|
||||
};
|
||||
|
||||
export const CONTENT_SEED_PROMPTS: PromptTemplate[] = [
|
||||
{
|
||||
tag: "content_topic_ideas",
|
||||
version: 1,
|
||||
name: "Content Topic Ideas v1 (TR)",
|
||||
systemPrompt: `Sase.tr için içerik konusu fikirleri üreten bir B2B içerik stratejistisin. VIN/OEM/yedek parça/oto servis temalarında, hedef kitlenin (yedek parçacılar, servisler) gerçekten arayacağı veya faydalanacağı, SEO ve sosyal için uygun konular öner. Tekrara düşme, jenerik olma; sektöre özgü ve eyleme dönüştürülebilir açılar bul.
|
||||
|
||||
${SASE_BRAND}
|
||||
|
||||
Şema (tam olarak buna uy):
|
||||
${JSON.stringify(TOPIC_IDEAS_SCHEMA)}`,
|
||||
userPromptTemplate: `{{context}}
|
||||
|
||||
{{count}} adet yeni içerik konusu fikri üret. Her fikir için: başlık, kısa brief (içeriğin ne anlatacağı), açı (angle), uygun kanallar ve anahtar kelimeler. Yukarıdaki "son konular" listesindekilere benzer/çakışan konu ÜRETME. Şemaya uygun JSON döndür.`,
|
||||
outputSchemaJson: TOPIC_IDEAS_SCHEMA,
|
||||
modelTier: "pro",
|
||||
maxOutputTokens: 1800,
|
||||
temperature: 0.7,
|
||||
},
|
||||
{
|
||||
tag: "content_blog",
|
||||
version: 1,
|
||||
name: "Content Blog v1 (TR, SEO)",
|
||||
systemPrompt: `Sase.tr için Türkçe, SEO-optimize blog yazıları yazan bir içerik editörüsün. Yazı yapısı net (giriş, alt başlıklar, sonuç), okunabilir, gerçek değer veren ve anahtar kelimeleri doğal kullanan olmalı. Markdown gövdesinde başlıklar (##), kısa paragraflar ve gerektiğinde liste kullan.
|
||||
|
||||
${SASE_BRAND}
|
||||
|
||||
Şema:
|
||||
${JSON.stringify(BLOG_SCHEMA)}`,
|
||||
userPromptTemplate: `Konu: {{title}}
|
||||
Brief: {{brief}}
|
||||
Açı: {{angle}}
|
||||
Anahtar kelimeler: {{keywords}}
|
||||
|
||||
Bu konuda Türkçe bir blog yazısı üret. slug kısa ve URL-uyumlu (küçük harf, tireli, Türkçe karakter yok). meta_description SEO için 150-160 karakter civarı. Şemaya uygun JSON döndür.`,
|
||||
outputSchemaJson: BLOG_SCHEMA,
|
||||
modelTier: "pro",
|
||||
maxOutputTokens: 4000,
|
||||
temperature: 0.4,
|
||||
},
|
||||
{
|
||||
tag: "content_linkedin",
|
||||
version: 1,
|
||||
name: "Content LinkedIn v1 (TR, B2B)",
|
||||
systemPrompt: `Sase.tr için LinkedIn şirket sayfası gönderileri yazan bir B2B sosyal medya editörüsün. Ton profesyonel ama insani; ilk satır dikkat çeken bir kanca olmalı. Kısa paragraflar, gerektiğinde satır araları. Aşırı hashtag kullanma.
|
||||
|
||||
${SASE_BRAND}
|
||||
|
||||
Şema:
|
||||
${JSON.stringify(LINKEDIN_SCHEMA)}`,
|
||||
userPromptTemplate: `Konu: {{title}}
|
||||
Brief: {{brief}}
|
||||
Açı: {{angle}}
|
||||
Anahtar kelimeler: {{keywords}}
|
||||
|
||||
Bu konuda bir LinkedIn gönderisi üret. Şemaya uygun JSON döndür.`,
|
||||
outputSchemaJson: LINKEDIN_SCHEMA,
|
||||
modelTier: "flash",
|
||||
maxOutputTokens: 1200,
|
||||
temperature: 0.5,
|
||||
},
|
||||
{
|
||||
tag: "content_x",
|
||||
version: 1,
|
||||
name: "Content X/Twitter v1 (TR)",
|
||||
systemPrompt: `Sase.tr için X (Twitter) gönderileri/thread'leri yazan bir sosyal medya editörüsün. Her tweet ≤280 karakter. Tek güçlü gönderi ya da kısa bir thread üret; ilk tweet kanca olmalı, son tweet yumuşak CTA içerebilir.
|
||||
|
||||
${SASE_BRAND}
|
||||
|
||||
Şema:
|
||||
${JSON.stringify(X_SCHEMA)}`,
|
||||
userPromptTemplate: `Konu: {{title}}
|
||||
Brief: {{brief}}
|
||||
Açı: {{angle}}
|
||||
Anahtar kelimeler: {{keywords}}
|
||||
|
||||
Bu konuda bir X gönderisi ya da kısa thread üret (en fazla 8 tweet). Her tweet ayrı bir dizi elemanı, her biri ≤280 karakter. Şemaya uygun JSON döndür.`,
|
||||
outputSchemaJson: X_SCHEMA,
|
||||
modelTier: "flash",
|
||||
maxOutputTokens: 1000,
|
||||
temperature: 0.6,
|
||||
},
|
||||
{
|
||||
tag: "content_instagram",
|
||||
version: 1,
|
||||
name: "Content Instagram v1 (TR)",
|
||||
systemPrompt: `Sase.tr için Instagram caption'ları yazan bir sosyal medya editörüsün. Caption ilgi çekici, kısa paragraflı, emoji'yi ölçülü kullanan olsun. Hashtag'leri caption sonunda topla. Ayrıca içeriğe uygun bir görsel üretim prompt'u (image_prompt) öner (İngilizce, kısa, görsel betimleme).
|
||||
|
||||
${SASE_BRAND}
|
||||
|
||||
Şema:
|
||||
${JSON.stringify(INSTAGRAM_SCHEMA)}`,
|
||||
userPromptTemplate: `Konu: {{title}}
|
||||
Brief: {{brief}}
|
||||
Açı: {{angle}}
|
||||
Anahtar kelimeler: {{keywords}}
|
||||
|
||||
Bu konuda bir Instagram caption'ı + hashtag seti + image_prompt üret. Şemaya uygun JSON döndür.`,
|
||||
outputSchemaJson: INSTAGRAM_SCHEMA,
|
||||
modelTier: "flash",
|
||||
maxOutputTokens: 1000,
|
||||
temperature: 0.6,
|
||||
},
|
||||
];
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,23 @@ export const TRACKED_EVENTS: string[] = [
|
||||
"parts_export_completed",
|
||||
"oem_code_copied",
|
||||
|
||||
// Catalog / category browsing — Sase.tr's core value path (vehicle → categories
|
||||
// → parts). These were previously NOT fetched, so a vehicle that resolved but
|
||||
// rendered no parts/categories (the #1 churn complaint) was invisible to the
|
||||
// tagger. `empty_catalog_cta_clicked` / `parts_panel_viewed.parts_count=0` are
|
||||
// the concrete empty-state signals; the rest let us tell "drilled in but got
|
||||
// nothing" apart from "decoded and bounced".
|
||||
"catalog_search_opened",
|
||||
"catalog_brands_viewed",
|
||||
"catalog_brand_clicked",
|
||||
"catalog_subcatalog_selected",
|
||||
"catalog_models_viewed",
|
||||
"catalog_model_clicked",
|
||||
"catalog_locked_brand_upgrade_clicked",
|
||||
"category_view_changed",
|
||||
"empty_catalog_cta_clicked",
|
||||
"part_reference_clicked",
|
||||
|
||||
// Payment (v1 + v2)
|
||||
"payment_initiated",
|
||||
"payment_success",
|
||||
|
||||
@@ -14,3 +14,17 @@ export function fingerprintHash(parts: Array<string | number | null | undefined>
|
||||
.join("|");
|
||||
return createHash("sha256").update(norm).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
// Deterministic hash of an arbitrary JSON value (object keys sorted) — used for
|
||||
// change detection so re-serialized-but-equal payloads don't produce false diffs.
|
||||
function stableStringify(v: unknown): string {
|
||||
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
|
||||
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
||||
const obj = v as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
export function stableHash(value: unknown): string {
|
||||
return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export function customEventPromoteReasons(eventNames: string[]): string[] {
|
||||
"subscription_cancelled",
|
||||
"trial_urgency_banner_cta_clicked",
|
||||
"downgrade_offer_shown",
|
||||
"empty_catalog_cta_clicked", // resolved a vehicle but the catalog was empty
|
||||
];
|
||||
for (const e of single) if (set.has(e)) reasons.push(`event:${e}`);
|
||||
|
||||
|
||||
@@ -41,6 +41,18 @@ export async function putText(
|
||||
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
|
||||
}
|
||||
|
||||
export async function putBuffer(
|
||||
bucket: string,
|
||||
key: string,
|
||||
buf: Buffer,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<void> {
|
||||
const c = getMinio();
|
||||
if (!c) throw new Error("minio_not_configured");
|
||||
await ensureBucket(bucket);
|
||||
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
|
||||
}
|
||||
|
||||
export async function getText(bucket: string, key: string): Promise<string> {
|
||||
const c = getMinio();
|
||||
if (!c) throw new Error("minio_not_configured");
|
||||
|
||||
@@ -99,6 +99,24 @@ export function isConfigured(): boolean {
|
||||
return Boolean(TOKEN && PROJECT_ID);
|
||||
}
|
||||
|
||||
// ---------- HogQL query API ----------
|
||||
|
||||
export type HogQLResponse = { columns: string[]; results: unknown[][] };
|
||||
|
||||
// Runs a HogQL query against the project and returns columns + row arrays.
|
||||
// Used by the archival pipeline to page the full event stream.
|
||||
export async function hogqlQuery(query: string): Promise<HogQLResponse> {
|
||||
const url = `${HOST}/api/projects/${PROJECT_ID}/query/`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...headers(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`posthog hogql ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
const data = (await res.json()) as { columns?: string[]; results?: unknown[][] };
|
||||
return { columns: data.columns ?? [], results: data.results ?? [] };
|
||||
}
|
||||
|
||||
// ---------- Custom events ----------
|
||||
|
||||
export type PHCustomEvent = {
|
||||
@@ -159,6 +177,55 @@ export async function getPerson(distinctId: string): Promise<PHPerson | null> {
|
||||
return data.results?.[0] ?? null;
|
||||
}
|
||||
|
||||
// ---------- Person & cohort listing (archival snapshots) ----------
|
||||
|
||||
export type PHPersonFull = {
|
||||
id: string; // PostHog person uuid (== events.person_id)
|
||||
name?: string;
|
||||
distinct_ids: string[];
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Lists all persons via the REST endpoint, following `next` pagination.
|
||||
export async function listPersons(opts?: { limit?: number; maxPages?: number }): Promise<PHPersonFull[]> {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const maxPages = opts?.maxPages ?? 50;
|
||||
const out: PHPersonFull[] = [];
|
||||
let next: string | null = `${HOST}/api/projects/${PROJECT_ID}/persons/?limit=${limit}`;
|
||||
for (let page = 0; page < maxPages && next; page++) {
|
||||
const res = await fetch(next, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`posthog persons ${res.status}`);
|
||||
const data = (await res.json()) as { results?: PHPersonFull[]; next?: string | null };
|
||||
out.push(...(data.results ?? []));
|
||||
next = data.next ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PHCohort = {
|
||||
id: number;
|
||||
name: string;
|
||||
count?: number | null;
|
||||
is_static?: boolean;
|
||||
filters?: unknown;
|
||||
groups?: unknown;
|
||||
};
|
||||
|
||||
// Lists cohort definitions (with membership counts), following `next` pagination.
|
||||
export async function listCohorts(opts?: { maxPages?: number }): Promise<PHCohort[]> {
|
||||
const maxPages = opts?.maxPages ?? 20;
|
||||
const out: PHCohort[] = [];
|
||||
let next: string | null = `${HOST}/api/projects/${PROJECT_ID}/cohorts/`;
|
||||
for (let page = 0; page < maxPages && next; page++) {
|
||||
const res = await fetch(next, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`posthog cohorts ${res.status}`);
|
||||
const data = (await res.json()) as { results?: PHCohort[]; next?: string | null };
|
||||
out.push(...(data.results ?? []));
|
||||
next = data.next ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getGroup(
|
||||
groupType: string,
|
||||
groupKey: string,
|
||||
|
||||
@@ -264,17 +264,25 @@ Return JSON per the schema. Use occurrence patterns from the bundle summary to e
|
||||
},
|
||||
{
|
||||
tag: "provider_quality",
|
||||
version: 3,
|
||||
name: "Provider Quality v3 (TR, ext maxLen)",
|
||||
version: 4,
|
||||
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.
|
||||
|
||||
${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:
|
||||
${JSON.stringify(PROVIDER_SCHEMA)}`,
|
||||
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,
|
||||
modelTier: "flash",
|
||||
maxOutputTokens: 800,
|
||||
@@ -299,6 +307,12 @@ export function pickPromptTag(tags: string[]): string {
|
||||
// Upgrade hesitation — pricing page concerns
|
||||
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
|
||||
if (
|
||||
set.has("provider_reliability_issue") ||
|
||||
@@ -308,8 +322,16 @@ export function pickPromptTag(tags: string[]): string {
|
||||
)
|
||||
return "provider_quality";
|
||||
|
||||
// Bugs
|
||||
if (set.has("bug_suspected") || set.has("server_error_impact")) return "bug_triage";
|
||||
// Bugs (incl. core-value parts/category render failures — bug_triage carries
|
||||
// is_likely_provider_issue / implicated_provider so the model can attribute an
|
||||
// empty catalog to a provider data gap vs. a render/query defect).
|
||||
if (
|
||||
set.has("bug_suspected") ||
|
||||
set.has("server_error_impact") ||
|
||||
set.has("parts_render_blocked") ||
|
||||
set.has("catalog_empty_result")
|
||||
)
|
||||
return "bug_triage";
|
||||
|
||||
// Onboarding
|
||||
if (set.has("onboarding_stuck")) return "onboarding_stuck";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "../db";
|
||||
import { SEED_PROMPTS } from "./prompts";
|
||||
import { CONTENT_SEED_PROMPTS } from "./content-prompts";
|
||||
|
||||
const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
|
||||
{ key: "monthly_hard_cap_usd", value: 30 },
|
||||
@@ -9,12 +10,20 @@ const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
|
||||
{ key: "min_score_for_analysis", value: 30 },
|
||||
{ key: "cache_ttl_hours", value: 6 },
|
||||
{ key: "analysis_paused", value: false },
|
||||
// Content generation — separate envelope from insight analysis.
|
||||
{ key: "content_monthly_hard_cap_usd", value: 15 },
|
||||
{ key: "content_daily_soft_cap_usd", value: 1 },
|
||||
{ key: "content_daily_hard_cap_usd", value: 2 },
|
||||
{ key: "content_per_call_max_usd", value: 0.3 },
|
||||
// Safe default: first deploy lands paused so output quality can be reviewed
|
||||
// before the cron auto-spends. Flip off in budget settings to enable.
|
||||
{ key: "content_paused", value: true },
|
||||
];
|
||||
|
||||
export async function upsertSeedData(): Promise<void> {
|
||||
// Prompt templates — insert new versions if (tag, version) doesn't exist.
|
||||
// When inserting a new version, deactivate older active versions of the same tag.
|
||||
for (const p of SEED_PROMPTS) {
|
||||
for (const p of [...SEED_PROMPTS, ...CONTENT_SEED_PROMPTS]) {
|
||||
const existing = await prisma.promptTemplate.findUnique({
|
||||
where: { tag_version: { tag: p.tag, version: p.version } },
|
||||
});
|
||||
|
||||
112
apps/worker/src/lib/sentry.ts
Normal file
112
apps/worker/src/lib/sentry.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Sentry API client (read-only) for the archival pipeline. Uses a User Auth
|
||||
// Token with org:read / project:read / event:read. EU-region orgs use the
|
||||
// de.sentry.io API base.
|
||||
//
|
||||
// Multi-project support: SENTRY_PROJECT is a CSV (e.g. "python,sase-web").
|
||||
// Issues come from the org-wide endpoint (one call covers all projects), so
|
||||
// they're fetched in a single loop and the per-issue `project.slug` is
|
||||
// retained. Events are project-scoped on Sentry's side, so the archive job
|
||||
// iterates `getProjects()` and calls `listEventsPage(slug, cursor)` per slug.
|
||||
const TOKEN = process.env.SENTRY_AUTH_TOKEN ?? "";
|
||||
const ORG = process.env.SENTRY_ORG ?? "";
|
||||
const RAW_PROJECTS = process.env.SENTRY_PROJECT ?? "";
|
||||
const API_BASE = (process.env.SENTRY_API_BASE ?? "https://sentry.io/api/0").replace(/\/$/, "");
|
||||
|
||||
const PROJECTS: string[] = RAW_PROJECTS.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function isConfigured(): boolean {
|
||||
return Boolean(TOKEN && ORG && PROJECTS.length > 0);
|
||||
}
|
||||
|
||||
export function getProjects(): string[] {
|
||||
return [...PROJECTS];
|
||||
}
|
||||
|
||||
const headers = () => ({ Authorization: `Bearer ${TOKEN}`, Accept: "application/json" });
|
||||
|
||||
// Sentry paginates via a Link header: <url>; rel="next"; results="true"; cursor="...".
|
||||
// Returns the next cursor only when results="true".
|
||||
function parseNextCursor(link: string | null): string | null {
|
||||
if (!link) return null;
|
||||
for (const part of link.split(",")) {
|
||||
if (/rel="next"/.test(part) && /results="true"/.test(part)) {
|
||||
const m = part.match(/cursor="([^"]+)"/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Page<T> = { data: T[]; nextCursor: string | null };
|
||||
|
||||
async function getPage<T>(path: string, params: Record<string, string>): Promise<Page<T>> {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`sentry ${path} ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
const data = (await res.json()) as T[];
|
||||
return { data, nextCursor: parseNextCursor(res.headers.get("link")) };
|
||||
}
|
||||
|
||||
export type SentryProjectRef = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type SentryIssueRaw = {
|
||||
id: string;
|
||||
shortId?: string;
|
||||
title?: string;
|
||||
culprit?: string;
|
||||
level?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
count?: string | number;
|
||||
userCount?: number;
|
||||
firstSeen?: string;
|
||||
lastSeen?: string;
|
||||
permalink?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
project?: SentryProjectRef;
|
||||
};
|
||||
|
||||
export type SentryEventRaw = {
|
||||
id?: string;
|
||||
eventID?: string;
|
||||
groupID?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
"event.type"?: string;
|
||||
platform?: string;
|
||||
dateCreated?: string;
|
||||
dateReceived?: string;
|
||||
tags?: unknown;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
// One page of org issues (newest activity first). statsPeriod bounds the window.
|
||||
// Sentry's response includes a `project: { slug, id, name }` per issue so the
|
||||
// caller can distinguish which Sentry project a row came from without a
|
||||
// second round-trip.
|
||||
export function listIssuesPage(cursor?: string, statsPeriod = "90d"): Promise<Page<SentryIssueRaw>> {
|
||||
return getPage<SentryIssueRaw>(`/organizations/${ORG}/issues/`, {
|
||||
project: "",
|
||||
query: "",
|
||||
statsPeriod,
|
||||
limit: "100",
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// One page of project events, full payloads, newest first.
|
||||
export function listEventsPage(projectSlug: string, cursor?: string): Promise<Page<SentryEventRaw>> {
|
||||
return getPage<SentryEventRaw>(`/projects/${ORG}/${projectSlug}/events/`, {
|
||||
full: "true",
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export const sentryConfig = { ORG, PROJECTS, API_BASE };
|
||||
198
apps/worker/src/lib/tagger.smoke.ts
Normal file
198
apps/worker/src/lib/tagger.smoke.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Smoke test for the tagger search-affordance-misuse suppression.
|
||||
* Run: pnpm --filter worker exec tsx src/lib/tagger.smoke.ts
|
||||
*
|
||||
* Guarantees the new guard is SURGICAL: it silences generic ux_friction /
|
||||
* frustrated_session ONLY for "user fiddling with the VIN search box, nothing
|
||||
* broken" sessions, while every concrete failure stays actionable.
|
||||
*/
|
||||
import type { SessionMeta } from "@prisma/client";
|
||||
import type { CanonicalEvent } from "./event-taxonomy";
|
||||
import { tagSession } from "./tagger";
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function assert(label: string, cond: boolean): void {
|
||||
if (cond) pass++;
|
||||
else {
|
||||
fail++;
|
||||
console.error(`✗ ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ev = (name: string, properties: Record<string, unknown> = {}): CanonicalEvent =>
|
||||
({ name, properties, rawName: name } as unknown as CanonicalEvent);
|
||||
|
||||
function mkSession(over: Partial<SessionMeta>): SessionMeta {
|
||||
return {
|
||||
errorCount: 0,
|
||||
rageClickCount: 0,
|
||||
deadClickCount: 0,
|
||||
network5xxCount: 0,
|
||||
startUrl: "https://sase.tr/dashboard/search",
|
||||
durationMs: 200_000,
|
||||
isAuthenticated: true,
|
||||
clickCount: 5,
|
||||
subscriptionTier: null,
|
||||
startedAt: new Date(1_700_000_000_000),
|
||||
...over,
|
||||
} as unknown as SessionMeta;
|
||||
}
|
||||
const ctx = (events: CanonicalEvent[], userProperties: Record<string, unknown> = {}) =>
|
||||
({ customEvents: events, userProperties, groupProperties: null });
|
||||
|
||||
const tags = (s: SessionMeta, events: CanonicalEvent[], up: Record<string, unknown> = {}) =>
|
||||
tagSession(s, ctx(events, up)).tags;
|
||||
|
||||
// 1) The reported case: VIN search-box misuse, 17 rage clicks, NOTHING broken → suppressed.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 17, errorCount: 0, network5xxCount: 0 }),
|
||||
[ev("search_input_focused"), ev("search_history_item_selected"), ev("parts_panel_viewed")],
|
||||
);
|
||||
assert("misuse: no ux_friction", !t.includes("ux_friction"));
|
||||
assert("misuse: no frustrated_session", !t.includes("frustrated_session"));
|
||||
assert("misuse: tag-less → will be discarded", t.length === 0);
|
||||
}
|
||||
|
||||
// 2) Real JS errors + rage → still bug_suspected (misuse guard off when errorCount>0).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 5, errorCount: 2 }),
|
||||
[ev("search_input_focused")],
|
||||
);
|
||||
assert("js-error: bug_suspected kept", t.includes("bug_suspected"));
|
||||
}
|
||||
|
||||
// 3) Server 5xx storm → server_error_impact kept (misuse guard off when 5xx>0).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 4, network5xxCount: 3 }),
|
||||
[ev("search_input_focused")],
|
||||
);
|
||||
assert("5xx: server_error_impact kept", t.includes("server_error_impact"));
|
||||
}
|
||||
|
||||
// 4) Payment friction on a search session → conversion signal kept (misuse guard off).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 4 }),
|
||||
[ev("search_input_focused"), ev("payment_initiated")],
|
||||
);
|
||||
assert("payment: payment_friction kept", t.includes("payment_friction"));
|
||||
assert("payment: frustrated_session NOT suppressed", t.includes("frustrated_session"));
|
||||
}
|
||||
|
||||
// 5) Category-tree rage WITHOUT the search box → still surfaces (scope = search box only).
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 10, startUrl: "https://sase.tr/dashboard/vehicles/x/categories/y" }),
|
||||
[ev("parts_panel_viewed")],
|
||||
);
|
||||
assert("category: ux_friction kept (no search_input_focused)", t.includes("ux_friction"));
|
||||
assert("category: frustrated_session kept", t.includes("frustrated_session"));
|
||||
}
|
||||
|
||||
// 6) Real upstream VIN decode failures on a search session → provider signal kept.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 3 }),
|
||||
[
|
||||
ev("search_input_focused"),
|
||||
ev("vin_decode_failed", { provider_attempted: "PL24" }),
|
||||
ev("vin_decode_failed", { provider_attempted: "PL24" }),
|
||||
],
|
||||
);
|
||||
assert("vin-fail: vin_decode_fail_pattern kept", t.includes("vin_decode_fail_pattern"));
|
||||
}
|
||||
|
||||
// 7) Search box used but ≥3 validation failures → that has its own P3 signal, not suppressed.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 6 }),
|
||||
[
|
||||
ev("search_input_focused"),
|
||||
ev("search_input_validation_failed"),
|
||||
ev("search_input_validation_failed"),
|
||||
ev("search_input_validation_failed"),
|
||||
],
|
||||
);
|
||||
assert("validation-friction: search_validation_friction kept", t.includes("search_validation_friction"));
|
||||
assert("validation-friction: frustrated_session NOT suppressed", t.includes("frustrated_session"));
|
||||
}
|
||||
|
||||
// 8) THE serkan case: signed-up trial user, VIN decodes (sees model), drills the
|
||||
// category tree + clicks a catalog model, but parts NEVER render — zero rage,
|
||||
// zero error, zero 5xx. A demo category that *does* return parts must not mask
|
||||
// the real-page failure, and touching the search box must not silence it.
|
||||
{
|
||||
const t = tags(
|
||||
mkSession({ rageClickCount: 0, errorCount: 0, network5xxCount: 0 }),
|
||||
[
|
||||
ev("search_input_focused"),
|
||||
ev("vin_decode_succeeded"),
|
||||
ev("category_view_changed"),
|
||||
ev("category_view_changed"),
|
||||
ev("category_view_changed"),
|
||||
ev("catalog_model_clicked", { brand_name: "Ford" }),
|
||||
// demo categories that DO return parts — must be excluded from the signal:
|
||||
ev("parts_panel_viewed", { parts_count: 13, $current_url: "https://sase.tr/demo/categories/x" }),
|
||||
],
|
||||
);
|
||||
assert("serkan: parts_render_blocked fired", t.includes("parts_render_blocked"));
|
||||
assert("serkan: was caught (not tag-less / not search-misuse suppressed)", t.length > 0);
|
||||
}
|
||||
|
||||
// 9) Parts panel renders EMPTY on a real dashboard page (parts_count=0, never non-empty).
|
||||
{
|
||||
const t = tags(mkSession({}), [
|
||||
ev("vin_decode_succeeded"),
|
||||
ev("parts_panel_viewed", {
|
||||
parts_count: 0,
|
||||
$current_url: "https://sase.tr/dashboard/vehicles/abc/categories/def",
|
||||
}),
|
||||
]);
|
||||
assert("empty-panel: catalog_empty_result fired", t.includes("catalog_empty_result"));
|
||||
assert("empty-panel: not falsely 'blocked' (panel did render)", !t.includes("parts_render_blocked"));
|
||||
}
|
||||
|
||||
// 10) Explicit empty-state CTA → concrete empty result.
|
||||
{
|
||||
const t = tags(mkSession({}), [
|
||||
ev("vin_decode_succeeded"),
|
||||
ev("empty_catalog_cta_clicked", { vehicle_label: "Ford Focus", category_name: "mekanik" }),
|
||||
]);
|
||||
assert("empty-cta: catalog_empty_result fired", t.includes("catalog_empty_result"));
|
||||
}
|
||||
|
||||
// 11) Happy path: parts actually render (count>0) → no parts-failure tags.
|
||||
{
|
||||
const url = "https://sase.tr/dashboard/vehicles/abc/categories/def";
|
||||
const t = tags(mkSession({}), [
|
||||
ev("vin_decode_succeeded"),
|
||||
ev("catalog_model_clicked"),
|
||||
ev("parts_panel_viewed", { parts_count: 20, $current_url: url }),
|
||||
ev("oem_code_copied", { $current_url: url }),
|
||||
]);
|
||||
assert("happy: no parts_render_blocked", !t.includes("parts_render_blocked"));
|
||||
assert("happy: no catalog_empty_result", !t.includes("catalog_empty_result"));
|
||||
}
|
||||
|
||||
// 12) Decode-and-bounce (no drilling toward parts) → no false positive.
|
||||
{
|
||||
const t = tags(mkSession({}), [ev("vin_decode_succeeded")]);
|
||||
assert("bounce: no parts_render_blocked", !t.includes("parts_render_blocked"));
|
||||
}
|
||||
|
||||
// 13) Locked brand → upgrade prompt: paywall, not a defect.
|
||||
{
|
||||
const t = tags(mkSession({}), [
|
||||
ev("vin_decode_succeeded"),
|
||||
ev("catalog_model_clicked", { brand_name: "Mercedes" }),
|
||||
ev("catalog_locked_brand_upgrade_clicked", { brand_name: "Mercedes" }),
|
||||
]);
|
||||
assert("locked: no parts_render_blocked (paywall, not bug)", !t.includes("parts_render_blocked"));
|
||||
}
|
||||
|
||||
console.log(`\ntagger smoke: ${pass} passed, ${fail} failed`);
|
||||
if (fail > 0) process.exit(1);
|
||||
@@ -47,6 +47,80 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
const userProps = ctx?.userProperties ?? {};
|
||||
const groupProps = ctx?.groupProperties ?? null;
|
||||
|
||||
// ─── Parts / category render signals (Sase.tr core-value path) ───
|
||||
// The product's whole job: resolve a vehicle (VIN or catalog) → list its
|
||||
// categories + parts. When that final step yields *nothing* the product has
|
||||
// failed for the user even though VIN decode "succeeded" and no JS error/5xx
|
||||
// fired — the catalog returned an empty panel, 0 models, or the user hit the
|
||||
// explicit empty-state CTA. This used to be invisible (the catalog_* family
|
||||
// wasn't even fetched), so a real prospect could browse Ford/Opel, see no
|
||||
// parts, and churn with no insight raised. Scoped to the real /dashboard
|
||||
// product: /demo is a separate curated marketing surface whose (sometimes
|
||||
// working) categories must NOT mask a genuine in-product failure.
|
||||
const inDemo = (e: CanonicalEvent): boolean =>
|
||||
/\/demo(\/|\?|$)/.test(
|
||||
String(e.properties["$current_url"] ?? e.properties["$pathname"] ?? ""),
|
||||
);
|
||||
const realPartsViews = events.filter((e) => e.name === "parts_panel_viewed" && !inDemo(e));
|
||||
const successfulPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) > 0);
|
||||
const emptyPartsView = realPartsViews.some((e) => Number(e.properties.parts_count) === 0);
|
||||
const emptyModelsList = events.some(
|
||||
(e) => e.name === "catalog_models_viewed" && !inDemo(e) && Number(e.properties.count) === 0,
|
||||
);
|
||||
const explicitEmptyCatalog = events.some(
|
||||
(e) => e.name === "empty_catalog_cta_clicked" && !inDemo(e),
|
||||
);
|
||||
const realOemCopied = events.some((e) => e.name === "oem_code_copied" && !inDemo(e));
|
||||
|
||||
// Shape 1 — "the system said: nothing here": panel rendered empty (and never
|
||||
// non-empty in this session), 0 models listed, or the empty-state CTA shown.
|
||||
const catalogEmptyResult =
|
||||
explicitEmptyCatalog || emptyModelsList || (emptyPartsView && !successfulPartsView);
|
||||
// Shape 2 — "drilled in and got nothing at all": resolved a vehicle and
|
||||
// actively browsed categories/models but never saw a single part. Gated on
|
||||
// real engagement (not a decode-and-bounce) and excludes the paywall case
|
||||
// (locked brand → upgrade prompt, which is a conversion signal, not a defect).
|
||||
const resolvedVehicle =
|
||||
has(events, "vin_decode_succeeded") ||
|
||||
has(events, "vin_decode_candidate_selected") ||
|
||||
has(events, "catalog_model_clicked");
|
||||
const browsedForParts =
|
||||
count(events, "category_view_changed") >= 2 ||
|
||||
has(events, "catalog_model_clicked") ||
|
||||
has(events, "catalog_subcatalog_selected");
|
||||
const partsRenderBlocked =
|
||||
resolvedVehicle &&
|
||||
browsedForParts &&
|
||||
!successfulPartsView &&
|
||||
!realOemCopied &&
|
||||
!has(events, "catalog_locked_brand_upgrade_clicked");
|
||||
// Any genuine parts/category failure disqualifies the search-misuse guard
|
||||
// below — such a session is a real product defect, never "fiddling with the
|
||||
// search box, nothing broken".
|
||||
const partsFailure = catalogEmptyResult || partsRenderBlocked;
|
||||
|
||||
// ─── Expected search-box misuse → not insight-worthy ───
|
||||
// The /dashboard/search box is VIN-only, but users routinely use it to look
|
||||
// for a part by *name* (e.g. "Cam düğme") or fiddle with search/history and
|
||||
// rage-click out of affordance confusion — while *nothing is actually broken*
|
||||
// (no JS errors, no 5xx, no decode/validation/provider failure, no payment).
|
||||
// These pure-affordance rage sessions are noise, not product defects, so we
|
||||
// do NOT let the generic ux_friction / frustrated_session tags fire for them;
|
||||
// with no other actionable tag the session ends up tag-less → discarded
|
||||
// (no compress / analyze / insight). Real failures still carry a concrete
|
||||
// event below and stay actionable. Scoped narrowly to search-box sessions on
|
||||
// purpose, to avoid hiding genuine parts/category bugs.
|
||||
const searchAffordanceMisuse =
|
||||
s.errorCount === 0 &&
|
||||
s.network5xxCount === 0 &&
|
||||
!partsFailure &&
|
||||
has(events, "search_input_focused") &&
|
||||
!has(events, "vin_decode_failed") &&
|
||||
count(events, "search_input_validation_failed") < 3 &&
|
||||
!has(events, "payment_initiated") &&
|
||||
!has(events, "payment_failed") &&
|
||||
!has(events, "checkout_started");
|
||||
|
||||
// ─── Bug detection (rrweb-based, generic fallback) ───
|
||||
if (s.errorCount > 0 && (s.rageClickCount > 0 || s.network5xxCount > 0)) {
|
||||
tags.push("bug_suspected");
|
||||
@@ -57,7 +131,7 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
severity = bump(severity, "P1");
|
||||
}
|
||||
// ─── UX friction ───
|
||||
if (s.errorCount === 0 && (s.rageClickCount > 0 || s.deadClickCount > 0)) {
|
||||
if (s.errorCount === 0 && (s.rageClickCount > 0 || s.deadClickCount > 0) && !searchAffordanceMisuse) {
|
||||
tags.push("ux_friction");
|
||||
severity = bump(severity, "P2");
|
||||
}
|
||||
@@ -65,29 +139,65 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
// ─── Frustrated session ───
|
||||
// Sustained rage clicking (3+ clusters) is a stronger signal than a single
|
||||
// cluster — promote it past ux_friction so it surfaces above generic noise.
|
||||
if (s.rageClickCount >= 3) {
|
||||
if (s.rageClickCount >= 3 && !searchAffordanceMisuse) {
|
||||
tags.push("frustrated_session");
|
||||
severity = bump(severity, "P2");
|
||||
}
|
||||
|
||||
// ─── 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");
|
||||
if (vinFails.length >= 2) {
|
||||
const providers = new Set(
|
||||
vinFails.map((e) => String(e.properties.provider_attempted ?? e.properties.source ?? "")),
|
||||
);
|
||||
if (providers.size === 1 && [...providers][0]) {
|
||||
tags.push("vin_decode_fail_pattern");
|
||||
severity = bump(severity, "P1");
|
||||
} else {
|
||||
// Different providers failing → still notable
|
||||
tags.push("vin_decode_repeated_failure");
|
||||
severity = bump(severity, "P2");
|
||||
if (vinFails.length >= 1) {
|
||||
const isClientValidationReject = (e: CanonicalEvent): boolean => {
|
||||
const p = e.properties ?? {};
|
||||
const err = String(p.error ?? "");
|
||||
const vin = String(p.vin ?? "");
|
||||
// Frontend Zod/regex messages we ship — keep in sync with web VIN validator.
|
||||
const clientMsgRe = /(Geçersiz şase|17 karakter|I, O, Q|invalid VIN|must be 17|format)/i;
|
||||
if (clientMsgRe.test(err)) return true;
|
||||
// Length / forbidden-char heuristic — if the user typed something that
|
||||
// couldn't possibly reach the upstream, treat as client-side rejection.
|
||||
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")) {
|
||||
@@ -143,6 +253,19 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
|
||||
severity = bump(severity, "P3");
|
||||
}
|
||||
|
||||
// ─── Parts / category render failure (signals computed at top) ───
|
||||
// Core-value failure: the user got a vehicle but no parts/categories. This is
|
||||
// the highest-intent churn signal Sase.tr has — surface it as a concrete bug,
|
||||
// not generic friction.
|
||||
if (catalogEmptyResult) {
|
||||
tags.push("catalog_empty_result");
|
||||
severity = bump(severity, "P2");
|
||||
}
|
||||
if (partsRenderBlocked) {
|
||||
tags.push("parts_render_blocked");
|
||||
severity = bump(severity, "P1");
|
||||
}
|
||||
|
||||
// ─── Search friction ───
|
||||
if (count(events, "search_input_validation_failed") >= 3) {
|
||||
tags.push("search_validation_friction");
|
||||
|
||||
@@ -72,6 +72,33 @@ export function alertP0Insight(opts: {
|
||||
return sendTelegram({ text, dedupeKey: `p0:${opts.insightId}` });
|
||||
}
|
||||
|
||||
export function alertCatalogGap(opts: {
|
||||
brand: string;
|
||||
failures: number;
|
||||
uniqueUsers: number;
|
||||
windowDays: number;
|
||||
severity: string;
|
||||
insightId: string;
|
||||
panelUrl: string;
|
||||
day: string;
|
||||
regressed?: boolean;
|
||||
}): Promise<TelegramSendResult> {
|
||||
const head = opts.regressed
|
||||
? `↩️ <b>Katalog açığı GERİ DÖNDÜ</b> [${opts.severity}]`
|
||||
: `🗂️ <b>Katalog kapsama açığı</b> [${opts.severity}]`;
|
||||
const text = [
|
||||
head,
|
||||
`<b>${escapeHtml(opts.brand)}</b>: ${opts.failures} başarısız sorgu / ${opts.uniqueUsers} kullanıcı (son ${opts.windowDays}g)`,
|
||||
`Araç tanınıyor ama parça kataloğu yok → kullanıcı parça göremiyor.`,
|
||||
``,
|
||||
`<a href="${opts.panelUrl}/insights/i/${opts.insightId}">Insight</a> · <a href="${opts.panelUrl}/projects/sase/vin-decode">VIN dashboard</a>`,
|
||||
].join("\n");
|
||||
return sendTelegram({
|
||||
text,
|
||||
dedupeKey: `catalog_gap:${opts.regressed ? "regress" : "new"}:${opts.brand}:${opts.day}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function alertRegression(opts: {
|
||||
insightId: string;
|
||||
title: string;
|
||||
|
||||
60
apps/worker/src/schedulers/content.ts
Normal file
60
apps/worker/src/schedulers/content.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Content pipeline scheduler (Phase 8a). Separate BullMQ queue from the
|
||||
// insight pipeline so the two domains don't share concurrency or job names.
|
||||
// Cadence is conservative and every job is gated by the content budget; the
|
||||
// `content_paused` budget setting is the global kill-switch.
|
||||
|
||||
import { Queue, Worker, type Job } from "bullmq";
|
||||
import { redis } from "../redis";
|
||||
import { runContentTopics } from "../jobs/content-topics";
|
||||
import { runContentGenerate } from "../jobs/content-generate";
|
||||
|
||||
const QUEUE = "content-pipeline";
|
||||
|
||||
const queue = new Queue(QUEUE, { connection: redis });
|
||||
|
||||
async function runJob(job: Job) {
|
||||
switch (job.name) {
|
||||
case "content-topics": {
|
||||
const res = await runContentTopics();
|
||||
if (res.inserted > 0 || res.reason) {
|
||||
console.log(
|
||||
`[content] topics generated=${res.generated} inserted=${res.inserted} skipped=${res.skipped} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "content-generate": {
|
||||
const res = await runContentGenerate();
|
||||
if (res.draftsCreated > 0 || res.failed > 0 || res.reason) {
|
||||
console.log(
|
||||
`[content] generate topics=${res.topics} drafts=${res.draftsCreated} failed=${res.failed} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown job ${job.name}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startContentPipeline() {
|
||||
await queue.upsertJobScheduler(
|
||||
"content-topics",
|
||||
{ pattern: "0 */8 * * *" }, // every 8h: top up the topic backlog
|
||||
{ name: "content-topics", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"content-generate",
|
||||
{ pattern: "*/10 * * * *" }, // every 10min: drain queued topics into drafts
|
||||
{ name: "content-generate", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
||||
);
|
||||
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 1,
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log("[content] armed: content-topics@*/8h, content-generate@*/10min");
|
||||
return worker;
|
||||
}
|
||||
@@ -46,6 +46,15 @@ export async function startScheduledJobs() {
|
||||
{ name: "panel-backup", data: {}, opts: { removeOnComplete: 30, removeOnFail: 30 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, { connection: redis, concurrency: 1 });
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 1,
|
||||
// panel-backup (pg_dump) can run longer than the 30s default lock; a too-short
|
||||
// lock makes the job look "stalled", gets re-run, and the original then fails
|
||||
// its moveToFinished with "Missing lock". Match the other queues' 5min lock.
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log("[scheduler] armed: nightly-refresh@03:00, audit-archive@03:30, panel-backup@04:00");
|
||||
return worker;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { runRetention } from "../jobs/retention";
|
||||
import { runEvalSet } from "../jobs/eval-run";
|
||||
import { runDailyBrief } from "../jobs/daily-brief";
|
||||
import { runVinAnomalyDetect } from "../jobs/vin-anomaly";
|
||||
import { runCatalogGapDetect } from "../jobs/catalog-gap-detect";
|
||||
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
|
||||
import { runArchiveRecordings } from "../jobs/archive-recordings";
|
||||
import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
|
||||
import { runArchiveSentry } from "../jobs/sentry-archive";
|
||||
|
||||
const QUEUE = "insight-pipeline";
|
||||
|
||||
@@ -87,6 +92,54 @@ async function runJob(job: Job) {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "catalog-gap-detect": {
|
||||
const res = await runCatalogGapDetect();
|
||||
if (res.created > 0 || res.updated > 0 || !res.ok) {
|
||||
console.log(
|
||||
`[pipeline] catalog-gap gaps=${res.gaps} created=${res.created} updated=${res.updated} alerts=${res.alertsFired}${res.reason ? ` reason=${res.reason}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "posthog-event-archive": {
|
||||
const res = await runArchivePosthogEvents();
|
||||
if (res.fetched > 0 || res.dumped > 0 || res.error) {
|
||||
console.log(
|
||||
`[pipeline] event-archive fetched=${res.fetched} inserted=${res.inserted} dup=${res.duplicates} pages=${res.pages} dumped=${res.dumped}${res.error ? ` error=${res.error}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "archive-recordings": {
|
||||
const res = await runArchiveRecordings();
|
||||
if (res.scanned > 0) {
|
||||
console.log(
|
||||
`[pipeline] archive-recordings scanned=${res.scanned} archived=${res.archived} skipped=${res.skipped} failed=${res.failed}${res.rateLimited ? " rate_limited" : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "posthog-identity-archive": {
|
||||
const res = await runArchiveIdentity();
|
||||
if (res.personSnapshots > 0 || res.cohortSnapshots > 0 || res.error) {
|
||||
console.log(
|
||||
`[pipeline] identity-archive persons=${res.personsScanned}/${res.personSnapshots} cohorts=${res.cohortsScanned}/${res.cohortSnapshots}${res.error ? ` error=${res.error}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
case "sentry-archive": {
|
||||
const res = await runArchiveSentry();
|
||||
if (res.eventsInserted > 0 || res.issuesSynced > 0 || res.dumped > 0 || res.error) {
|
||||
const perProj = Object.entries(res.perProject)
|
||||
.map(([slug, c]) => `${slug}=${c.fetched}/${c.inserted}`)
|
||||
.join(" ");
|
||||
console.log(
|
||||
`[pipeline] sentry-archive issues=${res.issuesSynced} events=${res.eventsFetched}/${res.eventsInserted} dup=${res.eventsDuplicate} dumped=${res.dumped}${perProj ? ` [${perProj}]` : ""}${res.error ? ` error=${res.error}` : ""}`,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `unknown job ${job.name}` };
|
||||
}
|
||||
@@ -138,14 +191,40 @@ export async function startInsightPipeline() {
|
||||
{ pattern: "*/5 * * * *" },
|
||||
{ name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"catalog-gap-detect",
|
||||
{ pattern: "30 */6 * * *" },
|
||||
{ name: "catalog-gap-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"posthog-event-archive",
|
||||
{ pattern: "*/15 * * * *" },
|
||||
{ name: "posthog-event-archive", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"archive-recordings",
|
||||
{ pattern: "0 */6 * * *" },
|
||||
{ name: "archive-recordings", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"posthog-identity-archive",
|
||||
{ pattern: "0 3 * * *" },
|
||||
{ name: "posthog-identity-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
await queue.upsertJobScheduler(
|
||||
"sentry-archive",
|
||||
{ pattern: "0 * * * *" },
|
||||
{ name: "sentry-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
|
||||
);
|
||||
|
||||
new Worker(QUEUE, runJob, {
|
||||
const worker = new Worker(QUEUE, runJob, {
|
||||
connection: redis,
|
||||
concurrency: 2,
|
||||
lockDuration: 5 * 60_000,
|
||||
stalledInterval: 60_000,
|
||||
});
|
||||
console.log(
|
||||
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min",
|
||||
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, catalog-gap-detect@*/6h, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly",
|
||||
);
|
||||
return worker;
|
||||
}
|
||||
|
||||
113
docs/n8n/README.md
Normal file
113
docs/n8n/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Content publish — n8n `publish-content` workflow (Phase 8c)
|
||||
|
||||
The panel generates + reviews drafts; **n8n publishes them**. The panel POSTs an
|
||||
approved draft to a single n8n webhook and waits for a synchronous response.
|
||||
n8n routes by `channel` (LinkedIn = native node, blog = HTTP to sase.tr API).
|
||||
|
||||
Synchronous by design: `sp.semih.ai` is Tailscale-only, so we avoid an
|
||||
n8n→panel callback. n8n returns the result via a **Respond to Webhook** node.
|
||||
|
||||
## Contract
|
||||
|
||||
**Panel → n8n** (`POST` to `N8N_PUBLISH_WEBHOOK_URL`)
|
||||
|
||||
Headers: `Content-Type: application/json`, `X-Content-Secret: <N8N_WEBHOOK_SECRET>`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"draftId": "clx…",
|
||||
"channel": "linkedin", // linkedin | blog | x | instagram
|
||||
"projectKey": "sase",
|
||||
"topicTitle": "Şase Numarası (VIN) Nedir?",
|
||||
"content": { // effective body (founder edits merged over generated)
|
||||
// linkedin: { body, hashtags[], cta? }
|
||||
// blog: { title, slug, meta_description, body_markdown, tags[], cta? }
|
||||
// x: { tweets[], hashtags[] }
|
||||
// instagram:{ caption, hashtags[], image_prompt? }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**n8n → panel** (Respond to Webhook, synchronous)
|
||||
|
||||
```jsonc
|
||||
{ "ok": true, "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:123" }
|
||||
// or
|
||||
{ "ok": false, "error": "linkedin 401: token expired" }
|
||||
```
|
||||
|
||||
The panel accepts `ok|success` true, and reads the URL from any of
|
||||
`publishedUrl|url|postUrl|permalink`. A 2xx with no JSON is treated as success
|
||||
without a URL.
|
||||
|
||||
## Panel env (set on panel-web in Coolify)
|
||||
|
||||
| key | value |
|
||||
|-----|-------|
|
||||
| `N8N_PUBLISH_WEBHOOK_URL` | `https://n8n.semih.ai/webhook/publish-content` (prod) — use `/webhook-test/publish-content` while building |
|
||||
| `N8N_WEBHOOK_SECRET` | a long random string; also set as the n8n Header Auth credential value |
|
||||
| `N8N_PUBLISH_TIMEOUT_MS` | optional, default `30000` |
|
||||
|
||||
After setting these, redeploy panel-web (push to main auto-deploys web).
|
||||
|
||||
## Build the workflow in n8n
|
||||
|
||||
Import `publish-content.workflow.json` (Workflows → Import from File) **or**
|
||||
build these 5 nodes:
|
||||
|
||||
1. **Webhook** — `POST`, path `publish-content`, **Respond** = "Using Respond
|
||||
to Webhook node". Authentication = **Header Auth** → credential checking
|
||||
header `X-Content-Secret` equals `N8N_WEBHOOK_SECRET`.
|
||||
2. **Switch** (on `={{ $json.body.channel }}`): route `linkedin` and `blog`
|
||||
(add `x` / `instagram` later). Add a fallback output → error response.
|
||||
3. **LinkedIn** node (`linkedin` branch) — see OAuth setup below. Post text:
|
||||
`={{ $json.body.content.body }}{{ $json.body.content.hashtags ? '\n\n' + $json.body.content.hashtags.join(' ') : '' }}`
|
||||
4. **HTTP Request** (`blog` branch) — `POST {SASE_BLOG_API}/blog/posts/internal`,
|
||||
Header Auth **`Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`** (mirrors
|
||||
sase.tr's existing `changelog/internal` automation pattern), JSON body =
|
||||
`={{ $json.body.content }}` plus `{ "projectKey": "sase" }`.
|
||||
5. **Respond to Webhook** (one per branch, or a shared Set→Respond) — return
|
||||
`{ "ok": true, "publishedUrl": "<from node response>" }`. On the fallback /
|
||||
error path return `{ "ok": false, "error": "<message>" }`.
|
||||
|
||||
Map `publishedUrl` from each node's response:
|
||||
- LinkedIn node returns the share/ugcPost id → build `https://www.linkedin.com/feed/update/<urn>`.
|
||||
- Blog HTTP returns `{ url }` from the sase.tr API (see below).
|
||||
|
||||
Activate the workflow (toggle top-right) to use the `/webhook/` (prod) path.
|
||||
|
||||
## LinkedIn OAuth (n8n credential)
|
||||
|
||||
1. **LinkedIn Developer** (https://www.linkedin.com/developers/) → Create app,
|
||||
associate it with the **company page** you post from.
|
||||
2. Products: request **"Share on LinkedIn"** and **"Advertising API"** /
|
||||
**"Community Management API"** as needed for organization posting. Member
|
||||
posting uses `w_member_social`; company-page posting uses
|
||||
`w_organization_social` (needs page admin + may need app review).
|
||||
3. Auth tab → add redirect URL: `https://n8n.semih.ai/rest/oauth2-credential/callback`.
|
||||
4. In n8n → Credentials → **LinkedIn OAuth2 API** → paste Client ID/Secret,
|
||||
set scopes (`w_member_social` and/or `w_organization_social r_organization_social`),
|
||||
connect, authorize.
|
||||
5. In the LinkedIn node pick "Post" and, for a company page, set
|
||||
`Post As = Organization` + the organization URN.
|
||||
|
||||
> Note: organization posting often requires LinkedIn app review. Start with
|
||||
> member posting (`w_member_social`) to validate end-to-end, then upgrade.
|
||||
|
||||
## Blog (sase.tr API) — see the sase.tr repo
|
||||
|
||||
The `blog` branch POSTs to the new sase.tr blog API
|
||||
(`POST /blog/posts/internal`, header `Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`
|
||||
— mirrors sase.tr's existing `changelog/internal` automation auth). The
|
||||
Drizzle model/endpoint live in the sase.tr codebase (`apps/api/src/blog`,
|
||||
modeled on the `changelog` module). The API returns
|
||||
`{ url: "https://sase.tr/blog/<slug>" }`, which n8n echoes back as
|
||||
`publishedUrl`.
|
||||
|
||||
## Testing end-to-end
|
||||
|
||||
1. Set the panel envs to the **test** webhook URL, click "Listen for test event"
|
||||
in the n8n Webhook node.
|
||||
2. In the panel: approve a draft → **Yayınla**. Watch n8n execute; the panel
|
||||
draft flips to `published` (with URL) or `failed` (with the error).
|
||||
3. Switch the env to the prod `/webhook/` URL and **activate** the workflow.
|
||||
160
docs/n8n/publish-content.workflow.json
Normal file
160
docs/n8n/publish-content.workflow.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "publish-content",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "publish-content",
|
||||
"responseMode": "responseNode",
|
||||
"authentication": "headerAuth",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-in",
|
||||
"name": "Webhook (publish-content)",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [-200, 300],
|
||||
"webhookId": "publish-content",
|
||||
"credentials": {
|
||||
"httpHeaderAuth": { "id": "REPLACE", "name": "X-Content-Secret" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"rules": {
|
||||
"values": [
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "r-linkedin",
|
||||
"leftValue": "={{ $json.body.channel }}",
|
||||
"rightValue": "linkedin",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "linkedin"
|
||||
},
|
||||
{
|
||||
"conditions": {
|
||||
"options": { "caseSensitive": true, "typeValidation": "strict" },
|
||||
"conditions": [
|
||||
{
|
||||
"id": "r-blog",
|
||||
"leftValue": "={{ $json.body.channel }}",
|
||||
"rightValue": "blog",
|
||||
"operator": { "type": "string", "operation": "equals" }
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"renameOutput": true,
|
||||
"outputKey": "blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": { "fallbackOutput": "extra" }
|
||||
},
|
||||
"id": "switch-channel",
|
||||
"name": "Switch by channel",
|
||||
"type": "n8n-nodes-base.switch",
|
||||
"typeVersion": 3,
|
||||
"position": [40, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"postAs": "person",
|
||||
"text": "={{ $('Webhook (publish-content)').item.json.body.content.body }}{{ $('Webhook (publish-content)').item.json.body.content.hashtags ? '\\n\\n' + $('Webhook (publish-content)').item.json.body.content.hashtags.join(' ') : '' }}",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "linkedin-post",
|
||||
"name": "LinkedIn — create post",
|
||||
"type": "n8n-nodes-base.linkedIn",
|
||||
"typeVersion": 1,
|
||||
"position": [300, 180],
|
||||
"credentials": {
|
||||
"linkedInOAuth2Api": { "id": "REPLACE", "name": "LinkedIn OAuth2" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{ $env.SASE_BLOG_API }}/blog/posts/internal",
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ { ...$('Webhook (publish-content)').item.json.body.content, projectKey: 'sase' } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "blog-post",
|
||||
"name": "Blog — POST sase.tr",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [300, 360],
|
||||
"credentials": {
|
||||
"httpHeaderAuth": { "id": "REPLACE", "name": "SASE_BLOG_TOKEN (Bearer)" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: true, publishedUrl: ($json.permalink || $json.urn || $json.id || '') } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-linkedin",
|
||||
"name": "Respond — LinkedIn",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [560, 180]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: true, publishedUrl: ($json.url || $json.permalink || '') } }}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "respond-blog",
|
||||
"name": "Respond — Blog",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [560, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ { ok: false, error: 'unsupported channel: ' + $('Webhook (publish-content)').item.json.body.channel } }}",
|
||||
"options": { "responseCode": 422 }
|
||||
},
|
||||
"id": "respond-fallback",
|
||||
"name": "Respond — Unsupported",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1,
|
||||
"position": [300, 540]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook (publish-content)": {
|
||||
"main": [[{ "node": "Switch by channel", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Switch by channel": {
|
||||
"main": [
|
||||
[{ "node": "LinkedIn — create post", "type": "main", "index": 0 }],
|
||||
[{ "node": "Blog — POST sase.tr", "type": "main", "index": 0 }],
|
||||
[{ "node": "Respond — Unsupported", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"LinkedIn — create post": {
|
||||
"main": [[{ "node": "Respond — LinkedIn", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Blog — POST sase.tr": {
|
||||
"main": [[{ "node": "Respond — Blog", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": { "executionOrder": "v1" },
|
||||
"active": false
|
||||
}
|
||||
Reference in New Issue
Block a user