fix: preserve complete consultation replies and recover retained history
This commit is contained in:
@@ -74,7 +74,9 @@ export interface TeacherRequest {
|
||||
discussionSnapshot?: unknown;
|
||||
discussionError?: string;
|
||||
replyParseError?: string;
|
||||
/** Original answer retained when reply parsing fails; never fed back as context. */
|
||||
/** No trustworthy complete reply could be recovered from the received text. */
|
||||
replyIncomplete?: boolean;
|
||||
/** Original answer retained for failed or repaired parsing; never fed back as context. */
|
||||
unparsedResponse?: string;
|
||||
toolActivity?: TeacherToolActivity[];
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ export interface TeacherReply {
|
||||
reply: string;
|
||||
quickReplies: string[];
|
||||
parseError?: string;
|
||||
/** No unambiguous, complete prose field could be recovered. */
|
||||
incomplete?: boolean;
|
||||
/** Prose escaping was repaired; retain the original bytes for diagnosis. */
|
||||
recovered?: boolean;
|
||||
}
|
||||
|
||||
// Resource limits, not writing instructions. Ordinary answers below the transport
|
||||
@@ -27,6 +31,26 @@ function stringEnd(raw: string, start: number): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A quote alone is not a field boundary: it may be punctuation in the prose. */
|
||||
function fieldBoundary(raw: string, end: number): boolean {
|
||||
let index = end;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
if (raw[index] === '}') {
|
||||
while (index < raw.length && (raw[index] === '}' || /\s/.test(raw[index]))) index++;
|
||||
return index === raw.length;
|
||||
}
|
||||
if (raw[index++] !== ',') return false;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
if (index === raw.length) return true; // Complete value, missing later fields.
|
||||
if (raw[index] !== '"') return false;
|
||||
const keyEnd = stringEnd(raw, index);
|
||||
if (keyEnd === undefined) return false;
|
||||
try { JSON.parse(raw.slice(index, keyEnd)); } catch { return false; }
|
||||
index = keyEnd;
|
||||
while (/\s/.test(raw[index] ?? '') && index < raw.length) index++;
|
||||
return raw[index] === ':';
|
||||
}
|
||||
|
||||
/** Find one complete value without interpreting nested legacy tool data. */
|
||||
function valueEnd(raw: string, start: number): number | undefined {
|
||||
if (raw[start] === '"') return stringEnd(raw, start);
|
||||
@@ -52,10 +76,11 @@ function valueEnd(raw: string, start: number): number | undefined {
|
||||
}
|
||||
|
||||
/** Recover only complete top-level fields; nested reply-looking data stays inert. */
|
||||
function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>; duplicate: boolean; toolStart?: number } {
|
||||
function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>; duplicate: boolean; toolStart?: number; proseStart?: number } {
|
||||
const values = new Map<string, unknown>(), keys = new Set<string>();
|
||||
let duplicate = false, index = raw.indexOf('{') + 1;
|
||||
let toolStart: number | undefined;
|
||||
let proseStart: number | undefined;
|
||||
const whitespace = () => { while (index < raw.length && /\s/.test(raw[index])) index++; };
|
||||
while (index > 0 && index < raw.length) {
|
||||
whitespace();
|
||||
@@ -71,8 +96,9 @@ function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>;
|
||||
keys.add(key);
|
||||
whitespace();
|
||||
if (key === 'tool') toolStart = index;
|
||||
if ((key === 'reply' || key === 'intro') && proseStart === undefined) proseStart = index;
|
||||
const next = valueEnd(raw, index);
|
||||
if (next === undefined) break;
|
||||
if (next === undefined || !fieldBoundary(raw, next)) break;
|
||||
if ((protocolKeys.has(key) || key === 'kind') && key !== 'tool' && !values.has(key)) {
|
||||
try { values.set(key, JSON.parse(raw.slice(index, next))); } catch { break; }
|
||||
}
|
||||
@@ -80,7 +106,33 @@ function fields(raw: string): { values: Map<string, unknown>; keys: Set<string>;
|
||||
whitespace();
|
||||
if (raw[index++] !== ',') break;
|
||||
}
|
||||
return { values, keys, duplicate, toolStart };
|
||||
return { values, keys, duplicate, toolStart, proseStart };
|
||||
}
|
||||
|
||||
/** Repair only prose escaping, never fabricate missing text or consume other fields. */
|
||||
function repairProse(raw: string, start: number | undefined): string | undefined {
|
||||
if (start === undefined || raw[start] !== '"') return undefined;
|
||||
for (let index = start + 1; index < raw.length; index++) {
|
||||
if (raw[index] === '\\') { index++; continue; }
|
||||
if (raw[index] !== '"' || !fieldBoundary(raw, index + 1)) continue;
|
||||
// Stop at the first field boundary even if later auxiliary data is broken.
|
||||
// Searching past it could swallow tool data into a fabricated prose field.
|
||||
let encoded = '"';
|
||||
for (let cursor = start + 1; cursor < index; cursor++) {
|
||||
const character = raw[cursor];
|
||||
if (character === '\\') { encoded += character + raw[++cursor]; continue; }
|
||||
encoded += character === '"' || character.charCodeAt(0) < 32
|
||||
? JSON.stringify(character).slice(1, -1) : character;
|
||||
}
|
||||
encoded += '"';
|
||||
if (encoded === raw.slice(start, index + 1)) return undefined;
|
||||
const repaired = raw.slice(0, start) + encoded + raw.slice(index + 1);
|
||||
try {
|
||||
JSON.parse(repaired);
|
||||
return repaired;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Candidate { start: number; end: number; content: string; incomplete?: boolean }
|
||||
@@ -88,6 +140,13 @@ function isEnvelope(raw: string): boolean {
|
||||
const scanned = fields(raw);
|
||||
if (scanned.keys.has('reply') || scanned.keys.has('quickReplies')
|
||||
|| (scanned.keys.has('intro') && scanned.keys.has('questions'))) return true;
|
||||
if (scanned.keys.has('intro')) {
|
||||
const repaired = repairProse(raw, scanned.proseStart);
|
||||
if (repaired) {
|
||||
const envelope = JSON.parse(repaired) as Record<string, unknown>;
|
||||
if (own(envelope, 'intro') && own(envelope, 'questions')) return true;
|
||||
}
|
||||
}
|
||||
// "questions" and "tool" also occur in ordinary API/code examples. A tool
|
||||
// alone identifies the retired reply protocol only through its legacy kind.
|
||||
if (scanned.toolStart !== undefined && raw[scanned.toolStart] === '{') {
|
||||
@@ -99,6 +158,16 @@ function isEnvelope(raw: string): boolean {
|
||||
|
||||
/** Markdown examples stay prose unless a top-level reply envelope identifies them. */
|
||||
function candidates(raw: string): Candidate[] {
|
||||
if (raw.startsWith('{') && isEnvelope(raw)) {
|
||||
const end = valueEnd(raw, 0);
|
||||
try {
|
||||
if (end === undefined) throw new Error();
|
||||
JSON.parse(raw.slice(0, end));
|
||||
} catch {
|
||||
// Literal newlines/code fences within damaged prose belong to this envelope.
|
||||
return [{ start: 0, end: raw.length, content: raw }];
|
||||
}
|
||||
}
|
||||
const result: Candidate[] = [];
|
||||
const inspectText = (start: number, end: number) => {
|
||||
for (let index = start; index < end; index++) {
|
||||
@@ -116,9 +185,14 @@ function candidates(raw: string): Candidate[] {
|
||||
}
|
||||
} else if (raw[index] === '{') {
|
||||
const objectEnd = valueEnd(raw, index);
|
||||
const stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end;
|
||||
const content = raw.slice(index, stop);
|
||||
if (isEnvelope(content)) result.push({ start: index, end: stop, content });
|
||||
let stop = objectEnd !== undefined && objectEnd <= end ? objectEnd : end;
|
||||
let content = raw.slice(index, stop);
|
||||
if (isEnvelope(content)) {
|
||||
// A stray quote can also fool bracket scanning. Keep the received tail
|
||||
// with the malformed envelope instead of appending it as normal prose.
|
||||
try { JSON.parse(content); } catch { stop = end; content = raw.slice(index, stop); }
|
||||
result.push({ start: index, end: stop, content });
|
||||
}
|
||||
index = stop - 1;
|
||||
}
|
||||
}
|
||||
@@ -145,6 +219,11 @@ function candidates(raw: string): Candidate[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Historical diagnostics must be identifiable as our envelope, not arbitrary raw text. */
|
||||
export function isTeacherReplyEnvelope(raw: string): boolean {
|
||||
return withinBytes(raw, MAX_TEACHER_REPLY_BYTES) && candidates(raw.trim()).length > 0;
|
||||
}
|
||||
|
||||
function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
const reply = own(envelope, 'reply') ? envelope.reply : envelope.intro;
|
||||
const suggestions = own(envelope, 'quickReplies') ? envelope.quickReplies : envelope.questions;
|
||||
@@ -159,7 +238,7 @@ function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
} else quickReplies = suggestions;
|
||||
}
|
||||
if (typeof reply !== 'string' || (!reply.trim() && !quickReplies.length)) {
|
||||
return { reply: INVALID_REPLY, quickReplies, parseError: INVALID_FORMAT };
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
}
|
||||
return { reply, quickReplies, ...(parseError ? { parseError } : {}) };
|
||||
}
|
||||
@@ -167,31 +246,40 @@ function projectReply(envelope: Record<string, unknown>): TeacherReply {
|
||||
/** Only text and suggested replies survive; legacy tool payloads are never restored. */
|
||||
export function parseTeacherReply(raw: string): TeacherReply {
|
||||
if (!withinBytes(raw, MAX_TEACHER_REPLY_BYTES)) {
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT };
|
||||
return { reply: INVALID_REPLY, quickReplies: [], parseError: RESOURCE_LIMIT, incomplete: true };
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT };
|
||||
if (!text) return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
const found = candidates(text);
|
||||
if (!found.length) return { reply: text, quickReplies: [] };
|
||||
const candidate = found[0];
|
||||
let result: TeacherReply;
|
||||
const recovered = fields(candidate.content);
|
||||
let recovered = fields(candidate.content);
|
||||
try {
|
||||
const envelope: unknown = JSON.parse(candidate.content);
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error();
|
||||
result = projectReply(envelope as Record<string, unknown>);
|
||||
} catch {
|
||||
result = projectReply(Object.fromEntries(recovered.values));
|
||||
result.parseError = INVALID_FORMAT;
|
||||
const repaired = repairProse(candidate.content, recovered.proseStart);
|
||||
if (repaired) {
|
||||
recovered = fields(repaired);
|
||||
result = { ...projectReply(JSON.parse(repaired)), recovered: true };
|
||||
} else {
|
||||
result = projectReply(Object.fromEntries(recovered.values));
|
||||
result.parseError = INVALID_FORMAT;
|
||||
}
|
||||
}
|
||||
if (candidate.incomplete) result.parseError = INVALID_FORMAT;
|
||||
if (recovered.duplicate || found.length > 1) {
|
||||
result = { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
||||
}
|
||||
if (candidate.incomplete || recovered.duplicate || found.length > 1) result.parseError = INVALID_FORMAT;
|
||||
// Multiple envelopes are ambiguous; never append their JSON as visible prose.
|
||||
if (found.length === 1) {
|
||||
const suffix = text.slice(candidate.end).trim();
|
||||
const brokenTail = /^[,\s]*"(?:reply|quickReplies|intro|questions|tool)"\s*:/.test(suffix)
|
||||
|| (suffix.length > 0 && /^[}\],\s]+$/.test(suffix));
|
||||
if (brokenTail) result.parseError = INVALID_FORMAT;
|
||||
result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix]
|
||||
if (!result.incomplete) result.reply = [text.slice(0, candidate.start).trim(), result.reply, brokenTail ? '' : suffix]
|
||||
.filter(part => part.length > 0).join('\n\n');
|
||||
}
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user