196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
// Single-agent teaching assistant — the Q&A engine for the learner end.
|
||
//
|
||
// One agent, one tool (`web_search`), retrieval-augmented: the system prompt
|
||
// carries the courseware's retrieved knowledge chunks + the learner's profile
|
||
// digest; when the knowledge is insufficient the model may call web_search
|
||
// (bounded turns). Streams SSE events:
|
||
//
|
||
// { type: 'text', delta } answer text deltas (AI SDK textStream)
|
||
// { type: 'tool', tool: 'web_search', query, status, resultCount?, error? }
|
||
// { type: 'done', sources, toolCalls }
|
||
//
|
||
// No multi-agent orchestration, no ASR, no runtime generation — the learner
|
||
// session stays cheap and bounded (rate-limited at the route).
|
||
|
||
import { jsonSchema, tool } from 'ai';
|
||
import type { LanguageModel } from 'ai';
|
||
import { streamLLM } from '@/lib/ai/llm';
|
||
import type { ThinkingConfig } from '@/lib/types/provider';
|
||
import type { WebSearchResult } from '@/lib/types/web-search';
|
||
import type { CoursewareKnowledge, RetrievedChunk } from './knowledge';
|
||
|
||
export interface QaTurn {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
}
|
||
|
||
export interface QaSearchExecutor {
|
||
(query: string): Promise<WebSearchResult>;
|
||
}
|
||
|
||
export interface QaAgentOptions {
|
||
model: LanguageModel;
|
||
courseware: CoursewareKnowledge;
|
||
chunks: RetrievedChunk[];
|
||
/** Compact learner profile digest (goals/level/preferences) or undefined. */
|
||
userProfile?: string;
|
||
/** web_search tool; omit to run without tools. */
|
||
webSearch?: {
|
||
execute: QaSearchExecutor;
|
||
/** Max tool turns (default 2). */
|
||
maxTurns?: number;
|
||
};
|
||
thinkingConfig?: ThinkingConfig;
|
||
}
|
||
|
||
export type QaStreamEvent =
|
||
| { type: 'text'; delta: string }
|
||
| {
|
||
type: 'tool';
|
||
tool: 'web_search';
|
||
query: string;
|
||
status: 'started' | 'done';
|
||
resultCount?: number;
|
||
error?: string;
|
||
}
|
||
| { type: 'done'; sources: RetrievedChunk[]; toolCalls: number };
|
||
|
||
export const QA_MAX_CONTEXT_TURNS = 8;
|
||
|
||
export function buildQaSystemPrompt(courseware: CoursewareKnowledge, chunks: RetrievedChunk[], userProfile?: string): string {
|
||
const language = courseware.language ?? '中文';
|
||
const knowledgeBlock = chunks
|
||
.map(
|
||
(chunk, index) =>
|
||
`[${index + 1}] 场景《${chunk.title}》(第 ${chunk.order} 页)\n${chunk.text}${
|
||
chunk.narration ? `\n讲解:${chunk.narration}` : ''
|
||
}`,
|
||
)
|
||
.join('\n\n');
|
||
|
||
return [
|
||
`你是一位严谨又亲切的教学助教,正在辅导学生学习课程《${courseware.title}》。`,
|
||
`请使用${language}回答。`,
|
||
'',
|
||
'回答规则:',
|
||
'1. 优先基于下方「课件知识」回答,尽量具体、准确;引用时标注对应的场景标题。',
|
||
'2. 知识不足或涉及课件之外的实时信息时,可以使用 web_search 工具搜索,并在回答中注明信息来源。',
|
||
'3. 不要编造课件中没有的内容;不知道就明确说不知道,并给出下一步建议。',
|
||
'4. 回答简洁(一般不超过 300 字),可用小标题或列表;鼓励学习者继续学习。',
|
||
...(userProfile ? ['', '## 学习者档案', userProfile] : []),
|
||
'',
|
||
'## 课件知识(检索到的相关内容)',
|
||
knowledgeBlock || '(本次未检索到相关课件内容)',
|
||
].join('\n');
|
||
}
|
||
|
||
/**
|
||
* Run the single-agent Q&A loop, yielding SSE events. The AI SDK drives the
|
||
* tool loop (`maxSteps`); web_search executions push tool events into a shared
|
||
* queue that is drained between text deltas.
|
||
*/
|
||
export async function* runQaAgent(
|
||
options: QaAgentOptions,
|
||
turns: QaTurn[],
|
||
): AsyncGenerator<QaStreamEvent> {
|
||
const { model, courseware, chunks, userProfile, webSearch, thinkingConfig } = options;
|
||
const maxTurns = webSearch?.maxTurns ?? 2;
|
||
const pendingToolEvents: QaStreamEvent[] = [];
|
||
let toolCalls = 0;
|
||
|
||
const tools = webSearch
|
||
? {
|
||
web_search: tool({
|
||
description:
|
||
'搜索互联网获取课件知识之外的实时或补充信息。输入一个简洁、独立的中文/英文搜索查询。',
|
||
// AI SDK v6 contract: JSON Schema via `inputSchema`. The schema
|
||
// type stays `Schema<unknown>`, so execute narrows at runtime.
|
||
inputSchema: jsonSchema({
|
||
type: 'object',
|
||
properties: { query: { type: 'string', minLength: 2, maxLength: 200 } },
|
||
required: ['query'],
|
||
}),
|
||
execute: async (input: unknown) => {
|
||
const rawQuery = (input as { query?: unknown } | undefined)?.query;
|
||
const query =
|
||
typeof rawQuery === 'string' && rawQuery.trim().length >= 2
|
||
? rawQuery.trim().slice(0, 200)
|
||
: '';
|
||
toolCalls += 1;
|
||
pendingToolEvents.push({
|
||
type: 'tool',
|
||
tool: 'web_search',
|
||
query,
|
||
status: 'started',
|
||
});
|
||
if (!query) {
|
||
pendingToolEvents.push({
|
||
type: 'tool',
|
||
tool: 'web_search',
|
||
query,
|
||
status: 'done',
|
||
error: 'empty search query',
|
||
});
|
||
return { answer: '', sources: [], error: '搜索查询为空' };
|
||
}
|
||
try {
|
||
const result = await webSearch.execute(query);
|
||
pendingToolEvents.push({
|
||
type: 'tool',
|
||
tool: 'web_search',
|
||
query,
|
||
status: 'done',
|
||
resultCount: result.sources?.length ?? 0,
|
||
});
|
||
return {
|
||
answer: result.answer ?? '',
|
||
sources:
|
||
result.sources?.slice(0, 5).map((r) => ({
|
||
title: r.title ?? '',
|
||
url: r.url ?? '',
|
||
snippet: r.content ?? '',
|
||
})) ?? [],
|
||
error: undefined,
|
||
};
|
||
} catch (error) {
|
||
pendingToolEvents.push({
|
||
type: 'tool',
|
||
tool: 'web_search',
|
||
query,
|
||
status: 'done',
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
return { answer: '', sources: [], error: '搜索失败' };
|
||
}
|
||
},
|
||
}),
|
||
}
|
||
: undefined;
|
||
|
||
const boundedTurns = turns.slice(-QA_MAX_CONTEXT_TURNS);
|
||
const stream = streamLLM(
|
||
{
|
||
model,
|
||
system: buildQaSystemPrompt(courseware, chunks, userProfile),
|
||
messages: boundedTurns,
|
||
...(tools ? { tools, maxSteps: maxTurns + 1 } : {}),
|
||
},
|
||
'qa-assistant',
|
||
thinkingConfig,
|
||
);
|
||
|
||
const drainToolEvents = function* drain(): Generator<QaStreamEvent> {
|
||
while (pendingToolEvents.length > 0) {
|
||
yield pendingToolEvents.shift() as QaStreamEvent;
|
||
}
|
||
};
|
||
|
||
for await (const delta of stream.textStream) {
|
||
yield* drainToolEvents();
|
||
yield { type: 'text', delta };
|
||
}
|
||
yield* drainToolEvents();
|
||
|
||
yield { type: 'done', sources: chunks, toolCalls };
|
||
}
|