fix: render consultation replies and typed tool activity

This commit is contained in:
2026-09-24 14:36:20 +08:00
parent a628860144
commit 97837cef90
19 changed files with 432 additions and 35 deletions

View File

@@ -83,6 +83,9 @@ export interface TeacherRequest {
discussionContext?: TeacherDiscussionContext;
discussionSnapshot?: TeacherDiscussionContent;
discussionError?: string;
/** Original answer retained when discussion parsing fails; never fed back as context. */
unparsedResponse?: string;
toolActivity?: TeacherToolActivity[];
intent?: TeacherRequestIntent;
sourceConversationId?: string;
@@ -105,6 +108,11 @@ export interface TeacherRequest {
cloudRequestId?: string;
progress?: string;
}
export interface TeacherToolActivity {
id: string;
name: string;
status: 'running' | 'completed' | 'failed';
}
export interface TeacherTopic {
discussion?: TeacherDiscussion;
role?: LegacyConsultationRole;

View File

@@ -223,8 +223,8 @@ function stringToken(raw: string, start: number): { value: string; end: number }
return undefined;
}
/** Recover only a complete top-level JSON string field, never nested tool data or a partial string. */
function recoverReply(raw: string): string | undefined {
/** Inspect only top-level fields, including when a later value was truncated. */
function findTopLevelField(raw: string, names: readonly string[]): number | undefined {
if (!raw.trimStart().startsWith('{')) return undefined;
let depth = 0, expectingKey = false;
for (let cursor = 0; cursor < raw.length; cursor++) {
@@ -232,16 +232,13 @@ function recoverReply(raw: string): string | undefined {
if (character === '"') {
const token = stringToken(raw, cursor);
if (!token) return undefined;
if (depth === 1 && expectingKey && token.value === 'reply') {
if (depth === 1 && expectingKey && names.includes(token.value)) {
let valueStart = token.end;
while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++;
if (raw[valueStart] !== ':') return undefined;
valueStart++;
while (/\s/.test(raw[valueStart] ?? '') && valueStart < raw.length) valueStart++;
if (raw[valueStart] !== '"') return undefined;
const value = stringToken(raw, valueStart)?.value;
if (value?.trim() && value.length <= MAX_REPLY_LENGTH) return value.trim();
return undefined;
return valueStart;
}
if (depth === 1) expectingKey = false;
cursor = token.end - 1;
@@ -257,6 +254,14 @@ function recoverReply(raw: string): string | undefined {
return undefined;
}
/** Recover a complete reply string, never nested tool data or a partial string. */
function recoverReply(raw: string): string | undefined {
const start = findTopLevelField(raw, ['reply']);
if (start === undefined || raw[start] !== '"') return undefined;
const value = stringToken(raw, start)?.value;
return value?.trim() && value.length <= MAX_REPLY_LENGTH ? value.trim() : undefined;
}
function invalidReply(candidate: string): TeacherDiscussionReply {
return { reply: recoverReply(candidate) ?? INVALID_REPLY, quickReplies: [], toolError: INVALID_TOOL };
}
@@ -264,18 +269,15 @@ function invalidReply(candidate: string): TeacherDiscussionReply {
/** Parse final model output. Callers keep the existing tool whenever toolError is present. */
export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply {
const trimmed = raw.trim();
// JSON-labelled fences are transport, including truncated fences. Ordinary programming fences remain text.
const fence = /(?:^|\n)[ \t]*```(?:json|makelore-teacher(?:-discussion)?)[ \t]*(?:\r?\n|$)/i.exec(trimmed);
const bareFence = /^```[ \t]*\r?\n(?=\s*[{[])/.exec(trimmed);
const opening = fence ?? bareFence;
const unfencedEnvelope = /\{\s*"(?:reply|quickReplies|tool)"\s*:/.exec(trimmed);
const startsJson = /^[{[]/.test(trimmed);
const looksStructured = !!opening || !!unfencedEnvelope || startsJson || /^```(?:json|makelore-teacher)/i.test(trimmed);
if (!looksStructured) {
return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] };
}
let candidate = !opening && !startsJson && unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index) : trimmed;
// A Markdown link, JSON example or code fence is not a discussion envelope.
// Only our explicit fence or top-level protocol fields select this parser.
const firstFence = /(?:^|\n)[ \t]*```([^\r\n]*)[ \t]*(?:\r?\n|$)/.exec(trimmed);
const language = firstFence?.[1].trim().toLowerCase();
const explicitFence = language === 'makelore-teacher' || language === 'makelore-teacher-discussion';
const opening = firstFence && (explicitFence || language === 'json' || language === '') ? firstFence : null;
const unfencedEnvelope = !firstFence && !trimmed.startsWith('{')
? /(?:^|\n)[ \t]*(\{\s*"(?:reply|quickReplies|tool)"\s*:)/.exec(trimmed) : null;
let candidate = unfencedEnvelope ? trimmed.slice(unfencedEnvelope.index).trimStart() : trimmed;
let validFence = true;
if (opening) {
const remainder = trimmed.slice(opening.index + opening[0].length);
@@ -283,6 +285,9 @@ export function parseTeacherDiscussionReply(raw: string): TeacherDiscussionReply
candidate = closing ? remainder.slice(0, closing.index) : remainder;
validFence = !!closing && !/(?:^|\n)[ \t]*```/.test(candidate);
}
if (!explicitFence && findTopLevelField(candidate, ['reply', 'quickReplies', 'tool']) === undefined) {
return { reply: trimmed.slice(0, MAX_REPLY_LENGTH) || INVALID_REPLY, quickReplies: [] };
}
if (new TextEncoder().encode(trimmed).length > MAX_INPUT_BYTES) return invalidReply(candidate.slice(0, MAX_INPUT_BYTES));
if (!validFence) return invalidReply(candidate);
try {