crmCRM
Nested detail page

证据账本与前端桥接 深度解析

这页看 Agent 如何证明事实、何时只提出建议,以及销售代表在前端怎样接住可恢复的 Agent 会话。

§ 00 · IMPORT CHAIN

导入链与契约

apps/agent/agent/tools/record_fact.ts:8 是工具入口,apps/agent/agent/lib/evidence.ts:99 负责打分,apps/agent/agent/lib/facts.ts:140 负责事务写入,apps/app/components/crm/agent-panel.tsx:471 负责保存前端会话索引。

证据链引用 1: apps/agent/agent/tools/record_fact.ts:8 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 2: apps/agent/agent/tools/record_fact.ts:19 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 3: apps/agent/agent/tools/record_fact.ts:47 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 4: apps/agent/agent/lib/evidence.ts:22 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 5: apps/agent/agent/lib/evidence.ts:97 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 6: apps/agent/agent/lib/evidence.ts:99 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 7: apps/agent/agent/lib/evidence.ts:128 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 8: apps/agent/agent/lib/facts.ts:41 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 9: apps/agent/agent/lib/facts.ts:91 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 10: apps/agent/agent/lib/facts.ts:124 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 11: apps/agent/agent/lib/facts.ts:137 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 12: apps/agent/agent/lib/facts.ts:140 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 13: apps/agent/agent/lib/facts.ts:148 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 14: apps/agent/agent/lib/facts.ts:165 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 15: apps/agent/agent/tools/schedule_recheck.ts:9 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 16: apps/app/lib/agent-bridge.ts:12 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 17: apps/app/lib/agent-bridge.ts:37 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 18: apps/app/components/crm/agent-panel.tsx:188 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 19: apps/app/components/crm/agent-panel.tsx:433 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 20: apps/app/components/crm/agent-panel.tsx:471 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 21: apps/app/components/crm/agent-panel.tsx:518 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 22: packages/db/prisma/schema.prisma:265 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

证据链引用 23: packages/db/prisma/schema.prisma:353 这一处说明 CRM 如何把模型观察转换为可审核事实、可恢复会话或人类可处理的问题;这些引用避免把 Agent 输出当成不可追踪的黑盒。

apps/agent/agent/tools/record_fact.ts · apps/agent/agent/tools/record_fact.ts:8-67TypeScript
8export default defineTool({
9	description:
10		"Record one claim about a contact — title, employer, a profile URL, seniority — together with the evidence for it. The evidence decides whether it is written to the record or offered to a rep as a suggestion. Never invent evidence you did not observe.",
11	inputSchema: z.object({
12		contactId: z.string(),
13		field: z
14			.enum(FACT_FIELDS as [FactField, ...FactField[]])
15			.describe("Which fact about them this is."),
16		value: z
17			.string()
18			.describe("The claim itself, exactly as the source states it."),
19		evidence: z
20			.array(
21				z.object({
22					kind: z
23						.enum(Object.keys(WEIGHTS) as [EvidenceKind, ...EvidenceKind[]])
24						.describe(
25							"What kind of thing you saw. Use `contradiction` when two sources disagree.",
26						),
27					detail: z
28						.string()
29						.describe(
30							"What it actually said, in one line a rep would understand.",
31						),
32					sourceUrl: z.string().optional(),
33				}),
34			)
35			.min(1)
36			.describe("Everything you observed. One entry per independent source."),
37		method: z
38			.string()
39			.describe(
40				'Where it came from: "linkedin.profile", "github.api", "crm.thread", "web".',
41			),
42		sourceUrl: z
43			.string()
44			.optional()
45			.describe("The page a rep should open to check."),
46	}),
47	async execute(input) {
48		focusOn({ contactId: input.contactId });
49
50		const result = await recordFact({
51			contactId: input.contactId,
52			field: input.field,
53			value: input.value,
54			evidence: input.evidence as Evidence[],
55			method: input.method,
56			sourceUrl: input.sourceUrl,
57		});
58
59		return {
60			stored: result.stored,
61			applied: result.applied,
62			band: result.band,
63			score: Number(result.score.toFixed(2)),
64			rationale: result.rationale,
65			...(result.reason ? { reason: result.reason } : {}),
66		};
67	},
§ 01 · CORE IMPL

核心实现,逐段拆

▸证据权重先给业务风险分层

apps/agent/agent/lib/evidence.ts:22 的 WEIGHTS 把邮箱回信、签名、LinkedIn、GitHub 身份等证据区分为 primary 和 non-primary;apps/agent/agent/lib/evidence.ts:128 要求 VERIFIED 必须有 primary evidence。

apps/agent/agent/lib/evidence.ts · apps/agent/agent/lib/evidence.ts:22-132TypeScript
22export const WEIGHTS: Record<EvidenceKind, Weighting> = {
23	"profile.email-match": {
24		weight: 0.95,
25		primary: true,
26		label: "their email address is on the profile",
27	},
28	"linkedin.employer-and-name": {
29		weight: 0.85,
30		primary: true,
31		label: "LinkedIn: employer and name both match",
32	},
33	"crm.thread-reply": {
34		weight: 0.85,
35		primary: true,
36		label: "they replied on a thread we have",
37	},
38	"crm.signature-block": {
39		weight: 0.8,
40		primary: true,
41		label: "their own email signature says so",
42	},
43	"github.account-identity": {
44		weight: 0.8,
45		primary: true,
46		label: "the GitHub account names them or their employer",
47	},
48	"crm.meeting-attendance": {
49		weight: 0.7,
50		primary: true,
51		label: "they attended a meeting on our calendar",
52	},
53	"web.cited-claim": {
54		weight: 0.4,
55		primary: false,
56		label: "a cited web source states it",
57	},
58	"handle.name-form": {
59		weight: 0.35,
60		primary: false,
61		label: "the handle is a form of their name",
62	},
63	"search.cites-profile": {
64		weight: 0.35,
65		primary: false,
66		label: "a search for them cites this profile",
67	},
68	"employer-only": {
69		weight: 0.2,
70		primary: false,
71		label: "the employer matches, the name does not",
72	},
73	contradiction: {
74		weight: 0,
75		primary: false,
76		label: "another source disagrees",
77	},
78};
79
80export type Evidence = {
81	kind: EvidenceKind;
82	detail: string;
83	sourceUrl?: string;
84};
85
86export type Scored = {
87	score: number;
88	band: FactBand | null;
89	hasPrimary: boolean;
90	rationale: string;
91};
92
93const CEILING = 0.99;
94
95const CONTRADICTED = 0.45;
96
97export const BAND_FLOOR = { VERIFIED: 0.85, PROBABLE: 0.55, POSSIBLE: 0.3 };
98
99export function scoreEvidence(evidence: Evidence[]): Scored {
100	if (evidence.length === 0) {
101		return {
102			score: 0,
103			band: null,
104			hasPrimary: false,
105			rationale: "No evidence.",
106		};
107	}
108
109	const contradicted = evidence.some((item) => item.kind === "contradiction");
110	const hasPrimary = evidence.some((item) => WEIGHTS[item.kind].primary);
111
112	const combined = evidence.reduce(
113		(remaining, item) => remaining * (1 - WEIGHTS[item.kind].weight),
114		1,
115	);
116
117	let score = Math.min(CEILING, 1 - combined);
118	if (contradicted) score = Math.min(score, CONTRADICTED);
119
120	return {
121		score,
122		band: bandFor(score, hasPrimary),
123		hasPrimary,
124		rationale: rationaleFor(evidence, contradicted, hasPrimary),
125	};
126}
127
128export function bandFor(score: number, hasPrimary: boolean): FactBand | null {
129	if (score >= BAND_FLOOR.VERIFIED && hasPrimary) return FactBand.VERIFIED;
130	if (score >= BAND_FLOOR.PROBABLE) return FactBand.PROBABLE;
131	if (score >= BAND_FLOOR.POSSIBLE) return FactBand.POSSIBLE;
132	return null;

▸写事实前尊重人工字段

apps/agent/agent/lib/facts.ts:91 先读已有 fact,apps/agent/agent/lib/facts.ts:126 判断 humanOwns,apps/agent/agent/lib/facts.ts:148 记录 evidence JSON。Agent 可以提议,但不能覆盖人类明确维护的事实。

apps/agent/agent/lib/facts.ts · apps/agent/agent/lib/facts.ts:91-190TypeScript
91	const existing = await db.contactFact.findMany({
92		where: { contactId, field },
93		select: { id: true, value: true, status: true },
94	});
95
96	if (
97		existing.some(
98			(fact) =>
99				fact.status === FactStatus.DISMISSED && sameValue(fact.value, trimmed),
100		)
101	) {
102		return {
103			...base,
104			stored: false,
105			applied: false,
106			reason:
107				"A person has already dismissed this exact value. Do not offer it again.",
108		};
109	}
110
111	const currentApplied = existing.find(
112		(fact) => fact.status === FactStatus.APPLIED,
113	);
114
115	if (currentApplied && sameValue(currentApplied.value, trimmed)) {
116		return {
117			...base,
118			stored: false,
119			applied: false,
120			reason: "Already on the record, from this same source. Nothing changed.",
121		};
122	}
123
124	const column = FIELDS[field].column;
125
126	if (
127		humanOwns({ field, column, contact, hasAgentFact: Boolean(currentApplied) })
128	) {
129		return {
130			...base,
131			stored: false,
132			applied: false,
133			reason: `A person already filled in ${field}. That outranks anything found on the web.`,
134		};
135	}
136
137	const applies = scored.band === FactBand.VERIFIED;
138	const sessionId = currentFocus().sessionId;
139
140	await db.$transaction(async (tx) => {
141		if (applies && currentApplied) {
142			await tx.contactFact.update({
143				where: { id: currentApplied.id },
144				data: { status: FactStatus.SUPERSEDED, supersededAt: new Date() },
145			});
146		}
147
148		await tx.contactFact.create({
149			data: {
150				contactId,
151				field,
152				value: trimmed,
153				score: scored.score,
154				band: scored.band as FactBand,
155				evidence: input.evidence as unknown as object,
156				method: input.method,
157				sourceUrl: input.sourceUrl ?? null,
158				sessionId,
159				status: applies ? FactStatus.APPLIED : FactStatus.PROPOSED,
160			},
161		});
162
163		if (!applies) return;
164
165		if (column) {
166			await tx.contact.update({
167				where: { id: contactId },
168				data: { [column]: trimmed },
169			});
170		}
171
172		if (field === "name") {
173			const split = splitName(trimmed);
174			if (split) {
175				await tx.contact.update({
176					where: { id: contactId },
177					data: { firstName: split.firstName, lastName: split.lastName },
178				});
179			}
180		}
181	});
182
183	return {
184		...base,
185		stored: true,
186		applied: applies,
187		reason: applies
188			? undefined
189			: "Kept as a proposal for a rep to accept or dismiss. This is a normal outcome, not a failure — do not try to raise the score.",
190	};

▸Bridge token 把前台会话绑定到记录

apps/app/lib/agent-bridge.ts:12 mintBridgeToken 生成短 TTL HMAC JWT,apps/app/components/crm/agent-panel.tsx:188 用 recordHeader 打开 useEveAgent,apps/app/components/crm/agent-panel.tsx:518 再把 session 写回 CRM。

apps/app/components/crm/agent-panel.tsx · apps/app/components/crm/agent-panel.tsx:188-293TypeScript
188	const agent = useEveAgent({
189		headers: recordHeader(record),
190		...(thread && "session" in thread
191			? { initialSession: thread.session, initialEvents: eventsOf(thread) }
192			: { initialEvents: eventsOf(thread) }),
193	});
194	const [draft, setDraft] = useState("");
195
196	const opening = useRef<string | null>(conversation?.title ?? null);
197
198	useSavedConversation({
199		record: recordFilter(record),
200		conversation,
201		opening,
202		session: agent.session ?? null,
203		messages: agent.data.messages.length,
204	});
205
206	const busy = agent.status === "submitted" || agent.status === "streaming";
207	const messages = toTranscript(agent.data.messages);
208	const question = pendingQuestion(agent.data.messages);
209
210	const { locked, ended } = composerState(thread, busy);
211
212	const ask = (message: string) => {
213		if (!message.trim() || locked) return;
214		opening.current ||= message.trim();
215		setDraft("");
216		void agent.send({ message: message.trim() });
217	};
218
219	return (
220		<div className="flex min-h-0 flex-1 flex-col">
221			<MessageScrollerProvider autoScroll defaultScrollPosition="end">
222				<MessageScroller className="flex-1">
223					<MessageScrollerViewport>
224						<MessageScrollerContent className="gap-3 px-5 py-4">
225							{messages.length === 0 && !busy ? (
226								<Idle kind={record.kind} onAsk={ask} />
227							) : null}
228
229							{messages.map((message) => (
230								<MessageScrollerItem key={message.id} messageId={message.id}>
231									<div className="space-y-3">
232										{message.items.map((item) => (
233											<Item key={item.id} item={item} />
234										))}
235									</div>
236								</MessageScrollerItem>
237							))}
238
239							{question ? (
240								<MessageScrollerItem messageId={question.requestId}>
241									<Question question={question} agent={agent} />
242								</MessageScrollerItem>
243							) : null}
244						</MessageScrollerContent>
245					</MessageScrollerViewport>
246
247					<MessageScrollerButton />
248				</MessageScroller>
249			</MessageScrollerProvider>
250
251			{agent.error ? <Failure message={agent.error.message} /> : null}
252
253			{thread?.status === "working" && !busy ? (
254				<p className="border-t px-5 py-2 text-muted-foreground text-xs">
255					Still working on the last question. Your next one can go in when it
256					finishes.
257				</p>
258			) : null}
259
260			{ended ? (
261				<div className="flex items-center justify-between gap-3 border-t px-5 py-2">
262					<p className="text-muted-foreground text-xs">
263						This conversation has ended.
264					</p>
265					<Button variant="outline" size="sm" onClick={onNewThread}>
266						Start a new conversation
267					</Button>
268				</div>
269			) : null}
270
271			<form
272				className="flex items-center gap-2 border-t px-5 py-3"
273				onSubmit={(event) => {
274					event.preventDefault();
275					ask(draft);
276				}}
277			>
278				<Input
279					value={draft}
280					onChange={(event) => setDraft(event.target.value)}
281					placeholder={copy.placeholder}
282					disabled={locked}
283				/>
284				<Button
285					type="submit"
286					size="icon-sm"
287					variant="outline"
288					disabled={locked}
289				>
290					{busy ? <Spinner /> : <Icon icon={Send} />}
291					<span className="sr-only">Ask</span>
292				</Button>
293			</form>
§ 02 · KEY FLOW

关键流程

agent observes claim → record_fact tool → scoreEvidence produces band → fact transaction stores APPLIED or PROPOSED → UI transcript shows source markers → conversation save preserves continuation

这个流程把可信度变成产品状态:packages/db/prisma/schema.prisma:265 的 ContactFact 存 evidence 与 status,apps/app/components/crm/agent-panel.tsx:400 的 Sources 组件把来源展示给代表。

§ 03 · EDGE

边界 / 失败模式

  • apps/agent/agent/lib/evidence.ts:117 设置 CEILING,测试 apps/agent/test/evidence.spec.ts:75 确认永不声称绝对确定。
  • apps/app/lib/agent-bridge.ts:20 没有 secret 就拒绝 mint,测试 apps/app/test/agent-bridge.spec.ts:74 覆盖。
  • apps/app/components/crm/agent-panel.tsx:253 在旧问题仍 working 时锁住下一问,避免前端把同一 session 搞乱。

证据账本需要稳定的结构化字段,packages/db/src/json.ts:1 到 packages/db/src/json.ts:13 定义的 JSON 类型让 evidence、metadata 和 outcome 能在工具、数据库与 UI 之间传递,而不是被压扁成无法审核的文本。对销售数据来说,这决定了之后能不能解释某个字段为什么被 Agent 改写。

前端桥接和后台写入都依赖同一个数据库客户端,packages/db/src/client.ts:55 与 packages/db/src/client.ts:70 是判断连接复用和生命周期的关键位置。AgentPanel 保存 continuation token 时看起来是 UI 行为,但它最终必须和 AgentTask、AgentConversation 的事务语义一致。

资料补全还会经过存储与安全读取边界,packages/db/src/blob.ts:25 说明二进制对象怎样封装,packages/db/src/safe-fetch.ts:8 则提醒外部抓取需要受控入口。证据系统如果只检查模型输出而忽略这些边界,头像、logo、网页引用和事实字段就会出现不同的可信度标准。

Generated · 2026-08-04Source · trycompai/crm← 返回 Trending 索引