fix: preserve complete consultation replies and recover retained history

This commit is contained in:
鲨鱼辣椒
2026-09-28 17:38:31 +08:00
parent dda2f0a3be
commit d530abe580
14 changed files with 643 additions and 38 deletions

View File

@@ -62,12 +62,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource
id: 'teacher:' + request.id + ':user', role: 'user' as const,
text: [...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'),
}]),
{
...(request.replyIncomplete ? [] : [{
id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const,
text: [request.response, ...(request.suggestedQuestions?.length
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map(text => '- ' + text).join('\n')]
: [])].join('\n\n'),
},
}]),
]);
}
// A bounded text estimate for the native model path, not an exact tokenizer or

View File

@@ -2,7 +2,7 @@ import type { TeacherRequest } from '../../shared/coding-teacher';
import { parseTeacherReply } from '../../shared/teacher-reply';
export function teacherReplyInstructions(): string {
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
}
/** The original response remains available for diagnosis when parsing is incomplete. */
@@ -10,11 +10,14 @@ export function applyTeacherReply(request: TeacherRequest, raw: string): void {
const parsed = parseTeacherReply(raw);
request.response = parsed.reply;
request.suggestedQuestions = parsed.quickReplies;
if (parsed.incomplete) request.replyIncomplete = true;
else delete request.replyIncomplete;
if (parsed.parseError) {
request.replyParseError = parsed.parseError;
request.unparsedResponse = raw;
} else {
delete request.replyParseError;
delete request.unparsedResponse;
delete request.discussionError;
}
if (parsed.parseError || parsed.recovered) request.unparsedResponse = raw;
else delete request.unparsedResponse;
}

View File

@@ -1,9 +1,40 @@
import { mkdir, readdir } from 'node:fs/promises';
import path from 'node:path';
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
import type { TeacherRequest, TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-reply';
import { TeacherError } from './config-client';
/** Repair only received, completed protocol replies; never resume a model request. */
function repairCompletedReply(request: TeacherRequest): void {
const raw = request.unparsedResponse;
if (request.status !== 'completed' || typeof raw !== 'string'
|| !(request.discussionError || request.replyParseError || request.replyIncomplete)
|| !isTeacherReplyEnvelope(raw)) return;
const parsed = parseTeacherReply(raw);
if (parsed.incomplete) {
// Keep the previous text and diagnostics as evidence, but do not present a
// legacy half-sentence as a complete reply.
request.replyIncomplete = true;
return;
}
const previous = request.response.trim();
// The retained raw data must extend or confirm the existing prose. A stale or
// unrelated archive must not replace a useful answer with different content.
if (previous && !parsed.reply.trim().startsWith(previous)
&& previous !== '这次回复没有完整解析,请重试。'
&& previous !== raw.trim() && !request.replyIncomplete) return;
request.response = parsed.reply;
request.suggestedQuestions = parsed.quickReplies;
delete request.replyIncomplete;
if (parsed.parseError) request.replyParseError = parsed.parseError;
else {
delete request.replyParseError;
delete request.discussionError;
}
// Unlike applying a new reply, reading history never discards the original.
}
export function teacherTopicId(id: string): string {
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id))
throw new TeacherError(400, 'teacher_topic_invalid', '智能体话题标识无效。');
@@ -60,16 +91,20 @@ export class TeacherTopicStore {
throw new TeacherError(409, 'teacher_topic_invalid', '智能体历史无法读取。');
let recovered = false;
// Legacy component fields are opaque archives: never validate, revive or delete them.
for (const request of topic.requests)
for (const request of topic.requests) {
if (request.status === 'preparing' || request.status === 'running') {
request.status = 'interrupted';
request.error = '应用已重启,本次回复中断。';
recovered = true;
}
}
if (recovered) {
topic.revision++;
await this.save(topic);
}
// Apply display repair after restart recovery is saved, so reading a
// mixed topic does not incidentally persist completed-reply repairs.
for (const request of topic.requests) repairCompletedReply(request);
return topic;
})();
this.cache.set(id, pending);