// 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; } 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 { 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`, 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 { 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 }; }