Commit 156a7cff by luoqi

feat(助手): P3 历史分层回传 —— 工具调用带回来了,但只带摘要

在此之前 `toApiMessage` **只回传文本块**,工具块整块丢弃。代价:
- 模型每轮从零开始,会重复调同一个工具;
- 它上一轮唯一能看到的只有**自己说过的话** —— 而那正是"背出成品句子"的温床。

现在按 blocks 原始顺序拼( 不把工具记录堆到末尾:先查什么、再说什么本身是脉络):
- 工具调用 → `〔调用 名字 {关键参数} → 结果摘要〕`,args 截 120 / result 截 220
- 失败 → **只留第一行且切掉堆栈**。️ 光靠长度截不住(实测:堆栈就在前 120 字里,
  回归当场红了)—— 要按结构剥,` at ` 之后的是给开发者看的,进历史纯属污染
- 卡片 / 确认单 → 只留一个存在性锚(内容是渲染产物,复述没意义且很长)

配套改提示词:原来那条「 不许从上文抄数字」是为"历史里没有数字"写的,
现在自相矛盾了。改成**条件对得上才能用**:摘要行里写着当时传的参数,
主管一换条件旧数就作废 —— 这正是"同一批人报出 12、17、13、70"的由来。

测试:web 20 passed(新增 7 条分层回传回归,含肥载荷截断与堆栈剥离);
service 1237 passed;tsc / build 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b823023a
...@@ -106,10 +106,12 @@ const DISPATCHER_EXTRA = ` ...@@ -106,10 +106,12 @@ const DISPATCHER_EXTRA = `
而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。 而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。
同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个,主管看到的是"这系统的数在乱飘"。 同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个,主管看到的是"这系统的数在乱飘"。
⛔ **不许从上文抄数字。** 多轮收窄时,上一句的人数属于**上一组条件**, ⚠️ 上文里的 \`〔调用 …〕\` 是**你之前几轮真的调过的工具**,那里的数可以用 ——
主管一改条件它立刻作废 —— 这正是数字会飘的原因。 但**必须条件对得上**:那一行里写着当时传的参数,主管一改条件(换治疗项、换时间档、
加画像条件),旧数就作废了。⛔ 条件变了还引用旧数,正是"同一批人报出 12、17、13、70"的由来。
⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。 ⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。
✅ 报人数前先自问:**这个数是这一轮哪个工具返回里的?** 答不上来就去调工具;工具返回的 \`note\` 是成品句子(「这批共 N 人。…」),照抄就对。 ✅ 报人数前先自问:**这个数出自哪一次调用?那次的条件和现在一样吗?**
两问有一个答不上来,就去调工具。
✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。 ✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。
2. **全景阶段不问意图、不做画像分层,直接出确认单。** 2. **全景阶段不问意图、不做画像分层,直接出确认单。**
......
...@@ -5,9 +5,11 @@ import { ...@@ -5,9 +5,11 @@ import {
findActiveSheet, findActiveSheet,
intentToPrompt, intentToPrompt,
intentToSheetOps, intentToSheetOps,
toApiMessage,
type Block, type Block,
type ChatMessage, type ChatMessage,
type DraftState, type DraftState,
type ToolStep,
} from './chat-blocks'; } from './chat-blocks';
/** /**
...@@ -188,3 +190,75 @@ describe('引导节点 intent 路由', () => { ...@@ -188,3 +190,75 @@ describe('引导节点 intent 路由', () => {
expect(intentToPrompt('nope.nope')).toBeNull(); expect(intentToPrompt('nope.nope')).toBeNull();
}); });
}); });
/**
* 历史回传 —— 🔴 P3 的核心:**带全脉络,但分层带**。
*
* 两个失败方向都很贵:全量带 → 确认单几百个 uuid 灌进上下文且诱导复述;
* 一点不带 → 模型每轮从零开始,只能拿自己上一轮说过的话当依据(背句子的温床)。
*/
describe('toApiMessage · 分层回传', () => {
const tool = (over: Partial<ToolStep> = {}): Block => ({
kind: 'tool',
step: { id: 't', tool: 'get_cohort_attributes', args: { temperature: 'hot' }, status: 'done', result: '278 人', ...over },
});
it('🔴 工具调用会回传(此前整块丢弃 —— 模型每轮从零开始)', () => {
const out = toApiMessage({ id: 'm', role: 'assistant', blocks: [tool()] })!;
expect(out.content).toContain('get_cohort_attributes');
expect(out.content).toContain('278 人');
});
it('🔴 按 blocks 原始顺序拼,⛔ 不把工具记录堆到末尾(先查什么再说什么本身是脉络)', () => {
const out = toApiMessage({
id: 'm',
role: 'assistant',
blocks: [{ kind: 'text', text: '我先看看' }, tool(), { kind: 'text', text: '这批 278 人' }],
})!;
expect(out.content.indexOf('我先看看')).toBeLessThan(out.content.indexOf('get_cohort'));
expect(out.content.indexOf('get_cohort')).toBeLessThan(out.content.indexOf('这批 278 人'));
});
it('🔴 肥载荷会被截断(⛔ 不许把几百个 uuid 灌回上下文)', () => {
const fat = JSON.stringify({ items: Array.from({ length: 400 }, (_, i) => `plan-uuid-${i}`) });
const out = toApiMessage({ id: 'm', role: 'assistant', blocks: [tool({ result: fat })] })!;
expect(out.content).toContain('(略)');
expect(out.content.length).toBeLessThan(500);
});
it('⛔ 失败只留错因,不带原始堆栈(脏东西进了历史就一直在)', () => {
const out = toApiMessage({
id: 'm',
role: 'assistant',
blocks: [tool({ status: 'error', error: 'patient x 不在当前租户范围内\n at Foo.bar (a.ts:1)\n at Baz'.repeat(20) })],
})!;
expect(out.content).toContain('失败:');
expect(out.content).not.toContain('at Foo.bar');
});
it('⭐ modelText 优先 —— 界面显示短的,模型拿到带完整批次 id 的那份', () => {
const out = toApiMessage({
id: 'm',
role: 'assistant',
blocks: [{ kind: 'text', text: '批次已确认', modelText: '批次已确认(id=abc-123)' }],
})!;
expect(out.content).toContain('abc-123');
});
it('⭐ 确认单只留一个状态锚,⛔ 不回传它的内容(事实在同轮的工具摘要里)', () => {
const out = toApiMessage({
id: 'm',
role: 'assistant',
blocks: [
{ kind: 'assignment_sheet', requestId: 'r1', sheet: sheet(1), state: 'superseded' },
{ kind: 'assignment_sheet', requestId: 'r2', sheet: sheet(2), state: 'active' },
],
})!;
expect(out.content).toContain('已作废');
expect(out.content).toContain('当前有效');
});
it('⭐ 全空的消息返回 null(⛔ 别往上下文里塞空壳)', () => {
expect(toApiMessage({ id: 'm', role: 'assistant', blocks: [] })).toBeNull();
});
});
...@@ -254,3 +254,82 @@ export function intentToPrompt( ...@@ -254,3 +254,82 @@ export function intentToPrompt(
return null; return null;
} }
} }
// ─────────────────────────────────────────────────────────
// 历史回传(分层)
// ─────────────────────────────────────────────────────────
/** 单条工具记录回传时的长度上限 —— 超出即截断并标注。 */
const HISTORY_CAP = { args: 120, result: 220 } as const;
/**
* 只取错误的**第一行、且切掉堆栈**。
*
* ⚠️ ⛔ 光靠长度截不住:堆栈常常就在前 120 字里(实测本模块的回归就是这么红的)。
* 要按**结构**剥 —— 换行之后、` at ` 之后的都是给开发者看的,进历史纯属污染。
*/
function errorReason(raw: string | undefined): string {
const first = (raw ?? '未知错误').split('\n')[0] ?? '未知错误';
const cut = first.split(/\s+at\s+/)[0] ?? first;
return cut.trim() || '未知错误';
}
function clip(s: string, max: number): string {
const t = s.replace(/\s+/g, ' ').trim();
return t.length <= max ? t : `${t.slice(0, max)}…(略)`;
}
/**
* 一次工具调用 → 一行**摘要**。
*
* ⚠️ 摘要不是省钱,是**保留脉络又不灌垃圾**:
* · 全量回传 → 确认单那类载荷几百个 uuid,贵且诱导模型去复述;
* · 一点不传 → 模型下一轮不知道自己查过什么,会重复调用,
* 而且只能拿**自己上一轮说过的话**当唯一依据 —— 那正是"背出成品句子"的温床。
* ⚠️ 失败只留错因,⛔ 不带原始堆栈(脏东西进了历史就一直在,除非压缩)。
*/
export function summarizeToolStep(step: ToolStep): string {
const args = step.args == null ? '' : clip(JSON.stringify(step.args), HISTORY_CAP.args);
const head = `〔调用 ${step.tool}${args && args !== '{}' ? ` ${args}` : ''}`;
if (step.status === 'error') return `${head} → 失败:${clip(errorReason(step.error), 120)}〕`;
if (step.status === 'running') return `${head} → 未完成〕`;
const raw = typeof step.result === 'string' ? step.result : JSON.stringify(step.result ?? '');
return `${head}${clip(raw, HISTORY_CAP.result)}〕`;
}
/**
* 把一条消息压平成后端要的 `{role, content}`。
*
* 🔴 2026-08-12(P3):**工具记录现在也回传**(分层摘要)。
* 在此之前只回传文本块,注释写的是"工具块不回传,模型自行重新决策" ——
* 代价是模型每一轮都从零开始:重复调同一个工具,且拿不到上一轮的任何锚。
* ⚠️ 按 blocks 的**原始顺序**拼,⛔ 别把工具记录都堆到末尾:
* 「先查了什么、再说了什么」本身就是脉络。
*/
export function toApiMessage(
m: ChatMessage,
): { role: 'user' | 'assistant'; content: string } | null {
const parts = m.blocks
.map((b): string => {
switch (b.kind) {
// ⭐ modelText 优先 —— 界面显示"批次已确认",模型拿到的那份还带着完整 uuid
case 'text':
return b.modelText ?? b.text;
case 'tool':
return `\n${summarizeToolStep(b.step)}\n`;
// ⚠️ 卡片内容不回传:它是渲染产物,模型复述它没有意义(而且很长)
case 'artifact':
return `\n〔已渲染卡片${b.artifact.title ? `:${b.artifact.title}` : ''}〕\n`;
// ⚠️ 确认单的事实在同一轮的 tool 摘要里已经有了,这里只留一个"它存在"的锚
case 'assignment_sheet':
return `\n〔确认单 ${b.state === 'active' ? '当前有效' : b.state === 'confirmed' ? '已确认' : b.state === 'superseded' ? '已作废' : '已撤销'}〕\n`;
default:
return '';
}
})
.join('')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!parts) return null;
return { role: m.role, content: parts };
}
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
findToolIdx, findToolIdx,
intentToPrompt, intentToPrompt,
intentToSheetOps, intentToSheetOps,
toApiMessage,
upsertArtifact, upsertArtifact,
type Block, type Block,
type ChatMessage, type ChatMessage,
...@@ -45,17 +46,6 @@ function extractRevokedId(result: unknown): string | null { ...@@ -45,17 +46,6 @@ function extractRevokedId(result: unknown): string | null {
} }
/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
const text = m.blocks
.filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
// ⭐ modelText 优先 —— 界面显示"批次已确认",模型拿到的那份还带着完整 uuid
.map((b) => b.modelText ?? b.text)
.join('')
.trim();
if (!text) return null;
return { role: m.role, content: text };
}
/** 听写音频上传 → 文字(后端 /assistant/transcribe → 自部署 SenseVoice,PII 不出内网)。 */ /** 听写音频上传 → 文字(后端 /assistant/transcribe → 自部署 SenseVoice,PII 不出内网)。 */
export async function transcribeAudio(blob: Blob): Promise<string> { export async function transcribeAudio(blob: Blob): Promise<string> {
...@@ -380,9 +370,10 @@ export function useAssistantChat() { ...@@ -380,9 +370,10 @@ export function useAssistantChat() {
/** /**
* ⭐ 确认单点了确认之后,**必须往消息流注入一条文本块**。 * ⭐ 确认单点了确认之后,**必须往消息流注入一条文本块**。
* *
* 因为 `toApiMessage` **只回传文本块**(工具块不回传,模型自行重新决策)—— * 确认是**在模型发言之外**发生的:它不是一次工具调用,所以 `toApiMessage` 的
* 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹, * 分层回传里没有它的痕迹。不注入的话,下一轮主管说「刚才那批改成 5 天」,
* 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。 * 模型手里完全没有"那批"的锚,会当成新需求重新提议一次。
* ⚠️ P3 之后工具调用**会**回传摘要了,但**人的动作仍然要靠这里补** —— 别混为一谈。
*/ */
const appendAssistantNote = useCallback( const appendAssistantNote = useCallback(
(text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => { (text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment