287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
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
|
|
// limit are preserved in full, including Markdown and long suggested questions.
|
|
export const MAX_TEACHER_REPLY_BYTES = 1024 * 1024;
|
|
export const MAX_TEACHER_QUICK_REPLIES = 128;
|
|
export const MAX_TEACHER_QUICK_REPLY_BYTES = 64 * 1024;
|
|
const INVALID_REPLY = '这次回复没有完整解析,请重试。';
|
|
const INVALID_FORMAT = '回复格式不完整或无效。';
|
|
const RESOURCE_LIMIT = '回复超过可处理的数据大小。';
|
|
const protocolKeys = new Set(['reply', 'quickReplies', 'intro', 'questions', 'tool']);
|
|
const own = (value: object, key: string) => Object.prototype.hasOwnProperty.call(value, key);
|
|
|
|
function withinBytes(value: string, max: number): boolean {
|
|
return value.length <= max && new TextEncoder().encode(value).length <= max;
|
|
}
|
|
|
|
function stringEnd(raw: string, start: number): number | undefined {
|
|
for (let index = start + 1; index < raw.length; index++) {
|
|
if (raw[index] === '\\') { index++; continue; }
|
|
if (raw[index] === '"') return index + 1;
|
|
}
|
|
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);
|
|
if (raw[start] !== '{' && raw[start] !== '[') {
|
|
let end = start;
|
|
while (end < raw.length && !/[,}\]\s]/.test(raw[end])) end++;
|
|
return end > start ? end : undefined;
|
|
}
|
|
const stack = [raw[start]];
|
|
for (let index = start + 1; index < raw.length; index++) {
|
|
const character = raw[index];
|
|
if (character === '"') {
|
|
const end = stringEnd(raw, index);
|
|
if (end === undefined) return undefined;
|
|
index = end - 1;
|
|
} else if (character === '{' || character === '[') stack.push(character);
|
|
else if (character === '}' || character === ']') {
|
|
if (stack.pop() !== (character === '}' ? '{' : '[')) return undefined;
|
|
if (!stack.length) return index + 1;
|
|
}
|
|
}
|
|
return 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; 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();
|
|
if (raw[index] !== '"') break;
|
|
const end = stringEnd(raw, index);
|
|
if (end === undefined) break;
|
|
let key: string;
|
|
try { key = JSON.parse(raw.slice(index, end)); } catch { break; }
|
|
index = end;
|
|
whitespace();
|
|
if (raw[index++] !== ':') break;
|
|
duplicate ||= keys.has(key);
|
|
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 || !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; }
|
|
}
|
|
index = next;
|
|
whitespace();
|
|
if (raw[index++] !== ',') break;
|
|
}
|
|
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 }
|
|
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] === '{') {
|
|
const kind = fields(raw.slice(scanned.toolStart)).values.get('kind');
|
|
if (typeof kind === 'string' && ['ideas', 'structure', 'flow', 'comparison'].includes(kind)) return true;
|
|
}
|
|
return /^\{\s*(?:reply|quickReplies)\s*:/.test(raw);
|
|
}
|
|
|
|
/** 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++) {
|
|
if (raw[index] === '`') {
|
|
let fenceEnd = index + 1;
|
|
while (raw[fenceEnd] === '`') fenceEnd++;
|
|
const close = raw.indexOf(raw.slice(index, fenceEnd), fenceEnd);
|
|
index = close >= 0 && close < end ? close + fenceEnd - index - 1 : end;
|
|
} else if (raw[index] === '[') {
|
|
const endOfArray = valueEnd(raw, index);
|
|
if (endOfArray !== undefined && endOfArray <= end) {
|
|
try {
|
|
if (Array.isArray(JSON.parse(raw.slice(index, endOfArray)))) index = endOfArray - 1;
|
|
} catch { /* A Markdown link is not a JSON array. */ }
|
|
}
|
|
} else if (raw[index] === '{') {
|
|
const objectEnd = valueEnd(raw, index);
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
const opening = /(?:^|\n)[ \t]*(`{3,}|~{3,})([^\r\n]*)\r?\n/g;
|
|
let processed = 0, match: RegExpExecArray | null;
|
|
while ((match = opening.exec(raw))) {
|
|
inspectText(processed, match.index);
|
|
const bodyStart = opening.lastIndex;
|
|
const marker = match[1][0], size = match[1].length;
|
|
const closing = new RegExp('(?:^|\\n)[ \\t]*' + marker + '{' + size + ',}[ \\t]*(?=\\r?\\n|$)', 'g');
|
|
closing.lastIndex = bodyStart;
|
|
const close = closing.exec(raw);
|
|
const body = raw.slice(bodyStart, close?.index ?? raw.length).trim();
|
|
const language = match[2].trim().toLowerCase();
|
|
const explicit = ['makelore-teacher', 'makelore-teacher-reply', 'makelore-teacher-discussion'].includes(language);
|
|
if (explicit || ((language === 'json' || language === '') && body.startsWith('{') && isEnvelope(body))) {
|
|
result.push({ start: match.index, end: close ? closing.lastIndex : raw.length, content: body, incomplete: !close });
|
|
}
|
|
processed = close ? closing.lastIndex : raw.length;
|
|
opening.lastIndex = processed;
|
|
}
|
|
inspectText(processed, raw.length);
|
|
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;
|
|
let parseError: string | undefined;
|
|
let quickReplies: string[] = [];
|
|
if (suggestions !== undefined) {
|
|
if (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string' || !item.trim())) {
|
|
parseError = INVALID_FORMAT;
|
|
} else if (suggestions.length > MAX_TEACHER_QUICK_REPLIES
|
|
|| suggestions.some(item => !withinBytes(item, MAX_TEACHER_QUICK_REPLY_BYTES))) {
|
|
parseError = RESOURCE_LIMIT;
|
|
} else quickReplies = suggestions;
|
|
}
|
|
if (typeof reply !== 'string' || (!reply.trim() && !quickReplies.length)) {
|
|
return { reply: INVALID_REPLY, quickReplies: [], parseError: INVALID_FORMAT, incomplete: true };
|
|
}
|
|
return { reply, quickReplies, ...(parseError ? { parseError } : {}) };
|
|
}
|
|
|
|
/** 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, incomplete: true };
|
|
}
|
|
const text = raw.trim();
|
|
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;
|
|
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 {
|
|
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 };
|
|
}
|
|
// 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;
|
|
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;
|
|
}
|