Files
openmaic/OpenMAIC/lib/qa/agent.ts
2026-08-16 14:58:47 +08:00

196 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}