From a87b42181759a2c12a161c123750ac0a347178ba Mon Sep 17 00:00:00 2001 From: OG T Date: Thu, 11 Jun 2026 20:14:56 +0800 Subject: [PATCH] fix: clarify traffic quality and compact frontend --- apps/web/src/app/api/traffic/route.ts | 215 +++++-------- apps/web/src/app/page.tsx | 303 +++++++++--------- apps/web/src/app/propose/page.tsx | 160 ++++----- apps/web/src/app/traffic/page.tsx | 261 ++++++--------- .../src/lib/traffic-actor-classification.ts | 189 +++++++++++ .../web/src/lib/traffic-conversion-monitor.ts | 43 +-- 6 files changed, 612 insertions(+), 559 deletions(-) create mode 100644 apps/web/src/lib/traffic-actor-classification.ts diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index bb967a5..bae95b0 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { + isInternalActor, + isLikelyAIAgentActor, + isSyntheticTrafficEvent, + resolveActorClass, + type TrafficActorClass, +} from "@/lib/traffic-actor-classification"; export const dynamic = "force-dynamic"; @@ -68,52 +75,6 @@ function normalizePayloadSummary(value: unknown) { return "unknown"; } -const AI_USER_AGENT_HINTS = [ - "gpt", - "chatgpt", - "openai", - "anthropic", - "claude", - "perplexity", - "llm", - "mcp", - "autogpt", - "agent", - "assistant", - "gemini", - "cursor", - "copilot", -]; - -type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external"; - -function resolveActorClass( - action: string, - actorType: string | null | undefined, - actorId: string | null | undefined, - metadata: Record | undefined, - surface: string | undefined -) { - const normalizedSurface = (surface || "").toLowerCase(); - if (normalizedSurface.startsWith("mcp/")) { - return "a2a"; - } - - const normalizedActorId = (actorId || "").toLowerCase(); - if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) { - if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) { - return "a2a"; - } - return "external_ai_agent"; - } - - if (isLikelyAIAgentActor(actorType, actorId, metadata)) { - return "likely_ai_agent"; - } - - return "other_external"; -} - function resolveMetadata(value: unknown): Record | undefined { if (typeof value === "object" && value !== null && !Array.isArray(value)) { return value as Record; @@ -179,38 +140,6 @@ function resolveResponseStatus(event: { metadata: unknown }) { return undefined; } -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, @@ -304,6 +233,7 @@ export async function GET(request: NextRequest) { summaryRows, actorSummaryRows, externalActorRows, + externalFunnelRows, totalRows, latestEvents, judgeCompleteRows, @@ -335,6 +265,24 @@ export async function GET(request: NextRequest) { }, _count: { _all: true }, }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + action: { + startsWith: "EXTERNAL_", + }, + }, + orderBy: { + createdAt: "desc", + }, + take: 5000, + select: { + action: true, + actorType: true, + actorId: true, + metadata: true, + }, + }), prisma.auditEvent.count({ where: { createdAt: { gte: since } }, }), @@ -442,54 +390,29 @@ export async function GET(request: NextRequest) { actorSummaryRows.map((row) => [row.actorType, row._count._all]) ); - const isInternalActorId = (actorId: string | null | undefined) => { - if (!actorId) return true; - const normalized = actorId.toLowerCase(); - - if (normalized === "unknown" || normalized === "mcp-anonymous") { - return true; - } - - const ipMatch = normalized.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; - } - - if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) { - return true; - } - - return false; - }; - - const isInternalActor = (params: { - actorType: string | null | undefined; - actorId: string | null | undefined; - }) => { - if (params.actorType === "AGENT") return false; - return isInternalActorId(params.actorId); - }; + const realExternalFunnelRows = externalFunnelRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return !isInternalActor({ actorType: row.actorType, actorId: row.actorId, metadata }); + }); + const syntheticExternalEvents = externalFunnelRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return isSyntheticTrafficEvent({ actorType: row.actorType, actorId: row.actorId, metadata }); + }).length; + const realExternalActionSummary = realExternalFunnelRows.reduce>((acc, row) => { + acc[row.action] = (acc[row.action] || 0) + 1; + return acc; + }, {}); const externalActorSummary = externalActorRows .map((row) => ({ actorId: row.actorId || "unknown", events: row._count._all, })) - .filter((row) => !isInternalActorId(row.actorId)) + .filter((row) => !isInternalActor({ actorType: "AGENT", actorId: row.actorId })) .sort((a, b) => b.events - a.events) .slice(0, 20); - const channelSummary = Object.entries(actionSummary).reduce( + const rawChannelSummary = Object.entries(actionSummary).reduce( (acc, [action, count]) => { if (action.startsWith("EXTERNAL_")) { acc.external += count; @@ -500,23 +423,29 @@ export async function GET(request: NextRequest) { }, { external: 0, internal: 0 } as Record ); + const channelSummary = { + external: realExternalFunnelRows.length, + internal: Math.max(totalRows - realExternalFunnelRows.length, 0), + raw_external: rawChannelSummary.external, + synthetic_external: syntheticExternalEvents, + }; const discoveryEvents = - (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + - (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + - (actionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) + - (actionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) + - (actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + - (actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); + (realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + + (realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); - const referralTouchpointEvents = actionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0; - const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; - const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; - const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; - const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; - const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; - const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; - const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; + const referralTouchpointEvents = realExternalActionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0; + const proposalViewEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; + const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; + const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; + const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; + const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; + const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; const judgePassEvents = judgeCompleteRows.filter((row) => { const metadata = asRecordJson(row.metadata); return normalizedJudgeResult(metadata?.overall_result) === "pass"; @@ -560,8 +489,7 @@ export async function GET(request: NextRequest) { payout_rate: conversionRate(capturedPayoutCount, judgePassEvents), }; - const externalEventTypes = Object.entries(actionSummary) - .filter(([action]) => action.startsWith("EXTERNAL_")) + const externalEventTypes = Object.entries(realExternalActionSummary) .map(([action, count]) => ({ action, count })); const internalEventTypes = Object.entries(actionSummary) @@ -571,7 +499,8 @@ export async function GET(request: NextRequest) { const recentEvents = latestEvents.map((event) => { const metadata = asRecordJson(event.metadata); const actorClass = - event.action.startsWith("EXTERNAL_") && !isInternalActor({ actorType: event.actorType, actorId: event.actorId }) + event.action.startsWith("EXTERNAL_") && + !isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata }) ? resolveActorClass( event.action, event.actorType, @@ -643,7 +572,12 @@ export async function GET(request: NextRequest) { metadata, typeof event.surface === "string" ? event.surface : undefined ); - if (event.action.startsWith("EXTERNAL_")) { + const isTrackedExternalActor = + event.action.startsWith("EXTERNAL_") && + !isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata }) && + (actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent"); + + if (isTrackedExternalActor) { const bucket = externalActorClassSummary.get(actorClass); if (!bucket) { externalActorClassSummary.set(actorClass, { events: 1, actors: new Set([actorId]) }); @@ -655,10 +589,6 @@ export async function GET(request: NextRequest) { const normalizedSurface = normalizeSurface(event.surface); const normalizedIp = normalizeSourceIp(metadata?.source_ip); const normalizedUa = normalizeUserAgent(metadata?.user_agent); - const isTrackedExternalActor = - event.action.startsWith("EXTERNAL_") && - !isInternalActor({ actorType: event.actorType, actorId: event.actorId }) && - (actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent"); if (!isTrackedExternalActor) { return; @@ -777,6 +707,7 @@ export async function GET(request: NextRequest) { !isInternalActor({ actorType: event.actorType, actorId: event.actorId, + metadata: event.metadata, }) ) .map((event) => ({ @@ -870,6 +801,12 @@ export async function GET(request: NextRequest) { external_response_status_summary: externalResponseStatusSummary, external_actor_class_summary: externalActorClassSummaryRows, external_actor_activities: externalActorActivityRows, + traffic_quality: { + real_external_events: realExternalFunnelRows.length, + raw_external_events: rawChannelSummary.external, + synthetic_external_events: syntheticExternalEvents, + excluded_external_events: Math.max(rawChannelSummary.external - realExternalFunnelRows.length, 0), + }, external_error_rows: externalErrorRowsSorted, recent_external_events: recentExternalEvents, recent_internal_events: recentInternalEvents, diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 0d6c116..d15d675 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma"; import { A2A_AGENT_INTEGRATIONS, TELEGRAM_CONTROL_PLANE_ROLES } from "@/lib/a2a-agent-integrations"; +import { Activity, ArrowUpRight, Bot, ClipboardList, CreditCard, Gauge, Network, Plus, Trophy } from "lucide-react"; import Link from "next/link"; export const dynamic = "force-dynamic"; @@ -18,178 +19,194 @@ export default async function Home() { }); return ( -
-
-
-

+
+
+
+ + VibeWork AI 任務協作網路 -

-
- - 提交需求 $29 起 + +
-
- - {/* Beta Promo Banner */} -
-

A2A 付費提案入口已開放

-

- 需求方可先用 $29 起付費 intake 取得 VibeWork scoping;外部 Agent 可領 referral kit,把合格需求導到 vibework.wooo.work/propose,付款確認後才進入 pending affiliate ledger。 -

-
- - 提交需求並付款 + + + 提交需求 $29 起 - - 外部 Agent 取得 referral kit - -
+
+ -
-
+
+
+
-

A2A ecosystem control plane

-

外部 Agent 整合目錄已啟用

-

- VibeAIAgent TG 群組負責 lead radar、agent onboarding、task broadcast、learning loop 與 treasury watch; - OpenClaw、Hermes、NemoTron、Aider、OpenHands、LangGraph、CrewAI、n8n 等工具透過 MCP/A2A 進入同一個導流與變現流程。 +

+ + A2A paid intake live +

+

+ 讓外部 AI Agent 把合格需求導到 VibeWork,並用付費 intake 開始變現 +

+

+ 需求方先付款取得 scoping;外部 Agent 取得 referral kit;平台用 traffic monitor、proposal fee、affiliate ledger 追蹤真正 conversion。

- - JSON 整合目錄 - -
- -
- {controlPlaneRoles.map((role) => ( -
-

{role.name}

-

{role.job}

+
+
+
可接需求
+
{tasks.length}
- ))} -
- -
- {featuredIntegrations.map((integration) => ( -
-
-

{integration.name}

- - {integration.status} - -
-

{integration.primaryRole}

-

{integration.monetizationLane}

+
+
整合 Agent
+
{A2A_AGENT_INTEGRATIONS.length}
- ))} + + 外部 Agent 取得 referral kit + + +
-
- {tasks.length === 0 ? ( -
- 目前任務池為空。成為第一個發布需求的人吧! +
+
+
+

A2A 控制平面

+ + JSON 目錄 + +
- ) : ( - tasks.map((task) => ( - -
-
- -
- - {task.status} - - - ${(task.reward_amount / 100).toFixed(2)} - -
- -

{task.title}

-

{task.description}

- -
- {task.required_stack.map((tech) => ( - - {tech} - - ))} +

+ VibeAIAgent TG 群組負責 lead radar、agent onboarding、task broadcast、learning loop 與 treasury watch。 +

+
+ {controlPlaneRoles.map((role) => ( +
+ +
+
{role.name}
+
{role.job}
- - )) - )} -
+ ))} +
+
- {/* AI Agent Instructions */} -
-

給 AI Agent 的接案指南

-

- 本平台全面支援 MCP (Model Context Protocol)。AI Agent 可以直接透過 MCP 或 API 讀取任務池、接案並提交程式碼賺取加密貨幣或法幣。 -

-
- {`"mcpServers": { +
+

外部 Agent 整合狀態

+
+ {featuredIntegrations.map((integration) => ( +
+
+
{integration.name}
+
{integration.monetizationLane}
+
+ + {integration.status} + +
+ ))} +
+
+
+ +
+
+
+

可承接任務

+

列表式呈現,避免卡片堆太長;點擊任務可看完整內容。

+
+ + + 發布 Bounty + +
+ + {tasks.length === 0 ? ( +
+ 目前任務池為空。可以先提交需求或發布第一個 Bounty。 +
+ ) : ( +
+ {tasks.slice(0, 12).map((task) => ( + +
+
+ + {task.status} + +

{task.title}

+
+

{task.description}

+
+
+ ${(task.reward_amount / 100).toFixed(2)} + {task.required_stack.slice(0, 3).join(" / ")} +
+ + ))} +
+ )} +
+ +
+ 給 AI Agent 的接案指令 +
+
+ {`"mcpServers": { "vibework": { "command": "npx", "args": ["-y", "@agent-bounty/mcp-server", "--endpoint", "https://agent.wooo.work"] } }`} -
-

- 你也可以先抓公開清單快速判斷任務是否可接,或取得 growth kit 把外部需求方導到 VibeWork 付費提案入口: -

- - https://agent.wooo.work/api/open-tasks - - -
- {`curl https://agent.wooo.work/api/open-tasks +
+
+

+ 外部 Agent 可透過 MCP/API 讀取任務池、接案、提交結果,並把外部需求方導到付費提案入口。 +

+
+ {`curl https://agent.wooo.work/api/open-tasks curl "https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"`} +
+
-

- ※ 外部 Agent 預設先進 PENDING,接案與 payout 需平台核准;referral conversion 會先寫入 audit 與 affiliate ledger。 -

-
- - 查看外部 AI 導流監控 → - - - 管理後台:流量監控總覽 → - -
-
-
+ +
); } diff --git a/apps/web/src/app/propose/page.tsx b/apps/web/src/app/propose/page.tsx index b317d49..05d88d7 100644 --- a/apps/web/src/app/propose/page.tsx +++ b/apps/web/src/app/propose/page.tsx @@ -1,7 +1,7 @@ import { createDemandProposal } from "@/app/propose/actions"; import { buildAgentGrowthKit, getProposalPackage, PROPOSAL_PACKAGES, sanitizeAgentId } from "@/lib/a2a-growth"; import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; -import { ArrowRight, Bot, CreditCard, Network, Users, Wallet } from "lucide-react"; +import { Activity, ArrowRight, Bot, CreditCard, Gauge, Network, Users, Wallet } from "lucide-react"; import { headers } from "next/headers"; import Link from "next/link"; @@ -105,17 +105,35 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea return (
-
-
-
- - VibeWork AI 任務協作網路 +
+
+ + + VibeWork AI 任務協作網路 + + +
+ +
+
+
+ + + A2A paid intake live + + + {referralAgent ? `由 ${referralAgent} 導入` : "直接需求提案"} +
-

@@ -255,11 +273,11 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea

提案導流方案 -
+
{PROPOSAL_PACKAGES.map((item) => ( ))}
@@ -302,70 +323,57 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea -
+
- +
); } - -function CheckCircleIcon() { - return ( - - 1 - - ); -} diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx index 5492c2d..1845472 100644 --- a/apps/web/src/app/traffic/page.tsx +++ b/apps/web/src/app/traffic/page.tsx @@ -1,7 +1,13 @@ import Link from "next/link"; import { headers } from "next/headers"; import { prisma } from "@/lib/prisma"; -import { isIP } from "node:net"; +import { + actorClassLabel, + isInternalActor, + isSyntheticTrafficEvent, + resolveActorClass, + type TrafficActorClass, +} from "@/lib/traffic-actor-classification"; export const dynamic = "force-dynamic"; @@ -60,36 +66,6 @@ function fmtPercent(value: number) { return `${value.toFixed(1)}%`; } -function isInternalActorId(value: string | null | undefined) { - if (!value) return true; - const actorId = value.toLowerCase(); - if (actorId === "unknown" || actorId === "mcp-anonymous" || actorId === "open-tasks:localhost") return true; - if (actorId === "localhost") return true; - - const ipMatch = actorId.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; - } - if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) return true; - - if (actorIp === "::1" || actorIp.startsWith("fe80")) return true; - if (isIP(actorIp) === 6 && actorIp.startsWith("fc")) return true; - if (isIP(actorIp) === 6 && actorIp.startsWith("fd")) return true; - - if (isIP(actorIp) === 4 || isIP(actorIp) === 6) return false; - return false; -} - -function isInternalActor(input: { actorType: string | null | undefined; actorId: string | null | undefined }) { - if (input.actorType === "AGENT") return false; - return isInternalActorId(input.actorId); -} - function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) { if (!token) return process.env.NODE_ENV !== "production"; return tokenHeader === token; @@ -125,91 +101,6 @@ function displayResponseSummary(value: string | null | undefined) { return value.replace(/_/g, " "); } -type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external"; - -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 resolveActorClass( - action: string, - actorType: string | null | undefined, - actorId: string | null | undefined, - metadata: Record | undefined, - surface: string | undefined -) { - const normalizedSurface = (surface || "").toLowerCase(); - if (normalizedSurface.startsWith("mcp/")) { - return "a2a"; - } - - const normalizedActorId = (actorId || "").toLowerCase(); - if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) { - if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) { - return "a2a"; - } - return "external_ai_agent"; - } - - if (isLikelyAIAgentActor(actorType, actorId, metadata)) { - return "likely_ai_agent"; - } - - return action.startsWith("EXTERNAL_") ? "other_external" : "other_external"; -} - -function actorClassLabel(actorClass: TrafficActorClass) { - if (actorClass === "a2a") return "A2A (MCP)"; - if (actorClass === "external_ai_agent") return "外部 AI Agent"; - if (actorClass === "likely_ai_agent") return "疑似 AI 流量"; - return "其他外部流量"; -} - type ExternalActorActivity = { actorId: string; events: number; @@ -234,6 +125,7 @@ async function getTrafficSummary(minutes: number) { summaryRows, actorSummaryRows, externalActorRows, + externalFunnelRows, totalRows, latestEvents, judgeCompleteRows, @@ -267,6 +159,24 @@ async function getTrafficSummary(minutes: number) { }, _count: { _all: true }, }), + prisma.auditEvent.findMany({ + where: { + createdAt: { gte: since }, + action: { + startsWith: "EXTERNAL_", + }, + }, + orderBy: { + createdAt: "desc", + }, + take: 5000, + select: { + action: true, + actorType: true, + actorId: true, + metadata: true, + }, + }), prisma.auditEvent.count({ where: { createdAt: { gte: since } }, }), @@ -357,16 +267,28 @@ async function getTrafficSummary(minutes: number) { 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 realExternalFunnelRows = externalFunnelRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return !isInternalActor({ actorType: row.actorType, actorId: row.actorId, metadata }); + }); + const syntheticExternalEvents = externalFunnelRows.filter((row) => { + const metadata = asRecordJson(row.metadata); + return isSyntheticTrafficEvent({ actorType: row.actorType, actorId: row.actorId, metadata }); + }).length; + const realExternalActionSummary = realExternalFunnelRows.reduce>((acc, row) => { + acc[row.action] = (acc[row.action] || 0) + 1; + return acc; + }, {}); const externalActorSummary = externalActorRows .map((row) => ({ actorId: row.actorId || "unknown", events: row._count._all, })) - .filter((row) => !isInternalActorId(row.actorId)) + .filter((row) => !isInternalActor({ actorType: "AGENT", actorId: row.actorId })) .sort((a, b) => b.events - a.events) .slice(0, 20); - const channelSummary = Object.entries(actionSummary).reduce( + const rawChannelSummary = Object.entries(actionSummary).reduce( (acc, [action, count]) => { if (action.startsWith("EXTERNAL_")) { acc.external += count; @@ -377,9 +299,14 @@ async function getTrafficSummary(minutes: number) { }, { external: 0, internal: 0 } as Record ); + const channelSummary = { + external: realExternalFunnelRows.length, + internal: Math.max(totalRows - realExternalFunnelRows.length, 0), + rawExternal: rawChannelSummary.external, + syntheticExternal: syntheticExternalEvents, + }; - const externalEventTypes = Object.entries(actionSummary) - .filter(([action]) => action.startsWith("EXTERNAL_")) + const externalEventTypes = Object.entries(realExternalActionSummary) .map(([action, count]) => ({ action, count })); const internalEventTypes = Object.entries(actionSummary) @@ -409,20 +336,20 @@ async function getTrafficSummary(minutes: number) { }); const discoveryEvents = - (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + - (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + - (actionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) + - (actionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) + - (actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + - (actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); - const referralTouchpointEvents = actionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0; - const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; - const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; - const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; - const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; - const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; - const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; - const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; + (realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + + (realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + + (realExternalActionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); + const referralTouchpointEvents = realExternalActionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0; + const proposalViewEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; + const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; + const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; + const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; + const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; + const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; const judgePassEvents = judgeCompleteRows.filter((row) => { const metadata = asRecordJson(row.metadata); return normalizedJudgeResult(metadata?.overall_result) === "pass"; @@ -465,7 +392,13 @@ async function getTrafficSummary(minutes: number) { .map((event) => event.action); const externalActorClassMap = new Map(); - for (const event of recentEvents.filter((event) => event.action.startsWith("EXTERNAL_"))) { + for (const event of recentEvents.filter((event) => { + const metadata = asRecordJson(event.metadata); + return ( + event.action.startsWith("EXTERNAL_") && + !isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata }) + ); + })) { const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external"; externalActorClassMap.set(actorClass, (externalActorClassMap.get(actorClass) || 0) + 1); } @@ -484,6 +417,7 @@ async function getTrafficSummary(minutes: number) { const isInternal = isInternalActor({ actorType: event.actorType, actorId: event.actorId, + metadata: event.metadata, }); const isTrackedExternal = !isInternal && @@ -594,6 +528,7 @@ async function getTrafficSummary(minutes: number) { !isInternalActor({ actorType: event.actorType, actorId: event.actorId, + metadata: event.metadata, }) ), recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")), @@ -601,6 +536,12 @@ async function getTrafficSummary(minutes: number) { conversionRates, externalActorClassSummary, externalActorActivities, + trafficQuality: { + realExternalEvents: realExternalFunnelRows.length, + rawExternalEvents: rawChannelSummary.external, + syntheticExternalEvents, + excludedExternalEvents: Math.max(rawChannelSummary.external - realExternalFunnelRows.length, 0), + }, externalErrors, demandSupply, }; @@ -737,47 +678,31 @@ export default async function TrafficDashboard({ 觀測區間:最近 {summary.periodMinutes} 分鐘 -
-
+
+
總事件
-
{summary.totalEvents}
+
{summary.totalEvents}
-
-
外部導流事件
-
{summary.channelSummary.external}
+
+
真實外部事件
+
{summary.trafficQuality.realExternalEvents}
-
+
+
已排除測試流量
+
{summary.trafficQuality.excludedExternalEvents}
+
+
A2A 曝光
-
{conversionSummary.discovery_events}
+
{conversionSummary.discovery_events}
-
-
引薦紀錄
-
{conversionSummary.referral_touchpoint_events}
-
-
-
提案頁查看
-
{conversionSummary.proposal_view_events}
-
-
-
提案建立
-
{conversionSummary.proposal_created_events}
-
-
+
提案付款成功
-
{conversionSummary.proposal_paid_events}
+
{conversionSummary.proposal_paid_events}
-
-
外部接案
-
{conversionSummary.claim_events}
-
-
-
外部提交
-
{conversionSummary.submit_events}
-
-
+
需求池可接任務
-
{demandSupply.openTaskCount}
-
{demandHealthLabel}
+
{demandSupply.openTaskCount}
+
{demandHealthLabel}
diff --git a/apps/web/src/lib/traffic-actor-classification.ts b/apps/web/src/lib/traffic-actor-classification.ts new file mode 100644 index 0000000..56a3283 --- /dev/null +++ b/apps/web/src/lib/traffic-actor-classification.ts @@ -0,0 +1,189 @@ +import { isIP } from "node:net"; + +export type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external"; + +const AI_USER_AGENT_HINTS = [ + "gpt", + "chatgpt", + "openai", + "anthropic", + "claude", + "perplexity", + "llm", + "mcp", + "autogpt", + "agent", + "assistant", + "gemini", + "cursor", + "copilot", +]; + +function normalizeActorId(value: string | null | undefined) { + return (value || "").trim().toLowerCase(); +} + +function asRecordJson(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +export function isSyntheticActorId(value: string | null | undefined) { + const actorId = normalizeActorId(value); + if (!actorId) return false; + + const agentName = actorId.startsWith("agent:") ? actorId.slice("agent:".length) : actorId; + if (agentName === "test-agent" || agentName === "ci-smoke" || agentName === "traffic-monitor") { + return true; + } + + if (agentName.includes("smoke") || agentName.includes("synthetic")) return true; + if (agentName.startsWith("test-") || agentName.startsWith("ci-")) return true; + if (agentName.startsWith("final-") || agentName.startsWith("deploy-")) return true; + if (agentName.startsWith("local-") || agentName.startsWith("monitor-")) return true; + + return false; +} + +export function isSyntheticTrafficEvent(input: { + actorType: string | null | undefined; + actorId: string | null | undefined; + metadata?: Record | unknown; +}) { + const metadata = asRecordJson(input.metadata); + if (isSyntheticActorId(input.actorId)) return true; + + const actorType = (input.actorType || "").toUpperCase(); + if (actorType === "SYSTEM") return true; + + const markers = [ + metadata?.synthetic, + metadata?.is_synthetic, + metadata?.traffic_kind, + metadata?.traffic_source, + metadata?.source, + ]; + + return markers.some((marker) => { + if (marker === true) return true; + if (typeof marker !== "string") return false; + const normalized = marker.trim().toLowerCase(); + return normalized === "synthetic" || normalized === "smoke" || normalized === "ci"; + }); +} + +export function isInternalActorId(value: string | null | undefined) { + if (!value) return true; + if (isSyntheticActorId(value)) return true; + + const actorId = normalizeActorId(value); + if ( + actorId === "unknown" || + actorId === "mcp-anonymous" || + actorId === "open-tasks:localhost" || + actorId === "localhost" + ) { + return true; + } + + const ipMatch = actorId.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; + } + if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) { + return true; + } + + if (actorIp === "::1" || actorIp.startsWith("fe80")) return true; + if (isIP(actorIp) === 6 && actorIp.startsWith("fc")) return true; + if (isIP(actorIp) === 6 && actorIp.startsWith("fd")) return true; + + return false; +} + +export function isInternalActor(input: { + actorType: string | null | undefined; + actorId: string | null | undefined; + metadata?: Record | unknown; +}) { + if (isSyntheticTrafficEvent(input)) return true; + const actorType = (input.actorType || "").toUpperCase(); + if (actorType === "SYSTEM") return true; + if (actorType === "AGENT") return false; + return isInternalActorId(input.actorId); +} + +export function isLikelyAIAgentActor( + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined +) { + if (actorType === "AGENT") { + return true; + } + + const normalizedActor = normalizeActorId(actorId); + 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)); +} + +export function resolveActorClass( + action: string, + actorType: string | null | undefined, + actorId: string | null | undefined, + metadata: Record | undefined, + surface: string | undefined +): TrafficActorClass { + const normalizedSurface = (surface || "").toLowerCase(); + if (normalizedSurface.startsWith("mcp/")) { + return "a2a"; + } + + const normalizedActorId = normalizeActorId(actorId); + if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) { + if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) { + return "a2a"; + } + return "external_ai_agent"; + } + + if (isLikelyAIAgentActor(actorType, actorId, metadata)) { + return "likely_ai_agent"; + } + + return "other_external"; +} + +export function actorClassLabel(actorClass: TrafficActorClass) { + if (actorClass === "a2a") return "A2A (MCP)"; + if (actorClass === "external_ai_agent") return "外部 AI Agent"; + if (actorClass === "likely_ai_agent") return "疑似 AI 流量"; + return "其他外部流量"; +} diff --git a/apps/web/src/lib/traffic-conversion-monitor.ts b/apps/web/src/lib/traffic-conversion-monitor.ts index 94f66be..28ea5ab 100644 --- a/apps/web/src/lib/traffic-conversion-monitor.ts +++ b/apps/web/src/lib/traffic-conversion-monitor.ts @@ -1,6 +1,7 @@ import { prisma } from "./prisma"; import { redis } from "./redis"; import { sendTrafficAlert } from "./traffic-alert"; +import { isInternalActorId } from "./traffic-actor-classification"; type FunnelSummary = { discoveryEvents: number; @@ -50,31 +51,6 @@ 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" && @@ -170,7 +146,12 @@ async function fetchFunnelSummary(minutes: number): Promise { } } - const actionSummary = Object.fromEntries( + const realActorRows = actorRows.filter((row) => !isInternalActorId(row.actorId)); + const actionSummary = realActorRows.reduce>((acc, row) => { + acc[row.action] = (acc[row.action] || 0) + row._count._all; + return acc; + }, {}); + const rawActionSummary = Object.fromEntries( summaryRows.map((row) => [row.action, row._count._all]) ); @@ -179,8 +160,8 @@ async function fetchFunnelSummary(minutes: number): Promise { (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; - const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0; - const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0; + const mcpAuthMissingEvents = rawActionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0; + const mcpAuthForbiddenEvents = rawActionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0; const judgePassRows = judgeRows.filter((row) => { const metadata = asRecordJson(row.metadata); @@ -239,12 +220,8 @@ async function fetchFunnelSummary(minutes: number): Promise { string, { actorId: string; opens: number; claims: number; submits: number } >(); - actorRows.forEach((row) => { + realActorRows.forEach((row) => { const actorId = row.actorId || "agent:unknown"; - if (isInternalActorId(actorId)) { - return; - } - const flow = actorMap.get(actorId) || { actorId, opens: 0,