Commit df0d263d by luoqi

fix(助手留痕): 补上会话 id,并把多步文本拼全 —— 两处都是「看起来对、其实会丢」

■ 会话 id(`workflowRunId`)
  上一版每轮各生成一个 run id,判断"哪几轮是一批"只能靠 `turnNo` 归 1 反推 ——
  那是启发式,️ 两位主管并发聊天时行是交错的,前端一旦裁剪历史 turnNo 还会错位。
  ⇒ 前端在**messages 为空**(= 一段新对话的第一句)时现生成 uuid,此后每轮原样带上;
    服务端落进 `workflow_run_id`,同一次对话各轮共用一个值。
  ️ 前端可改的入参:只用于归组, 不参与鉴权取数;非 uuid 一律丢弃(服务端另生成),
     别让它成为写库的注入面(该列是 uuid 类型)。
   这也正好落在产品设计上:没有"新建会话",会话跟着他此刻在做的那件事走 ——
    离开工作台、组件重挂,messages 归空,下一句就是新的一段。

■ outputText 只落了最后一步
  🔴 AI SDK 的 `OnFinishEvent` 继承的是**最后一步**的 `StepResult`,`ev.text` 只有末步那段。
  模型在工具之间穿插说的话(「我先查一下」)全在前面的步里,只取 ev.text 就永远不进记录 ——
  而排查"它当时到底说了什么"全靠这一列。
  ️ 线上实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢
    (它把话都留到了最后一步), 别指望每次都这样。
  ⇒ `joinStepTexts` 拼所有步,去重末步(它通常已在 steps 末项里)。

验证:
  · tsc(service + web)通过;jest 85 套 1330 例全过(新增 5);eslint 干净
  · 真模型跑三轮:会话A 两轮共用 run=1cd20f63、会话B 独立 run=985bf1d3,
    按 run 分组正好 2 个会话 

️ 未做,留给产品定:界面 63 处写「时效」、0 处「时限」,而文档已改口径为「时限」。
  提示词的铁律是「使用者的词汇表 = 他在界面上见过的那些」⇒ 现在**该改的是文档或界面**,
   不能只把提示词单方面改成「时限」。
parent fe79f0f3
...@@ -18,10 +18,22 @@ interface UploadedAudio { ...@@ -18,10 +18,22 @@ interface UploadedAudio {
mimetype: string; mimetype: string;
} }
/** 只认标准 uuid —— conversationId 直接进 `workflow_run_id`(uuid 列),⛔ 别让脏值打到数据库。 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface ChatBody { interface ChatBody {
messages: ModelMessage[]; messages: ModelMessage[];
model?: string; model?: string;
/** /**
* 这一串对话的 id —— 前端在**开始一段新对话时**现生成,此后每轮原样带上。
*
* 🔴 它是「哪几轮属于同一次对话」的**唯一可靠依据**。此前只能靠 `turnNo` 归 1 反推,
* 而那是启发式:两位主管并发聊天时行是交错的,前端一旦裁剪历史 turnNo 也会错位。
* ⚠️ 前端可改的入参 —— 只用于**归组**,⛔ 不参与任何鉴权或取数。
* 非 uuid 一律丢弃(服务端另生成),⛔ 不让它成为写库的注入面。
*/
conversationId?: string;
/**
* 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。 * 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。
* *
* 🔴 移交那句话里**没有诊所**(「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」), * 🔴 移交那句话里**没有诊所**(「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」),
...@@ -199,6 +211,7 @@ export class AssistantController { ...@@ -199,6 +211,7 @@ export class AssistantController {
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。 // ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段) // ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}), ...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
...(UUID_RE.test(body.conversationId ?? '') ? { conversationId: body.conversationId } : {}),
// ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0) // ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
...(typeof body.activePatientId === 'string' && body.activePatientId ...(typeof body.activePatientId === 'string' && body.activePatientId
? { activePatientId: body.activePatientId } ? { activePatientId: body.activePatientId }
......
...@@ -18,6 +18,7 @@ import { ...@@ -18,6 +18,7 @@ import {
slimInputSnapshot, slimInputSnapshot,
briefToolArgs, briefToolArgs,
truncateOutputText, truncateOutputText,
joinStepTexts,
assistantCallKey, assistantCallKey,
assistantInputHash, assistantInputHash,
type ToolTraceEntry, type ToolTraceEntry,
...@@ -104,6 +105,11 @@ export interface AssistantChatInput { ...@@ -104,6 +105,11 @@ export interface AssistantChatInput {
*/ */
activePatientId?: string; activePatientId?: string;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */ /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
/**
* 这一串对话的 id —— 落进 `agent_invocations.workflow_run_id`,
* 同一次对话的每一轮共用一个值。⛔ 缺失时服务端现生成(那一轮自成一组)。
*/
conversationId?: string;
scope?: TenantScopeContext; scope?: TenantScopeContext;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
...@@ -976,8 +982,12 @@ export class AssistantService { ...@@ -976,8 +982,12 @@ export class AssistantService {
promptVersion: ASSISTANT_PROMPT_VERSION, promptVersion: ASSISTANT_PROMPT_VERSION,
modelProvider: resolved.provider, modelProvider: resolved.provider,
modelName: resolved.modelId, modelName: resolved.modelId,
// 助手一轮 = 一个 run。⛔ 不跨轮串:每轮的上下文都不同,串起来没有可比性。 /**
workflowRunId: randomUUID(), * 🔴 **同一次对话的每一轮共用一个 run id** —— 这是「哪几轮是一批」的唯一可靠依据。
* ⚠️ 曾经每轮各生成一个,只能靠 `turnNo` 归 1 反推会话边界 —— 那是启发式:
* 并发对话行是交错的,前端裁剪历史时 turnNo 还会错位。
*/
workflowRunId: input.conversationId ?? randomUUID(),
inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot), inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
// ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨) // ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨)
inputSnapshot: snapshot as unknown as Prisma.InputJsonValue, inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
...@@ -1051,6 +1061,14 @@ export class AssistantService { ...@@ -1051,6 +1061,14 @@ export class AssistantService {
toolTrace: readonly ToolTraceEntry[], toolTrace: readonly ToolTraceEntry[],
ev: { ev: {
text?: string; text?: string;
/**
* 🔴 **多步时必须拼所有步的文本** —— `OnFinishEvent` 继承的是**最后一步**的
* `StepResult`,`ev.text` 只有末步那段。模型在工具之间穿插说的话
* (「我先查一下」「这批人比预想的少」)全在前面的步里,只取 `ev.text` 就丢了。
* ⚠️ 实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢,
* 因为它把话都留到了最后一步。⛔ 别指望它每次都这样。
*/
steps?: ReadonlyArray<{ text?: string }>;
finishReason?: string; finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口 // AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>; totalUsage?: Record<string, unknown>;
...@@ -1085,7 +1103,7 @@ export class AssistantService { ...@@ -1085,7 +1103,7 @@ export class AssistantService {
toolCallCount: toolTrace.length, toolCallCount: toolTrace.length,
finishReason: ev.finishReason ?? null, finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
outputText: truncateOutputText(ev.text), outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)),
promptTokens, promptTokens,
completionTokens, completionTokens,
totalTokens: num('totalTokens') || promptTokens + completionTokens, totalTokens: num('totalTokens') || promptTokens + completionTokens,
......
...@@ -3,6 +3,7 @@ import { ...@@ -3,6 +3,7 @@ import {
slimInputSnapshot, slimInputSnapshot,
briefToolArgs, briefToolArgs,
truncateOutputText, truncateOutputText,
joinStepTexts,
assistantCallKey, assistantCallKey,
assistantInputHash, assistantInputHash,
} from '../src/modules/assistant/assistant-invocation'; } from '../src/modules/assistant/assistant-invocation';
...@@ -106,6 +107,30 @@ describe('truncateOutputText', () => { ...@@ -106,6 +107,30 @@ describe('truncateOutputText', () => {
}); });
}); });
describe('joinStepTexts —— 多步时把每一步的话都留下', () => {
it('🔴 拼所有步,⛔ 不只取最后一步', () => {
const steps = [{ text: '我先查一下' }, { text: '' }, { text: '这批人比预想的少' }];
expect(joinStepTexts(steps, '这批人比预想的少')).toBe('我先查一下\n这批人比预想的少');
});
it('末步文本不在 steps 里时补上', () => {
expect(joinStepTexts([{ text: '第一步' }], '收尾这句')).toBe('第一步\n收尾这句');
});
it('⛔ 不把末步拼两遍', () => {
expect(joinStepTexts([{ text: 'a' }, { text: 'b' }], 'b')).toBe('a\nb');
});
it('没有 steps 时退回 ev.text(单步调用)', () => {
expect(joinStepTexts(undefined, '就一句')).toBe('就一句');
expect(joinStepTexts([], '就一句')).toBe('就一句');
});
it('全空返回 undefined', () => {
expect(joinStepTexts([{ text: '' }, {}], undefined)).toBeUndefined();
});
});
describe('callKey 按现场分', () => { describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => { it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment'); expect(assistantCallKey(true)).toBe('assistant_assignment');
......
...@@ -70,6 +70,8 @@ export async function transcribeAudio(blob: Blob): Promise<string> { ...@@ -70,6 +70,8 @@ export async function transcribeAudio(blob: Blob): Promise<string> {
export function useAssistantChat() { export function useAssistantChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
/** 当前这段对话的 id(见下方 send 里那段)—— 组件重挂即换新的。 */
const conversationIdRef = useRef<string>('');
const [status, setStatus] = useState<ChatStatus>('idle'); const [status, setStatus] = useState<ChatStatus>('idle');
/** /**
* 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。 * 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
...@@ -181,6 +183,16 @@ export function useAssistantChat() { ...@@ -181,6 +183,16 @@ export function useAssistantChat() {
*/ */
const apiMessages = [...messages, userMsg].flatMap(toApiMessages); const apiMessages = [...messages, userMsg].flatMap(toApiMessages);
/**
* ⭐ 这一串对话的 id —— **messages 为空就是一段新对话的第一句**,此刻现生成;
* 此后每轮原样带上,服务端拿它把同一次对话的各轮串成一组。
*
* 🔴 助手没有"新建会话"(产品定),会话跟着他此刻在做的那件事走 ——
* 离开工作台、组件重挂,messages 归空,下一句就是新的一段。
* ⚠️ 只用于**归组**,⛔ 不参与鉴权取数;服务端非 uuid 一律丢弃。
*/
if (messages.length === 0) conversationIdRef.current = crypto.randomUUID();
setMessages((prev) => [...prev, userMsg, assistantMsg]); setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming'); setStatus('streaming');
emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物) emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)
...@@ -458,6 +470,7 @@ export function useAssistantChat() { ...@@ -458,6 +470,7 @@ export function useAssistantChat() {
body: JSON.stringify({ body: JSON.stringify({
messages: apiMessages, messages: apiMessages,
model, model,
conversationId: conversationIdRef.current,
...(useAssistantStore.getState().activeClinicId ...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId } ? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}), : {}),
......
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