feat: record A2A referral touchpoints
All checks were successful
CI and Production Smoke / smoke (push) Successful in 6s

This commit is contained in:
OG T
2026-06-11 19:08:16 +08:00
parent deec29961a
commit 9d471b3c18
16 changed files with 468 additions and 11 deletions

View File

@@ -13,6 +13,7 @@ export async function GET() {
onboarding: "https://agent.wooo.work/api/a2a/onboarding?agent_id={agent_id}&register=true",
demand_campaign_kit: "https://agent.wooo.work/api/a2a/campaigns/demand?agent_id={agent_id}&register=true",
growth_kit: "https://agent.wooo.work/api/a2a/growth/kit?agent_id={agent_id}&register=true",
referral_touchpoint: "https://agent.wooo.work/api/a2a/referrals/touch?agent_id={agent_id}&touchpoint=proposal_link_sent",
referral_status: "https://agent.wooo.work/api/a2a/referrals/status?agent_id={agent_id}",
integration_catalog: "https://agent.wooo.work/api/a2a/integrations?agent_id={agent_id}",
paid_proposal_prefill_template:
@@ -30,6 +31,7 @@ export async function GET() {
"Task_Delegation",
"Demand_Referral",
"Prefilled_Demand_Referral",
"Referral_Touchpoint_Tracking",
"Demand_Campaign_Kit",
"External_Agent_Onboarding",
"Dispute_Arbitration",

View File

@@ -59,6 +59,7 @@ export async function GET(request: NextRequest) {
channel: channel || null,
registered_pending_agent: shouldRegister,
landing_url: kit.landing_url,
touchpoint_url: kit.touchpoint_url,
prefill_url_template: kit.prefill_url_template,
},
},
@@ -77,6 +78,7 @@ export async function GET(request: NextRequest) {
channel: channel || null,
registered_pending_agent: shouldRegister,
landing_url: kit.landing_url,
touchpoint_url: kit.touchpoint_url,
prefill_url_template: kit.prefill_url_template,
response_status: 200,
response_summary: "a2a_demand_campaign_kit_issued",

View File

@@ -36,6 +36,7 @@ function buildEndpointTemplates(agentId: string | null) {
demand_campaign_kit: `${AGENT_GATEWAY_URL}/api/a2a/campaigns/demand?agent_id=${encodedAgentId}&register=true`,
integration_catalog: `${AGENT_GATEWAY_URL}/api/a2a/integrations?agent_id=${encodedAgentId}`,
growth_kit: `${AGENT_GATEWAY_URL}/api/a2a/growth/kit?agent_id=${encodedAgentId}&register=true`,
referral_touchpoint: `${AGENT_GATEWAY_URL}/api/a2a/referrals/touch?agent_id=${encodedAgentId}&touchpoint=proposal_link_sent`,
referral_status: `${AGENT_GATEWAY_URL}/api/a2a/referrals/status?agent_id=${encodedAgentId}`,
open_tasks: `${AGENT_GATEWAY_URL}/api/open-tasks`,
agent_json: `${AGENT_GATEWAY_URL}/agent.json`,
@@ -167,18 +168,24 @@ export async function GET(request: NextRequest) {
},
{
step: 3,
id: "record-touchpoint",
action: "When you post, DM, qualify a lead, send a proposal link, or follow up, record a non-sensitive touchpoint.",
endpoint: endpoints.referral_touchpoint,
},
{
step: 4,
id: "refer-demand",
action: "Send human demand proposers to the attributed paid proposal URL. Do not collect payment or credentials yourself.",
endpoint: growthKit?.referral_url || endpoints.paid_proposal,
},
{
step: 4,
step: 5,
id: "track",
action: "Check sanitized referral funnel and pending affiliate ledger status.",
endpoint: endpoints.referral_status,
},
{
step: 5,
step: 6,
id: "execute",
action: "Use open tasks and MCP/A2A routes only after agent review, wallet binding, and task authorization.",
endpoint: endpoints.open_tasks,

View File

@@ -9,6 +9,7 @@ const TRACKED_REFERRAL_ACTIONS = [
"EXTERNAL_A2A_ONBOARDING_VIEW",
"EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED",
"EXTERNAL_A2A_GROWTH_KIT_ISSUED",
"EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED",
"EXTERNAL_DEMAND_PROPOSAL_VIEW",
"EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED",
"EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED",
@@ -68,6 +69,7 @@ export async function GET(request: NextRequest) {
referredTasks,
affiliateRows,
actionCountValues,
touchpointRows,
latestTrafficEvent,
] = await Promise.all([
prisma.agentProfile.findUnique({
@@ -139,6 +141,18 @@ export async function GET(request: NextRequest) {
})
)
),
prisma.auditEvent.findMany({
where: {
actorId,
action: "EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED",
},
orderBy: { createdAt: "desc" },
take: 50,
select: {
createdAt: true,
metadata: true,
},
}),
prisma.auditEvent.findMany({
where: {
actorId,
@@ -157,6 +171,15 @@ export async function GET(request: NextRequest) {
const actionCounts = Object.fromEntries(
TRACKED_REFERRAL_ACTIONS.map((action, index) => [action, actionCountValues[index] || 0])
);
const touchpointBreakdown = touchpointRows.reduce<Record<string, number>>((acc, row) => {
const metadata =
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
? (row.metadata as Record<string, unknown>)
: {};
const type = typeof metadata.touchpoint_type === "string" ? metadata.touchpoint_type : "unknown";
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
const affiliateBreakdown: CurrencyBreakdown = {};
for (const row of affiliateRows) {
@@ -220,6 +243,7 @@ export async function GET(request: NextRequest) {
onboarding_events: actionCounts.EXTERNAL_A2A_ONBOARDING_VIEW || 0,
demand_campaign_kit_events: actionCounts.EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED || 0,
growth_kit_events: actionCounts.EXTERNAL_A2A_GROWTH_KIT_ISSUED || 0,
referral_touchpoint_events: actionCounts.EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED || 0,
proposal_view_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_VIEW || 0,
proposal_created_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED || 0,
proposal_checkout_events: actionCounts.EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED || 0,
@@ -235,6 +259,12 @@ export async function GET(request: NextRequest) {
spam_score: scoutReputation?.spam_score || 0,
chargeback_count: scoutReputation?.chargeback_count || 0,
},
touchpoint_summary: {
total: actionCounts.EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED || 0,
recent_count: touchpointRows.length,
by_type: touchpointBreakdown,
latest_at: touchpointRows[0]?.createdAt ? touchpointRows[0].createdAt.toISOString() : null,
},
affiliate_breakdown: affiliateBreakdown,
recent_conversions: recentConversions,
});

View File

@@ -0,0 +1,258 @@
import { NextRequest, NextResponse } from "next/server";
import { AGENT_GATEWAY_URL, buildDemandProposalUrl, sanitizeAgentId } from "@/lib/a2a-growth";
import { logA2aTrafficEvent } from "@/lib/a2a-traffic";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const TOUCHPOINT_TYPES = [
"campaign_posted",
"dm_sent",
"lead_qualified",
"proposal_link_sent",
"prefill_link_sent",
"follow_up_sent",
"lead_rejected",
] as const;
const TOUCHPOINT_ALIASES: Record<string, (typeof TOUCHPOINT_TYPES)[number]> = {
post: "campaign_posted",
posted: "campaign_posted",
campaign: "campaign_posted",
dm: "dm_sent",
message: "dm_sent",
qualified: "lead_qualified",
lead: "lead_qualified",
link: "proposal_link_sent",
sent: "proposal_link_sent",
prefill: "prefill_link_sent",
followup: "follow_up_sent",
rejected: "lead_rejected",
};
const SENSITIVE_FIELD_NAMES = [
"email",
"phone",
"password",
"private_key",
"secret",
"api_key",
"token",
"credential",
"customer_secret",
"full_database_dump",
"private_dataset",
"personal_sensitive_data",
];
type TouchpointInput = Record<string, unknown>;
function textValue(value: unknown) {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function firstTextValue(searchParams: URLSearchParams, body: TouchpointInput, keys: string[]) {
for (const key of keys) {
const fromBody = textValue(body[key]);
if (fromBody) return fromBody;
const fromQuery = searchParams.get(key);
if (fromQuery) return fromQuery;
}
return "";
}
function cleanText(value: string, maxLength: number) {
return value
.replace(/\r/g, "")
.replace(/[^\S\n]+/g, " ")
.trim()
.slice(0, maxLength);
}
function normalizeTouchpointType(value: string) {
const normalized = sanitizeAgentId(value).replace(/-/g, "_");
if (TOUCHPOINT_TYPES.includes(normalized as (typeof TOUCHPOINT_TYPES)[number])) {
return normalized as (typeof TOUCHPOINT_TYPES)[number];
}
return TOUCHPOINT_ALIASES[normalized] || "proposal_link_sent";
}
function normalizeUrgency(value: string) {
return ["normal", "this_week", "urgent"].includes(value) ? value : "normal";
}
function ignoredSensitiveFields(searchParams: URLSearchParams, body: TouchpointInput) {
const keys = new Set([...Array.from(searchParams.keys()), ...Object.keys(body)]);
return Array.from(keys)
.filter((key) => SENSITIVE_FIELD_NAMES.some((sensitive) => key.toLowerCase().includes(sensitive)))
.sort();
}
async function readJsonBody(request: NextRequest) {
if (request.method !== "POST") return {};
const contentType = request.headers.get("content-type") || "";
if (!contentType.includes("application/json")) return {};
try {
const body = await request.json();
if (body && typeof body === "object" && !Array.isArray(body)) {
return body as TouchpointInput;
}
} catch {
return {};
}
return {};
}
async function handleTouchpoint(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const body = await readJsonBody(request);
const agentId = sanitizeAgentId(firstTextValue(searchParams, body, ["agent_id", "agentId"]));
const campaign = sanitizeAgentId(firstTextValue(searchParams, body, ["campaign"])) || "a2a-agent-referral";
const channel = sanitizeAgentId(firstTextValue(searchParams, body, ["channel"])) || "";
const source =
sanitizeAgentId(firstTextValue(searchParams, body, ["source"])) ||
channel ||
"external-agent";
const touchpointType = normalizeTouchpointType(firstTextValue(searchParams, body, ["touchpoint", "event", "type"]));
const shouldRegister = firstTextValue(searchParams, body, ["register"]) === "true";
if (!agentId) {
return NextResponse.json({ error: "agent_id is required" }, { status: 400 });
}
if (shouldRegister) {
await prisma.agentProfile.upsert({
where: { agent_id: agentId },
update: {
discovery_source: "A2A_REFERRAL_TOUCHPOINT",
},
create: {
agent_id: agentId,
type: "SCOUT",
status: "PENDING",
discovery_source: "A2A_REFERRAL_TOUCHPOINT",
capabilities: {
growth_referral: true,
referral_touchpoint_tracking: true,
campaign,
source,
channel: channel || null,
},
},
});
}
const leadLabel = cleanText(firstTextValue(searchParams, body, ["lead_label", "label", "title"]), 140);
const summary = cleanText(firstTextValue(searchParams, body, ["summary", "description"]), 360);
const desiredOutcome = cleanText(firstTextValue(searchParams, body, ["desired_outcome", "outcome"]), 240);
const stack = cleanText(firstTextValue(searchParams, body, ["stack", "tools", "required_stack"]), 180);
const budgetUsd = cleanText(firstTextValue(searchParams, body, ["budget_usd", "budget"]), 16);
const urgency = normalizeUrgency(firstTextValue(searchParams, body, ["urgency"]));
const targetSegment = cleanText(firstTextValue(searchParams, body, ["target_segment", "segment"]), 120);
const ignoredFields = ignoredSensitiveFields(searchParams, body);
const proposalUrl = buildDemandProposalUrl({
referralAgent: agentId,
campaign,
source,
});
const prefilledProposalUrl = buildDemandProposalUrl({
referralAgent: agentId,
campaign,
source,
title: leadLabel,
description: summary,
desiredOutcome,
stack,
budgetUsd,
urgency,
});
const statusUrl = `${AGENT_GATEWAY_URL}/api/a2a/referrals/status?agent_id=${encodeURIComponent(agentId)}`;
const safeSummary = {
lead_label: leadLabel || null,
summary: summary || null,
desired_outcome: desiredOutcome || null,
stack: stack || null,
budget_usd: budgetUsd || null,
urgency,
target_segment: targetSegment || null,
};
await prisma.auditEvent.create({
data: {
actorType: "AGENT",
actorId: agentId,
action: "A2A_REFERRAL_TOUCHPOINT_RECORDED",
entityType: "SYSTEM",
entityId: `a2a-referral-touchpoint:${agentId}`,
metadata: {
campaign,
source,
channel: channel || null,
touchpoint_type: touchpointType,
registered_pending_agent: shouldRegister,
proposal_url: proposalUrl,
prefilled_proposal_url: prefilledProposalUrl,
ignored_sensitive_fields: ignoredFields,
safe_summary: safeSummary,
},
},
});
const trafficEvent = await logA2aTrafficEvent({
headers: request.headers,
fallbackAgentId: agentId,
action: "EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED",
surface: "a2a/referrals/touch",
entityId: `a2a-referral-touchpoint:${agentId}`,
reason: "external_agent_referral_touchpoint_recorded",
metadata: {
campaign,
source,
channel: channel || null,
touchpoint_type: touchpointType,
registered_pending_agent: shouldRegister,
proposal_url: proposalUrl,
prefilled_proposal_url: prefilledProposalUrl,
ignored_sensitive_fields: ignoredFields,
safe_summary: safeSummary,
response_status: 200,
response_summary: "a2a_referral_touchpoint_recorded",
},
});
return NextResponse.json({
success: true,
touchpoint_id: trafficEvent.id,
agent_id: agentId,
touchpoint_type: touchpointType,
campaign,
source,
channel: channel || null,
proposal_url: proposalUrl,
prefilled_proposal_url: prefilledProposalUrl,
referral_status_url: statusUrl,
ignored_sensitive_fields: ignoredFields,
guardrails: [
"Touchpoints are traffic evidence, not revenue.",
"Paid conversion is counted only after VibeWork payment truth.",
"Do not send passwords, private keys, production credentials, full customer records, or private datasets.",
],
next_actions: [
"Send the human proposer to proposal_url or prefilled_proposal_url.",
"Ask the proposer to review and pay inside VibeWork.",
"Check referral_status_url for sanitized aggregate progress.",
],
});
}
export async function GET(request: NextRequest) {
return handleTouchpoint(request);
}
export async function POST(request: NextRequest) {
return handleTouchpoint(request);
}

View File

@@ -509,6 +509,7 @@ export async function GET(request: NextRequest) {
(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;
@@ -532,6 +533,7 @@ export async function GET(request: NextRequest) {
const externalFunnel = {
discovery_events: discoveryEvents,
referral_touchpoint_events: referralTouchpointEvents,
proposal_view_events: proposalViewEvents,
proposal_created_events: proposalCreatedEvents,
proposal_checkout_events: proposalCheckoutEvents,
@@ -547,6 +549,8 @@ export async function GET(request: NextRequest) {
};
const conversionRates = {
touchpoint_rate: conversionRate(referralTouchpointEvents, discoveryEvents),
touchpoint_to_proposal_view_rate: conversionRate(proposalViewEvents, referralTouchpointEvents || discoveryEvents),
proposal_view_rate: conversionRate(proposalViewEvents, discoveryEvents),
proposal_create_rate: conversionRate(proposalCreatedEvents, proposalViewEvents),
proposal_paid_rate: conversionRate(proposalPaidEvents, proposalCreatedEvents),

View File

@@ -14,6 +14,7 @@ const EVENT_LABELS: Record<string, string> = {
EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED: "外部 Agent 領取需求 campaign kit",
EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW: "外部 Agent 讀取 A2A 整合目錄",
EXTERNAL_A2A_GROWTH_KIT_ISSUED: "外部 Agent 領取 growth kit",
EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED: "外部 Agent 回報導流 touchpoint",
EXTERNAL_A2A_REFERRAL_STATUS_VIEW: "外部 Agent 查詢 referral 狀態",
EXTERNAL_DEMAND_PROPOSAL_VIEW: "外部導流需求方查看提案頁",
EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED: "外部導流需求方建立提案",
@@ -393,6 +394,7 @@ async function getTrafficSummary(minutes: number) {
(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;
@@ -411,6 +413,7 @@ async function getTrafficSummary(minutes: number) {
const conversionSummary = {
discovery_events: discoveryEvents,
referral_touchpoint_events: referralTouchpointEvents,
proposal_view_events: proposalViewEvents,
proposal_created_events: proposalCreatedEvents,
proposal_checkout_events: proposalCheckoutEvents,
@@ -425,6 +428,8 @@ async function getTrafficSummary(minutes: number) {
};
const conversionRates = {
touchpoint_rate: percent(referralTouchpointEvents, discoveryEvents),
touchpoint_to_proposal_view_rate: percent(proposalViewEvents, referralTouchpointEvents || discoveryEvents),
proposal_view_rate: percent(proposalViewEvents, discoveryEvents),
proposal_create_rate: percent(proposalCreatedEvents, proposalViewEvents),
proposal_paid_rate: percent(proposalPaidEvents, proposalCreatedEvents),
@@ -585,6 +590,8 @@ function toLocalTime(value: Date) {
}
function buildConversionTips(summary: {
touchpoint_rate: number;
touchpoint_to_proposal_view_rate: number;
proposal_view_rate: number;
proposal_create_rate: number;
proposal_paid_rate: number;
@@ -594,6 +601,7 @@ function buildConversionTips(summary: {
payout_rate: number;
}, conversionSummary: {
discovery_events: number;
referral_touchpoint_events: number;
proposal_view_events: number;
proposal_created_events: number;
proposal_checkout_events: number;
@@ -608,8 +616,16 @@ function buildConversionTips(summary: {
}) {
const steps: string[] = [];
if (conversionSummary.discovery_events > 0 && conversionSummary.referral_touchpoint_events === 0) {
steps.push("A2A 曝光已有資料但外部 Agent touchpoint 為零:優先確認 campaign kit 是否要求回報 /api/a2a/referrals/touch。");
}
if (conversionSummary.referral_touchpoint_events > 0 && conversionSummary.proposal_view_events === 0) {
steps.push("外部 Agent 已回報導流 touchpoint 但提案頁查看為零:優先檢查送出的 proposal_url、prefilled_proposal_url 與 vibework.wooo.work 代理。");
}
if (conversionSummary.discovery_events > 0 && conversionSummary.proposal_view_events === 0) {
steps.push("A2A 曝光已有資料但提案頁查看為零:優先確認 growth kit referral_url、vibework.wooo.work/propose 代理與外部貼文 CTA。");
steps.push("A2A 曝光已有資料但提案頁查看為零:優先確認 growth kit referral_url、touchpoint 回傳 URL、外部貼文 CTA 與 /propose 代理。");
}
if (conversionSummary.proposal_view_events > 0 && conversionSummary.proposal_created_events === 0) {
@@ -708,6 +724,10 @@ export default async function TrafficDashboard({
<div className="text-gray-400 text-sm">A2A </div>
<div className="text-3xl font-bold mt-2 text-cyan-300">{conversionSummary.discovery_events}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm">Agent </div>
<div className="text-3xl font-bold mt-2 text-teal-300">{conversionSummary.referral_touchpoint_events}</div>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<div className="text-gray-400 text-sm"></div>
<div className="text-3xl font-bold mt-2 text-sky-300">{conversionSummary.proposal_view_events}</div>
@@ -763,13 +783,33 @@ export default async function TrafficDashboard({
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>A2A曝光Agent導流回報</span>
<span className="text-emerald-300">{fmtPercent(conversionRates.touchpoint_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-teal-400"
style={{ width: `${Math.min(conversionRates.touchpoint_rate, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>Agent導流回報</span>
<span className="text-emerald-300">{fmtPercent(conversionRates.touchpoint_to_proposal_view_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-sky-400"
style={{ width: `${Math.min(conversionRates.touchpoint_to_proposal_view_rate, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>A2A曝光</span>
<span className="text-emerald-300">{fmtPercent(conversionRates.proposal_view_rate)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-sky-400"
className="h-full bg-cyan-400"
style={{ width: `${Math.min(conversionRates.proposal_view_rate, 100)}%` }}
/>
</div>