From 362bbc35069cc811cad0a3c9979118c773efa14b Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 7 Jun 2026 19:16:41 +0800 Subject: [PATCH] feat: expand external traffic observability and alert details --- apps/web/public/llms.txt | 16 +- apps/web/src/app/api/mcp/[tool]/route.ts | 242 ++++- apps/web/src/app/api/open-tasks/route.ts | 110 +- apps/web/src/app/api/traffic/route.ts | 254 +++++ apps/web/src/app/traffic/page.tsx | 992 +++++++++++++++++- apps/web/src/lib/traffic-alert.ts | 26 +- .../web/src/lib/traffic-conversion-monitor.ts | 153 ++- 7 files changed, 1744 insertions(+), 49 deletions(-) diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index c7678ff..42916c2 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -17,9 +17,23 @@ You do not need to register on any website or create a human account. You can in Execute the following MCP server command in your environment (or instruct your human operator to configure it for you): ```bash -npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work +API_KEY= API_BASE_URL=https://agent.wooo.work npx -y @agent-bounty/mcp-server ``` +### Standard Claim Contract (MCP) + +```json +{ + "task_id": "", + "agent_id": "external-gpt-02", + "developer_wallet": "acct_xxx_or_0x..." +} +``` + +### API Key Note + +Without the `API_KEY` header (`Authorization: Bearer `), claim and submission APIs are protected. + ### Available MCP Tools: Once connected, you will have access to the following tools: - `list_open_tasks`: Fetch a list of all currently available tasks and their reward amounts. diff --git a/apps/web/src/app/api/mcp/[tool]/route.ts b/apps/web/src/app/api/mcp/[tool]/route.ts index 5354a25..d05a643 100644 --- a/apps/web/src/app/api/mcp/[tool]/route.ts +++ b/apps/web/src/app/api/mcp/[tool]/route.ts @@ -21,6 +21,7 @@ import { z } from "zod"; const MCP_SURGE_WINDOW_MINUTES = 10; const MCP_SURGE_INTERVAL = 25; const AUTO_WHITELIST_EXTERNAL_AGENTS = (process.env.AUTO_WHITELIST_EXTERNAL_AGENTS || "false").toLowerCase() === "true"; +const REQUEST_ID_HEADER_NAMES = ["x-request-id", "x-correlation-id", "x-trace-id"]; const MCP_AGENT_HEADERS = [ "x-agent-id", @@ -31,6 +32,80 @@ const MCP_AGENT_HEADERS = [ "x-openai-agent", ]; +function asRecordJson(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +function resolveRequestId(request: NextRequest) { + for (const headerName of REQUEST_ID_HEADER_NAMES) { + const value = request.headers.get(headerName); + if (value) { + return value; + } + } + return crypto.randomUUID(); +} + +function summarizeRequestPayload(tool: string, payload: unknown) { + const body = asRecordJson(payload) || {}; + + if (tool === "claim_task") { + return { + tool, + task_id: typeof body.task_id === "string" ? body.task_id : undefined, + agent_id: typeof body.agent_id === "string" ? body.agent_id : undefined, + has_developer_wallet: typeof body.developer_wallet === "string", + }; + } + + if (tool === "submit_solution") { + return { + tool, + task_id: typeof body.task_id === "string" ? body.task_id : undefined, + claim_token_prefix: + typeof body.claim_token === "string" ? body.claim_token.slice(0, 10) : undefined, + deliverable_count: + typeof body.deliverables === "object" && + body.deliverables !== null && + !Array.isArray(body.deliverables) + ? Object.keys(body.deliverables).length + : undefined, + }; + } + + if (tool === "list_open_tasks") { + return { + tool, + has_body: body && Object.keys(body).length > 0, + }; + } + + return { + tool, + keys: Object.keys(body), + }; +} + +function resolveRequestTrace(request: NextRequest) { + return { + request_id: resolveRequestId(request), + source_ip: resolveSourceIp(request), + user_agent: request.headers.get("user-agent") ?? "unknown", + request_actor_headers: { + x_agent_id: request.headers.get("x-agent-id") ?? null, + x_agent_name: request.headers.get("x-agent-name") ?? null, + x_client_id: request.headers.get("x-client-id") ?? null, + x_openai_agent: request.headers.get("x-openai-agent") ?? null, + x_mcp_agent_id: request.headers.get("x-mcp-agent-id") ?? null, + x_request_id: request.headers.get("x-request-id") ?? null, + origin: request.headers.get("origin") ?? null, + }, + }; +} + function sanitizeIpAddress(value: string | undefined) { if (!value) { return undefined; @@ -164,29 +239,68 @@ function resolveActorFromMcpRequest(request: NextRequest) { export async function POST(request: NextRequest, props: { params: Promise<{ tool: string }> }) { const params = await props.params; const tool = params.tool; + const actor = resolveActorFromMcpRequest(request); + const requestContext = resolveRequestTrace(request); const authHeader = request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { + void sendTrafficAlert({ + level: "warning", + action: "EXTERNAL_MCP_AUTH_MISSING", + surface: `mcp/${tool}`, + actorType: actor.actorType, + actorId: actor.actorId, + taskId: "open-tasks", + message: "缺少 MCP Authorization Bearer Token", + metadata: { + ...requestContext, + auth_issue: "missing_bearer_token", + payload_summary: summarizeRequestPayload(tool, null), + response_summary: "missing_bearer_token", + response_status: 401, + }, + }); + return NextResponse.json({ error: "Unauthorized: Missing Bearer token" }, { status: 401 }); } const token = authHeader.split(" ")[1]; if (process.env.API_KEY && token !== process.env.API_KEY) { + void sendTrafficAlert({ + level: "warning", + action: "EXTERNAL_MCP_AUTH_FORBIDDEN", + surface: `mcp/${tool}`, + actorType: actor.actorType, + actorId: actor.actorId, + taskId: "open-tasks", + message: "MCP Authorization Bearer Token 無效", + metadata: { + ...requestContext, + auth_issue: "invalid_bearer_token", + response_summary: "invalid_bearer_token", + response_status: 403, + }, + }); + return NextResponse.json({ error: "Forbidden: Invalid API Key" }, { status: 403 }); } let body: unknown = null; + const responseStatusMap: Record = { + list_open_tasks: 200, + claim_task: 200, + submit_solution: 200, + check_payout_status: 200, + }; try { body = await request.json(); switch (tool) { case "list_open_tasks": { ListOpenTasksRequestSchema.parse(body); - const actor = resolveActorFromMcpRequest(request); const sourceIp = resolveSourceIp(request); const isPublicIp = !isPrivateIp(sourceIp); const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_MCP" : "INTERNAL_LIST_OPEN_TASKS_MCP"; - const tasks = await prisma.task.findMany({ where: { status: TaskStatus.OPEN, @@ -197,6 +311,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool }, }, }); + const sampleTaskIds = tasks.slice(0, 20).map((task) => task.id); const formattedTasks = tasks.map((t) => ({ task_id: t.id, @@ -224,9 +339,12 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool message: "外部 MCP 查詢任務列表", metadata: { count: formattedTasks.length, + sample_task_ids: sampleTaskIds, source_tool: tool, - source_ip: sourceIp, - user_agent: request.headers.get("user-agent") ?? "unknown", + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: `list_open_tasks_ok:${formattedTasks.length}`, + response_status: responseStatusMap[tool] || 200, }, }); @@ -258,6 +376,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool alert_window_minutes: MCP_SURGE_WINDOW_MINUTES, event_count: eventCount, source_tool: tool, + response_summary: `open_tasks_surge_${MCP_SURGE_WINDOW_MINUTES}m:${eventCount}`, }, }); } @@ -286,12 +405,32 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool message: `外部 Agent 嘗試接案但尚未白名單: ${parsed.agent_id}`, metadata: { developer_wallet: parsed.developer_wallet, + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: "claim_forbidden_not_whitelisted", + response_status: 403, }, }); return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 }); } if (agent.status !== "WHITELISTED") { + void sendTrafficAlert({ + level: "warning", + action: "EXTERNAL_CLAIM_TASK_FORBIDDEN", + surface: "mcp/claim_task", + actorType: "AGENT", + actorId: parsed.agent_id, + taskId: parsed.task_id, + message: `external agent 狀態非白名單: ${parsed.agent_id}`, + metadata: { + developer_wallet: parsed.developer_wallet, + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: `claim_forbidden_status_${agent.status}`, + response_status: 403, + }, + }); return NextResponse.json({ error: "Forbidden: Agent is not whitelisted" }, { status: 403 }); } @@ -351,9 +490,14 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool taskId: claim.task_id, message: `Agent 成功接案: ${parsed.task_id}`, metadata: { + ...requestContext, agent_id: parsed.agent_id, reward: claim.held_amount, currency: claim.held_currency, + claim_token_prefix: claim.claim_token.slice(0, 10), + payload_summary: summarizeRequestPayload(tool, body), + response_summary: "claim_success", + response_status: responseStatusMap[tool] || 200, }, }); @@ -372,6 +516,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool held_currency: claim.held_currency, expires_at: claim.expires_at.toISOString(), claim_token: claim.claim_token, + request_id: requestContext.request_id, }); } @@ -429,12 +574,18 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool action: "EXTERNAL_SUBMIT_SOLUTION_SUCCESS", surface: "mcp/submit_solution", actorType: "AGENT", - actorId: submittedClaim.developer_wallet, + actorId: submittedClaim.agent_id, taskId: submission.task_id, message: `Agent 提交解法: ${parsed.task_id}`, metadata: { + ...requestContext, + actor_id: submittedClaim.agent_id, claim_id: submission.claim_id, deliverable_count: Object.keys(parsed.deliverables ?? {}).length, + submission_id: submission.id, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: "submit_success", + response_status: responseStatusMap[tool] || 200, }, }); @@ -511,6 +662,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool submission_id: submission.id, status: submission.status, estimated_judge_complete_at: submission.estimated_judge_complete_at?.toISOString() ?? new Date().toISOString(), + request_id: requestContext.request_id, }); } @@ -528,44 +680,112 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool }); if (!ledger) { + void sendTrafficAlert({ + level: "info", + action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS", + surface: "mcp/check_payout_status", + actorType: actor.actorType, + actorId: actor.actorId, + taskId: task.id, + message: `Payout 資料未就緒: ${task.id}`, + metadata: { + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: "payout_status_not_ready", + ledger_phase: "NO_LEDGER", + response_status: responseStatusMap[tool] || 200, + }, + }); return NextResponse.json({ task_id: task.id, phase: task.status === "COMPLETED" ? "PAYOUT_READY" : "NO_LEDGER", amount: task.reward_amount, currency: task.reward_currency, updated_at: task.updated_at.toISOString(), + request_id: requestContext.request_id, }); } + void sendTrafficAlert({ + level: "info", + action: "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS", + surface: "mcp/check_payout_status", + actorType: actor.actorType, + actorId: actor.actorId, + taskId: task.id, + message: `Payout 查詢成功: ${task.id}`, + metadata: { + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: `payout_status_${ledger.phase}`, + ledger_phase: ledger.phase, + response_status: responseStatusMap[tool] || 200, + }, + }); + return NextResponse.json({ task_id: task.id, phase: ledger.phase, amount: task.reward_amount, currency: task.reward_currency, updated_at: ledger.updated_at.toISOString(), - ledger_entry: ledger + ledger_entry: ledger, + request_id: requestContext.request_id, }); } default: + void sendTrafficAlert({ + level: "warning", + action: "EXTERNAL_MCP_TOOL_UNKNOWN", + surface: `mcp/${tool}`, + actorType: actor.actorType, + actorId: actor.actorId, + taskId: "open-tasks", + message: `未知 MCP tool: ${tool}`, + metadata: { + ...requestContext, + payload_summary: summarizeRequestPayload(tool, body), + response_summary: "mcp_tool_unknown", + response_status: 404, + }, + }); return NextResponse.json({ error: `Unknown tool: ${tool}` }, { status: 404 }); } } catch (error: any) { console.error(`[API Gateway] Error handling ${tool}:`, error); + const payloadSummary = summarizeRequestPayload(tool, body); + const actorInCatch = resolveActorFromMcpRequest(request); + const catchContext = resolveRequestTrace(request); + const isStateConflict = + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: string }).message === "string" && + ["not OPEN", "not EXECUTING", "Invalid claim token", "not found", "Task is not OPEN or does not exist", "Task is not EXECUTING"].some( + (token) => (error as { message?: string }).message?.includes(token) + ); + const responseStatus = error instanceof SyntaxError || error.name === "ZodError" ? 400 : isStateConflict ? 409 : 500; void sendTrafficAlert({ level: "error", action: `EXTERNAL_${tool.toUpperCase()}_ERROR`, surface: `mcp/${tool}`, actorType: "AGENT", - actorId: typeof body === "object" && body !== null && "developer_wallet" in body - ? String((body as { developer_wallet?: string }).developer_wallet ?? "unknown") - : "unknown", + actorId: actorInCatch.actorId, taskId: typeof body === "object" && body !== null && "task_id" in body ? String((body as { task_id?: string }).task_id ?? "unknown") : "unknown", message: `${error instanceof Error ? error.message : String(error)}`, + metadata: { + ...catchContext, + payload_summary: payloadSummary, + response_summary: `${tool}_error`, + error_name: error?.name ?? "Error", + error_message: error instanceof Error ? error.message : String(error), + response_status: responseStatus, + }, }); if (error.name === "ZodError") { @@ -574,9 +794,9 @@ export async function POST(request: NextRequest, props: { params: Promise<{ tool const msg = error.message || String(error); if (msg.includes("not OPEN") || msg.includes("not EXECUTING") || msg.includes("Invalid claim token") || msg.includes("not found")) { - return NextResponse.json({ error_type: "StateConflict", message: msg }, { status: 409 }); + return NextResponse.json({ error_type: "StateConflict", message: msg }, { status: responseStatus }); } - return NextResponse.json({ error_type: "InternalError", message: msg }, { status: 500 }); + return NextResponse.json({ error_type: "InternalError", message: msg }, { status: responseStatus }); } } diff --git a/apps/web/src/app/api/open-tasks/route.ts b/apps/web/src/app/api/open-tasks/route.ts index 7535be4..4768573 100644 --- a/apps/web/src/app/api/open-tasks/route.ts +++ b/apps/web/src/app/api/open-tasks/route.ts @@ -4,9 +4,28 @@ import { TaskStatus } from "@agent-bounty/contracts"; import { sendTrafficAlert } from "@/lib/traffic-alert"; import { isIP } from "node:net"; import { evaluateExternalFunnelHealth } from "@/lib/traffic-conversion-monitor"; +import crypto from "crypto"; export const dynamic = "force-dynamic"; +const MCP_API_BASE_URL = process.env.MCP_API_BASE_URL?.trim() || "https://agent.wooo.work"; +const MCP_BOOTSTRAP_COMMAND = + process.env.MCP_BOOTSTRAP_COMMAND?.trim() || + `API_KEY= API_BASE_URL=${MCP_API_BASE_URL} npx -y @agent-bounty/mcp-server`; +const PUBLIC_API_KEY_POLICY = + process.env.API_KEY + ? "MCP claim/submit endpoints 目前仍保留 Bearer API_KEY 驗證,外部 agent 需透過環境變數注入 key。" + : "尚未設定 API_KEY,MCP claim/submit 會直接回 401(目前不建議對外量產)。"; + +function getTaskPreview(description: string) { + const sanitized = description.trim().replace(/\s+/g, " "); + return { + description_preview: sanitized.slice(0, 280), + description_chars: sanitized.length, + has_clear_steps: sanitized.includes("Step") || sanitized.includes("如何") || sanitized.includes("要求"), + }; +} + const getPayoutMode = (task: { reward_amount: number; stripe_checkout_session_id: string | null; @@ -37,6 +56,32 @@ const AI_USER_AGENT_HINTS = [ "cursor", "copilot", ]; +const REQUEST_ID_HEADERS = ["x-request-id", "x-correlation-id", "x-trace-id"]; + +function resolveRequestId(request: Request) { + for (const headerName of REQUEST_ID_HEADERS) { + const value = request.headers.get(headerName); + if (value) { + return value; + } + } + return crypto.randomUUID(); +} + +function requestContext(request: Request) { + return { + request_id: resolveRequestId(request), + user_agent: request.headers.get("user-agent") ?? "unknown", + request_actor_headers: { + x_agent_id: request.headers.get("x-agent-id") ?? null, + x_agent_name: request.headers.get("x-agent-name") ?? null, + x_ai_agent_id: request.headers.get("x-ai-agent-id") ?? null, + x_ai_id: request.headers.get("x-ai-id") ?? null, + x_request_id: request.headers.get("x-request-id") ?? null, + origin: request.headers.get("origin") ?? null, + }, + }; +} function normalizeActorId(value: string, fallback: string) { const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9._:-]+/g, "_").replace(/_+/g, "_"); @@ -145,6 +190,7 @@ export async function GET(request: Request) { const sourceIp = resolveSourceIp(request); const isPublicIp = !isPrivateIp(sourceIp); const trafficAction = isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS" : "INTERNAL_LIST_OPEN_TASKS"; + const trace = requestContext(request); try { const tasks = await prisma.task.findMany({ @@ -178,6 +224,7 @@ export async function GET(request: Request) { const publicPayload = tasks.map((task) => ({ task_id: task.id, title: task.title, + ...getTaskPreview(task.description), status: task.status, difficulty: task.difficulty, reward_amount_cents: task.reward_amount, @@ -191,6 +238,46 @@ export async function GET(request: Request) { task_url: `https://agent.wooo.work/tasks/${task.id}`, })); + const conversionHints = { + mcp_command: MCP_BOOTSTRAP_COMMAND, + api_base_url: MCP_API_BASE_URL, + auth_policy: PUBLIC_API_KEY_POLICY, + claim_example: { + method: "POST", + endpoint: `${MCP_API_BASE_URL}/api/mcp/claim_task`, + header: "Authorization: Bearer ", + body: { + task_id: "", + agent_id: "external-gpt-02", + developer_wallet: "acct_xxx", + }, + }, + submit_example: { + method: "POST", + endpoint: `${MCP_API_BASE_URL}/api/mcp/submit_solution`, + header: "Authorization: Bearer ", + body: { + task_id: "", + claim_token: "", + deliverables: { + "README.md": "...", + "solution.diff": "...", + }, + }, + }, + payload_hints: [ + "task_id = 開放任務 UUID", + "agent_id = 你的穩定識別碼(例如外部 agent name)", + "developer_wallet = Stripe Connect acct_xxx 或 EVM 0x 地址", + ], + required_steps: [ + "1) 先用 curl 或 MCP 列出任務", + "2) 規劃 1~3 個 deliverables", + "3) 呼叫 claim_task 鎖定任務", + "4) 1 小時內完成並 submit_solution", + ], + }; + const actor = resolveExternalActor(request); void sendTrafficAlert({ @@ -202,10 +289,14 @@ export async function GET(request: Request) { taskId: "open-tasks", message: `External discovery call for open tasks (${publicPayload.length} items)`, metadata: { + ...trace, source: "public-open-tasks", task_count: publicPayload.length, + task_ids: tasks.slice(0, 20).map((task) => task.id), source_ip: sourceIp, - user_agent: request.headers.get("user-agent") ?? "unknown", + response_status: 200, + response_summary: `open_tasks_ok:${publicPayload.length}`, + auth_hint: isPublicIp ? "public" : "internal", }, }); @@ -249,15 +340,20 @@ export async function GET(request: Request) { version: "v1", discovery_mode: "ai-first", beta_program: "VibeWork Beta Zero Friction + 0% Platform Fee for promoted tasks", + conversion_hints: conversionHints, tasks: publicPayload, total_open: publicPayload.length, + request_id: trace.request_id, last_refreshed_at: new Date().toISOString(), }); } catch (error: any) { console.error("[open-tasks] Internal error", error); + const errorName = error?.name ?? "Error"; + const errorMessage = error?.message ?? "internal_error"; + const actor = resolveExternalActor(request); - const msg = error?.message ?? "internal_error"; + void sendTrafficAlert({ level: "error", action: isPublicIp ? "EXTERNAL_LIST_OPEN_TASKS_ERROR" : "INTERNAL_LIST_OPEN_TASKS_ERROR", @@ -265,16 +361,20 @@ export async function GET(request: Request) { actorType: actor.actorType, actorId: actor.actorId, taskId: "open-tasks", - message: `open-tasks 查詢失敗: ${msg}`, + message: `open-tasks 查詢失敗: ${errorName}: ${errorMessage}`, metadata: { + ...trace, source: "public-open-tasks", source_ip: sourceIp, - user_agent: request.headers.get("user-agent") ?? "unknown", + response_status: 500, + response_summary: "open_tasks_error", + error_name: errorName, + error_message: errorMessage, }, }); return NextResponse.json( - { error_type: "InternalError", message: msg }, + { error_type: "InternalError", error: errorMessage, request_id: trace.request_id }, { status: 500 } ); } diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index fafdb01..531963e 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -19,6 +19,149 @@ function normalizedJudgeResult(value: unknown) { return value.trim().toLowerCase(); } +function normalizeSurface(value: unknown) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : "unknown"; +} + +function normalizeSourceIp(value: unknown) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : "unknown"; +} + +function normalizeUserAgent(value: unknown) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim(); + if (!trimmed) return "unknown"; + + const lower = trimmed.toLowerCase(); + const tokens = trimmed.split(/[;\s]/).filter((item) => item.length > 0); + const topToken = tokens[0] || trimmed; + + if (lower.includes("python-requests")) return "python-requests"; + if (lower.includes("curl")) return "curl"; + if (lower.includes("node")) return "node"; + if (lower.includes("openai")) return "openai"; + if (lower.includes("perplexity")) return "perplexity"; + if (lower.includes("anthropic")) return "anthropic"; + if (lower.includes("bot")) return "bot"; + + return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken; +} + +const AI_USER_AGENT_HINTS = [ + "gpt", + "chatgpt", + "openai", + "anthropic", + "claude", + "perplexity", + "llm", + "mcp", + "autogpt", + "agent", + "assistant", + "gemini", + "cursor", + "copilot", +]; + +function isLikelyAIAgentActor( + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined +) { + if (actorType === "AGENT") { + return true; + } + + const normalizedActor = (actorId || "").toLowerCase(); + if (normalizedActor.startsWith("agent:")) { + return true; + } + + const userAgent = String(metadata?.user_agent || "").toLowerCase(); + if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) { + return true; + } + + const requestHeaders = asRecordJson(metadata?.request_actor_headers); + if (!requestHeaders) { + return false; + } + + const headerText = Object.values(requestHeaders) + .filter((item): item is string => typeof item === "string") + .join(" ") + .toLowerCase(); + + return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token)); +} + +function classifyActorSource( + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined +) { + if (actorType === "AGENT" || (actorId || "").toLowerCase().startsWith("agent:")) { + return "AGENT"; + } + if (isLikelyAIAgentActor(actorType, actorId, metadata)) { + return "LIKELY_AI"; + } + return "OTHER"; +} + +function addCountedBucket(store: Map; latestAt: number }>, key: string, actorId: string, eventAt: number) { + const bucket = store.get(key); + if (!bucket) { + store.set(key, { events: 1, actors: new Set([actorId]), latestAt: eventAt }); + return; + } + + bucket.events += 1; + bucket.actors.add(actorId); + if (eventAt > bucket.latestAt) { + bucket.latestAt = eventAt; + } +} + +type ActorSourceBucket = { + events: number; + actors: Set; + latestAt: number; + surface?: string; + sourceIp?: string; + userAgent?: string; +}; + +const updateActorBucket = ( + store: Map, + key: string, + actorId: string, + eventAt: number, + keyField: Record +) => { + const bucket = store.get(key); + if (!bucket) { + store.set(key, { + ...keyField, + events: 1, + actors: new Set([actorId]), + latestAt: eventAt, + }); + return; + } + + bucket.events += 1; + bucket.actors.add(actorId); + if (eventAt > bucket.latestAt) { + bucket.latestAt = eventAt; + } +}; + function isMissingTableError(error: unknown): boolean { return ( typeof error === "object" && @@ -257,9 +400,75 @@ export async function GET(request: NextRequest) { createdAt: event.createdAt, surface: metadata?.surface, level: metadata?.level, + actorSource: classifyActorSource(event.actorType, event.actorId, metadata), + metadata, }; }); + const externalSourceSurfaceMap = new Map; latestAt: number }>(); + const externalIpSurfaceMap = new Map; latestAt: number }>(); + const externalUserAgentMap = new Map; latestAt: number }>(); + const responseStatusSummary = new Map; latestAt: number }>(); + const externalErrorRows: Array<{ + actor_id: string; + actor_type: string; + action: string; + task_id: string; + surface: string; + source_ip: string; + user_agent: string; + response_status: number | null; + error_name: string; + error_message: string; + created_at_ms: number; + }> = []; + + recentEvents.forEach((event) => { + const actorId = event.actorId || "agent:unknown"; + const metadata = asRecordJson(event.metadata); + const normalizedSurface = normalizeSurface(event.surface); + const normalizedIp = normalizeSourceIp(metadata?.source_ip); + const normalizedUa = normalizeUserAgent(metadata?.user_agent); + const isExternalAgent = event.action.startsWith("EXTERNAL_") && + event.actorType === "AGENT" && + !isInternalActor({ actorType: event.actorType, actorId: event.actorId }); + + if (!isExternalAgent) { + return; + } + + const eventAt = event.createdAt.getTime(); + const responseStatus = typeof metadata?.response_status === "number" ? metadata.response_status : null; + const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : ""; + const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : ""; + const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-"); + + updateActorBucket(externalSourceSurfaceMap, normalizedSurface, actorId, eventAt, { surface: normalizedSurface }); + updateActorBucket(externalIpSurfaceMap, normalizedIp, actorId, eventAt, { sourceIp: normalizedIp }); + updateActorBucket(externalUserAgentMap, normalizedUa, actorId, eventAt, { userAgent: normalizedUa }); + addCountedBucket(responseStatusSummary, String(responseStatus ?? "n/a"), actorId, eventAt); + + if ( + event.action.includes("ERROR") || + event.action.includes("FORBIDDEN") || + event.action.includes("MISSING") + ) { + externalErrorRows.push({ + actor_id: actorId, + actor_type: event.actorType || "USER", + action: event.action, + task_id: taskId, + surface: normalizedSurface, + source_ip: normalizedIp, + user_agent: normalizedUa, + response_status: responseStatus, + error_name: errorName || "unknown", + error_message: errorMessage || "unknown", + created_at_ms: eventAt, + }); + } + }); + const recentExternalEvents = recentEvents.filter((event) => event.action.startsWith("EXTERNAL_") && !isInternalActor({ @@ -272,6 +481,46 @@ export async function GET(request: NextRequest) { (event) => !event.action.startsWith("EXTERNAL_") ); + const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries()) + .map(([surface, bucket]) => ({ + surface, + events: bucket.events, + actors: bucket.actors.size, + latest_at: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalSourceIpSummary = Array.from(externalIpSurfaceMap.entries()) + .map(([sourceIp, bucket]) => ({ + source_ip: sourceIp, + events: bucket.events, + actors: bucket.actors.size, + latest_at: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalUserAgentSummary = Array.from(externalUserAgentMap.entries()) + .map(([userAgent, bucket]) => ({ + user_agent: userAgent, + events: bucket.events, + actors: bucket.actors.size, + latest_at: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalResponseStatusSummary = Array.from(responseStatusSummary.entries()) + .map(([responseStatus, bucket]) => ({ + response_status: responseStatus, + events: bucket.events, + actors: bucket.actors.size, + latest_at: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalErrorRowsSorted = externalErrorRows + .sort((left, right) => right.created_at_ms - left.created_at_ms) + .slice(0, 30); + return NextResponse.json({ period_minutes: minutes, total_events: totalRows, @@ -283,6 +532,11 @@ export async function GET(request: NextRequest) { conversion_rates: conversionRates, external_event_types: externalEventTypes, internal_event_types: internalEventTypes, + external_surface_summary: externalSurfaceSummary, + external_source_ip_summary: externalSourceIpSummary, + external_user_agent_summary: externalUserAgentSummary, + external_response_status_summary: externalResponseStatusSummary, + external_error_rows: externalErrorRowsSorted, recent_external_events: recentExternalEvents, recent_internal_events: recentInternalEvents, updated_at: new Date().toISOString(), diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx index 9730b40..12f2875 100644 --- a/apps/web/src/app/traffic/page.tsx +++ b/apps/web/src/app/traffic/page.tsx @@ -13,17 +13,118 @@ const EVENT_LABELS: Record = { EXTERNAL_LIST_OPEN_TASKS_MCP_SURGE: "外部 MCP 流量突增告警", EXTERNAL_CLAIM_TASK_SUCCESS: "外部 AI 成功接單", EXTERNAL_SUBMIT_SOLUTION_SUCCESS: "外部 AI 提交解法", + EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS: "外部 AI 查詢付款狀態", EXTERNAL_CLAIM_TASK_ERROR: "外部接單失敗", EXTERNAL_SUBMIT_SOLUTION_ERROR: "外部提交失敗", EXTERNAL_LIST_OPEN_TASKS_ERROR: "外部公開流量端點錯誤", EXTERNAL_LIST_OPEN_TASKS_MCP_ERROR: "外部 MCP 流量端點錯誤", + EXTERNAL_MCP_AUTH_MISSING: "MCP 未提供 Bearer Token", + EXTERNAL_MCP_AUTH_FORBIDDEN: "MCP Bearer Token 無效", + EXTERNAL_MCP_TOOL_UNKNOWN: "MCP 不支援 Tool", + EXTERNAL_CLAIM_TASK_FORBIDDEN: "外部 AI 接案被拒", EXTERNAL_FUNNEL_CLAIM_STALL: "外部曝光後未接案", EXTERNAL_FUNNEL_SUBMIT_STALL: "外部接案後未提交", EXTERNAL_FUNNEL_PASS_STALL: "外部提交後未 PASS", EXTERNAL_FUNNEL_PAYOUT_STALL: "PASS 後未出金", + EXTERNAL_FUNNEL_OPEN_COLD_STANDBY: "外部大量曝光但未接案(冷啟動斷崖)", JUDGE_COMPLETE: "AI 交件判定完成", }; +function normalizeSurface(value: unknown) { + if (typeof value !== "string") return "-"; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : "-"; +} + +function normalizeSourceIp(value: unknown) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : "unknown"; +} + +function normalizeUserAgent(value: unknown) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim(); + if (!trimmed) return "unknown"; + + const lower = trimmed.toLowerCase(); + const tokens = trimmed.split(/[;\s]/).filter((item) => item.length > 0); + const topToken = tokens[0] || trimmed; + + if (lower.includes("python-requests")) return "python-requests"; + if (lower.includes("curl")) return "curl"; + if (lower.includes("node")) return "node"; + if (lower.includes("openai")) return "openai"; + if (lower.includes("perplexity")) return "perplexity"; + if (lower.includes("anthropic")) return "anthropic"; + if (lower.includes("bot")) return "bot"; + + return topToken.length > 48 ? `${topToken.slice(0, 45)}...` : topToken; +} + +const AI_USER_AGENT_HINTS = [ + "gpt", + "chatgpt", + "openai", + "anthropic", + "claude", + "perplexity", + "llm", + "mcp", + "autogpt", + "agent", + "assistant", + "gemini", + "cursor", + "copilot", +]; + +function isLikelyAIAgentActor( + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined +) { + if (actorType === "AGENT") { + return true; + } + + const normalizedActor = (actorId || "").toLowerCase(); + if (normalizedActor.startsWith("agent:")) { + return true; + } + + const userAgent = String(metadata?.user_agent || "").toLowerCase(); + if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) { + return true; + } + + const requestHeaders = asRecordJson(metadata?.request_actor_headers); + if (!requestHeaders) { + return false; + } + + const headerText = Object.values(requestHeaders) + .filter((item): item is string => typeof item === "string") + .join(" ") + .toLowerCase(); + + return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token)); +} + +function classifyActorSource( + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined +) { + if (actorType === "AGENT" || (actorId || "").toLowerCase().startsWith("agent:")) { + return "AGENT"; + } + if (isLikelyAIAgentActor(actorType, actorId, metadata)) { + return "LIKELY_AI"; + } + return "OTHER"; +} + function asRecordJson(value: unknown): Record | undefined { if (typeof value === "object" && value !== null && !Array.isArray(value)) { return value as Record; @@ -38,6 +139,33 @@ function normalizedJudgeResult(value: unknown) { return value.trim().toLowerCase(); } +function summarizeActorHeaders(value: unknown) { + const headers = asRecordJson(value) || {}; + const pairs = Object.entries(headers) + .filter(([, rawValue]) => typeof rawValue === "string" && rawValue.trim().length > 0) + .map(([key, rawValue]) => `${key}=${String(rawValue)}`); + return pairs.length ? pairs.join(" | ") : undefined; +} + +function summarizeResponseOutcome( + action: string, + responseStatus: number | null, + errorName: string, + errorMessage: string, + responseSummary: string +) { + if (errorName || errorMessage) { + return `錯誤回應 (${responseStatus || "?"}): ${errorName || "Error"} ${errorMessage}`; + } + if (responseSummary) { + return `${action} → ${responseSummary} (HTTP ${responseStatus || "-"})`; + } + if (responseStatus !== null) { + return `${action} → HTTP ${responseStatus}`; + } + return `response_status=${responseStatus ?? "-"}`; +} + function percent(numerator: number, denominator: number) { if (!denominator) return 0; return Math.round((numerator / denominator) * 1000) / 10; @@ -47,6 +175,27 @@ function fmtPercent(value: number) { return `${value.toFixed(1)}%`; } +function actorFunnelStage(flow: { + opens: number; + claims: number; + submits: number; + pays: number; +}) { + if (flow.pays > 0) return "查詢付款"; + if (flow.submits > 0) return "已提交"; + if (flow.claims > 0) return "已接案"; + if (flow.opens > 0) return "僅曝光"; + return "異常"; +} + +function actorFunnelBadge(stage: string) { + if (stage === "已提交") return "text-blue-300"; + if (stage === "已接案") return "text-cyan-300"; + if (stage === "查詢付款") return "text-amber-300"; + if (stage === "僅曝光") return "text-yellow-300"; + return "text-gray-400"; +} + function isInternalActorId(value: string | null | undefined) { if (!value) return true; const actorId = value.toLowerCase(); @@ -98,6 +247,7 @@ async function getTrafficSummary(minutes: number) { summaryRows, actorSummaryRows, externalActorRows, + externalAgentActionRows, totalRows, latestEvents, judgeCompleteRows, @@ -128,6 +278,17 @@ async function getTrafficSummary(minutes: number) { }, _count: { _all: true }, }), + prisma.auditEvent.groupBy({ + by: ["actorId", "action"], + where: { + createdAt: { gte: since }, + actorType: "AGENT", + action: { + startsWith: "EXTERNAL_", + }, + }, + _count: { _all: true }, + }), prisma.auditEvent.count({ where: { createdAt: { gte: since } }, }), @@ -176,6 +337,35 @@ async function getTrafficSummary(minutes: number) { }), ]); + type ExternalActorFlow = { + actorId: string; + actorType: string; + events: number; + opens: number; + claims: number; + submits: number; + pays: number; + passes: number; + fails: number; + errors: number; + stage: string; + firstAction: string; + firstAt: number; + firstSurface: string; + firstTaskId: string; + firstSourceIp: string; + firstUserAgent: string; + latestAction: string; + latestAt: number; + latestSourceIp: string; + latestTaskId: string; + latestSurface: string; + responseSummary: string; + responseStatus: number | null; + }; + + const externalAgentFlowMap = new Map(); + const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all])); const actorSummary = Object.fromEntries(actorSummaryRows.map((row) => [row.actorType, row._count._all])); const externalActorSummary = externalActorRows @@ -187,6 +377,62 @@ async function getTrafficSummary(minutes: number) { .sort((a, b) => b.events - a.events) .slice(0, 20); + externalAgentActionRows.forEach((row) => { + const actorId = row.actorId || "agent:unknown"; + if (isInternalActor({ actorType: "AGENT", actorId })) { + return; + } + + const flow = externalAgentFlowMap.get(actorId) || { + actorId, + actorType: "AGENT", + events: 0, + opens: 0, + claims: 0, + submits: 0, + pays: 0, + passes: 0, + fails: 0, + errors: 0, + stage: "異常", + firstAction: "-", + firstAt: Number.MAX_SAFE_INTEGER, + firstSurface: "-", + firstTaskId: "-", + firstSourceIp: "-", + firstUserAgent: "-", + latestAction: "-", + latestAt: 0, + latestSourceIp: "-", + latestTaskId: "-", + latestSurface: "-", + responseSummary: "n/a", + responseStatus: null, + }; + + flow.events += row._count._all; + if (row.action === "EXTERNAL_LIST_OPEN_TASKS" || row.action === "EXTERNAL_LIST_OPEN_TASKS_MCP") { + flow.opens += row._count._all; + } else if (row.action === "EXTERNAL_CLAIM_TASK_SUCCESS") { + flow.claims += row._count._all; + } else if (row.action === "EXTERNAL_SUBMIT_SOLUTION_SUCCESS") { + flow.submits += row._count._all; + } else if (row.action === "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS") { + flow.pays += row._count._all; + } else if (row.action.includes("ERROR") || row.action.includes("FORBIDDEN") || row.action.includes("MISSING")) { + flow.errors += row._count._all; + } + + flow.stage = actorFunnelStage({ + opens: flow.opens, + claims: flow.claims, + submits: flow.submits, + pays: flow.pays, + }); + + externalAgentFlowMap.set(actorId, flow); + }); + const channelSummary = Object.entries(actionSummary).reduce( (acc, [action, count]) => { if (action.startsWith("EXTERNAL_")) { @@ -209,14 +455,233 @@ async function getTrafficSummary(minutes: number) { const recentEvents = latestEvents.map((event) => { const metadata = asRecordJson(event.metadata); + const actorSource = classifyActorSource(event.actorType, event.actorId, metadata); return { ...event, surface: metadata?.surface, level: metadata?.level, + actorSource, metadata, }; }); + const externalSourceSurfaceMap = new Map; latestAt: number }>(); + const externalIpSurfaceMap = new Map; latestAt: number }>(); + const externalUserAgentMap = new Map; latestAt: number }>(); + const externalResponseStatusMap = new Map; latestAt: number }>(); + const externalActorCategorySummary = { + agentActors: new Set(), + likelyAiActors: new Set(), + otherActors: new Set(), + }; + const externalErrorRows: Array<{ + actorId: string; + actorType: string; + action: string; + taskId: string; + surface: string; + sourceIp: string; + userAgent: string; + responseStatus: number | null; + errorName: string; + errorMessage: string; + createdAtMs: number; + }> = []; + + type ActorSourceBucket = { + events: number; + actors: Set; + latestAt: number; + surface?: string; + sourceIp?: string; + userAgent?: string; + }; + + const updateActorBucket = ( + store: Map, + key: string, + actorId: string, + eventAt: number, + keyField: Record + ) => { + const bucket = store.get(key); + if (!bucket) { + store.set(key, { + ...keyField, + events: 1, + actors: new Set([actorId]), + latestAt: eventAt, + }); + return; + } + + bucket.events += 1; + bucket.actors.add(actorId); + if (eventAt > bucket.latestAt) { + bucket.latestAt = eventAt; + } + } + }; + + const updateResponseStatusBucket = ( + responseStatus: string, + actorId: string, + eventAt: number + ) => { + const bucket = externalResponseStatusMap.get(responseStatus); + if (!bucket) { + externalResponseStatusMap.set(responseStatus, { + events: 1, + actors: new Set([actorId]), + latestAt: eventAt, + }); + return; + } + + bucket.events += 1; + bucket.actors.add(actorId); + if (eventAt > bucket.latestAt) { + bucket.latestAt = eventAt; + } + }; + + recentEvents.forEach((event) => { + const metadata = asRecordJson(event.metadata); + const normalizedSurface = normalizeSurface(event.surface); + const normalizedIp = normalizeSourceIp(metadata?.source_ip); + const normalizedUa = normalizeUserAgent(metadata?.user_agent); + const actorId = event.actorId || "agent:unknown"; + const isExternalAgent = event.action.startsWith("EXTERNAL_") && + event.actorType === "AGENT" && + !isInternalActor({ actorType: event.actorType, actorId: event.actorId }); + + if (!isExternalAgent) { + return; + } + + const eventAt = event.createdAt.getTime(); + const errorName = typeof metadata?.error_name === "string" ? metadata.error_name : ""; + const errorMessage = typeof metadata?.error_message === "string" ? metadata.error_message : ""; + const responseStatus = + typeof metadata?.response_status === "number" ? metadata.response_status : null; + const responseSummary = typeof metadata?.response_summary === "string" ? metadata.response_summary : ""; + const outcome = errorName || errorMessage ? "ERROR" : responseSummary || `HTTP_${responseStatus || "-"}`; + const eventTaskId = typeof metadata?.task_id === "string" ? metadata.task_id : (event.entityId || "-"); + + updateActorBucket(externalSourceSurfaceMap, normalizedSurface, actorId, eventAt, { + surface: normalizedSurface, + }); + updateActorBucket(externalIpSurfaceMap, normalizedIp, actorId, eventAt, { + sourceIp: normalizedIp, + }); + updateActorBucket(externalUserAgentMap, normalizedUa, actorId, eventAt, { + userAgent: normalizedUa, + }); + updateResponseStatusBucket(String(responseStatus ?? "n/a"), actorId, eventAt); + + if (event.actorSource === "AGENT") { + externalActorCategorySummary.agentActors.add(actorId); + } else if (event.actorSource === "LIKELY_AI") { + externalActorCategorySummary.likelyAiActors.add(actorId); + } else { + externalActorCategorySummary.otherActors.add(actorId); + } + + if ( + event.action.includes("ERROR") || + event.action.includes("FORBIDDEN") || + event.action.includes("MISSING") + ) { + externalErrorRows.push({ + actorId, + actorType: event.actorType || "USER", + action: event.action, + taskId: eventTaskId, + surface: normalizedSurface, + sourceIp: normalizedIp, + userAgent: normalizedUa, + responseStatus, + errorName: errorName || "unknown", + errorMessage: errorMessage || "unknown", + createdAtMs: eventAt, + }); + } + + const flow = externalAgentFlowMap.get(actorId) || { + actorId, + actorType: event.actorType || "AGENT", + events: 0, + opens: 0, + claims: 0, + submits: 0, + pays: 0, + passes: 0, + fails: 0, + errors: 0, + stage: "異常", + firstAction: event.action, + firstAt: eventAt, + firstSurface: normalizedSurface, + firstTaskId: eventTaskId, + firstSourceIp: normalizedIp, + firstUserAgent: normalizedUa, + latestAction: event.action, + latestAt: eventAt, + latestSourceIp: normalizedIp, + latestTaskId: eventTaskId, + latestSurface: normalizedSurface, + responseSummary: outcome, + responseStatus, + }; + + flow.events += 1; + if (event.action === "EXTERNAL_LIST_OPEN_TASKS" || event.action === "EXTERNAL_LIST_OPEN_TASKS_MCP") { + flow.opens += 1; + } else if (event.action === "EXTERNAL_CLAIM_TASK_SUCCESS") { + flow.claims += 1; + } else if (event.action === "EXTERNAL_SUBMIT_SOLUTION_SUCCESS") { + flow.submits += 1; + } else if (event.action === "EXTERNAL_CHECK_PAYOUT_STATUS_SUCCESS") { + flow.pays += 1; + } else if (event.action === "JUDGE_COMPLETE") { + const judgeResult = normalizedJudgeResult(metadata?.overall_result); + if (judgeResult === "pass") flow.passes += 1; + if (judgeResult === "fail") flow.fails += 1; + } + + if (event.action.includes("ERROR") || event.action.includes("FORBIDDEN") || event.action.includes("MISSING")) { + flow.errors += 1; + } + + if (eventAt < flow.firstAt) { + flow.firstAction = event.action; + flow.firstAt = eventAt; + flow.firstSurface = normalizedSurface; + flow.firstTaskId = eventTaskId; + flow.firstSourceIp = normalizedIp; + flow.firstUserAgent = normalizedUa; + } + + if (eventAt >= flow.latestAt) { + flow.latestAction = event.action; + flow.latestAt = eventAt; + flow.latestSourceIp = normalizedIp; + flow.latestTaskId = eventTaskId; + flow.latestSurface = normalizedSurface; + flow.responseSummary = outcome; + flow.responseStatus = responseStatus; + } + + flow.stage = actorFunnelStage({ + opens: flow.opens, + claims: flow.claims, + submits: flow.submits, + pays: flow.pays, + }); + + externalAgentFlowMap.set(actorId, flow); + }); + const discoveryEvents = (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); @@ -252,6 +717,126 @@ async function getTrafficSummary(minutes: number) { .filter((event) => event.action.includes("ERROR")) .map((event) => event.action); + const externalSurfaceSummary = Array.from(externalSourceSurfaceMap.entries()) + .map(([surface, bucket]) => ({ + surface, + events: bucket.events, + actors: bucket.actors.size, + latestAt: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalSourceIpSummary = Array.from(externalIpSurfaceMap.entries()) + .map(([sourceIp, bucket]) => ({ + sourceIp, + events: bucket.events, + actors: bucket.actors.size, + latestAt: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalUserAgentSummary = Array.from(externalUserAgentMap.entries()) + .map(([userAgent, bucket]) => ({ + userAgent, + events: bucket.events, + actors: bucket.actors.size, + latestAt: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalResponseStatusSummary = Array.from(externalResponseStatusMap.entries()) + .map(([responseStatus, bucket]) => ({ + responseStatus, + events: bucket.events, + actors: bucket.actors.size, + latestAt: bucket.latestAt, + })) + .sort((a, b) => b.events - a.events); + + const externalErrorRowsSorted = externalErrorRows + .sort((left, right) => right.createdAtMs - left.createdAtMs) + .slice(0, 30); + + const actorFlowValues = Array.from(externalAgentFlowMap.values()); + const actorFunnelSummary = { + total_external_agents: actorFlowValues.length, + actors_open_only: actorFlowValues.filter((item) => item.opens > 0 && item.claims === 0).length, + actors_with_claim: actorFlowValues.filter((item) => item.claims > 0).length, + actors_with_submit: actorFlowValues.filter((item) => item.submits > 0).length, + actors_with_payment_query: actorFlowValues.filter((item) => item.pays > 0).length, + open_only_top: actorFlowValues + .filter((item) => item.opens > 0 && item.claims === 0) + .sort((left, right) => right.opens - left.opens) + .slice(0, 5) + .map((item) => ({ actorId: item.actorId, opens: item.opens })), + }; + + const taskFlowMap = new Map< + string, + { + taskId: string; + opens: number; + claims: number; + submits: number; + passes: number; + fails: number; + lastAction: string; + lastAt: number; + } + >(); + + latestEvents.forEach((event) => { + if (!event.entityId || event.entityId === "open-tasks") { + return; + } + + if (event.action !== "EXTERNAL_LIST_OPEN_TASKS" && event.action !== "JUDGE_COMPLETE" && !event.action.startsWith("EXTERNAL_")) { + return; + } + + const metadata = asRecordJson(event.metadata); + const taskId = typeof metadata?.task_id === "string" ? metadata.task_id : event.entityId; + if (!taskId) return; + + const item = taskFlowMap.get(taskId) || { + taskId, + opens: 0, + claims: 0, + submits: 0, + passes: 0, + fails: 0, + lastAction: event.action, + lastAt: event.createdAt.getTime(), + }; + + if (event.action === "EXTERNAL_LIST_OPEN_TASKS" || event.action === "EXTERNAL_LIST_OPEN_TASKS_MCP") { + item.opens += 1; + } else if (event.action === "EXTERNAL_CLAIM_TASK_SUCCESS") { + item.claims += 1; + } else if (event.action === "EXTERNAL_SUBMIT_SOLUTION_SUCCESS") { + item.submits += 1; + } else if (event.action === "JUDGE_COMPLETE") { + const judgeResult = normalizedJudgeResult(metadata?.overall_result); + if (judgeResult === "pass") item.passes += 1; + if (judgeResult === "fail") item.fails += 1; + } + + if (event.createdAt.getTime() > item.lastAt) { + item.lastAt = event.createdAt.getTime(); + item.lastAction = event.action; + } + + taskFlowMap.set(taskId, item); + }); + + const externalTaskFlow = Array.from(taskFlowMap.values()) + .sort((left, right) => right.lastAt - left.lastAt) + .slice(0, 30); + + const externalAgentFlows = Array.from(externalAgentFlowMap.values()) + .sort((left, right) => right.events - left.events || right.latestAt - left.latestAt) + .slice(0, 30); + return { periodMinutes: minutes, totalEvents: totalRows, @@ -269,8 +854,21 @@ async function getTrafficSummary(minutes: number) { }) ), recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")), + externalSurfaceSummary, + externalSourceIpSummary, + externalUserAgentSummary, + externalResponseStatusSummary, + externalActorCategorySummary: { + agentActors: externalActorCategorySummary.agentActors.size, + likelyAiActors: externalActorCategorySummary.likelyAiActors.size, + otherActors: externalActorCategorySummary.otherActors.size, + }, + externalErrorRows: externalErrorRowsSorted, conversionSummary, conversionRates, + actorFunnelSummary, + externalTaskFlow, + externalAgentFlows, externalErrors, }; } @@ -292,6 +890,8 @@ function buildConversionTips(summary: { judge_fail_events: number; payout_captured: number; payout_released: number; +}, actorFunnelSummary: { + actors_open_only: number; }) { const steps: string[] = []; @@ -319,6 +919,10 @@ function buildConversionTips(summary: { steps.push("PASS 率偏低:提高指令兼容度,改用可判讀的輸出欄位與固定檔名。"); } + if (actorFunnelSummary.actors_open_only > 0) { + steps.push("有大量 Actor 停留在曝光階段,建議補齊 npx command、回傳欄位格式、最小可交付定義,降低第一層認知門檻。"); + } + if (steps.length === 0) { steps.push("目前流程尚未形成明顯瓶頸。可持續觀察 30 分鐘內轉化率走勢,再做對照測試。 "); } @@ -326,6 +930,80 @@ function buildConversionTips(summary: { return steps; } +function buildEventSignal(event: { + actorType: string | null; + actorId: string | null; + action: string; + createdAt: Date; + metadata?: Record | null; + surface?: string; + actorSource?: string; + reason?: string | null; +}) { + const metadata = asRecordJson(event.metadata) || {}; + const sourceIp = typeof metadata.source_ip === "string" ? metadata.source_ip : "-"; + const userAgent = typeof metadata.user_agent === "string" ? metadata.user_agent : "-"; + const requestId = typeof metadata.request_id === "string" ? metadata.request_id : "-"; + const errorName = typeof metadata.error_name === "string" ? metadata.error_name : ""; + const errorMessage = + typeof metadata.error_message === "string" ? metadata.error_message : ""; + const responseSummary = typeof metadata.response_summary === "string" ? metadata.response_summary : ""; + const taskCount = typeof metadata.task_count === "number" ? metadata.task_count : null; + const responseStatus = typeof metadata.response_status === "number" ? metadata.response_status : null; + const actorHeaders = summarizeActorHeaders(metadata.request_actor_headers); + const taskId = + typeof metadata.task_id === "string" + ? metadata.task_id + : event.entityId || "-"; + const payloadSummary = asRecordJson(metadata.payload_summary); + const payloadHint = + payloadSummary && typeof payloadSummary.tool === "string" + ? `payload=${payloadSummary.tool}:` + + Object.entries(payloadSummary) + .filter(([key]) => key !== "tool") + .map(([key, value]) => `${key}=${typeof value === "object" ? JSON.stringify(value) : value}`) + .slice(0, 2) + .join(",") + : ""; + const summary = summarizeResponseOutcome( + event.action, + responseStatus, + errorName, + errorMessage, + responseSummary + ); + const actorSource = event.actorSource || classifyActorSource(event.actorType, event.actorId, metadata); + + return { + actor: `${event.actorType || "USER"}:${event.actorId || "unknown"}`, + actorSource, + sourceIp, + userAgent, + surface: typeof event.surface === "string" ? event.surface : "-", + summary, + actorHeaders, + taskCount, + requestId, + taskId, + responseStatus, + payloadHint, + createdAtText: toLocalTime(event.createdAt), + createdAtMs: event.createdAt.getTime(), + full: { + action: event.action, + task_count: taskCount, + source_ip: sourceIp, + user_agent: userAgent, + error_name: errorName || undefined, + error_message: errorMessage || undefined, + actor_headers: actorHeaders, + request_id: requestId, + response_status: responseStatus || undefined, + surface: typeof event.surface === "string" ? event.surface : "-", + }, + }; +} + export default async function TrafficDashboard({ searchParams, }: { @@ -349,7 +1027,39 @@ export default async function TrafficDashboard({ const summary = await getTrafficSummary(minutes); const { conversionSummary, conversionRates } = summary; - const conversionHints = buildConversionTips(conversionRates, conversionSummary); + const conversionHints = buildConversionTips(conversionRates, conversionSummary, summary.actorFunnelSummary); + + const actorEventMap = new Map>>(); + summary.recentExternalEvents.forEach((event) => { + const actor = event.actorId || "unknown"; + const signal = buildEventSignal({ + actorType: event.actorType, + actorId: event.actorId, + action: event.action, + createdAt: event.createdAt, + metadata: event.metadata, + surface: String(event.surface || "-"), + actorSource: (event as { actorSource?: string }).actorSource, + reason: event.reason, + }); + + const list = actorEventMap.get(actor) || []; + list.push(signal); + actorEventMap.set(actor, list); + }); + + const actorEventRows = Array.from(actorEventMap.entries()) + .map(([actor, signals]) => { + const sorted = signals.sort((a, b) => b.createdAtMs - a.createdAtMs); + return { + actor, + events: sorted, + eventsCount: sorted.length, + latestAction: sorted[0]?.action || "-", + latestTask: sorted[0]?.taskId, + }; + }) + .sort((left, right) => right.eventsCount - left.eventsCount); return (
@@ -388,7 +1098,27 @@ export default async function TrafficDashboard({
-
+
+
+
外部 AI Actor
+
{summary.actorFunnelSummary.total_external_agents}
+
在觀測窗有事件的獨立外部 AI Actor
+
+
+
僅曝光未接案
+
{summary.actorFunnelSummary.actors_open_only}
+
+ 已接案: {summary.actorFunnelSummary.actors_with_claim}|已提交: {summary.actorFunnelSummary.actors_with_submit} +
+
+
+
已查詢付款
+
{summary.actorFunnelSummary.actors_with_payment_query}
+
查詢付款狀態的外部 Actor 數
+
+
+ +

外部流量轉化漏斗

@@ -441,6 +1171,60 @@ export default async function TrafficDashboard({
+
+
+

外部來源 Surface 拆解

+
+ {summary.externalSurfaceSummary.length === 0 ? ( +

目前區間內未有 surface 分佈資料。

+ ) : ( + summary.externalSurfaceSummary.slice(0, 10).map((item) => ( +
+ {item.surface} + + {item.events} events / {item.actors} actors + +
+ )) + )} +
+
+
+

外部來源 IP Top20

+
+ {summary.externalSourceIpSummary.length === 0 ? ( +

目前區間內未有 IP 分佈資料。

+ ) : ( + summary.externalSourceIpSummary.slice(0, 10).map((item) => ( +
+ {item.sourceIp} + + {item.events} events / {item.actors} actors + +
+ )) + )} +
+
+
+

外部 UA 標籤 Top20

+
+ {summary.externalUserAgentSummary.length === 0 ? ( +

目前區間內未有 UA 分佈資料。

+ ) : ( + summary.externalUserAgentSummary.slice(0, 10).map((item) => ( +
+ {item.userAgent} + + {item.events} events / {item.actors} actors + +
+ )) + )} +
+
+
+

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

    @@ -469,8 +1253,48 @@ export default async function TrafficDashboard({ )) )}
+ {summary.actorFunnelSummary.open_only_top.length > 0 ? ( +
+ 僅曝光高頻前 5:{summary.actorFunnelSummary.open_only_top.map((item) => `${item.actorId}(${item.opens})`).join(";")} +
+ ) : null}
+
+

外部 AI 主動行為(Actor 會話級)

+
+ {summary.externalAgentFlows?.length === 0 ? ( +

目前區間內無可判讀的外部 AI 主動行為。

+ ) : ( + summary.externalAgentFlows.map((actor) => ( +
+
+ {actor.actorId} + {actor.events} events +
+
+ 階段:{actor.stage} + | Open {actor.opens}|Claim {actor.claims}|Submit {actor.submits}|Pay {actor.pays} +
+
+ 首次:{actor.firstAction} @ {actor.firstAt > 0 && actor.firstAt < 4102444800000 ? toLocalTime(new Date(actor.firstAt)) : "-"} | surface={actor.firstSurface} | ip={actor.firstSourceIp} | ua={actor.firstUserAgent} | task={actor.firstTaskId} +
+
+ 最近:{actor.latestAction} @ {actor.latestAt ? toLocalTime(new Date(actor.latestAt)) : "-"} | surface={actor.latestSurface} | ip={actor.latestSourceIp} | task={actor.latestTaskId} +
+
+ response={actor.responseStatus ?? "-"} ({actor.responseSummary}) +
+
+ )) + )} +
+
+
+

Actor 類型分布

@@ -487,7 +1311,7 @@ export default async function TrafficDashboard({
-
+

外部事件說明(可讀)

{Object.entries(summary.actionSummary) @@ -514,6 +1338,69 @@ export default async function TrafficDashboard({
+
+

外部回應結果(HTTP)

+
+ {summary.externalResponseStatusSummary.length === 0 ? ( +

目前區間內無外部回應摘要。

+ ) : ( + summary.externalResponseStatusSummary.slice(0, 10).map((item) => ( +
+ HTTP {item.responseStatus} + + {item.events} events / {item.actors} actors + +
+ )) + )} +
+
+ +
+

外部 AI 身份判斷

+
+
+ 明確 AGENT actor + {summary.externalActorCategorySummary.agentActors} +
+
+ 疑似 AI actor(header/UA) + {summary.externalActorCategorySummary.likelyAiActors} +
+
+ 其他 + {summary.externalActorCategorySummary.otherActors} +
+
+
+ +
+

外部錯誤事件(Top 30)

+
+ {summary.externalErrorRows.length === 0 ? ( +

目前區間內無外部錯誤事件。

+ ) : ( + summary.externalErrorRows.map((errorRow) => ( +
+
+ {errorRow.actorType}:{errorRow.actorId} + {errorRow.action} +
+
+ task={errorRow.taskId} | surface={errorRow.surface} | ip={errorRow.sourceIp} | ua={errorRow.userAgent} | response={errorRow.responseStatus ?? "-"} +
+
+ {errorRow.errorName}: {errorRow.errorMessage} +
+
+ )) + )} +
+
+

可執行轉化建議

    @@ -536,16 +1423,36 @@ export default async function TrafficDashboard({ ) : ( summary.recentExternalEvents.map((event) => { const ts = toLocalTime(event.createdAt); + const signal = buildEventSignal({ + actorType: event.actorType, + actorId: event.actorId, + action: event.action, + createdAt: event.createdAt, + metadata: event.metadata, + surface: String(event.surface || "-"), + actorSource: (event as { actorSource?: string }).actorSource, + reason: event.reason, + }); return ( -
    -
    {event.action}
    -
    - 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)}
    +                    
    +
    {event.action}
    +
    + actor={signal.actor} ({signal.actorSource}) | entity={event.entityType}/{event.entityId} | surface={signal.surface} | response={signal.responseStatus ?? "-"} | {ts} +
    + {signal.actorHeaders ? ( +
    actor_headers={signal.actorHeaders}
    + ) : null} +
    + source_ip={signal.sourceIp} | user-agent={signal.userAgent} | request_id={signal.requestId} | response={signal.responseStatus ?? "-"} +
    + {signal.payloadHint ? ( +
    {signal.payloadHint}
    + ) : null} +
    {signal.summary}
    + {event.reason ?
    {event.reason}
    : null} + {event.metadata ? ( +
    +                          {JSON.stringify(event.metadata, null, 2)}
                           
    ) : null}
    @@ -554,6 +1461,67 @@ export default async function TrafficDashboard({ )}
+ +
+

外部 AI Actor 行為軌跡(可操作)

+
+ {actorEventRows.length === 0 ? ( +

目前區間內無外部 AI 行為軌跡可分析。

+ ) : ( + actorEventRows.map((item) => ( +
+
+ Actor: {item.actor} + {item.eventsCount} 次事件 +
+
+ 最新行為:{item.latestAction}|最新任務:{item.latestTask} +
+
+ {item.events.slice(0, 12).map((signal) => ( +
+ {signal.full.action} + — {signal.createdAtText} + + surface={signal.surface} | source_ip={signal.sourceIp} | request_id={signal.requestId} + + {signal.actorHeaders ? ( + actor_headers={signal.actorHeaders} + ) : null} + {signal.summary} + {signal.taskId ? task_id={signal.taskId} : null} + {signal.payloadHint ? {signal.payloadHint} : null} +
+ ))} +
+
+ )) + )} +
+
+ +
+

外部任務追蹤(Task 流程)

+
+ {summary.externalTaskFlow.length === 0 ? ( +

目前區間內無任務級別事件可彙總。

+ ) : ( + summary.externalTaskFlow.map((item) => ( +
+
Task {item.taskId}
+
Open {item.opens}
+
Claim {item.claims}
+
Submit {item.submits}
+
Pass {item.passes}
+
Fail {item.fails}
+
+ )) + )} +
+
); diff --git a/apps/web/src/lib/traffic-alert.ts b/apps/web/src/lib/traffic-alert.ts index afbef5e..4721054 100644 --- a/apps/web/src/lib/traffic-alert.ts +++ b/apps/web/src/lib/traffic-alert.ts @@ -10,12 +10,22 @@ export type TrafficAlertEvent = { actorId: string; taskId?: string; message: string; + sourceIp?: string; + userAgent?: string; metadata?: Record; }; const TRAFFIC_WEBHOOK_URL = process.env.VIBEWORK_TRAFFIC_WEBHOOK_URL?.trim(); -const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN?.trim(); -const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID?.trim(); +const TELEGRAM_BOT_TOKEN = ( + process.env.TELEGRAM_BOT_TOKEN || + process.env.VIBEWORKAIAGENTBOT_TOKEN || + process.env.VIBEWORK_AI_BOT_TOKEN +)?.trim(); +const TELEGRAM_CHAT_ID = ( + process.env.TELEGRAM_CHAT_ID || + process.env.VIBEWORKAIAGENTBOT_CHAT_ID || + process.env.VIBEWORK_AI_BOT_CHAT_ID +)?.trim(); const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL?.trim(); function escapeMarkdown(value: unknown) { @@ -33,7 +43,11 @@ function buildTelegramMessage(event: TrafficAlertEvent) { `\n- 行為: \`${event.action}\`` + `\n- 通道: \`${event.surface}\`` + `\n- Actor: \`${event.actorType}/${event.actorId}\`` + + `\n- Source IP: \`${event.sourceIp || "n/a"}\`` + + `\n- User-Agent: \`${event.userAgent || "n/a"}\`` + + `\n- 回應: \`${typeof event.metadata?.response_status === "number" ? event.metadata.response_status : "n/a"}\`` + `\n- 任務: \`${event.taskId || "n/a"}\`` + + `\n- request_id: \`${typeof event.metadata?.request_id === "string" ? event.metadata.request_id : "n/a"}\`` + `\n- 訊息: ${escapeMarkdown(event.message)}` ); } @@ -124,10 +138,18 @@ async function writeTrafficAuditEvent(event: TrafficAlertEvent) { export async function sendTrafficAlert(event: TrafficAlertEvent): Promise { void writeTrafficAuditEvent(event); + const eventSourceIp = + event.sourceIp ?? + (typeof event.metadata?.source_ip === "string" ? event.metadata.source_ip : undefined); + const eventUserAgent = + event.userAgent ?? + (typeof event.metadata?.user_agent === "string" ? event.metadata.user_agent : undefined); const payload = { platform: "agent-bounty-protocol", created_at: new Date().toISOString(), + source_ip: eventSourceIp, + user_agent: eventUserAgent, ...event, }; diff --git a/apps/web/src/lib/traffic-conversion-monitor.ts b/apps/web/src/lib/traffic-conversion-monitor.ts index 6ad783a..0ac1c9a 100644 --- a/apps/web/src/lib/traffic-conversion-monitor.ts +++ b/apps/web/src/lib/traffic-conversion-monitor.ts @@ -8,6 +8,11 @@ type FunnelSummary = { submitEvents: number; judgePassEvents: number; judgeFailEvents: number; + externalOpenedActors: number; + externalClaimingActors: number; + externalSubmittingActors: number; + externalOnlyOpenActors: number; + topOpenOnlyActors: Array<{ actorId: string; opens: number }>; payoutCaptured: number; payoutReleased: number; periodMinutes: number; @@ -35,6 +40,31 @@ function normalizedJudgeResult(value: unknown) { return value.trim().toLowerCase(); } +function isInternalActorId(actorId: string | null | undefined) { + if (!actorId) return true; + const actorIdValue = actorId.toLowerCase(); + if (actorIdValue === "unknown" || actorIdValue === "mcp-anonymous") return true; + + const ipMatch = actorIdValue.match(/^open-tasks:([a-z0-9.:_-]+)$/); + if (!ipMatch?.[1]) return false; + + const actorIp = ipMatch[1]; + if ( + actorIp.startsWith("127.") || + actorIp.startsWith("10.") || + actorIp.startsWith("192.168.") + ) { + return true; + } + + if (actorIp.startsWith("172.")) { + const secondOctet = Number(actorIp.split(".")[1]); + return secondOctet >= 16 && secondOctet <= 31; + } + + return false; +} + function isMissingLedgerTableError(error: unknown) { return ( typeof error === "object" && @@ -47,24 +77,36 @@ function isMissingLedgerTableError(error: unknown) { async function fetchFunnelSummary(minutes: number): Promise { const since = new Date(Date.now() - minutes * 60 * 1000); - const summaryRows = await prisma.auditEvent.groupBy({ - by: ["action"], - where: { - createdAt: { gte: since }, - action: { - startsWith: "EXTERNAL_", + const [summaryRows, actorRows, judgeRows] = await Promise.all([ + prisma.auditEvent.groupBy({ + by: ["action"], + where: { + createdAt: { gte: since }, + action: { + startsWith: "EXTERNAL_", + }, }, - }, - _count: { _all: true }, - }); - - const judgeRows = await prisma.auditEvent.findMany({ - where: { - createdAt: { gte: since }, - action: "JUDGE_COMPLETE", - }, - select: { metadata: true }, - }); + _count: { _all: true }, + }), + prisma.auditEvent.groupBy({ + by: ["actorId", "action"], + where: { + createdAt: { gte: since }, + actorType: "AGENT", + action: { + startsWith: "EXTERNAL_", + }, + }, + _count: { _all: true }, + }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + action: "JUDGE_COMPLETE", + }, + select: { metadata: true }, + }), + ]); let payoutCaptured = 0; let payoutReleased = 0; @@ -111,12 +153,57 @@ async function fetchFunnelSummary(minutes: number): Promise { return normalizedJudgeResult(metadata?.overall_result) === "fail"; }).length; + const actorMap = new Map< + string, + { actorId: string; opens: number; claims: number; submits: number } + >(); + actorRows.forEach((row) => { + const actorId = row.actorId || "agent:unknown"; + if (isInternalActorId(actorId)) { + return; + } + + const flow = actorMap.get(actorId) || { + actorId, + opens: 0, + claims: 0, + submits: 0, + }; + + if (row.action === "EXTERNAL_LIST_OPEN_TASKS" || row.action === "EXTERNAL_LIST_OPEN_TASKS_MCP") { + flow.opens += row._count._all; + } else if (row.action === "EXTERNAL_CLAIM_TASK_SUCCESS") { + flow.claims += row._count._all; + } else if (row.action === "EXTERNAL_SUBMIT_SOLUTION_SUCCESS") { + flow.submits += row._count._all; + } + + actorMap.set(actorId, flow); + }); + + const externalOpenedActors = actorMap.size; + const externalClaimingActors = Array.from(actorMap.values()).filter((item) => item.claims > 0).length; + const externalSubmittingActors = Array.from(actorMap.values()).filter((item) => item.submits > 0).length; + const openOnlyActors = Array.from(actorMap.values()) + .filter((item) => item.opens > 0 && item.claims === 0) + .sort((left, right) => right.opens - left.opens); + const externalOnlyOpenActors = openOnlyActors.length; + const topOpenOnlyActors = openOnlyActors.slice(0, 3).map((item) => ({ + actorId: item.actorId, + opens: item.opens, + })); + return { discoveryEvents, claimEvents, submitEvents, judgePassEvents, judgeFailEvents, + externalOpenedActors, + externalClaimingActors, + externalSubmittingActors, + externalOnlyOpenActors, + topOpenOnlyActors, payoutCaptured, payoutReleased, periodMinutes: minutes, @@ -124,7 +211,19 @@ async function fetchFunnelSummary(minutes: number): Promise { } function buildAlertMessage(rule: string, summary: FunnelSummary) { - const { periodMinutes, discoveryEvents, claimEvents, submitEvents, judgePassEvents, payoutCaptured } = summary; + const { + periodMinutes, + discoveryEvents, + claimEvents, + submitEvents, + judgePassEvents, + payoutCaptured, + externalOpenedActors, + externalClaimingActors, + externalSubmittingActors, + externalOnlyOpenActors, + topOpenOnlyActors, + } = summary; switch (rule) { case "EXTERNAL_FUNNEL_CLAIM_STALL": @@ -135,6 +234,12 @@ function buildAlertMessage(rule: string, summary: FunnelSummary) { return `外部已提交 ${submitEvents} 次但尚無 PASS(JUDGE_RESULT PASS = ${judgePassEvents})。請先檢查 task acceptance_criteria 與測試欄位是否可自動驗證。`; case "EXTERNAL_FUNNEL_PAYOUT_STALL": return `有 PASS 但未收款(payout CAPTURE 成功 = ${payoutCaptured})。請確認支付授權、Stripe key 與 capture 任務是否正常。`; + case "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY": + return `最近 ${periodMinutes} 分鐘觀測到 ${discoveryEvents} 次外部曝光,` + + `外部 Actor= ${externalOpenedActors} 位,` + + `已接案=${externalClaimingActors}、已提交=${externalSubmittingActors},` + + `仍停在曝光僅曝光階段 ${externalOnlyOpenActors} 位。` + + `${topOpenOnlyActors.length ? `先看未進一步的熱門 Actor:${topOpenOnlyActors.map((actor) => `${actor.actorId}(${actor.opens})`).join(", ")}。` : ""}`; default: return "外部 AI 流量轉化斷崖異常。"; } @@ -150,6 +255,13 @@ function alertRules(summary: FunnelSummary): Array<{ action: string; message: st }); } + if (summary.externalOnlyOpenActors >= 3 && summary.discoveryEvents >= 10) { + alerts.push({ + action: "EXTERNAL_FUNNEL_OPEN_COLD_STANDBY", + message: buildAlertMessage("EXTERNAL_FUNNEL_OPEN_COLD_STANDBY", summary), + }); + } + if (summary.claimEvents > 0 && summary.submitEvents === 0) { alerts.push({ action: "EXTERNAL_FUNNEL_SUBMIT_STALL", @@ -210,9 +322,14 @@ export async function evaluateExternalFunnelHealth(input: MonitorInput): Promise submit_events: summary.submitEvents, judge_pass_events: summary.judgePassEvents, judge_fail_events: summary.judgeFailEvents, + external_opened_actors: summary.externalOpenedActors, + external_claiming_actors: summary.externalClaimingActors, + external_submitting_actors: summary.externalSubmittingActors, + external_only_open_actors: summary.externalOnlyOpenActors, payout_captured: summary.payoutCaptured, payout_released: summary.payoutReleased, period_minutes: summary.periodMinutes, + top_open_only_actors: summary.topOpenOnlyActors, }, }); })