证据账本与前端桥接 深度解析
这页看 Agent 如何证明事实、何时只提出建议,以及销售代表在前端怎样接住可恢复的 Agent 会话。
导入链与契约
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 输出当成不可追踪的黑盒。
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 },
核心实现,逐段拆
▸证据权重先给业务风险分层
apps/agent/agent/lib/evidence.ts:22 的 WEIGHTS 把邮箱回信、签名、LinkedIn、GitHub 身份等证据区分为 primary 和 non-primary;apps/agent/agent/lib/evidence.ts:128 要求 VERIFIED 必须有 primary evidence。
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 可以提议,但不能覆盖人类明确维护的事实。
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。
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>
关键流程
这个流程把可信度变成产品状态:packages/db/prisma/schema.prisma:265 的 ContactFact 存 evidence 与 status,apps/app/components/crm/agent-panel.tsx:400 的 Sources 组件把来源展示给代表。
边界 / 失败模式
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、网页引用和事实字段就会出现不同的可信度标准。