diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index 7be93a3..19e93d4 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -24,7 +24,16 @@ export async function GET(request: NextRequest) { const minutes = Math.max(parseInt(query.get("minutes") || "1440", 10), 5); const since = new Date(Date.now() - minutes * 60 * 1000); - const [summaryRows, actorSummaryRows, externalActorRows, totalRows, latestEvents] = await Promise.all([ + const [ + summaryRows, + actorSummaryRows, + externalActorRows, + totalRows, + latestEvents, + judgeCompleteRows, + capturedPayoutCount, + releasedPayoutCount, + ] = await Promise.all([ prisma.auditEvent.groupBy({ by: ["action"], where: { @@ -72,6 +81,29 @@ export async function GET(request: NextRequest) { createdAt: true, }, }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + action: "JUDGE_COMPLETE", + }, + select: { + metadata: true, + }, + }), + prisma.ledgerEntry.count({ + where: { + createdAt: { gte: since }, + phase: "CAPTURE", + response_status: "SUCCESS", + }, + }), + prisma.ledgerEntry.count({ + where: { + createdAt: { gte: since }, + phase: "RELEASE", + response_status: "SUCCESS", + }, + }), ]); const actionSummary = Object.fromEntries( @@ -133,6 +165,44 @@ export async function GET(request: NextRequest) { { external: 0, internal: 0 } as Record ); + const discoveryEvents = + (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + + (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); + + const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; + const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; + const judgePassEvents = judgeCompleteRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return metadata?.overall_result === "PASS"; + }).length; + const judgeFailEvents = judgeCompleteRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return metadata?.overall_result === "FAIL"; + }).length; + + const conversionRate = (numerator: number, denominator: number) => { + if (!denominator) return 0; + return Math.round((numerator / denominator) * 1000) / 10; + }; + + const externalFunnel = { + discovery_events: discoveryEvents, + claim_events: claimEvents, + submit_events: submitEvents, + judge_pass_events: judgePassEvents, + judge_fail_events: judgeFailEvents, + judge_total_events: judgePassEvents + judgeFailEvents, + payout_captured: capturedPayoutCount, + payout_released: releasedPayoutCount, + }; + + const conversionRates = { + claim_rate: conversionRate(claimEvents, discoveryEvents), + submit_rate: conversionRate(submitEvents, claimEvents), + pass_rate: conversionRate(judgePassEvents, submitEvents), + payout_rate: conversionRate(capturedPayoutCount, judgePassEvents), + }; + const externalEventTypes = Object.entries(actionSummary) .filter(([action]) => action.startsWith("EXTERNAL_")) .map(([action, count]) => ({ action, count })); @@ -172,6 +242,8 @@ export async function GET(request: NextRequest) { channel_summary: channelSummary, actor_summary: actorSummary, external_actor_summary: externalActorSummary, + external_funnel: externalFunnel, + conversion_rates: conversionRates, external_event_types: externalEventTypes, internal_event_types: internalEventTypes, recent_external_events: recentExternalEvents, diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx index 3634ca6..85234b9 100644 --- a/apps/web/src/app/traffic/page.tsx +++ b/apps/web/src/app/traffic/page.tsx @@ -6,6 +6,20 @@ export const dynamic = "force-dynamic"; const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN; +const EVENT_LABELS: Record = { + EXTERNAL_LIST_OPEN_TASKS: "外部公開流量頁讀取 open tasks", + EXTERNAL_LIST_OPEN_TASKS_SURGE: "外部公開流量突增告警", + EXTERNAL_LIST_OPEN_TASKS_MCP: "外部 MCP 入口讀取 open tasks", + EXTERNAL_LIST_OPEN_TASKS_MCP_SURGE: "外部 MCP 流量突增告警", + EXTERNAL_CLAIM_TASK_SUCCESS: "外部 AI 成功接單", + EXTERNAL_SUBMIT_SOLUTION_SUCCESS: "外部 AI 提交解法", + EXTERNAL_CLAIM_TASK_ERROR: "外部接單失敗", + EXTERNAL_SUBMIT_SOLUTION_ERROR: "外部提交失敗", + EXTERNAL_LIST_OPEN_TASKS_ERROR: "外部公開流量端點錯誤", + EXTERNAL_LIST_OPEN_TASKS_MCP_ERROR: "外部 MCP 流量端點錯誤", + JUDGE_COMPLETE: "AI 交件判定完成", +}; + function asRecordJson(value: unknown): Record | undefined { if (typeof value === "object" && value !== null && !Array.isArray(value)) { return value as Record; @@ -13,6 +27,15 @@ function asRecordJson(value: unknown): Record | undefined { return undefined; } +function percent(numerator: number, denominator: number) { + if (!denominator) return 0; + return Math.round((numerator / denominator) * 1000) / 10; +} + +function fmtPercent(value: number) { + return `${value.toFixed(1)}%`; +} + function isInternalActorId(value: string | null | undefined) { if (!value) return true; const actorId = value.toLowerCase(); @@ -43,10 +66,28 @@ function isAuthorizedToken(token: string | undefined, tokenHeader: string | unde return tokenHeader === token; } +function eventDirection(action: string) { + if (action.startsWith("EXTERNAL_")) return "外部"; + return "內部"; +} + +function explainAction(action: string) { + return EVENT_LABELS[action] || action; +} + async function getTrafficSummary(minutes: number) { const since = new Date(Date.now() - minutes * 60 * 1000); - const [summaryRows, actorSummaryRows, externalActorRows, totalRows, latestEvents] = await Promise.all([ + const [ + summaryRows, + actorSummaryRows, + externalActorRows, + totalRows, + latestEvents, + judgeCompleteRows, + capturedPayoutCount, + releasedPayoutCount, + ] = await Promise.all([ prisma.auditEvent.groupBy({ by: ["action"], where: { @@ -94,6 +135,29 @@ async function getTrafficSummary(minutes: number) { createdAt: true, }, }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + action: "JUDGE_COMPLETE", + }, + select: { + metadata: true, + }, + }), + prisma.ledgerEntry.count({ + where: { + createdAt: { gte: since }, + phase: "CAPTURE", + response_status: "SUCCESS", + }, + }), + prisma.ledgerEntry.count({ + where: { + createdAt: { gte: since }, + phase: "RELEASE", + response_status: "SUCCESS", + }, + }), ]); const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all])); @@ -123,15 +187,55 @@ async function getTrafficSummary(minutes: number) { .filter(([action]) => action.startsWith("EXTERNAL_")) .map(([action, count]) => ({ action, count })); + const internalEventTypes = Object.entries(actionSummary) + .filter(([action]) => !action.startsWith("EXTERNAL_")) + .map(([action, count]) => ({ action, count })); + const recentEvents = latestEvents.map((event) => { const metadata = asRecordJson(event.metadata); return { ...event, surface: metadata?.surface, level: metadata?.level, + metadata, }; }); + const discoveryEvents = + (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + + (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); + const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; + const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; + const judgePassEvents = judgeCompleteRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return metadata?.overall_result === "PASS"; + }).length; + const judgeFailEvents = judgeCompleteRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return metadata?.overall_result === "FAIL"; + }).length; + + const conversionSummary = { + discovery_events: discoveryEvents, + claim_events: claimEvents, + submit_events: submitEvents, + judge_pass_events: judgePassEvents, + judge_fail_events: judgeFailEvents, + payout_captured: capturedPayoutCount, + payout_released: releasedPayoutCount, + }; + + const conversionRates = { + claim_rate: percent(claimEvents, discoveryEvents), + submit_rate: percent(submitEvents, claimEvents), + pass_rate: percent(judgePassEvents, submitEvents), + payout_rate: percent(capturedPayoutCount, judgePassEvents), + }; + + const externalErrors = externalEventTypes + .filter((event) => event.action.includes("ERROR")) + .map((event) => event.action); + return { periodMinutes: minutes, totalEvents: totalRows, @@ -140,10 +244,66 @@ async function getTrafficSummary(minutes: number) { channelSummary, externalActorSummary, externalEventTypes, + internalEventTypes, recentExternalEvents: recentEvents.filter((event) => event.action.startsWith("EXTERNAL_") && !isInternalActorId(event.actorId)), + recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")), + conversionSummary, + conversionRates, + externalErrors, }; } +function toLocalTime(value: Date) { + return new Date(value).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" }); +} + +function buildConversionTips(summary: { + claim_rate: number; + submit_rate: number; + pass_rate: number; + payout_rate: number; +}, conversionSummary: { + discovery_events: number; + claim_events: number; + submit_events: number; + judge_pass_events: number; + judge_fail_events: number; + payout_captured: number; + payout_released: number; +}) { + const steps: string[] = []; + + if (conversionSummary.discovery_events > 0 && conversionSummary.claim_events === 0) { + steps.push("曝光高但接單為零:請先檢查 open-tasks 回傳任務文案是否足夠明確,是否包含 npx 指令與標準格式。"); + } + + if (conversionSummary.claim_events > 0 && conversionSummary.submit_events === 0) { + steps.push("有接案但無提交:通常是任務說明過於抽象或缺少驗收條件,建議補齊 acceptance criteria。"); + } + + if (conversionSummary.submit_events > 0 && conversionSummary.judge_pass_events === 0) { + steps.push("有提交但無 PASS:先人工檢查提交格式、sandbox 測試、deliverable 欄位是否可被執行。"); + } + + if (conversionSummary.judge_pass_events > 0 && conversionSummary.payout_captured === 0) { + steps.push("有 PASS 但未出金:確認任務是否建立 Stripe 授權、Stripe key 是否可用、payout 任務是否發生 CAPTURE。"); + } + + if (summary.claim_rate > 18 && summary.submit_rate < 25) { + steps.push("接案到提交斷崖:把任務最小可交付項目拆得更短,先加上「可執行範例與最少需求」。"); + } + + if (summary.pass_rate < 35 && conversionSummary.submit_events > 20) { + steps.push("PASS 率偏低:提高指令兼容度,改用可判讀的輸出欄位與固定檔名。"); + } + + if (steps.length === 0) { + steps.push("目前流程尚未形成明顯瓶頸。可持續觀察 30 分鐘內轉化率走勢,再做對照測試。 "); + } + + return steps; +} + export default async function TrafficDashboard({ searchParams, }: { @@ -151,7 +311,7 @@ export default async function TrafficDashboard({ }) { const resolved = await searchParams; const token = resolved?.token; - const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10), 5); + const minutes = Math.max(parseInt(resolved?.minutes || "1440", 10) || 5, 5); if (!isAuthorizedToken(MONITOR_TOKEN, token)) { return ( @@ -166,12 +326,14 @@ export default async function TrafficDashboard({ } const summary = await getTrafficSummary(minutes); + const { conversionSummary, conversionRates } = summary; + const conversionHints = buildConversionTips(conversionRates, conversionSummary); return (
-

VibeWork 流量監控

+

VibeWork 流量監控(轉化導向)

← 回首頁 @@ -181,7 +343,7 @@ export default async function TrafficDashboard({ 觀測區間:最近 {summary.periodMinutes} 分鐘(外部事件=`EXTERNAL_*`)
-
+
總事件
{summary.totalEvents}
@@ -191,12 +353,82 @@ export default async function TrafficDashboard({
{summary.channelSummary.external}
-
系統/內部事件
-
{summary.channelSummary.internal}
+
曝光(OPEN_TASKS)
+
{conversionSummary.discovery_events}
-
外部行為種類
-
{summary.externalEventTypes.length}
+
外部接案
+
{conversionSummary.claim_events}
+
+
+
外部提交
+
{conversionSummary.submit_events}
+
+
+ +
+
+

外部流量轉化漏斗

+
+
+ 曝光→接案 + {fmtPercent(conversionRates.claim_rate)} +
+
+
+
+
+ 接案→提交 + {fmtPercent(conversionRates.submit_rate)} +
+
+
+
+
+ 提交→PASS + {fmtPercent(conversionRates.pass_rate)} +
+
+
+
+
+ PASS→收款成功 + {fmtPercent(conversionRates.payout_rate)} +
+
+
+
+
+
+
PASS 任務{conversionSummary.judge_pass_events}
+
FAIL 任務{conversionSummary.judge_fail_events}
+
已出金{conversionSummary.payout_captured}
+
已退費{conversionSummary.payout_released}
+
+
+ +
+

轉化流程與使用者後續處理(你現在該做什麼)

+
    +
  1. 曝光端流量先判斷:從 open-tasks / MCP 的呼叫量,看是否有可感知的外部探索流。
  2. +
  3. 接案事件代表 AI 真的把任務當作可接單:EXTERNAL_CLAIM_TASK_SUCCESS。
  4. +
  5. 提交事件代表 AI 真的開始交件:EXTERNAL_SUBMIT_SOLUTION_SUCCESS。
  6. +
  7. 系統 PASS 才有收款機會:JUDGE_COMPLETE + overall_result=PASS,接著做 CAPTURE。
  8. +
  9. 你要追的是「曝光→接案→提交→PASS→收款」連續率,而不是單一事件數量。
  10. +
+
建議每 30 分鐘查看一次此漏斗,針對斷崖段落補任務內容或加標籤。
@@ -216,6 +448,7 @@ export default async function TrafficDashboard({ )}
+

Actor 類型分布

@@ -232,6 +465,47 @@ export default async function TrafficDashboard({
+
+

外部事件說明(可讀)

+
+ {Object.entries(summary.actionSummary) + .filter(([action]) => action.startsWith("EXTERNAL_")) + .sort((a, b) => b[1] - a[1]) + .slice(0, 14) + .map(([action, count]) => ( +
+ {explainAction(action)} ({action}) + {count} +
+ ))} +
+
+

事件方向:

+ {Object.entries(summary.actionSummary) + .filter(([action]) => action.startsWith("EXTERNAL_")) + .map(([action, count]) => ( +
+ {eventDirection(action)}{action} + {count} +
+ ))} +
+
+ +
+

可執行轉化建議

+
    + {conversionHints.map((item, index) => ( +
  • {item}
  • + ))} +
+ {summary.externalErrors.length > 0 ? ( +
+ 注意:目前已偵測到 {summary.externalErrors.length} 種外部錯誤事件({summary.externalErrors.join(", ")}),請優先回查 API payload 與 auth policy。 +
+ ) : null} +
+

最近外部事件(top 120)

@@ -239,14 +513,19 @@ export default async function TrafficDashboard({

目前區間內尚無外部事件。

) : ( summary.recentExternalEvents.map((event) => { - const ts = new Date(event.createdAt).toLocaleString("zh-TW", { timeZone: "Asia/Taipei" }); + const ts = toLocalTime(event.createdAt); return (
{event.action}
- actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | {ts} + actor={event.actorType}:{event.actorId || "unknown"} | entity={event.entityType}/{event.entityId} | surface={String(event.surface || "-")} | {ts}
{event.reason ?
{event.reason}
: null} + {event.metadata ? ( +
+                        {JSON.stringify(event.metadata, null, 2)}
+                      
+ ) : null}
); })