95 lines
4.2 KiB
TypeScript
95 lines
4.2 KiB
TypeScript
import type { NextRequest } from 'next/server';
|
|
import { authorizeLearningRuntime, runtimeUnauthorized } from '@/lib/makelore-runtime/auth';
|
|
import {
|
|
courseKnowledge,
|
|
loadMakeloreCourse,
|
|
parseLearningBinding,
|
|
resolveMakeloreCourseModule,
|
|
runtimeSceneId,
|
|
} from '@/lib/makelore-runtime/course-store';
|
|
import { isRuntimeRunRequest } from '@/lib/makelore-runtime/protocol';
|
|
import { retrieveChunks } from '@/lib/qa/knowledge';
|
|
import { runQaAgent, type QaTurn } from '@/lib/qa/agent';
|
|
import { resolveModel } from '@/lib/server/resolve-model';
|
|
|
|
export const maxDuration = 120;
|
|
|
|
export async function POST(request: NextRequest) {
|
|
if (!authorizeLearningRuntime(request)) return runtimeUnauthorized();
|
|
const body: unknown = await request.json().catch(() => null);
|
|
if (!isRuntimeRunRequest(body)) return Response.json({ error: 'invalid_request' }, { status: 400 });
|
|
const binding = parseLearningBinding(body.binding.key);
|
|
if (!binding) return Response.json({ error: 'invalid_binding' }, { status: 400 });
|
|
const course = await loadMakeloreCourse(binding.courseId, binding.contentHash);
|
|
if (!course) return Response.json({ error: 'course_not_found' }, { status: 404 });
|
|
const module = await resolveMakeloreCourseModule(course, {
|
|
moduleId: body.input.anchor?.moduleId,
|
|
moduleContentHash: body.input.anchor?.moduleContentHash,
|
|
});
|
|
if (!module) return Response.json({ error: 'course_module_not_found' }, { status: 404 });
|
|
|
|
const knowledge = courseKnowledge(module);
|
|
const message = body.input.message!.trim().slice(0, 4000);
|
|
const retrieved = retrieveChunks(knowledge.knowledge, message, 3);
|
|
const anchor = body.input.anchor;
|
|
const sceneIndex = module.manifest?.scenes.findIndex((scene, index) =>
|
|
(Boolean(anchor?.sceneId) &&
|
|
anchor?.sceneId === runtimeSceneId(course, module.descriptor, index)) ||
|
|
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder),
|
|
) ?? -1;
|
|
const anchored = sceneIndex >= 0
|
|
? knowledge.knowledge.scenes[sceneIndex]
|
|
: knowledge.knowledge.scenes.find((scene) =>
|
|
(anchor?.sceneOrder !== undefined && scene.order === anchor.sceneOrder) ||
|
|
(Boolean(anchor?.sceneId) && scene.sceneId === anchor?.sceneId),
|
|
);
|
|
const chunks = anchored && !retrieved.some((chunk) => chunk.sceneId === anchored.sceneId)
|
|
? [{ ...anchored, score: Number.MAX_SAFE_INTEGER }, ...retrieved].slice(0, 3)
|
|
: retrieved;
|
|
const history: QaTurn[] = (body.input.history ?? [])
|
|
.filter((turn): turn is QaTurn => Boolean(turn)
|
|
&& (turn.role === 'user' || turn.role === 'assistant')
|
|
&& typeof turn.content === 'string')
|
|
.slice(-7)
|
|
.map((turn) => ({ role: turn.role, content: turn.content.slice(0, 4000) }));
|
|
history.push({ role: 'user', content: message });
|
|
const resolved = await resolveModel({ stage: 'qa-assistant' });
|
|
const encoder = new TextEncoder();
|
|
let sequence = 0;
|
|
const event = (type: string, payload: Record<string, unknown>, terminal = false) => `${JSON.stringify({
|
|
event_id: `${body.run_id}:${++sequence}`,
|
|
type,
|
|
payload,
|
|
schema_version: 1,
|
|
terminal,
|
|
...(terminal ? { outcome: 'completed' } : {}),
|
|
})}\n`;
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
try {
|
|
for await (const update of runQaAgent({ model: resolved.model, courseware: knowledge, chunks, thinkingConfig: resolved.thinkingConfig }, history)) {
|
|
if (update.type === 'text') {
|
|
controller.enqueue(encoder.encode(event('learning.assistant.delta', { delta: update.delta })));
|
|
} else if (update.type === 'done') {
|
|
controller.enqueue(encoder.encode(event('learning.assistant.completed', {
|
|
sources: update.sources.map((source) => ({ sceneId: source.sceneId, order: source.order, title: source.title })),
|
|
}, true)));
|
|
}
|
|
}
|
|
} catch {
|
|
controller.enqueue(encoder.encode(event('learning.assistant.failed', { code: 'learning_runtime_failed', message: '助教暂时无法回答' }, true)));
|
|
} finally {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
'Content-Encoding': 'identity',
|
|
'Cache-Control': 'no-store, no-transform',
|
|
},
|
|
});
|
|
}
|