diff --git a/client/electron/ai/handlers/sessionAnalyzeHandler.ts b/client/electron/ai/handlers/sessionAnalyzeHandler.ts index 21265d68..995d7944 100644 --- a/client/electron/ai/handlers/sessionAnalyzeHandler.ts +++ b/client/electron/ai/handlers/sessionAnalyzeHandler.ts @@ -37,6 +37,8 @@ function register(): void { audioText: string | null; }>; duration: number; // 已转为秒 + /** 分析模式:full 全量 / partial 增量片段(网关据此选择 Prompt 模板) */ + mode?: 'full' | 'partial'; language?: string; authToken?: string; userApiKey?: string; @@ -45,7 +47,7 @@ function register(): void { const startMs = Date.now(); const kfCount = args.keyframes?.length ?? 0; const segCount = args.audioSegments?.length ?? 0; - logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`); + logger.info(`[AI] [session-analyze] IPC received: keyframes=${kfCount}, audioSegments=${segCount}, duration=${args.duration}s, mode=${args.mode ?? 'full'}, language=${args.language ?? 'zh'}, hasAuth=${!!args.authToken}`); const reqBody = { keyframes: args.keyframes.map(kf => ({ @@ -59,6 +61,7 @@ function register(): void { audio_text: seg.audioText, })), duration: args.duration, + mode: args.mode ?? 'full', language: args.language ?? 'zh', }; diff --git a/client/electron/audioCapture.ts b/client/electron/audioCapture.ts index 85757a9b..bc07da2b 100644 --- a/client/electron/audioCapture.ts +++ b/client/electron/audioCapture.ts @@ -9,6 +9,11 @@ * 5. PCM 数据块通过 IPC 回传主进程,添加单调时间戳后推送给消费者 * * @ai-context: 系统音频捕获:渲染进程 getDisplayMedia 采集、主进程聚合分块。 + * + * TODO(现场课程): 当前仅支持系统音频环回(捕获电脑播放的声音,适配网课场景)。 + * 后续「现场课程」需扩展麦克风输入源:listAudioSources 增加枚举 + * navigator.mediaDevices 的 audioinput 设备,getUserMedia 直接以 deviceId + * 采集麦克风,并与环回源并列供用户选择(或双路混合)。 */ import { desktopCapturer, DesktopCapturerSource, BrowserWindow } from 'electron'; @@ -122,14 +127,18 @@ export class AudioCapture { * 开始音频捕获 * * 1. 如果未指定 sourceId,自动选择第一个可用的系统音频源 - * 2. 向渲染进程发送启动指令(含 sourceId + 配置) - * 3. 渲染进程负责 getUserMedia 和音频切片 + * 2. 方案A:若指定的是窗口源(window:xxx),优先尝试窗口级音频捕获, + * 同时解析一个屏幕环回源作为降级候选(Windows 上窗口源音频支持 + * 依赖 Chromium 版本,失败时渲染进程自动回退环回源) + * 3. 向渲染进程发送启动指令(含 sourceId + fallbackSourceId + 配置) + * 4. 渲染进程负责 getUserMedia 和音频切片 */ async start(win: BrowserWindow, sourceId?: string): Promise { if (this.capturing || this.disposed) return; // 解析音频源 let resolvedSourceId = sourceId ?? null; + let fallbackSourceId: string | null = null; if (!resolvedSourceId) { const sources = await listAudioSources(); if (sources.length === 0) { @@ -138,6 +147,15 @@ export class AudioCapture { } resolvedSourceId = sources[0].id; logger.info(`[AudioCapture] 自动选择音频源: ${sources[0].name} (${resolvedSourceId})`); + } else if (resolvedSourceId.startsWith('window:')) { + // 方案A:窗口源直采目标应用(如 B站客户端/浏览器)的音频, + // 预解析屏幕环回源作为降级候选,防御窗口级捕获不受支持的环境 + const sources = await listAudioSources(); + fallbackSourceId = sources[0]?.id ?? null; + logger.info( + `[AudioCapture] 方案A 窗口源音频捕获: ${resolvedSourceId}, ` + + `降级候选: ${fallbackSourceId ?? '无'}`, + ); } this.capturing = true; @@ -153,6 +171,7 @@ export class AudioCapture { if (!win.isDestroyed()) { win.webContents.send('audio_capture_do_start', { sourceId: resolvedSourceId, + fallbackSourceId, options: this.options, }); } diff --git a/client/electron/cspPolicy.ts b/client/electron/cspPolicy.ts index 60408ca5..870d2440 100644 --- a/client/electron/cspPolicy.ts +++ b/client/electron/cspPolicy.ts @@ -43,9 +43,10 @@ export function installCspPolicy(isDev: boolean): void { const extraConnectSrc = buildExtraConnectSrc(); // 开发环境:允许 unsafe-inline/unsafe-eval(Vite HMR 需要),worker-src 需要 blob: // 生产环境:禁止 unsafe-eval,保留 unsafe-inline(Tailwind 运行时需要) + // img-src 放行 keyframe:(课堂关键帧本地图片自定义协议,见 ipc/keyframeStorage.ts) const csp = isDev - ? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:;` - : `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`; + ? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: keyframe: https://*.supabase.co; font-src 'self' data:;` + : `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: keyframe: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`; callback({ responseHeaders: { diff --git a/client/electron/ipc/channels.ts b/client/electron/ipc/channels.ts index ef2a7178..41413440 100644 --- a/client/electron/ipc/channels.ts +++ b/client/electron/ipc/channels.ts @@ -56,6 +56,10 @@ export const IPC_CHANNELS = { // 文件系统 FS_READ_FILE: 'fs:read-file', + // 课堂关键帧图片持久化 + KEYFRAME_SAVE: 'keyframe_save', + KEYFRAME_CLEANUP: 'keyframe_cleanup', + // 存储路径 STORAGE_CHANGE_PATH: 'storage:change-path', STORAGE_GET_ACTIVE_PATH: 'storage:get-active-path', diff --git a/client/electron/ipc/keyframeStorage.ts b/client/electron/ipc/keyframeStorage.ts new file mode 100644 index 00000000..e4af8b1e --- /dev/null +++ b/client/electron/ipc/keyframeStorage.ts @@ -0,0 +1,113 @@ +/** + * 课堂关键帧图片持久化 IPC + keyframe:// 自定义协议 + * Classroom keyframe image persistence IPC handlers and custom + * keyframe:// protocol for renderer-side image loading. + * + * @ai-context: 关键帧 JPEG 本地落盘到 {userData}/captures/{sessionId}/{keyframeId}.jpg, + * 遵守本地优先原则(图片不上传)。渲染进程通过自定义 keyframe:// 协议加载: + * 开发页面源为 http://localhost、生产为 file://,file:// 子资源会被 + * webSecurity/CSP 拦截,故注册 standard+secure 专用协议(CSP img-src 已放行 keyframe:)。 + * (Custom protocol is used because file:// subresources are blocked by + * webSecurity/CSP under both dev http origin and prod file origin.) + * @ai-context: sessionId/keyframeId 仅允许 UUID/安全字符(防路径穿越); + * keyframe_cleanup 删除整个会话目录,由笔记删除路径调用(失败静默)。 + */ +import { app, net, protocol } from 'electron'; +import * as path from 'path'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { pathToFileURL } from 'url'; +import { safeHandle } from '../ipcUtils.js'; +import { logger } from '../logger.js'; + +/** 自定义协议名与固定 host(keyframe://capture/{sessionId}/{keyframeId}.jpg) */ +const KEYFRAME_SCHEME = 'keyframe'; +const KEYFRAME_HOST = 'capture'; + +/** 安全 ID 校验:仅允许 UUID/字母数字/下划线/连字符,最长 64(防路径穿越) */ +const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +function isSafeId(id: unknown): id is string { + return typeof id === 'string' && SAFE_ID_RE.test(id); +} + +/** 关键帧图片根目录:{userData}/captures */ +function capturesRoot(): string { + return path.join(app.getPath('userData'), 'captures'); +} + +/** + * 注册 keyframe:// 为特权 scheme。 + * 必须在 app ready 之前调用(main.ts 模块顶层)。 + * (Must be called before app ready.) + */ +export function registerKeyframeScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: KEYFRAME_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true }, + }, + ]); +} + +/** + * 注册 keyframe:// 协议 handler 与 keyframe_save / keyframe_cleanup IPC。 + * app ready 后调用一次(main.ts whenReady 中)。 + */ +export function registerKeyframeIpcHandlers(): void { + // ---- keyframe:// 协议:映射到本地 captures 目录(只读) ---- + if (protocol.isProtocolHandled(KEYFRAME_SCHEME)) { + protocol.unhandle(KEYFRAME_SCHEME); + } + protocol.handle(KEYFRAME_SCHEME, async (request) => { + try { + const url = new URL(request.url); + const [sessionId, fileName] = url.pathname.replace(/^\//, '').split('/'); + const keyframeId = fileName?.endsWith('.jpg') ? fileName.slice(0, -4) : undefined; + if (url.hostname !== KEYFRAME_HOST || !isSafeId(sessionId) || !isSafeId(keyframeId)) { + return new Response('Bad Request', { status: 400 }); + } + const filePath = path.join(capturesRoot(), sessionId, `${keyframeId}.jpg`); + return await net.fetch(pathToFileURL(filePath).toString()); + } catch (err) { + logger.warn(`[Keyframe] Protocol request failed: ${String(err)}`); + return new Response('Not Found', { status: 404 }); + } + }); + + // ---- 保存关键帧 JPEG(返回可渲染 URL)---- + safeHandle( + 'keyframe_save', + async (_event, args: { sessionId: string; keyframeId: string; imageBase64: string }) => { + const { sessionId, keyframeId, imageBase64 } = args ?? {}; + if (!isSafeId(sessionId) || !isSafeId(keyframeId)) { + throw new Error('非法的 sessionId/keyframeId'); + } + if (typeof imageBase64 !== 'string' || imageBase64.length === 0) { + throw new Error('imageBase64 不能为空'); + } + // 容忍 data URL 前缀(tolerate optional data URL prefix) + const commaIdx = imageBase64.indexOf(','); + const rawBase64 = imageBase64.startsWith('data:') && commaIdx >= 0 + ? imageBase64.slice(commaIdx + 1) + : imageBase64; + + const dir = path.join(capturesRoot(), sessionId); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, `${keyframeId}.jpg`), Buffer.from(rawBase64, 'base64')); + return { + success: true, + url: `${KEYFRAME_SCHEME}://${KEYFRAME_HOST}/${sessionId}/${keyframeId}.jpg`, + }; + }, + ); + + // ---- 清理会话目录(笔记删除时调用,失败由调用方静默处理)---- + safeHandle('keyframe_cleanup', async (_event, args: { sessionId: string }) => { + const sessionId = args?.sessionId; + if (!isSafeId(sessionId)) { + throw new Error('非法的 sessionId'); + } + await rm(path.join(capturesRoot(), sessionId), { recursive: true, force: true }); + return { success: true }; + }); +} diff --git a/client/electron/main.ts b/client/electron/main.ts index 96e7fe66..7c5f539f 100644 --- a/client/electron/main.ts +++ b/client/electron/main.ts @@ -35,6 +35,7 @@ import { loadEnvironment } from './envLoader.js'; import { installCspPolicy } from './cspPolicy.js'; import { registerDbIpcHandlers } from './db/dbIpcHandlers.js'; import { registerStorageIpcHandlers } from './storageIpcHandlers.js'; +import { registerKeyframeScheme, registerKeyframeIpcHandlers } from './ipc/keyframeStorage.js'; // ================================================================ // 性能优化:启用 GPU 光栅化与零拷贝 @@ -60,6 +61,9 @@ if (process.platform === 'darwin') { // ================================================================ loadEnvironment(__dirname); +// keyframe:// 特权 scheme 注册(必须在 app ready 前,详见 ipc/keyframeStorage.ts) +registerKeyframeScheme(); + // ================================================================ // 模块级状态 // ================================================================ @@ -139,6 +143,8 @@ if (!gotTheLock) { // 数据访问与存储/备份 IPC(详见 db/dbIpcHandlers.ts、storageIpcHandlers.ts) registerDbIpcHandlers(); registerStorageIpcHandlers(); + // 课堂关键帧图片持久化 IPC + keyframe:// 协议(详见 ipc/keyframeStorage.ts) + registerKeyframeIpcHandlers(); // 隐藏默认 Electron 菜单栏 Menu.setApplicationMenu(null); diff --git a/client/electron/preload.ts b/client/electron/preload.ts index 09c20909..c94ac57d 100644 --- a/client/electron/preload.ts +++ b/client/electron/preload.ts @@ -86,6 +86,9 @@ const ALLOWED_CHANNELS = [ // AI 流式输出 IPC channel 'ai:stream:start', 'ai:stream:cancel', + // 课堂关键帧图片持久化 IPC channel + 'keyframe_save', + 'keyframe_cleanup', ] as const; /** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */ diff --git a/client/public/sounds/pomodoro_complete.wav b/client/public/sounds/pomodoro_complete.wav new file mode 100644 index 00000000..6230db2b Binary files /dev/null and b/client/public/sounds/pomodoro_complete.wav differ diff --git a/client/public/sounds/pomodoro_tick.wav b/client/public/sounds/pomodoro_tick.wav deleted file mode 100644 index 5a1975cf..00000000 Binary files a/client/public/sounds/pomodoro_tick.wav and /dev/null differ diff --git a/client/src/features/classroom/hooks/useAudioRecovery.ts b/client/src/features/classroom/hooks/useAudioRecovery.ts new file mode 100644 index 00000000..011ed7cd --- /dev/null +++ b/client/src/features/classroom/hooks/useAudioRecovery.ts @@ -0,0 +1,114 @@ +/** + * 课堂音频自动恢复 hook(静音诊断 / 窗口源回退 / 设备变更重启) + * + * @ai-context: 从 useClassroomAudio 拆出的独立恢复层。三条恢复路径: + * ①窗口源静音回退——Windows 上窗口源(window:xxx)getUserMedia 可能"成功但 + * 给出纯静音轨"(不抛错,异常降级不会触发),SilenceTracker 检出连续静音后 + * 自动 stop→start 以环回源重启(每会话仅回退一次); + * ②输出设备不匹配提示——已在环回源仍持续静音,说明视频声音输出到了 + * 非默认设备,提示用户核对默认输出设备; + * ③设备变更重启——默认输出设备切换后环回仍绑定旧设备,devicechange 时 + * 自动重启重新绑定。 + * @ai-context: 仅依赖 refs 的稳定回调,重启经 restartingRef 互斥防止并发。 + */ +import { useEffect, useRef, useCallback } from 'react'; +import type { CaptureMode, SessionStatus } from '@/lib/capture'; +import { + computeChunkRms, SilenceTracker, + getDefaultOutputDeviceLabel, subscribeDeviceChange, +} from '@/lib/audio/outputDeviceMonitor'; + +/** 与 useSessionControl 启动参数保持一致的音频采集配置 */ +const AUDIO_START_OPTIONS = { chunkDurationMs: 5000, sampleRate: 16000, channels: 1 }; +/** 重启前等待渲染端管道清理完成的时间 */ +const RESTART_CLEANUP_DELAY_MS = 500; + +interface UseAudioRecoveryOptions { + status: SessionStatus; + mode: CaptureMode; + /** 方案A:会话选中窗口的源 ID(回退环回后置空) */ + audioSourceId?: string | null; + onNotify: (type: 'warning' | 'error', message: string) => void; +} + +export function useAudioRecovery({ status, mode, audioSourceId, onNotify }: UseAudioRecoveryOptions) { + const notifyRef = useRef(onNotify); + notifyRef.current = onNotify; + /** 当前生效的音频源(窗口源回退环回后变为 undefined) */ + const effectiveSourceRef = useRef(audioSourceId ?? undefined); + const silenceTrackerRef = useRef(new SilenceTracker()); + /** 本会话是否已执行过窗口源→环回回退 */ + const fellBackRef = useRef(false); + const restartingRef = useRef(false); + + const audioEnabled = status === 'capturing' && (mode === 'audio' || mode === 'mixed'); + + // 会话开始时重置恢复状态(audioSourceId 取会话启动瞬间的快照) + useEffect(() => { + if (status !== 'capturing') return; + effectiveSourceRef.current = audioSourceId ?? undefined; + fellBackRef.current = false; + silenceTrackerRef.current.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 仅会话开始时快照 audioSourceId + }, [status]); + + /** stop→start 重启音频捕获(互斥),成功返回 true */ + const restartCapture = useCallback(async (sourceId?: string): Promise => { + if (!window.electronAPI || restartingRef.current) return false; + restartingRef.current = true; + try { + await window.electronAPI.invoke('audio_capture_stop'); + await new Promise((r) => setTimeout(r, RESTART_CLEANUP_DELAY_MS)); + const result = await window.electronAPI.invoke('audio_capture_start', { + ...AUDIO_START_OPTIONS, sourceId, + }) as { success: boolean; error?: string }; + if (result.success) silenceTrackerRef.current.reset(); + else console.warn('[useAudioRecovery] 音频捕获重启失败:', result.error); + return result.success; + } catch (err) { + console.error('[useAudioRecovery] 音频捕获重启异常:', err); + return false; + } finally { + restartingRef.current = false; + } + }, []); + + // 静音诊断:窗口源先自动回退环回;已是环回仍静音则提示核对输出设备 + useEffect(() => { + if (!audioEnabled || !window.electronAPI) return; + const off = window.electronAPI.on('audio_capture_chunk', (...args: unknown[]) => { + const chunk = args[0] as { audioBuffer: ArrayBuffer }; + if (!silenceTrackerRef.current.push(computeChunkRms(chunk.audioBuffer))) return; + + const src = effectiveSourceRef.current; + if (src?.startsWith('window:') && !fellBackRef.current) { + // 方案A 漏洞补偿:窗口源"成功但静音"不抛错,只能靠静音检测触发回退 + fellBackRef.current = true; + effectiveSourceRef.current = undefined; + console.warn('[useAudioRecovery] 窗口音频源持续静音,自动回退系统环回'); + void restartCapture(undefined).then((ok) => { + if (ok) notifyRef.current('warning', '窗口音频源持续静音,已自动切换为系统环回捕获'); + }); + return; + } + void getDefaultOutputDeviceLabel().then((label) => { + notifyRef.current('warning', + `持续收到静音音频:请确认视频声音正在播放,且输出到系统默认设备${label ? `「${label}」` : ''}(音频捕获只能录到默认输出设备的声音)`); + }); + }); + return off; + }, [audioEnabled, restartCapture]); + + // 设备变更自动重启:重新绑定新的默认输出设备 + useEffect(() => { + if (!audioEnabled || !window.electronAPI) return; + const unsubscribe = subscribeDeviceChange(() => { + console.info('[useAudioRecovery] 检测到音频设备变更,自动重启音频捕获'); + void restartCapture(effectiveSourceRef.current).then((ok) => { + if (ok) notifyRef.current('warning', '检测到音频输出设备变更,已自动重新绑定音频捕获'); + else notifyRef.current('error', '音频设备变更后重启捕获失败,请停止后重新开始采集'); + }); + }); + return unsubscribe; + }, [audioEnabled, restartCapture]); +} diff --git a/client/src/features/classroom/hooks/useClassroomAnalysis.ts b/client/src/features/classroom/hooks/useClassroomAnalysis.ts index f05db85e..241b6655 100644 --- a/client/src/features/classroom/hooks/useClassroomAnalysis.ts +++ b/client/src/features/classroom/hooks/useClassroomAnalysis.ts @@ -32,11 +32,13 @@ interface UseClassroomAnalysisOptions { setSmartBundle: React.Dispatch>>; videoFilePath: string | null; recordingStatus: RecordingStatus | null; + /** 真实采集会话 ID(关联笔记与关键帧图片目录),缺省时持久化处退化为随机 UUID */ + captureSessionIdRef?: React.MutableRefObject; onWarn: (message: string) => void; } export function useClassroomAnalysis({ - language, smartBundle, setSmartBundle, videoFilePath, recordingStatus, onWarn, + language, smartBundle, setSmartBundle, videoFilePath, recordingStatus, captureSessionIdRef, onWarn, }: UseClassroomAnalysisOptions) { const [isAnalyzing, setIsAnalyzing] = useState(false); const [analysisResult, setAnalysisResult] = useState(null); @@ -55,7 +57,10 @@ export function useClassroomAnalysis({ timeline: smartBundle.timeline ?? [], duration: smartBundle.duration ?? 0, }; - const result = await analyzeSession(fullBundle, { language }); + const result = await analyzeSession(fullBundle, { + language, + sessionId: captureSessionIdRef?.current ?? undefined, + }); setAnalysisResult(result); // 全量分析完成,释放所有 keyframe imageBase64 内存 setSmartBundle((prev) => ({ @@ -67,7 +72,7 @@ export function useClassroomAnalysis({ } finally { setIsAnalyzing(false); } - }, [smartBundle, language, setSmartBundle]); + }, [smartBundle, language, setSmartBundle, captureSessionIdRef]); /** Path C:视频分析 */ const handleVideoAnalyze = useCallback(async (filePath?: string) => { @@ -97,12 +102,16 @@ export function useClassroomAnalysis({ const result = await mergeNotes(partials, { duration: durationMs / 1000, language, + sessionId: captureSessionIdRef?.current ?? undefined, }); setAnalysisResult(result); } catch { // 降级:本地拼接片段笔记(无需 AI,零网络,避免全量重发) + // 为每个片段插入分隔标题,避免拼接后内容边界不清 setAnalysisResult({ - content: partials.join('\n\n---\n\n'), + content: partials + .map((p, idx) => `## 片段 ${idx + 1}\n\n${p.trim()}`) + .join('\n\n---\n\n'), keyframesAnalyzed: keyframeCount, modelUsed: 'local-concat', }); @@ -110,7 +119,7 @@ export function useClassroomAnalysis({ } finally { setIsAnalyzing(false); } - }, [language, onWarn]); + }, [language, onWarn, captureSessionIdRef]); const handleDismissAnalysis = useCallback(() => { setAnalysisResult(null); diff --git a/client/src/features/classroom/hooks/useClassroomAudio.ts b/client/src/features/classroom/hooks/useClassroomAudio.ts index c0a38634..6d03aa25 100644 --- a/client/src/features/classroom/hooks/useClassroomAudio.ts +++ b/client/src/features/classroom/hooks/useClassroomAudio.ts @@ -10,6 +10,11 @@ * 累积到 chunkDurationMs 再整块发送,保证 VAD/ASR 拿到完整音频段。 * @ai-context: 健康 watchdog 区分两种故障——"开始后 15s 从未收到音频块" * (管道未启动)与"曾正常但中断 >10s"(设备变更/被抢占),各自独立提示。 + * @ai-context: 静音诊断/窗口源回退/设备变更自动重启见 useAudioRecovery。 + * + * TODO(现场课程): 当前音频源固定为系统环回/窗口源(网课场景)。现场课程需 + * 支持麦克风输入:getUserMedia({ audio: { deviceId } }) 直采 audioinput 设备, + * 并启用 VADMarker 的背景噪声校准(见 vadMarker.ts 同名 TODO)。 */ import { useState, useEffect, useRef } from 'react'; import type { AudioChunkData, CaptureMode, SessionStatus, CaptureManager } from '@/lib/capture'; @@ -23,6 +28,8 @@ const CHUNK_GAP_TIMEOUT_MS = 10000; interface AudioStartPayload { sourceId: string; + /** 方案A 窗口源不可用时的环回降级候选(主进程解析) */ + fallbackSourceId?: string | null; options: { sampleRate: number; channels: number; chunkDurationMs: number }; } @@ -39,6 +46,30 @@ interface UseClassroomAudioOptions { onNotify: (type: 'warning' | 'error', message: string) => void; } +/** + * 以桌面源打开系统/窗口音频流。 + * 方案A 防御:窗口源(window:xxx)音频捕获在部分 Windows/Chromium 环境 + * 不受支持(NotFoundError/NotAllowedError),失败时自动回退环回降级候选。 + */ +async function openDesktopAudioStream(payload: AudioStartPayload): Promise { + const request = (id: string) => navigator.mediaDevices.getUserMedia({ + audio: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: id, + } as MediaTrackConstraintSet, + }); + try { + return await request(payload.sourceId); + } catch (err) { + if (!payload.fallbackSourceId) throw err; + console.warn( + `[useClassroomCapture] 窗口源音频捕获失败(${payload.sourceId}),回退系统环回:`, + err, + ); + return request(payload.fallbackSourceId); + } +} + export function useClassroomAudio({ captureManager, status, mode, onNotify }: UseClassroomAudioOptions) { const [audioHealth, setAudioHealth] = useState({ lastChunkTime: 0, chunkCount: 0, isHealthy: true, @@ -121,12 +152,8 @@ export function useClassroomAudio({ captureManager, status, mode, onNotify }: Us const payload = args[0] as AudioStartPayload; (async () => { try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: payload.sourceId, - } as MediaTrackConstraintSet, - }); + // 方案A:优先窗口源直采,失败自动回退环回降级候选 + const stream = await openDesktopAudioStream(payload); const audioCtx = new AudioContext({ sampleRate: payload.options.sampleRate }); // 关键修复:非用户手势调用栈中创建的 AudioContext 默认 suspended, // 必须显式 resume(),否则 onaudioprocess 永不触发(0 音频块)。 diff --git a/client/src/features/classroom/hooks/useClassroomCapture.ts b/client/src/features/classroom/hooks/useClassroomCapture.ts index 56fc4999..3fa13891 100644 --- a/client/src/features/classroom/hooks/useClassroomCapture.ts +++ b/client/src/features/classroom/hooks/useClassroomCapture.ts @@ -21,6 +21,7 @@ import type { } from '@/lib/capture'; import { useClassroomEvents } from './useClassroomEvents'; import { useClassroomAudio } from './useClassroomAudio'; +import { useAudioRecovery } from './useAudioRecovery'; import { useWindowWatcher } from './useWindowWatcher'; import { useSessionControl } from './useSessionControl'; import { useClassroomAnalysis } from './useClassroomAnalysis'; @@ -59,7 +60,8 @@ export function useClassroomCapture() { const { selectedWindow } = windowWatcher; useEffect(() => { - if (!window.electronAPI || !selectedWindow) { + // 仅音频模式无截图采集,清空重启回调防止帧 watchdog 误重启截图 + if (!window.electronAPI || !selectedWindow || mode === 'audio') { frameRestartRef.current = null; return; } @@ -77,7 +79,7 @@ export function useClassroomCapture() { console.error('[useClassroomCapture] 保底重启失败:', err); } }; - }, [selectedWindow, config.screenshotInterval, status]); + }, [selectedWindow, config.screenshotInterval, status, mode]); // CaptureManager 单例 const captureManager = useMemo( @@ -104,16 +106,22 @@ export function useClassroomCapture() { captureManager, status, mode, onNotify: notify, }); + // 音频自动恢复:静音诊断 / 窗口源回退环回 / 设备变更重启 + useAudioRecovery({ + status, mode, audioSourceId: selectedWindow?.id, onNotify: notify, + }); + const analysis = useClassroomAnalysis({ language: config.language, smartBundle: events.smartBundle, setSmartBundle: events.setSmartBundle, videoFilePath: events.videoFilePath, recordingStatus: events.recordingStatus, + captureSessionIdRef: events.captureSessionIdRef, onWarn: (message) => notify('warning', message), }); - const notes = useClassroomNotes(courseMeta); + const notes = useClassroomNotes(courseMeta, events.smartBundle); /** 开始采集前重置本轮会话数据 */ const resetForStart = useCallback(() => { diff --git a/client/src/features/classroom/hooks/useClassroomEvents.ts b/client/src/features/classroom/hooks/useClassroomEvents.ts index f444b0f9..21c56303 100644 --- a/client/src/features/classroom/hooks/useClassroomEvents.ts +++ b/client/src/features/classroom/hooks/useClassroomEvents.ts @@ -28,6 +28,8 @@ import type { } from '@/lib/capture'; import { analyzePartial } from '@/lib/ai/sessionAnalyzer'; import { detectCourseFromFrame } from '@/lib/ai/courseDetector'; +import { remapKeyframeMarkers } from '../utils/tipTapImageUtils'; +import { persistKeyframeImage } from '../utils/keyframePersistence'; import { transcribeWithRetry, toAsrLanguage, useAsrSemaphore } from '../utils/asrTranscriber'; /** 触发一次增量分析所需的关键帧数 */ @@ -69,8 +71,22 @@ export function useClassroomEvents({ const pendingKeyframesRef = useRef([]); const isPartialAnalyzingRef = useRef(false); const courseDetectedRef = useRef(false); + /** @ai-context 会话时间基准(epoch ms):记录首帧 timestamp,供 analyzePartial 换算相对秒数 */ + const sessionStartMsRef = useRef(null); + /** 采集会话 ID(smart:keyframe 事件携带),供笔记持久化关联与关键帧图片清理 */ + const captureSessionIdRef = useRef(null); + /** 已派发增量分析的关键帧累计数,用于 [图:N] 局部编号 → 全局编号重映射 */ + const analyzedKeyframeOffsetRef = useRef(0); const asr = useAsrSemaphore(); + // 会话结束回到 idle 时重置时间基准(暂停/恢复不重置,避免相对时间戳跳变) + useEffect(() => { + if (status === 'idle') { + sessionStartMsRef.current = null; + analyzedKeyframeOffsetRef.current = 0; + } + }, [status]); + // 提取结果 useEffect(() => { const offCompleted = captureEventBus.on<{ @@ -104,11 +120,19 @@ export function useClassroomEvents({ const offKeyframe = captureEventBus.on<{ sessionId: string; keyframe: KeyFrame }>( 'smart:keyframe', (data) => { + // 记录首帧时间作为会话时间基准(epoch ms) + if (sessionStartMsRef.current === null) { + sessionStartMsRef.current = data.keyframe.timestamp; + } + captureSessionIdRef.current = data.sessionId; setSmartBundle((prev) => ({ ...prev, keyframes: [...(prev.keyframes ?? []), data.keyframe], })); + // 后台异步保存关键帧图片并回填 fileUrl(失败静默;分析后 imageBase64 仍会被清空) + persistKeyframeImage(data.sessionId, data.keyframe, setSmartBundle); + // AI 课程识别:仅第 1 帧触发一次 if (aiDetectEnabled && !courseDetectedRef.current) { courseDetectedRef.current = true; @@ -125,10 +149,14 @@ export function useClassroomEvents({ pendingKeyframesRef.current.push(data.keyframe); if (pendingKeyframesRef.current.length >= INCREMENTAL_BATCH_SIZE && !isPartialAnalyzingRef.current) { const batch = pendingKeyframesRef.current.splice(0, INCREMENTAL_BATCH_SIZE); + // 记录本批在全量关键帧序列中的偏移(派发顺序即到达顺序) + const globalOffset = analyzedKeyframeOffsetRef.current; + analyzedKeyframeOffsetRef.current += batch.length; isPartialAnalyzingRef.current = true; - analyzePartial(batch, { language }) + analyzePartial(batch, sessionStartMsRef.current ?? batch[0].timestamp, { language }) .then((partial) => { - partialNotesRef.current.push(partial); + // [图:N] 批内局部编号 → 全局编号,供合并后统一替换图片 + partialNotesRef.current.push(remapKeyframeMarkers(partial, globalOffset, batch.length)); setPartialCount(partialNotesRef.current.length); // 分析完成,释放 keyframe imageBase64 内存 const batchIds = new Set(batch.map((kf) => kf.id)); @@ -150,7 +178,10 @@ export function useClassroomEvents({ ); const offBundleReady = captureEventBus.on<{ sessionId: string; bundle: SessionBundle }>( 'smart:bundle_ready', - (data) => setSmartBundle(data.bundle), + (data) => { + captureSessionIdRef.current = data.sessionId; + setSmartBundle(data.bundle); + }, ); return () => { offKeyframe(); offBundleReady(); }; }, [language, aiDetectEnabled, setCourseMeta]); @@ -264,5 +295,6 @@ export function useClassroomEvents({ videoFilePath, setVideoFilePath, partialCount, setPartialCount, transcribedCount, partialNotesRef, pendingKeyframesRef, isPartialAnalyzingRef, + captureSessionIdRef, }; } diff --git a/client/src/features/classroom/hooks/useClassroomNotes.ts b/client/src/features/classroom/hooks/useClassroomNotes.ts index 2eba88dd..c1ef35f7 100644 --- a/client/src/features/classroom/hooks/useClassroomNotes.ts +++ b/client/src/features/classroom/hooks/useClassroomNotes.ts @@ -6,15 +6,30 @@ * 与全站一致;Note.content 存储 TipTap JSON(Markdown 需先转换)。 * 采集序号从已有笔记内容中反查"YYYY/M/D 第N次采集"标记的最大值 +1, * 使同一天多次采集可追加到同一篇笔记且分段可读。 + * @ai-context: 落地前先经 resolveKeyframeMarkers 把 [图:N] 标记替换为 + * 本地关键帧图片(keyframe:// URL),无标记时按时间就近兜底插入。 */ import { useCallback } from 'react'; import { noteStore } from '@/lib/storage'; import { createWithLog, updateWithLog } from '@/lib/storage/writeWithLog'; import { markdownToTipTapJson, appendMarkdownToTipTapJson } from '../utils/tipTapConverter'; +import { resolveKeyframeMarkers } from '../utils/tipTapImageUtils'; import type { CourseNoteItem } from '../components/NoteInsertDialog'; -import type { CourseMeta } from '@/lib/capture'; +import type { CourseMeta, SessionBundle } from '@/lib/capture'; + +export function useClassroomNotes(courseMeta: CourseMeta, smartBundle?: Partial) { + /** [图:N] 标记 → 关键帧图片替换(相对秒数与 analyzePartial 同基准:首帧 timestamp) */ + const applyKeyframeImages = useCallback((markdown: string): string => { + const kfs = smartBundle?.keyframes ?? []; + if (kfs.length === 0) return markdown; + const base = kfs[0].timestamp; + const refs = kfs.map((kf) => ({ + fileUrl: kf.fileUrl, + relativeSeconds: Math.max(0, (kf.timestamp - base) / 1000), + })); + return resolveKeyframeMarkers(markdown, refs); + }, [smartBundle]); -export function useClassroomNotes(courseMeta: CourseMeta) { /** 查询同课程名的已有笔记(用于"追加到已有笔记"下拉列表) */ const fetchCourseNotes = useCallback(async (courseName: string): Promise => { if (!courseName) return []; @@ -41,19 +56,19 @@ export function useClassroomNotes(courseMeta: CourseMeta) { const mergedContent = appendMarkdownToTipTapJson( existing?.content ?? '', sessionLabel, - markdownContent, + applyKeyframeImages(markdownContent), ); await updateWithLog(noteStore, 'notes', noteId, { content: mergedContent, updatedAt: new Date(), wordCount: markdownContent.length, }); - }, []); + }, [applyKeyframeImages]); /** 创建新的课程笔记(Markdown 转 TipTap JSON) */ const createCourseNote = useCallback(async (title: string, markdownContent: string) => { const now = new Date(); - const tipTapContent = markdownToTipTapJson(markdownContent); + const tipTapContent = markdownToTipTapJson(applyKeyframeImages(markdownContent)); await createWithLog(noteStore, 'notes', { title, content: tipTapContent, @@ -64,7 +79,7 @@ export function useClassroomNotes(courseMeta: CourseMeta) { wordCount: markdownContent.length, pinned: false, }); - }, [courseMeta]); + }, [courseMeta, applyKeyframeImages]); /** 计算当天同课程的采集序号(用于"第N次采集"标签) */ const getSessionSeq = useCallback(async (): Promise => { diff --git a/client/src/features/classroom/hooks/useSessionControl.ts b/client/src/features/classroom/hooks/useSessionControl.ts index ec135419..2203df59 100644 --- a/client/src/features/classroom/hooks/useSessionControl.ts +++ b/client/src/features/classroom/hooks/useSessionControl.ts @@ -67,8 +67,9 @@ export function useSessionControl({ const probeGateway = useCallback(async () => { try { const gatewayUrl = requireGatewayUrl(); + // GET 而非 HEAD:网关中间件对 HEAD 返回 405,会导致每次启动误报"网关不可用" const healthResp = await fetch(`${gatewayUrl}/health`, { - method: 'HEAD', + method: 'GET', signal: AbortSignal.timeout(5000), }); if (!healthResp.ok) { @@ -106,10 +107,15 @@ export function useSessionControl({ } const audioEnabled = mode === 'audio' || mode === 'mixed'; - await window.electronAPI.invoke('screen_capture_start', { - windowId: selectedWindow.id, - interval: config.screenshotInterval, - }); + const visionEnabled = mode !== 'audio'; + // 仅音频模式不启动截图采集:无视觉需求时截图纯属资源浪费, + // 且会让"帧"计数在音频模式下持续增长造成困惑 + if (visionEnabled) { + await window.electronAPI.invoke('screen_capture_start', { + windowId: selectedWindow.id, + interval: config.screenshotInterval, + }); + } await captureManager.startSession({ windowId: selectedWindow.id, windowTitle: selectedWindow.title, @@ -124,8 +130,11 @@ export function useSessionControl({ if (audioEnabled) { try { + // 方案A:优先以选中窗口为音频源(直采 B站客户端/浏览器等目标应用声音), + // 窗口级捕获不受支持时主进程会下发环回降级候选,由渲染端自动回退 const audioResult = await window.electronAPI.invoke('audio_capture_start', { chunkDurationMs: 5000, sampleRate: 16000, channels: 1, + sourceId: selectedWindow.id, }) as IPCAudioStartResult; if (!audioResult.success) { console.warn('[useClassroomCapture] Audio start failed:', audioResult.error); @@ -159,7 +168,9 @@ export function useSessionControl({ if (session.pendingKeyframesRef.current.length > 0 && !session.isPartialAnalyzingRef.current) { try { const remaining = session.pendingKeyframesRef.current.splice(0); - const partial = await analyzePartial(remaining, { language: config.language }); + // 与增量分析保持同一时间基准:会话首帧的 epoch 毫秒 + const sessionStartMs = bundle.keyframes[0]?.timestamp ?? remaining[0].timestamp; + const partial = await analyzePartial(remaining, sessionStartMs, { language: config.language }); session.partialNotesRef.current.push(partial); session.setPartialCount(session.partialNotesRef.current.length); } catch { /* 静默失败 */ } diff --git a/client/src/features/classroom/utils/asrTranscriber.ts b/client/src/features/classroom/utils/asrTranscriber.ts index 5d19673f..c92ac3de 100644 --- a/client/src/features/classroom/utils/asrTranscriber.ts +++ b/client/src/features/classroom/utils/asrTranscriber.ts @@ -21,11 +21,23 @@ interface TranscribePayload { channels: number; } -/** ASR 转写(15s 超时,失败后最多重试 1 次,指数退避) */ +/** 转写响应(与后端 TranscribeResponse 对应的关键字段) */ +interface TranscribeResponse { + text: string; + model_used?: string; + warning?: string | null; +} + +/** ASR 转写(15s 超时,失败后最多重试 1 次,指数退避);fallback 降级响应视为失败抛错 */ export async function transcribeWithRetry(payload: TranscribePayload, retries = 1): Promise { for (let attempt = 0; attempt <= retries; attempt++) { try { - const resp = await aiClient.post<{ text: string }>('/api/v1/asr/transcribe', payload, { timeout: 15000 }); + const resp = await aiClient.post('/api/v1/asr/transcribe', payload, { timeout: 15000 }); + // fallback 降级响应(含 warning 或 fallback 空文本)按失败处理: + // 抛错进入重试,最终由上层 markFailure 健康监测捕获,不再静默当作"无语音" + if (resp.warning || (!resp.text?.trim() && resp.model_used === 'fallback')) { + throw new Error(resp.warning || 'ASR 服务降级,转写结果为空'); + } return resp.text?.trim() || null; } catch (err) { if (attempt < retries) { diff --git a/client/src/features/classroom/utils/keyframePersistence.test.ts b/client/src/features/classroom/utils/keyframePersistence.test.ts new file mode 100644 index 00000000..cf58b7c9 --- /dev/null +++ b/client/src/features/classroom/utils/keyframePersistence.test.ts @@ -0,0 +1,84 @@ +/** + * keyframePersistence 单元测试 + * 覆盖:非 Electron 环境防护、保存成功回填 fileUrl、保存失败静默降级 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { Dispatch, SetStateAction } from 'react'; +import { persistKeyframeImage } from './keyframePersistence'; +import type { KeyFrame, SessionBundle } from '@/lib/capture'; + +type ElectronAPI = Window['electronAPI']; + +function makeKeyframe(): KeyFrame { + return { + id: 'kf-1', + timestamp: 1000, + imageBase64: 'aW1n', + changeType: 'slide_change', + }; +} + +function setElectronAPI(api: unknown): void { + (window as { electronAPI?: unknown }).electronAPI = api; +} + +describe('persistKeyframeImage', () => { + const originalElectronAPI = (window as { electronAPI?: ElectronAPI }).electronAPI; + + afterEach(() => { + setElectronAPI(originalElectronAPI); + vi.restoreAllMocks(); + }); + + it('should return early without throwing when electronAPI is absent (non-Electron env)', () => { + // Arrange: 浏览器 / PWA 环境无 window.electronAPI + setElectronAPI(undefined); + const setBundle = vi.fn(); + + // Act & Assert: 不应抛 "Cannot read properties of undefined" + expect(() => persistKeyframeImage('s-1', makeKeyframe(), setBundle)).not.toThrow(); + expect(setBundle).not.toHaveBeenCalled(); + }); + + it('should back-fill fileUrl on keyframe and bundle when save succeeds', async () => { + // Arrange + const invoke = vi.fn().mockResolvedValue({ url: 'keyframe://s-1/kf-1.jpg' }); + setElectronAPI({ invoke }); + const kf = makeKeyframe(); + let bundle: Partial = { keyframes: [kf] }; + const setBundle: Dispatch>> = vi.fn( + (action: SetStateAction>) => { + bundle = typeof action === 'function' ? action(bundle) : action; + }, + ); + + // Act + persistKeyframeImage('s-1', kf, setBundle); + await vi.waitFor(() => expect(setBundle).toHaveBeenCalled()); + + // Assert: IPC 参数正确 + 事件对象与 bundle 均回填 fileUrl + expect(invoke).toHaveBeenCalledWith('keyframe_save', { + sessionId: 's-1', + keyframeId: 'kf-1', + imageBase64: 'aW1n', + }); + expect(kf.fileUrl).toBe('keyframe://s-1/kf-1.jpg'); + expect(bundle.keyframes?.[0].fileUrl).toBe('keyframe://s-1/kf-1.jpg'); + }); + + it('should warn silently on save failure without updating bundle', async () => { + // Arrange + const invoke = vi.fn().mockRejectedValue(new Error('disk full')); + setElectronAPI({ invoke }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const setBundle = vi.fn(); + + // Act + persistKeyframeImage('s-1', makeKeyframe(), setBundle); + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled()); + + // Assert: 静默降级,不更新 bundle + expect(setBundle).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/features/classroom/utils/keyframePersistence.ts b/client/src/features/classroom/utils/keyframePersistence.ts new file mode 100644 index 00000000..33d5aa8b --- /dev/null +++ b/client/src/features/classroom/utils/keyframePersistence.ts @@ -0,0 +1,45 @@ +/** + * 关键帧图片本地持久化(渲染进程侧) + * Persist keyframe images to local disk via IPC and back-fill fileUrl. + * + * @ai-context: smart:keyframe 到达时后台异步调用主进程 keyframe_save, + * 成功后把 keyframe:// URL 回填到 keyframe.fileUrl(事件对象与 + * pendingKeyframesRef / bundle_ready 共享同一引用,故原地写 fileUrl 的同时 + * 需对 smartBundle 做不可变更新);失败仅 console.warn 静默降级, + * 不阻塞采集。增量分析完成后清空 imageBase64 的内存策略保持不变。 + */ +import type { Dispatch, SetStateAction } from 'react'; +import type { KeyFrame, SessionBundle } from '@/lib/capture'; + +/** + * 后台保存关键帧图片并回填 fileUrl + * @param sessionId 采集会话 ID(作为图片目录名) + * @param keyframe 关键帧对象(保存成功后原地写入 fileUrl) + * @param setBundle smartBundle 状态更新器(不可变同步 fileUrl) + */ +export function persistKeyframeImage( + sessionId: string, + keyframe: KeyFrame, + setBundle: Dispatch>>, +): void { + // 非 Electron 环境(浏览器/PWA)无 electronAPI,直接跳过持久化 + if (typeof window === 'undefined' || !window.electronAPI) return; + window.electronAPI.invoke('keyframe_save', { + sessionId, + keyframeId: keyframe.id, + imageBase64: keyframe.imageBase64, + }).then((res) => { + const url = (res as { url?: string } | undefined)?.url; + if (!url) return; + // 同步到事件对象引用(pendingKeyframesRef/bundle_ready 共享同一引用) + keyframe.fileUrl = url; + setBundle((prev) => ({ + ...prev, + keyframes: (prev.keyframes ?? []).map((kf) => + kf.id === keyframe.id ? { ...kf, fileUrl: url } : kf, + ), + })); + }).catch((err) => { + console.warn('[keyframePersistence] 关键帧图片保存失败:', err); + }); +} diff --git a/client/src/features/classroom/utils/tipTapConverter.test.ts b/client/src/features/classroom/utils/tipTapConverter.test.ts new file mode 100644 index 00000000..6d1ac2af --- /dev/null +++ b/client/src/features/classroom/utils/tipTapConverter.test.ts @@ -0,0 +1,142 @@ +/** + * tipTapConverter / tipTapImageUtils 单元测试 + * 覆盖:图片语法解析、[图:N] 标记替换、编号重映射、兜底插入 + */ +import { describe, it, expect } from 'vitest'; +import { markdownToTipTapJson } from './tipTapConverter'; +import { + resolveKeyframeMarkers, + remapKeyframeMarkers, + type KeyframeImageRef, +} from './tipTapImageUtils'; + +interface TipTapNode { + type: string; + attrs?: Record; + content?: TipTapNode[]; + text?: string; +} + +function parseDoc(json: string): TipTapNode[] { + return (JSON.parse(json) as { content: TipTapNode[] }).content; +} + +describe('markdownToTipTapJson 图片语法解析', () => { + it('独立一行的 ![alt](src) 转为段落包裹的 image 节点', () => { + const nodes = parseDoc(markdownToTipTapJson('![第1帧](keyframe://capture/s1/k1.jpg)')); + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe('paragraph'); + const img = nodes[0].content?.[0]; + expect(img?.type).toBe('image'); + expect(img?.attrs).toEqual({ src: 'keyframe://capture/s1/k1.jpg', alt: '第1帧' }); + }); + + it('图片行结束当前列表,且与标题/段落共存', () => { + const md = '## 标题\n\n- 要点一\n\n![图](keyframe://capture/s/k.jpg)\n\n正文'; + const nodes = parseDoc(markdownToTipTapJson(md)); + expect(nodes.map((n) => n.type)).toEqual(['heading', 'bulletList', 'paragraph', 'paragraph']); + expect(nodes[2].content?.[0].type).toBe('image'); + }); + + it('行内混排的图片语法不作为图片节点处理', () => { + const nodes = parseDoc(markdownToTipTapJson('前缀 ![图](http://x/y.jpg) 后缀')); + expect(nodes[0].type).toBe('paragraph'); + expect(nodes[0].content?.[0].type).toBe('text'); + }); +}); + +describe('resolveKeyframeMarkers 标记替换', () => { + const keyframes: KeyframeImageRef[] = [ + { fileUrl: 'keyframe://capture/s/k1.jpg', relativeSeconds: 10 }, + { relativeSeconds: 70 }, // 无 fileUrl(保存失败) + { fileUrl: 'keyframe://capture/s/k3.jpg', relativeSeconds: 200 }, + ]; + + it('[图:N] 替换为对应帧的 Markdown 图片', () => { + const result = resolveKeyframeMarkers('## A(00:10)\n\n[图:1]\n\n内容', keyframes); + expect(result).toContain('![第1帧](keyframe://capture/s/k1.jpg)'); + expect(result).not.toContain('[图:1]'); + }); + + it('无 fileUrl 或越界的标记被移除(优雅降级)', () => { + const result = resolveKeyframeMarkers('[图:2]\n\n[图:9]\n\n正文', keyframes); + expect(result).not.toContain('[图:'); + expect(result).not.toContain('!['); + expect(result).toContain('正文'); + }); + + it('支持全角冒号与行内混排(图片另起一行)', () => { + const result = resolveKeyframeMarkers('知识点说明 [图:3]', keyframes); + const lines = result.split('\n'); + expect(lines[0]).toBe('知识点说明'); + expect(lines[1]).toBe('![第3帧](keyframe://capture/s/k3.jpg)'); + }); + + it('存在标记时不触发兜底插入', () => { + const md = '## A(00:10)\n\n[图:1]\n\n## B(03:20)\n\n内容'; + const result = resolveKeyframeMarkers(md, keyframes); + // 仅有标记替换产生的 1 张图片,B 标题下不兜底插入 + expect(result.match(/!\[/g)).toHaveLength(1); + }); +}); + +describe('resolveKeyframeMarkers 兜底插入', () => { + const keyframes: KeyframeImageRef[] = [ + { fileUrl: 'keyframe://capture/s/k1.jpg', relativeSeconds: 15 }, + { fileUrl: 'keyframe://capture/s/k2.jpg', relativeSeconds: 205 }, + ]; + + it('整篇无标记时按时间就近在二级标题下插入图片', () => { + const md = '## A(00:10)\n\n内容A\n\n## B(03:20)\n\n内容B'; + const result = resolveKeyframeMarkers(md, keyframes); + const lines = result.split('\n'); + // A(10s)就近匹配 k1(15s),B(200s)就近匹配 k2(205s) + expect(lines[lines.indexOf('## A(00:10)') + 2]).toBe('![第1帧](keyframe://capture/s/k1.jpg)'); + expect(lines[lines.indexOf('## B(03:20)') + 2]).toBe('![第2帧](keyframe://capture/s/k2.jpg)'); + }); + + it('每帧最多使用一次,标题多于帧数时后续标题无图', () => { + const md = '## A(00:10)\n\n## B(00:12)\n\n## C(00:14)'; + const result = resolveKeyframeMarkers(md, keyframes); + expect(result.match(/!\[/g)).toHaveLength(2); + }); + + it('无时间标注的二级标题不插入;options 可关闭兜底', () => { + const noTime = resolveKeyframeMarkers('## 无时间标题\n\n内容', keyframes); + expect(noTime).not.toContain('!['); + const disabled = resolveKeyframeMarkers('## A(00:10)\n\n内容', keyframes, { fallbackInsert: false }); + expect(disabled).not.toContain('!['); + }); + + it('全部帧无 fileUrl 时不插入', () => { + const result = resolveKeyframeMarkers('## A(00:10)', [{ relativeSeconds: 5 }]); + expect(result).toBe('## A(00:10)'); + }); +}); + +describe('remapKeyframeMarkers 编号重映射', () => { + it('批内局部编号映射为全局编号', () => { + expect(remapKeyframeMarkers('[图:1] x [图:5]', 5, 5)).toBe('[图:6] x [图:10]'); + }); + + it('越界编号被移除', () => { + expect(remapKeyframeMarkers('[图:0][图:6]', 5, 5)).toBe(''); + }); + + it('全角冒号同样重映射', () => { + expect(remapKeyframeMarkers('[图:2]', 10, 5)).toBe('[图:12]'); + }); +}); + +describe('标记替换 → TipTap 转换端到端', () => { + it('替换后的 Markdown 可转出 image 节点', () => { + const keyframes: KeyframeImageRef[] = [ + { fileUrl: 'keyframe://capture/s/k1.jpg', relativeSeconds: 0 }, + ]; + const md = resolveKeyframeMarkers('## 知识点(00:00)\n\n[图:1]', keyframes); + const nodes = parseDoc(markdownToTipTapJson(md)); + const images = nodes.filter((n) => n.content?.[0]?.type === 'image'); + expect(images).toHaveLength(1); + expect(images[0].content?.[0].attrs?.src).toBe('keyframe://capture/s/k1.jpg'); + }); +}); diff --git a/client/src/features/classroom/utils/tipTapConverter.ts b/client/src/features/classroom/utils/tipTapConverter.ts index f2160339..941f9c14 100644 --- a/client/src/features/classroom/utils/tipTapConverter.ts +++ b/client/src/features/classroom/utils/tipTapConverter.ts @@ -33,7 +33,7 @@ function stripInlineMd(s: string): string { /** * 将 Markdown 文本转换为 TipTap JSON 文档字符串 - * 支持:标题(#~######)、分隔线(---)、无序列表(- / *)、普通段落 + * 支持:标题(#~######)、分隔线(---)、无序列表(- / *)、图片(![alt](src))、普通段落 */ export function markdownToTipTapJson(md: string): string { const lines = md.split('\n'); @@ -75,6 +75,18 @@ export function markdownToTipTapJson(md: string): string { continue; } + // 图片:独立一行的 ![alt](src) + // 编辑器 Image 扩展为 inline 模式,image 节点需包裹在 paragraph 内 + const img = trimmed.match(/^!\[([^\]]*)\]\(([^()\s]+)\)$/); + if (img) { + flushList(); + nodes.push({ + type: 'paragraph', + content: [{ type: 'image', attrs: { src: img[2], alt: img[1] } }], + }); + continue; + } + // 无序列表项 const li = trimmed.match(/^[-*]\s+(.*)$/); if (li) { diff --git a/client/src/features/classroom/utils/tipTapImageUtils.ts b/client/src/features/classroom/utils/tipTapImageUtils.ts new file mode 100644 index 00000000..41f56f18 --- /dev/null +++ b/client/src/features/classroom/utils/tipTapImageUtils.ts @@ -0,0 +1,126 @@ +/** + * 关键帧图片标记处理工具([图:N] → Markdown 图片) + * Keyframe image marker utilities: remap batch-local markers and + * resolve [图:N] markers into markdown images with local file URLs. + * + * @ai-context: 与 AI 网关 Prompt 协议对应——模型在与某帧强相关的知识点处 + * 单独一行输出 [图:N](N 为分析时关键帧数组的 1-based 序号)。本模块负责: + * ①增量批次局部编号重映射为全局编号(remapKeyframeMarkers) + * ②[图:N] → ![第N帧](fileUrl) 替换,无 fileUrl 时移除标记优雅降级 + * ③整篇无标记时按时间就近在二级标题下兜底插入至多一张关键帧图片 + * (fallback: nearest keyframe per H2 heading with MM:SS time, at most one)。 + */ + +/** 供标记替换使用的关键帧引用(fileUrl 缺省表示图片未成功落盘) */ +export interface KeyframeImageRef { + fileUrl?: string; + /** 课程内相对秒数(与笔记标题中的 MM:SS 时间标注同基准) */ + relativeSeconds: number; +} + +/** [图:N] 标记匹配(兼容半角/全角冒号) */ +const MARKER_RE = /\[图[::](\d+)\]/g; + +/** 二级标题内的 MM:SS 时间标注(如 "## 二叉树遍历(03:25)") */ +const HEADING_TIME_RE = /^##\s.*?(\d{1,3})[::](\d{2})/; + +export interface ResolveOptions { + /** 整篇无 [图:N] 标记时是否按时间就近兜底插入图片(默认开启) */ + fallbackInsert?: boolean; +} + +/** + * 将增量批次内的局部帧编号(1-based)重映射为全局编号 + * 越界编号(模型幻觉产生)直接移除 + * + * @param markdown 增量分析返回的片段 Markdown + * @param globalOffset 本批第 1 帧在全量关键帧数组中的 0-based 偏移 + * @param batchSize 本批帧数(用于越界校验) + */ +export function remapKeyframeMarkers( + markdown: string, + globalOffset: number, + batchSize: number, +): string { + return markdown.replace(MARKER_RE, (_m, n: string) => { + const local = parseInt(n, 10); + if (local < 1 || local > batchSize) return ''; + return `[图:${globalOffset + local}]`; + }); +} + +/** + * 把 [图:N] 标记替换为 Markdown 图片 ![第N帧](fileUrl) + * - N 为分析时关键帧数组的 1-based 序号 + * - 对应帧无 fileUrl(保存失败)时移除标记,优雅降级 + * - 整篇无任何标记时,按时间就近在各二级标题下兜底插入最多 1 张图片 + */ +export function resolveKeyframeMarkers( + markdown: string, + keyframes: KeyframeImageRef[], + options?: ResolveOptions, +): string { + let hasMarker = false; + const out: string[] = []; + + for (const line of markdown.split('\n')) { + const markers: number[] = []; + const stripped = line.replace(MARKER_RE, (_m, n: string) => { + hasMarker = true; + markers.push(parseInt(n, 10)); + return ''; + }); + if (markers.length === 0) { + out.push(line); + continue; + } + // 保留标记外的剩余文本(标记要求单独成行,此处兼容行内混排) + const rest = stripped.replace(/\s+$/, ''); + if (rest.trim()) out.push(rest); + for (const n of markers) { + const url = keyframes[n - 1]?.fileUrl; + if (url) out.push(`![第${n}帧](${url})`); + } + } + + let result = out.join('\n'); + if (!hasMarker && (options?.fallbackInsert ?? true)) { + result = insertFallbackImages(result, keyframes); + } + return result; +} + +/** + * 兜底插入:模型未输出任何 [图:N] 时,为每个带 MM:SS 时间标注的 + * 二级标题就近匹配一张未使用的关键帧图片(每帧最多用一次) + */ +function insertFallbackImages( + markdown: string, + keyframes: KeyframeImageRef[], +): string { + const usable = keyframes + .map((kf, idx) => ({ idx, fileUrl: kf.fileUrl, seconds: kf.relativeSeconds })) + .filter((kf): kf is { idx: number; fileUrl: string; seconds: number } => !!kf.fileUrl); + if (usable.length === 0) return markdown; + + const used = new Set(); + const out: string[] = []; + for (const line of markdown.split('\n')) { + out.push(line); + const m = line.match(HEADING_TIME_RE); + if (!m) continue; + const headingSeconds = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + // 时间就近选择未使用的帧 + let best: { idx: number; dist: number; fileUrl: string } | null = null; + for (const kf of usable) { + if (used.has(kf.idx)) continue; + const dist = Math.abs(kf.seconds - headingSeconds); + if (!best || dist < best.dist) best = { idx: kf.idx, dist, fileUrl: kf.fileUrl }; + } + if (best) { + used.add(best.idx); + out.push('', `![第${best.idx + 1}帧](${best.fileUrl})`); + } + } + return out.join('\n'); +} diff --git a/client/src/lib/ai/asrWorker.ts b/client/src/lib/ai/asrWorker.ts index 5e2d5d56..8b8f88f1 100644 --- a/client/src/lib/ai/asrWorker.ts +++ b/client/src/lib/ai/asrWorker.ts @@ -2,7 +2,7 @@ * ASR 语音转写 Worker * * 实现 PipelineWorker 接口,将音频块发送给 ASR 模型进行语音转文字。 - * 通过 ai-gateway 的 /api/v1/asr/transcribe 端点调用 Paraformer / GLM-4-Audio。 + * 通过 ai-gateway 的 /api/v1/asr/transcribe 端点调用 Qwen3-ASR-Flash / GLM-ASR。 * * @ai-context: 语音转写 Worker 封装,供智能采集链路调用。 */ @@ -32,6 +32,7 @@ interface TranscribeApiResponse { confidence: number; model_used: string; processing_time_ms: number; + warning?: string | null; } // ================================================================ @@ -67,7 +68,13 @@ export class ASRWorker implements PipelineWorker { }, ); - // 空结果跳过 + // fallback 降级响应(含 warning 或 fallback 空文本)视为失败: + // 抛错交由 Pipeline 错误隔离与日志,不再静默当作"无语音"跳过 + if (response.warning || (!response.text?.trim() && response.model_used === 'fallback')) { + throw new Error(response.warning || 'ASR 服务降级,转写结果为空'); + } + + // 空结果跳过(正常无语音) if (!response.text || response.text.trim() === '') { return null; } diff --git a/client/src/lib/ai/sessionAnalyzer.ts b/client/src/lib/ai/sessionAnalyzer.ts index 10e549f3..5b14a5ca 100644 --- a/client/src/lib/ai/sessionAnalyzer.ts +++ b/client/src/lib/ai/sessionAnalyzer.ts @@ -29,6 +29,8 @@ interface TranscribeResponse { text: string; confidence: number; model_used: string; + /** 网关 ASR 降级时携带的警告信息(gateway warning on ASR fallback) */ + warning?: string; } /** @@ -50,6 +52,9 @@ async function transcribeSegment( channels: 1, }, ); + // @ai-context ASR fallback 空响应不视为成功转写,避免污染分析上下文 + // (ASR fallback empty response must not be treated as success) + if (resp.warning || resp.model_used === 'fallback') return null; return resp.text?.trim() || null; } catch (e) { console.warn('[sessionAnalyzer] 音频段转写失败:', e); @@ -67,15 +72,25 @@ async function transcribeSegment( */ export async function analyzeSession( bundle: SessionBundle, - options?: { language?: string }, + options?: { language?: string; sessionId?: string }, ): Promise { // 鉴权获取 const { data: { session } } = await supabase.auth.getSession(); const userKey = getActiveUserKey(); - // 构造 IPC 参数(camelCase,ms → s) + // 构造 IPC 参数(camelCase,epoch ms → 课程内相对秒数) + // @ai-context KeyFrame.timestamp 为 epoch 毫秒,直接 /1000 会被网关格式化为 + // 巨大分钟数;与 analyzePartial / applyKeyframeImages 统一以首帧 timestamp + // 为基准做差(negative clamped to 0),无关键帧时才回退到首音频段 + const sessionStartMs = bundle.keyframes.length > 0 + ? bundle.keyframes[0].timestamp + : bundle.audioSegments.length > 0 + ? bundle.audioSegments[0].timestampStart + : 0; + const toRelativeSeconds = (ms: number) => Math.max(0, (ms - sessionStartMs) / 1000); + const keyframes = bundle.keyframes.map((kf) => ({ - timestamp: kf.timestamp / 1000, + timestamp: toRelativeSeconds(kf.timestamp), imageBase64: kf.imageBase64, changeType: kf.changeType, })); @@ -84,8 +99,8 @@ export async function analyzeSession( const lang = options?.language === 'en' ? 'en' : options?.language === 'mixed' ? 'auto' : 'zh'; const audioSegments = await Promise.all( bundle.audioSegments.map(async (seg) => ({ - timestampStart: seg.timestampStart / 1000, - timestampEnd: seg.timestampEnd / 1000, + timestampStart: toRelativeSeconds(seg.timestampStart), + timestampEnd: toRelativeSeconds(seg.timestampEnd), audioText: seg.audioText ?? await transcribeSegment(seg, lang), })), ); @@ -109,7 +124,8 @@ export async function analyzeSession( // 自动持久化分析结果 try { await classroomNoteStore.create({ - sessionId: crypto.randomUUID(), + // 优先使用真实采集 sessionId(供关键帧图片目录关联清理),缺省时保持随机 UUID + sessionId: options?.sessionId ?? crypto.randomUUID(), title: `课堂笔记 ${new Date().toLocaleString('zh-CN')}`, content: analyzeResult.content, keyframesAnalyzed: analyzeResult.keyframesAnalyzed, @@ -131,16 +147,19 @@ export async function analyzeSession( /** * 小批次关键帧增量分析,返回 Markdown 片段笔记 * 复用现有 ai_session_analyze IPC(小批次走单 chunk 路径,本身就快) + * @param sessionStartMs 会话开始的 epoch 毫秒,用于换算课程内相对秒数 */ export async function analyzePartial( keyframes: KeyFrame[], + sessionStartMs: number, options?: { language?: string }, ): Promise { const { data: { session } } = await supabase.auth.getSession(); const userKey = getActiveUserKey(); + // @ai-context 时间戳以会话开始时刻为基准换算为相对秒数(relative seconds) const kfPayload = keyframes.map((kf) => ({ - timestamp: kf.timestamp / 1000, + timestamp: Math.max(0, (kf.timestamp - sessionStartMs) / 1000), imageBase64: kf.imageBase64, changeType: kf.changeType, })); @@ -151,6 +170,7 @@ export async function analyzePartial( duration: keyframes.length > 0 ? (keyframes[keyframes.length - 1].timestamp - keyframes[0].timestamp) / 1000 : 0, + mode: 'partial', language: options?.language, authToken: session?.access_token, userApiKey: userKey, @@ -168,7 +188,7 @@ export async function analyzePartial( */ export async function mergeNotes( partials: string[], - options?: { duration?: number; language?: string }, + options?: { duration?: number; language?: string; sessionId?: string }, ): Promise { const { data: { session } } = await supabase.auth.getSession(); const userKey = getActiveUserKey(); @@ -191,7 +211,8 @@ export async function mergeNotes( // 自动持久化 try { await classroomNoteStore.create({ - sessionId: crypto.randomUUID(), + // 优先使用真实采集 sessionId(供关键帧图片目录关联清理),缺省时保持随机 UUID + sessionId: options?.sessionId ?? crypto.randomUUID(), title: `课堂笔记 ${new Date().toLocaleString('zh-CN')}`, content: analyzeResult.content, keyframesAnalyzed: 0, diff --git a/client/src/lib/audio/audioConfig.ts b/client/src/lib/audio/audioConfig.ts index aa9cd959..f888de52 100644 --- a/client/src/lib/audio/audioConfig.ts +++ b/client/src/lib/audio/audioConfig.ts @@ -91,11 +91,11 @@ export const SOUND_DEFINITIONS: SoundDefinition[] = [ // 深潜音效 { id: 'pomodoro_start', name: '深潜开始', category: 'pomodoro', filePath: '/sounds/pomodoro_start.wav' }, { id: 'pomodoro_pause', name: '深潜暂停', category: 'pomodoro', filePath: '/sounds/pomodoro_pause.wav' }, - { id: 'pomodoro_tick', name: '深潜滴答', category: 'pomodoro', filePath: '/sounds/pomodoro_tick.wav' }, { id: 'pomodoro_tick_final', name: '深潜最终滴答', category: 'pomodoro', filePath: '/sounds/pomodoro_tick_final.wav' }, { id: 'pomodoro_5min_warning', name: '5分钟提醒', category: 'pomodoro', filePath: '/sounds/pomodoro_5min_warning.wav' }, { id: 'pomodoro_work_complete', name: '工作完成', category: 'pomodoro', filePath: '/sounds/pomodoro_work_complete.wav' }, { id: 'pomodoro_break_end', name: '休息结束', category: 'pomodoro', filePath: '/sounds/pomodoro_break_end.wav' }, + { id: 'pomodoro_complete', name: '整轮完成', category: 'pomodoro', filePath: '/sounds/pomodoro_complete.wav' }, { id: 'rate_remember', name: '评分-记得', category: 'pomodoro', filePath: '/sounds/rate_remember.wav' }, { id: 'rate_fuzzy', name: '评分-模糊', category: 'pomodoro', filePath: '/sounds/rate_fuzzy.wav' }, { id: 'rate_forgot', name: '评分-忘记', category: 'pomodoro', filePath: '/sounds/rate_forgot.wav' }, diff --git a/client/src/lib/audio/outputDeviceMonitor.test.ts b/client/src/lib/audio/outputDeviceMonitor.test.ts new file mode 100644 index 00000000..0e80d8e2 --- /dev/null +++ b/client/src/lib/audio/outputDeviceMonitor.test.ts @@ -0,0 +1,90 @@ +/** + * outputDeviceMonitor 纯逻辑单元测试 + * + * @ai-context: 覆盖 computeChunkRms(RMS 计算含空/静音/满幅边界)与 + * SilenceTracker(连续静音诊断触发一次、有声复位后可再次诊断)。 + * 设备枚举/订阅为浏览器副作用不在此测试。 + */ +import { describe, it, expect } from 'vitest'; +import { computeChunkRms, SilenceTracker } from './outputDeviceMonitor'; + +/** 构造指定幅值的 PCM Float32 音频块 */ +function makeChunk(amplitude: number, length = 1024): ArrayBuffer { + const samples = new Float32Array(length).fill(amplitude); + return samples.buffer; +} + +describe('computeChunkRms', () => { + it('空缓冲区应返回 0', () => { + // Arrange + const empty = new Float32Array(0).buffer; + // Act & Assert + expect(computeChunkRms(empty)).toBe(0); + }); + + it('全零(数字静音)应返回 0', () => { + // Arrange + const silent = makeChunk(0); + // Act & Assert + expect(computeChunkRms(silent)).toBe(0); + }); + + it('恒定幅值信号的 RMS 应等于该幅值', () => { + // Arrange + const chunk = makeChunk(0.5); + // Act + const rms = computeChunkRms(chunk); + // Assert + expect(rms).toBeCloseTo(0.5, 5); + }); +}); + +describe('SilenceTracker', () => { + const SILENT = 0.0001; // 低于静音阈值 0.0005 + const VOICED = 0.02; // 高于静音阈值 + + it('连续静音恰好达到 4 块时触发一次诊断', () => { + // Arrange + const tracker = new SilenceTracker(); + // Act & Assert:前 3 块不触发 + expect(tracker.push(SILENT)).toBe(false); + expect(tracker.push(SILENT)).toBe(false); + expect(tracker.push(SILENT)).toBe(false); + // 第 4 块恰好触发 + expect(tracker.push(SILENT)).toBe(true); + expect(tracker.isSilent).toBe(true); + }); + + it('已诊断后继续静音不重复触发', () => { + // Arrange + const tracker = new SilenceTracker(); + for (let i = 0; i < 4; i++) tracker.push(SILENT); + // Act & Assert + expect(tracker.push(SILENT)).toBe(false); + expect(tracker.push(SILENT)).toBe(false); + }); + + it('有声块复位状态,之后再次持续静音可重新诊断', () => { + // Arrange + const tracker = new SilenceTracker(); + for (let i = 0; i < 4; i++) tracker.push(SILENT); + // Act:有声复位 + expect(tracker.push(VOICED)).toBe(false); + expect(tracker.isSilent).toBe(false); + // Assert:再次连续静音 4 块可再触发 + for (let i = 0; i < 3; i++) expect(tracker.push(SILENT)).toBe(false); + expect(tracker.push(SILENT)).toBe(true); + }); + + it('reset 清空计数与诊断状态', () => { + // Arrange + const tracker = new SilenceTracker(); + for (let i = 0; i < 4; i++) tracker.push(SILENT); + // Act + tracker.reset(); + // Assert + expect(tracker.isSilent).toBe(false); + for (let i = 0; i < 3; i++) expect(tracker.push(SILENT)).toBe(false); + expect(tracker.push(SILENT)).toBe(true); + }); +}); diff --git a/client/src/lib/audio/outputDeviceMonitor.ts b/client/src/lib/audio/outputDeviceMonitor.ts new file mode 100644 index 00000000..fe76d4e7 --- /dev/null +++ b/client/src/lib/audio/outputDeviceMonitor.ts @@ -0,0 +1,132 @@ +/** + * 音频输出设备监测与静音诊断 + * + * @ai-context: 系统音频环回(WASAPI Loopback)只捕获「默认输出设备」上的混音。 + * 若网课/视频软件把声音输出到了非默认设备(HDMI 显示器音响、蓝牙耳机等), + * 环回捕获到的将是持续静音——这是"监听 0 句"的常见根因。 + * 本模块提供:①输出设备枚举与默认设备识别 ②设备变更订阅 + * ③纯函数 RMS 计算与连续静音诊断(SilenceTracker),供采集管道判定 + * "收到了音频块但全是静音"并向用户给出设备侧提示。 + * @ai-context: computeChunkRms / SilenceTracker 为纯逻辑无副作用,可安全单测; + * 设备枚举/订阅为浏览器 API 副作用,已物理分离。 + * + * TODO(现场课程): 现场课程接入麦克风后,本模块需同步枚举 audioinput 设备, + * 并对麦克风静音(拔出/系统禁用/权限吊销)给出对应诊断。 + */ + +/** 输出设备信息 */ +export interface AudioOutputDeviceInfo { + deviceId: string; + label: string; + isDefault: boolean; +} + +/** 低于此 RMS 视为静音块(数字环回静音时 RMS 接近 0,远低于 VAD 语音阈值 0.008) */ +const SILENT_RMS_THRESHOLD = 0.0005; +/** 连续静音块达到此数量触发诊断(5s/块 × 4 = 20s 持续静音) */ +const SILENT_CHUNKS_TO_DIAGNOSE = 4; + +// ================================================================ +// 纯逻辑(可安全单测) +// ================================================================ + +/** 计算一个 PCM Float32 音频块的 RMS 能量(纯函数) */ +export function computeChunkRms(audioBuffer: ArrayBuffer): number { + const samples = new Float32Array(audioBuffer); + if (samples.length === 0) return 0; + let sumSquares = 0; + for (let i = 0; i < samples.length; i++) { + sumSquares += samples[i] * samples[i]; + } + return Math.sqrt(sumSquares / samples.length); +} + +/** + * 连续静音诊断器:跟踪音频块能量,连续静音达到阈值时触发一次诊断。 + * 触发后进入"已诊断"状态,直到检测到有声块才复位(避免重复弹提示)。 + */ +export class SilenceTracker { + private consecutiveSilent = 0; + private diagnosed = false; + + /** + * 推入一个音频块的 RMS 能量 + * @returns true 表示「本次恰好达到诊断阈值」,调用方应提示用户检查输出设备 + */ + push(rms: number): boolean { + if (rms >= SILENT_RMS_THRESHOLD) { + // 有声:复位,允许后续再次诊断 + this.consecutiveSilent = 0; + this.diagnosed = false; + return false; + } + this.consecutiveSilent++; + if (!this.diagnosed && this.consecutiveSilent >= SILENT_CHUNKS_TO_DIAGNOSE) { + this.diagnosed = true; + return true; + } + return false; + } + + /** 是否处于持续静音状态 */ + get isSilent(): boolean { + return this.consecutiveSilent >= SILENT_CHUNKS_TO_DIAGNOSE; + } + + reset(): void { + this.consecutiveSilent = 0; + this.diagnosed = false; + } +} + +// ================================================================ +// 设备枚举与订阅(副作用) +// ================================================================ + +/** + * 枚举系统音频输出设备。 + * Chromium 中默认设备的 deviceId 为 'default',label 带 "Default - " 前缀。 + */ +export async function getAudioOutputDevices(): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices + .filter((d) => d.kind === 'audiooutput') + .map((d) => ({ + deviceId: d.deviceId, + label: d.label || '未知输出设备', + isDefault: d.deviceId === 'default', + })); + } catch (err) { + console.warn('[outputDeviceMonitor] 枚举输出设备失败:', err); + return []; + } +} + +/** 获取当前默认输出设备名称(去掉 Chromium 的 "Default - " 前缀),失败返回 null */ +export async function getDefaultOutputDeviceLabel(): Promise { + const devices = await getAudioOutputDevices(); + const def = devices.find((d) => d.isDefault); + if (!def) return null; + return def.label.replace(/^Default\s*-\s*/i, ''); +} + +/** + * 订阅音频设备变更(插拔耳机/切换默认输出等),返回取消订阅函数。 + * devicechange 可能短时间连续触发多次,内部做 1s 防抖。 + */ +export function subscribeDeviceChange(handler: () => void): () => void { + let debounceTimer: ReturnType | null = null; + const onChange = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + handler(); + }, 1000); + }; + navigator.mediaDevices.addEventListener('devicechange', onChange); + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + navigator.mediaDevices.removeEventListener('devicechange', onChange); + }; +} diff --git a/client/src/lib/capture/captureTypes.ts b/client/src/lib/capture/captureTypes.ts index 4685a754..102fdf5d 100644 --- a/client/src/lib/capture/captureTypes.ts +++ b/client/src/lib/capture/captureTypes.ts @@ -146,6 +146,8 @@ export interface KeyFrame { timestamp: number; imageBase64: string; changeType: 'slide_change' | 'writing' | 'scene_change' | 'periodic'; + /** 图片本地持久化后的可渲染 URL(keyframe:// 协议),保存失败时缺省 */ + fileUrl?: string; } /** @ai-context VAD 标记器切出的语音段,含编码后的音频数据,支持流式 ASR 转写 */ diff --git a/client/src/lib/capture/frameHash.test.ts b/client/src/lib/capture/frameHash.test.ts new file mode 100644 index 00000000..1c3b956d --- /dev/null +++ b/client/src/lib/capture/frameHash.test.ts @@ -0,0 +1,96 @@ +/** + * frameHash 单元测试 — dHash 纯函数核心 + * + * @ai-context + * 中文:jsdom 环境不提供 OffscreenCanvas,因此只测试可注入像素数据的 + * 纯函数核心(rgbaToGray / computeDHashFromGray / hammingDistance / + * isSimilar);Canvas 薄封装 computeFrameHash 在 smartSampler.test.ts + * 中通过 mock 全局对象间接覆盖。 + * English: jsdom lacks OffscreenCanvas, so tests here cover the pure, + * pixel-injectable core functions; the thin Canvas wrapper computeFrameHash + * is covered indirectly in smartSampler.test.ts via global mocks. + */ + +import { describe, it, expect } from 'vitest'; +import { + DHASH_WIDTH, + DHASH_HEIGHT, + HASH_BITS, + rgbaToGray, + computeDHashFromGray, + hammingDistance, + isSimilar, +} from './frameHash'; + +/** 按 f(x, y) 生成 9×8 灰度网格(行优先) */ +function makeGray(f: (x: number, y: number) => number): Uint8ClampedArray { + const gray = new Uint8ClampedArray(DHASH_WIDTH * DHASH_HEIGHT); + for (let y = 0; y < DHASH_HEIGHT; y++) { + for (let x = 0; x < DHASH_WIDTH; x++) { + gray[y * DHASH_WIDTH + x] = f(x, y); + } + } + return gray; +} + +describe('rgbaToGray', () => { + it('按 BT.601 加权将 RGBA 转为灰度', () => { + // 纯白 + 纯黑 + 纯红 三个像素 + const rgba = new Uint8ClampedArray([ + 255, 255, 255, 255, + 0, 0, 0, 255, + 255, 0, 0, 255, + ]); + const gray = rgbaToGray(rgba); + expect(gray).toHaveLength(3); + expect(gray[0]).toBe(255); + expect(gray[1]).toBe(0); + expect(gray[2]).toBe(Math.round(255 * 0.299)); + }); +}); + +describe('computeDHashFromGray', () => { + it('相同图像哈希距离为 0', () => { + const a = makeGray((x, y) => (x * 13 + y * 7) % 256); + const b = makeGray((x, y) => (x * 13 + y * 7) % 256); + const hashA = computeDHashFromGray(a); + const hashB = computeDHashFromGray(b); + expect(hashA).toBe(hashB); + expect(hammingDistance(hashA, hashB)).toBe(0); + }); + + it('明显不同图像(渐变方向相反)距离为最大 64 位', () => { + const asc = computeDHashFromGray(makeGray((x) => x * 10)); + const desc = computeDHashFromGray(makeGray((x) => (DHASH_WIDTH - 1 - x) * 10)); + expect(hammingDistance(asc, desc)).toBe(HASH_BITS); + }); + + it('局部微小差异产生小的汉明距离', () => { + const base = makeGray((x) => x * 10); + // 仅翻转第 0 行前 3 个相邻比较(30>20>10>0 递减,其余保持递增) + const tweaked = makeGray((x, y) => + y === 0 ? [30, 20, 10, 0, 10, 20, 30, 40, 50][x] : x * 10, + ); + const distance = hammingDistance( + computeDHashFromGray(base), + computeDHashFromGray(tweaked), + ); + expect(distance).toBe(3); + }); +}); + +describe('hammingDistance / isSimilar', () => { + it('正确统计不同位数量', () => { + expect(hammingDistance(0n, 0n)).toBe(0); + expect(hammingDistance(0n, 0b111n)).toBe(3); + expect(hammingDistance(0b1010n, 0b0101n)).toBe(4); + }); + + it('阈值边界:常规阈值 5 判定重复,板书收紧阈值 2 判定不重复', () => { + const a = 0n; + const b = 0b111n; // 距离 3 + expect(isSimilar(a, b, 5)).toBe(true); + expect(isSimilar(a, b, 2)).toBe(false); + expect(isSimilar(a, b, 3)).toBe(true); + }); +}); diff --git a/client/src/lib/capture/frameHash.ts b/client/src/lib/capture/frameHash.ts new file mode 100644 index 00000000..2b9fbf6d --- /dev/null +++ b/client/src/lib/capture/frameHash.ts @@ -0,0 +1,120 @@ +/** + * 关键帧感知哈希(dHash)— 帧间内容去重 + * + * @ai-context + * 中文:基于差值哈希(dHash)的关键帧去重工具。将 ImageBitmap 经 + * OffscreenCanvas 缩放到 9×8 灰度图,比较水平相邻像素亮度生成 64 位 + * 指纹(bigint),通过汉明距离判断两帧内容是否近似相同。核心算法 + * computeDHashFromGray 是可注入像素数据的纯函数,便于在 jsdom(无 + * OffscreenCanvas)环境下单测;Canvas 部分仅做薄封装。纯客户端计算, + * 无任何网络/原生依赖。 + * + * English: Perceptual difference-hash (dHash) utilities for keyframe + * deduplication. An ImageBitmap is downscaled to a 9×8 grayscale grid via + * OffscreenCanvas, and adjacent horizontal luminance comparisons produce a + * 64-bit fingerprint (bigint). Hamming distance between fingerprints tells + * whether two frames are visually near-identical. The core algorithm + * computeDHashFromGray is a pure function accepting raw pixel data so it can + * be unit-tested without OffscreenCanvas (jsdom); the Canvas layer is a thin + * wrapper. Pure client-side computation with no network/native dependencies. + */ + +// ================================================================ +// 常量 +// ================================================================ + +/** dHash 采样网格宽度(每行比较 8 对相邻像素,需 9 列) */ +export const DHASH_WIDTH = 9; +/** dHash 采样网格高度 */ +export const DHASH_HEIGHT = 8; +/** 哈希总位数:8 列差值 × 8 行 = 64 位 */ +export const HASH_BITS = (DHASH_WIDTH - 1) * DHASH_HEIGHT; + +// ================================================================ +// 纯函数核心(可单测,无 Canvas 依赖) +// ================================================================ + +/** + * RGBA 像素数组 → 灰度数组(ITU-R BT.601 加权) + * @param rgba 长度为 pixelCount * 4 的 RGBA 数据 + */ +export function rgbaToGray(rgba: Uint8ClampedArray): Uint8ClampedArray { + const pixelCount = rgba.length >> 2; + const gray = new Uint8ClampedArray(pixelCount); + for (let i = 0; i < pixelCount; i++) { + const o = i * 4; + gray[i] = Math.round( + rgba[o] * 0.299 + rgba[o + 1] * 0.587 + rgba[o + 2] * 0.114, + ); + } + return gray; +} + +/** + * 从灰度像素计算 dHash(纯函数,可注入像素数据单测) + * @param gray 长度为 width * height 的灰度数据(行优先) + * @returns 64 位哈希(bigint),每位表示 gray[y][x] < gray[y][x+1] + */ +export function computeDHashFromGray( + gray: ArrayLike, + width: number = DHASH_WIDTH, + height: number = DHASH_HEIGHT, +): bigint { + let hash = 0n; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width - 1; x++) { + hash <<= 1n; + if (gray[y * width + x] < gray[y * width + x + 1]) { + hash |= 1n; + } + } + } + return hash; +} + +/** 两个哈希间的汉明距离(不同位的数量) */ +export function hammingDistance(a: bigint, b: bigint): number { + let xor = a ^ b; + let count = 0; + while (xor !== 0n) { + count += Number(xor & 1n); + xor >>= 1n; + } + return count; +} + +/** 汉明距离 ≤ threshold 视为两帧内容近似相同 */ +export function isSimilar(a: bigint, b: bigint, threshold: number): boolean { + return hammingDistance(a, b) <= threshold; +} + +// ================================================================ +// Canvas 薄封装 +// ================================================================ + +/** + * 计算 ImageBitmap 的感知哈希(dHash) + * + * @ai-context + * 中文:将位图缩放绘制到 9×8 OffscreenCanvas 后读取像素并调用纯函数 + * 计算哈希;不负责 bitmap.close(),由调用方管理生命周期以便复用位图。 + * English: Downscales the bitmap onto a 9×8 OffscreenCanvas, reads pixels + * and delegates to the pure hash function. Does NOT close the bitmap — + * caller owns its lifecycle so the bitmap can be reused for compression. + * + * @returns 64 位哈希;Canvas 不可用或读取失败时返回 null(调用方应放行该帧) + */ +export async function computeFrameHash(bitmap: ImageBitmap): Promise { + try { + const canvas = new OffscreenCanvas(DHASH_WIDTH, DHASH_HEIGHT); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + + ctx.drawImage(bitmap, 0, 0, DHASH_WIDTH, DHASH_HEIGHT); + const { data } = ctx.getImageData(0, 0, DHASH_WIDTH, DHASH_HEIGHT); + return computeDHashFromGray(rgbaToGray(data)); + } catch (err) { + console.debug('[frameHash] 哈希计算失败,放行该帧', err); + return null; + } +} diff --git a/client/src/lib/capture/smartCaptureController.ts b/client/src/lib/capture/smartCaptureController.ts index a4e0403c..fb21d177 100644 --- a/client/src/lib/capture/smartCaptureController.ts +++ b/client/src/lib/capture/smartCaptureController.ts @@ -30,7 +30,9 @@ export class SmartCaptureController { start(sessionId: string): void { this.smartStartTime = Date.now(); this.smartSampler = new SmartSampler(); - this.vadMarker = new VADMarker(); + // 网课模式固定 loopback 源:跳过背景噪声校准,直接使用预设阈值 + // TODO(现场课程): 麦克风输入时改为 sourceType: 'microphone' 启用校准 + this.vadMarker = new VADMarker({ sourceType: 'loopback' }); // 流式 ASR:语音段完成后立即发射事件,由上层 Hook 触发转写 this.vadMarker.onSegmentReady = (segment) => { diff --git a/client/src/lib/capture/smartSampler.test.ts b/client/src/lib/capture/smartSampler.test.ts new file mode 100644 index 00000000..af4246b1 --- /dev/null +++ b/client/src/lib/capture/smartSampler.test.ts @@ -0,0 +1,183 @@ +/** + * SmartSampler 感知哈希去重集成测试 + * + * @ai-context + * 中文:jsdom 不提供 OffscreenCanvas / createImageBitmap,此处通过 + * vi.stubGlobal 注入可控 mock:imageBuffer 首字节作为 seed,mock 的 + * getImageData 按 seed 生成确定性灰度图案,从而精确控制帧间 dHash + * 汉明距离(seed1↔seed2 距离 64,seed1↔seed3 距离 3)。 + * English: jsdom lacks OffscreenCanvas / createImageBitmap; controllable + * mocks are injected via vi.stubGlobal. The first byte of imageBuffer acts + * as a seed, and the mocked getImageData renders deterministic grayscale + * patterns per seed, giving precise control over inter-frame dHash Hamming + * distances (seed1↔seed2 = 64, seed1↔seed3 = 3). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SmartSampler } from './smartSampler'; +import type { ScreenshotData } from './captureTypes'; + +// ================================================================ +// Mock:按 seed 生成确定性像素图案 +// ================================================================ + +/** seed → 灰度图案:1=递增渐变;2=递减渐变(距 seed1 64 位);3=距 seed1 3 位 */ +function grayForSeed(seed: number, x: number, y: number, width: number): number { + if (seed === 2) return (width - 1 - x) * 10; + if (seed === 3 && y === 0) return [30, 20, 10, 0, 10, 20, 30, 40, 50][x]; + return x * 10; +} + +class MockCtx { + private seed = 0; + + drawImage(bitmap: { seed: number }): void { + this.seed = bitmap.seed; + } + + getImageData(_x: number, _y: number, w: number, h: number): { data: Uint8ClampedArray } { + const data = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const g = grayForSeed(this.seed, x, y, w); + const o = (y * w + x) * 4; + data[o] = data[o + 1] = data[o + 2] = g; + data[o + 3] = 255; + } + } + return { data }; + } +} + +class MockOffscreenCanvas { + private readonly ctx = new MockCtx(); + + constructor(public width: number, public height: number) {} + + getContext(): MockCtx { + return this.ctx; + } + + async convertToBlob(): Promise { + return new Blob(['jpeg-mock'], { type: 'image/jpeg' }); + } +} + +/** 构造 ScreenshotData,imageBuffer 首字节为 seed */ +function makeFrame(seed: number, changeScore: number, hasChanged = true): ScreenshotData { + return { + imageBuffer: new Uint8Array([seed]).buffer, + width: 100, + height: 80, + hasChanged, + changeScore, + }; +} + +// ================================================================ +// 测试 +// ================================================================ + +describe('SmartSampler 感知哈希去重', () => { + beforeEach(() => { + vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas); + vi.stubGlobal('createImageBitmap', async (blob: Blob) => { + const bytes = new Uint8Array(await blob.arrayBuffer()); + return { seed: bytes[0] ?? 0, width: 100, height: 80, close: vi.fn() }; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('连续相同帧仅采集 1 帧', async () => { + const sampler = new SmartSampler(); + const f1 = await sampler.processFrame(makeFrame(1, 0.5)); + expect(f1).not.toBeNull(); + + const f2 = await sampler.processFrame(makeFrame(1, 0.5)); + expect(f2).toBeNull(); + expect(sampler.getKeyframes()).toHaveLength(1); + }); + + it('内容不同的帧正常捕获', async () => { + const sampler = new SmartSampler(); + const f1 = await sampler.processFrame(makeFrame(1, 0.5)); + const f2 = await sampler.processFrame(makeFrame(2, 0.5)); + expect(f1).not.toBeNull(); + expect(f2).not.toBeNull(); + expect(sampler.getKeyframes()).toHaveLength(2); + }); + + it('定时兜底触发同样去重,且跳过时重置兜底计时', async () => { + const nowSpy = vi.spyOn(Date, 'now'); + const debugSpy = vi.spyOn(console, 'debug'); + const sampler = new SmartSampler(); + + nowSpy.mockReturnValue(1_000_000); + await sampler.processFrame(makeFrame(1, 0.5)); + expect(sampler.getKeyframes()).toHaveLength(1); + + // 16s 后静止画面触发兜底 → 感知哈希判定重复,跳过 + nowSpy.mockReturnValue(1_016_000); + const f2 = await sampler.processFrame(makeFrame(1, 0, false)); + expect(f2).toBeNull(); + expect(debugSpy).toHaveBeenCalledWith( + '[SmartSampler] 跳过帧:感知哈希重复', + expect.stringContaining('distance='), + expect.stringContaining('dupThreshold='), + expect.any(String), + ); + + // 跳过时已重置 lastCaptureTime:100ms 后的帧不再触发兜底判定 + debugSpy.mockClear(); + nowSpy.mockReturnValue(1_016_100); + const f3 = await sampler.processFrame(makeFrame(1, 0, false)); + expect(f3).toBeNull(); + expect(debugSpy).toHaveBeenCalledWith('[SmartSampler] 跳过帧:未满足捕获条件'); + expect(sampler.getKeyframes()).toHaveLength(1); + }); + + it('小差异帧(距离 3 ≤ 常规阈值 5)被跳过', async () => { + const sampler = new SmartSampler(); + await sampler.processFrame(makeFrame(1, 0.5)); + const f2 = await sampler.processFrame(makeFrame(3, 0.5)); + expect(f2).toBeNull(); + expect(sampler.getKeyframes()).toHaveLength(1); + }); + + it('渐进板书帧使用收紧阈值:距离 3 > 2 仍正常捕获', async () => { + const sampler = new SmartSampler(); + await sampler.processFrame(makeFrame(1, 0.5)); + // score 0.15 → writing 区间且由变化触发,收紧阈值 2 < 距离 3 → 捕获 + const f2 = await sampler.processFrame(makeFrame(3, 0.15)); + expect(f2).not.toBeNull(); + expect(f2!.changeType).toBe('writing'); + expect(sampler.getKeyframes()).toHaveLength(2); + }); + + it('changeThreshold 提升至 0.12:0.08–0.12 区间的变化不再触发落帧', async () => { + const nowSpy = vi.spyOn(Date, 'now'); + const sampler = new SmartSampler(); + + nowSpy.mockReturnValue(1_000_000); + await sampler.processFrame(makeFrame(1, 0.5)); + + // 100ms 后变化分数 0.1(旧阈值会触发)→ 不满足新阈值且未到兜底间隔 + nowSpy.mockReturnValue(1_000_100); + const f2 = await sampler.processFrame(makeFrame(2, 0.1)); + expect(f2).toBeNull(); + expect(sampler.getKeyframes()).toHaveLength(1); + }); + + it('reset 后哈希状态清空,相同帧可重新捕获', async () => { + const sampler = new SmartSampler(); + await sampler.processFrame(makeFrame(1, 0.5)); + sampler.reset(); + const f2 = await sampler.processFrame(makeFrame(1, 0.5)); + expect(f2).not.toBeNull(); + expect(sampler.getKeyframes()).toHaveLength(1); + }); +}); diff --git a/client/src/lib/capture/smartSampler.ts b/client/src/lib/capture/smartSampler.ts index 0dd36e46..b2210bd8 100644 --- a/client/src/lib/capture/smartSampler.ts +++ b/client/src/lib/capture/smartSampler.ts @@ -2,18 +2,24 @@ * 智能采样器 — Path B 轻量采集核心 * * @ai-context - * Path B 不做逐帧 AI 推理,而是通过变化检测 + 定时间隔筛选出关键帧, - * 再用 Canvas API 压缩为 JPEG base64,大幅降低存储与传输开销。 + * 中文:Path B 不做逐帧 AI 推理,而是通过变化检测 + 定时间隔筛选出关键帧, + * 落帧前用感知哈希(dHash)与上一关键帧比对去重(静止画面不再被定时兜底 + * 重复采集),再用 Canvas API 压缩为 JPEG base64,大幅降低存储与传输开销。 + * English: Path B skips per-frame AI inference; keyframes are selected via + * change detection + periodic fallback, deduplicated against the last + * captured keyframe using a perceptual dHash before compression, then + * JPEG-encoded via Canvas API to cut storage/transfer cost. */ import type { ScreenshotData, KeyFrame } from './captureTypes'; +import { computeFrameHash, hammingDistance, isSimilar } from './frameHash'; // ================================================================ // 配置类型 // ================================================================ export interface SmartSamplerConfig { - /** 变化分数阈值,高于此值视为画面切换,默认 0.08 */ + /** 变化分数阈值,高于此值视为画面切换,默认 0.12 */ changeThreshold: number; /** 定时间隔兜底(ms),超过则强制抓帧,默认 15000 */ periodicIntervalMs: number; @@ -24,12 +30,17 @@ export interface SmartSamplerConfig { } const DEFAULT_CONFIG: SmartSamplerConfig = { - changeThreshold: 0.08, + changeThreshold: 0.12, periodicIntervalMs: 15_000, jpegQuality: 0.7, maxWidth: 1280, }; +/** 感知哈希去重阈值:与上一关键帧汉明距离 ≤ 5(64 位)视为重复帧,跳过 */ +const HASH_DUP_THRESHOLD = 5; +/** 渐进板书帧(变化触发且 0 < score < 0.3)收紧阈值:距离 ≤ 2 才跳过,避免漏采渐进内容 */ +const WRITING_HASH_DUP_THRESHOLD = 2; + // ================================================================ // SmartSampler // ================================================================ @@ -38,6 +49,8 @@ export class SmartSampler { private readonly config: SmartSamplerConfig; private keyframes: KeyFrame[] = []; private lastCaptureTime = 0; + /** 上一已捕获关键帧的感知哈希,用于帧间内容去重 */ + private lastFrameHash: bigint | null = null; constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; @@ -83,7 +96,39 @@ export class SmartSampler { periodicTrigger, ); - const imageBase64 = await this.compressToJpegBase64(frameData); + // 只解码一次 ImageBitmap,供感知哈希与 JPEG 压缩共用,避免重复解码 + const bitmap = await createImageBitmap( + new Blob([frameData.imageBuffer], { type: 'image/png' }), + ); + + // 感知哈希去重:与上一已捕获关键帧比较,定时兜底触发同样走此去重 + const hash = await computeFrameHash(bitmap); + if (hash !== null && this.lastFrameHash !== null) { + // 渐进板书帧收紧跳过阈值,避免漏采渐进内容 + const isWritingChange = hasSignificantChange && changeType === 'writing'; + const dupThreshold = isWritingChange + ? WRITING_HASH_DUP_THRESHOLD + : HASH_DUP_THRESHOLD; + if (isSimilar(hash, this.lastFrameHash, dupThreshold)) { + console.debug( + '[SmartSampler] 跳过帧:感知哈希重复', + `distance=${hammingDistance(hash, this.lastFrameHash)}`, + `dupThreshold=${dupThreshold}`, + `writing=${isWritingChange}`, + ); + bitmap.close(); + // 重置兜底计时,静止画面不再每帧重复触发兜底判定 + this.lastCaptureTime = now; + return null; + } + console.debug( + '[SmartSampler] 感知哈希判定为新内容', + `distance=${hammingDistance(hash, this.lastFrameHash)}`, + ); + } + + const imageBase64 = await this.compressToJpegBase64(bitmap, frameData); + bitmap.close(); const keyframe: KeyFrame = { id: crypto.randomUUID(), @@ -94,6 +139,7 @@ export class SmartSampler { this.keyframes.push(keyframe); this.lastCaptureTime = now; + if (hash !== null) this.lastFrameHash = hash; return keyframe; } @@ -106,6 +152,7 @@ export class SmartSampler { reset(): void { this.keyframes = []; this.lastCaptureTime = 0; + this.lastFrameHash = null; } // ================================================================ @@ -130,14 +177,18 @@ export class SmartSampler { } /** - * 将 PNG ArrayBuffer 压缩为 JPEG base64 + * 将已解码的 ImageBitmap 压缩为 JPEG base64 * * @ai-context * 渲染进程没有 sharp 等原生模块,使用 OffscreenCanvas + toBlob 实现 - * 硬件加速的 GPU 友好压缩,避免主线程阻塞。 + * 硬件加速的 GPU 友好压缩,避免主线程阻塞。位图由调用方解码并负责 + * close(),以便与感知哈希共用同一次解码结果。 */ - private async compressToJpegBase64(frameData: ScreenshotData): Promise { - const { imageBuffer, width, height } = frameData; + private async compressToJpegBase64( + bitmap: ImageBitmap, + frameData: ScreenshotData, + ): Promise { + const { width, height } = frameData; const { maxWidth, jpegQuality } = this.config; // 等比缩放 @@ -145,19 +196,14 @@ export class SmartSampler { const targetW = Math.round(width * scale); const targetH = Math.round(height * scale); - // PNG ArrayBuffer → ImageBitmap(零拷贝解码) - const bitmap = await createImageBitmap(new Blob([imageBuffer], { type: 'image/png' })); - const canvas = new OffscreenCanvas(targetW, targetH); const ctx = canvas.getContext('2d'); if (!ctx) { // Fallback:2D 上下文获取失败时返回空字符串,由调用方决定是否跳过 - bitmap.close(); return ''; } ctx.drawImage(bitmap, 0, 0, targetW, targetH); - bitmap.close(); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: jpegQuality }); return blobToBase64(blob); diff --git a/client/src/lib/capture/vadMarker.test.ts b/client/src/lib/capture/vadMarker.test.ts new file mode 100644 index 00000000..86b099c9 --- /dev/null +++ b/client/src/lib/capture/vadMarker.test.ts @@ -0,0 +1,162 @@ +/** + * VADMarker 单元测试 — 校准跳过(loopback)与自适应校准(microphone) + * + * @ai-context: 锁定网课/现场课程双场景行为:loopback 构造即已校准 + * (预设阈值 0.008,UI 不出现校准提示);microphone 前 10 块自适应校准。 + * 时间相关逻辑用 fake timers 控制。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { VADMarker } from './vadMarker'; +import type { AudioChunkData } from './captureTypes'; + +/** 构造指定幅值的音频块(16kHz 单声道 100ms) */ +function makeChunk(amplitude: number, samples = 1600): AudioChunkData { + return { + audioBuffer: new Float32Array(samples).fill(amplitude).buffer, + sampleRate: 16000, + channels: 1, + durationMs: (samples / 16000) * 1000, + timestamp: Date.now(), + } as AudioChunkData; +} + +describe('VADMarker — loopback(网课模式)跳过校准', () => { + it('构造后立即处于已校准状态,使用预设阈值 0.008', () => { + // Arrange & Act + const marker = new VADMarker({ sourceType: 'loopback' }); + const stats = marker.getStats(); + // Assert + expect(stats.calibrated).toBe(true); + expect(stats.currentThreshold).toBe(0.008); + }); + + it('默认配置(不传 sourceType)即为 loopback,跳过校准', () => { + // Arrange & Act + const marker = new VADMarker(); + // Assert + expect(marker.getStats().calibrated).toBe(true); + }); + + it('显式传入 energyThreshold 时不被预设值覆盖', () => { + // Arrange & Act + const marker = new VADMarker({ sourceType: 'loopback', energyThreshold: 0.02 }); + // Assert + expect(marker.getStats().currentThreshold).toBe(0.02); + }); + + it('首个有声块即可被识别为语音(无校准等待期)', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'loopback' }); + // Act:第 1 块就是语音(能量 0.1 > 阈值 0.008) + marker.processChunk(makeChunk(0.1)); + // Assert + expect(marker.getStats().lastVoiceTimestamp).toBeGreaterThan(0); + }); + + it('reset 后仍保持已校准状态', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'loopback' }); + marker.processChunk(makeChunk(0.1)); + // Act + marker.reset(); + // Assert + expect(marker.getStats().calibrated).toBe(true); + }); +}); + +describe('VADMarker — microphone(现场课程)自适应校准', () => { + it('构造后未校准,需前 10 块采样', () => { + // Arrange & Act + const marker = new VADMarker({ sourceType: 'microphone' }); + // Assert + expect(marker.getStats().calibrated).toBe(false); + }); + + it('处理 10 块后完成校准,阈值 = max(0.008, 平均底噪 × 2.5)', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'microphone' }); + // Act:10 块恒定底噪 0.02(模拟嘈杂教室) + for (let i = 0; i < 10; i++) marker.processChunk(makeChunk(0.02)); + // Assert + const stats = marker.getStats(); + expect(stats.calibrated).toBe(true); + expect(stats.currentThreshold).toBeCloseTo(0.05, 5); // 0.02 × 2.5 + }); + + it('底噪极低时阈值不低于下限 0.008', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'microphone' }); + // Act:安静环境底噪 0.001 + for (let i = 0; i < 10; i++) marker.processChunk(makeChunk(0.001)); + // Assert + expect(marker.getStats().currentThreshold).toBe(0.008); + }); + + it('reset 后回到未校准状态', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'microphone' }); + for (let i = 0; i < 10; i++) marker.processChunk(makeChunk(0.02)); + // Act + marker.reset(); + // Assert + expect(marker.getStats().calibrated).toBe(false); + }); +}); + +describe('VADMarker — loopback 语音分段', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('语音后静音超过 800ms 完成分段并触发 onSegmentReady', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'loopback' }); + const onReady = vi.fn(); + marker.onSegmentReady = onReady; + // Act:语音 500ms(>= minSpeechDurationMs 300) + marker.processChunk(makeChunk(0.1)); + vi.advanceTimersByTime(500); + marker.processChunk(makeChunk(0.1)); + // 静音 900ms(>= silenceDurationMs 800) + vi.advanceTimersByTime(900); + marker.processChunk(makeChunk(0)); + // Assert + expect(onReady).toHaveBeenCalledTimes(1); + expect(marker.getSegments()).toHaveLength(1); + expect(marker.getStats().segmentCount).toBe(1); + }); + + it('连续语音达到 maxSpeechDurationMs 时强制分段(兼容 GLM ≤30s 限制)', () => { + // Arrange:为加快测试用 3s 强制分段阈值 + const marker = new VADMarker({ sourceType: 'loopback', maxSpeechDurationMs: 3000 }); + const onReady = vi.fn(); + marker.onSegmentReady = onReady; + // Act:持续语音 4s(每 500ms 一块,无静音间隙) + for (let i = 0; i <= 8; i++) { + marker.processChunk(makeChunk(0.1)); + vi.advanceTimersByTime(500); + } + // Assert:3s 处被强制切出第一段,后续语音继续累积为新段 + expect(onReady).toHaveBeenCalledTimes(1); + expect(marker.getSegments()).toHaveLength(1); + }); + + it('强制分段后新语音段继续正常按静音断句', () => { + // Arrange + const marker = new VADMarker({ sourceType: 'loopback', maxSpeechDurationMs: 2000 }); + const onReady = vi.fn(); + marker.onSegmentReady = onReady; + // Act:连续语音 2.5s 触发强制分段 + for (let i = 0; i <= 5; i++) { + marker.processChunk(makeChunk(0.1)); + vi.advanceTimersByTime(500); + } + expect(onReady).toHaveBeenCalledTimes(1); + // 新段语音 500ms 后静音 900ms → 第二段按静音正常断句 + marker.processChunk(makeChunk(0.1)); + vi.advanceTimersByTime(900); + marker.processChunk(makeChunk(0)); + // Assert + expect(onReady).toHaveBeenCalledTimes(2); + expect(marker.getSegments()).toHaveLength(2); + }); +}); diff --git a/client/src/lib/capture/vadMarker.ts b/client/src/lib/capture/vadMarker.ts index c0d66c73..cb8b9178 100644 --- a/client/src/lib/capture/vadMarker.ts +++ b/client/src/lib/capture/vadMarker.ts @@ -4,6 +4,14 @@ * @ai-context * Path B 通过 RMS 能量检测将连续语音切段, * 每段完成后立即触发 onSegmentReady 回调,支持流式 ASR 转写。 + * @ai-context: 校准仅对 microphone 源生效;loopback(网课系统环回)为数字 + * 信号无环境底噪,构造时直接标记已校准并使用预设阈值,不进入校准期 + * (避免 UI 出现无意义的"正在校准音频阈值"提示)。 + * + * TODO(现场课程): 背景噪声校准为「现场课程」麦克风输入场景预留——麦克风 + * 存在真实环境底噪(空调/键盘/人声嘈杂),自适应阈值届时才真正发挥作用。 + * 现场课程实现时以 sourceType: 'microphone' 构造启用校准,并将校准结果 + * 持久化供下次会话作为初始阈值(避免每次启动等待 10 块采样期),请勿删除。 */ import type { AudioChunkData, AudioSegment, TimelineEntry } from './captureTypes'; @@ -14,20 +22,38 @@ import { encodeWavBase64 } from './wavEncoder'; // ================================================================ export interface VADMarkerConfig { - /** RMS 能量阈值,超过视为有语音,默认 0.01(校准后自适应调整) */ + /** RMS 能量阈值,超过视为有语音,默认 0.01(microphone 校准后自适应调整) */ energyThreshold: number; - /** 静音持续超过此时长视为语音段结束(ms),默认 1500 */ + /** 静音持续超过此时长视为语音段结束(ms),默认 800(对齐主流流式 ASR 断句延迟) */ silenceDurationMs: number; /** 最短语音时长(ms),低于则丢弃,默认 300 */ minSpeechDurationMs: number; + /** + * 最长语音段时长(ms),达到即强制分段,默认 28000—— + * 保证段长兼容 GLM-ASR 备选(≤30s 硬限制),也避免长段拉高转写延迟 + */ + maxSpeechDurationMs: number; + /** + * 音频源类型:loopback(系统环回,网课默认)跳过背景噪声校准; + * microphone(现场课程麦克风)启用前 N 块自适应校准 + */ + sourceType: 'loopback' | 'microphone'; } const DEFAULT_VAD_MARKER_CONFIG: VADMarkerConfig = { energyThreshold: 0.01, - silenceDurationMs: 1500, + silenceDurationMs: 800, minSpeechDurationMs: 300, + maxSpeechDurationMs: 28_000, + sourceType: 'loopback', }; +/** + * loopback 预设阈值:与校准下限一致。数字环回静音时 RMS≈0, + * 校准结果恒为 max(0.008, ~0×2.5)=0.008,故直接预设省去校准期 + */ +const LOOPBACK_ENERGY_THRESHOLD = 0.008; + // ================================================================ // VADMarker // ================================================================ @@ -68,7 +94,7 @@ export class VADMarker { private lastSampleRate = 16_000; private lastChannels = 1; - // ── 自适应阈值校准 ── + // ── 自适应阈值校准(仅 microphone 源启用,见文件头 TODO(现场课程))── private calibrationSamples: number[] = []; private calibrated = false; private readonly CALIBRATION_CHUNKS = 10; @@ -76,6 +102,14 @@ export class VADMarker { constructor(config?: Partial) { this.config = { ...DEFAULT_VAD_MARKER_CONFIG, ...config }; + // 网课模式(loopback):数字环回无环境底噪,跳过校准直接用预设阈值, + // UI 不再出现"正在校准音频阈值"提示 + if (this.config.sourceType === 'loopback') { + if (config?.energyThreshold === undefined) { + this.config.energyThreshold = LOOPBACK_ENERGY_THRESHOLD; + } + this.calibrated = true; + } } /** @@ -97,7 +131,8 @@ export class VADMarker { } const rmsEnergy = Math.sqrt(sumSquares / samples.length); - // ── 自适应阈值校准:前 N 个块计算背景噪声底噪 ── + // ── 自适应阈值校准:前 N 个块计算背景噪声底噪(仅 microphone 源, + // loopback 在构造时已标记 calibrated,不进入此分支)── if (!this.calibrated) { this.calibrationSamples.push(rmsEnergy); if (this.calibrationSamples.length >= this.CALIBRATION_CHUNKS) { @@ -124,6 +159,12 @@ export class VADMarker { this.speechBuffer.push(new Float32Array(samples)); this.energyAccumulator += rmsEnergy; this.energySampleCount++; + + // 连续语音达到最长段限制:强制分段(兼容 GLM-ASR ≤30s,降低长段转写延迟), + // 下一个有声块会自动开启新段,语音内容不丢失 + if (now - this.speechStartTime >= this.config.maxSpeechDurationMs) { + this.finalizeSpeechSegment(now); + } } else if (this.isSpeaking) { // 静音中,判断是否超过静音阈值 const silenceElapsed = now - this.lastVoiceTime; @@ -171,7 +212,8 @@ export class VADMarker { this.energyAccumulator = 0; this.energySampleCount = 0; this.calibrationSamples = []; - this.calibrated = false; + // loopback 无需校准,复位后仍保持已校准状态 + this.calibrated = this.config.sourceType === 'loopback'; this.processedChunks = 0; } diff --git a/client/src/lib/storage/classroomNoteStore.test.ts b/client/src/lib/storage/classroomNoteStore.test.ts new file mode 100644 index 00000000..9a19905a --- /dev/null +++ b/client/src/lib/storage/classroomNoteStore.test.ts @@ -0,0 +1,119 @@ +/** + * classroomNoteStore 单元测试 + * 覆盖:delete 时 keyframe_cleanup 的 sessionId 共用保护 + * (同一采集会话可能产生多条笔记,仅当无其他引用时才清理图片目录) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => { + const count = vi.fn<() => Promise>(); + const filter = vi.fn((_predicate: (n: { id: string }) => boolean) => ({ count })); + const first = vi.fn(); + const equals = vi.fn(() => ({ filter, first })); + const where = vi.fn(() => ({ equals })); + return { + count, + filter, + equals, + where, + get: vi.fn(), + del: vi.fn(), + }; +}); + +vi.mock('@/lib/storage/database', () => ({ + db: { + classroomNotes: { + get: mocks.get, + delete: mocks.del, + where: mocks.where, + }, + }, +})); +vi.mock('@/lib/search/dexieSearchIndexer', () => ({ + dexieSearchIndexer: { upsert: vi.fn(), remove: vi.fn() }, +})); + +import { classroomNoteStore } from './classroomNoteStore'; + +function setElectronAPI(api: unknown): void { + (window as { electronAPI?: unknown }).electronAPI = api; +} + +describe('classroomNoteStore.delete', () => { + const originalElectronAPI = (window as { electronAPI?: unknown }).electronAPI; + const invoke = vi.fn().mockResolvedValue(undefined); + + const note = { + id: 'note-1', + sessionId: 'session-a', + title: '课堂笔记', + content: '## 知识点', + keyframesAnalyzed: 3, + modelUsed: 'glm-4.6v-flash', + sourceType: 'smart' as const, + duration: 60, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + setElectronAPI({ invoke }); + mocks.get.mockResolvedValue(note); + mocks.del.mockResolvedValue(undefined); + }); + + afterEach(() => { + setElectronAPI(originalElectronAPI); + }); + + it('should cleanup keyframe dir when no other note references the sessionId', async () => { + // Arrange: 无其他笔记共用 sessionId + mocks.count.mockResolvedValue(0); + + // Act + await classroomNoteStore.delete('note-1'); + + // Assert + expect(mocks.where).toHaveBeenCalledWith('sessionId'); + expect(mocks.equals).toHaveBeenCalledWith('session-a'); + expect(invoke).toHaveBeenCalledWith('keyframe_cleanup', { sessionId: 'session-a' }); + }); + + it('should NOT cleanup when another note still references the same sessionId', async () => { + // Arrange: 全量分析 + 片段合并共用 sessionId,另一条笔记仍存在 + mocks.count.mockResolvedValue(1); + + // Act + await classroomNoteStore.delete('note-1'); + + // Assert: 记录被删但不清理共享图片目录 + expect(mocks.del).toHaveBeenCalledWith('note-1'); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('should exclude the deleted note itself when counting references', async () => { + // Arrange + mocks.count.mockResolvedValue(0); + + // Act + await classroomNoteStore.delete('note-1'); + + // Assert: filter 谓词排除当前被删 id + const predicate = mocks.filter.mock.calls[0][0] as (n: { id: string }) => boolean; + expect(predicate({ id: 'note-1' })).toBe(false); + expect(predicate({ id: 'note-2' })).toBe(true); + }); + + it('should skip cleanup gracefully when electronAPI is absent (non-Electron env)', async () => { + // Arrange + setElectronAPI(undefined); + + // Act & Assert: 不抛错、不查询引用计数 + await expect(classroomNoteStore.delete('note-1')).resolves.toBeUndefined(); + expect(mocks.where).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/lib/storage/classroomNoteStore.ts b/client/src/lib/storage/classroomNoteStore.ts index 36521baf..b8c3a745 100644 --- a/client/src/lib/storage/classroomNoteStore.ts +++ b/client/src/lib/storage/classroomNoteStore.ts @@ -55,8 +55,24 @@ export const classroomNoteStore = { }, async delete(id: string): Promise { + const note = await db.classroomNotes.get(id); await db.classroomNotes.delete(id); // v1.2.0: 删除搜索索引 try { await dexieSearchIndexer.remove(id, 'classroom'); } catch { /* 忽略 */ } + // 清理该会话的本地关键帧图片目录(仅桌面端,失败静默) + // 同一采集会话可能产生多条笔记(全量分析 + 片段合并共用 sessionId), + // 仅当没有其他笔记仍引用该 sessionId 时才执行清理 + if (note?.sessionId && typeof window !== 'undefined' && window.electronAPI) { + try { + const otherRefs = await db.classroomNotes + .where('sessionId').equals(note.sessionId) + .filter((n) => n.id !== id) + .count(); + if (otherRefs === 0) { + window.electronAPI.invoke('keyframe_cleanup', { sessionId: note.sessionId }) + .catch(() => { /* 忽略 */ }); + } + } catch { /* 忽略 */ } + } }, }; diff --git a/docs/product/migration-spec.md b/docs/product/migration-spec.md index 416e32c1..0308db4b 100644 --- a/docs/product/migration-spec.md +++ b/docs/product/migration-spec.md @@ -1230,6 +1230,28 @@ B14 清单预估的 310/316/307 行偏大,实测三页面为 278/295/287,** | `TestJWTDevMode` 3 项 | 依赖真实网络(JWKS),离网必败;应改为 mock JWKS 响应(§7) | | npm audit 镁像限制 | 默认 registry 为 npmmirror 不支持 advisories API,需 `--registry=https://registry.npmjs.org` | +### 15.6 发版链路修复与部署首跑验证(v0.25.0 实发闭环) + +PR #2 合并后 Version & Release 连续两次失败,逐层定位出两个独立根因并全部修复: + +| # | 根因 | 修复 | +|---|------|------| +| 1 | **GH013**:main ruleset 强制"必须走 PR",把 semantic-release 自身的发版推送(`chore(release)` 提交 + tag → `HEAD:main`)一并拦截 | `version-release.yml` 的 checkout 与 semantic-release 环境改用 `secrets.RELEASE_TOKEN`(细粒度 PAT,带 `\|\| github.token` 回退);ruleset Bypass list 添加 Repository admin(PR #3) | +| 2 | **PAT 空权限 403**(`Permission denied to Aparencia`):token 创建时未勾选仓库与权限 | PAT 补齐:Repository access=Entropydecrease、Contents/Issues/Pull requests 均 Read and write(细粒度 PAT 改权限即时生效,无需重新生成) | + +**发版验证**:rerun 后 Version & Release success(41s),产出 `chore(release): v0.25.0` + tag + GitHub Release,dev 已快进同步。此为比 `release:dry` 干跑更强的实发闭环验证,A3 销项。 + +**部署首跑验证**(PR #4 为两个部署工作流补 `workflow_dispatch` 手动触发入口后执行): + +| 工作流 | 结果 | 关键步骤 | +|--------|------|---------| +| Deploy Website | ✅ success(1m13s) | 构建 → scp 上传 `/opt/Entropydecrease/website` → nginx 重启 → 冒烟(首页含「熵减」)→ IndexNow | +| Deploy AI Gateway(deploy-server) | ✅ success | 上传 server 源码 → `docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build` → 健康检查 | + +第十五章 15.2 登记的"部署链路从未实跑"风险就此关闭:重写后的部署配置(真实路径、`--env-file`、代码上传)已在生产服务器实际执行成功。 + +**安全遗留(低危登记)**:RELEASE_TOKEN 为无过期 PAT,建议改 90 天期限并到期轮换。 + --- -*文档版本:v2.8 | 更新时间:2026-07-29 | 新增:第十五章 Git 策略落地(main/dev、续接历史)与完整性/安全性双审查(含 2 阻塞 + 1 高危修复与技术债清单)* +*文档版本:v2.9 | 更新时间:2026-07-30 | 新增:15.6 发版链路修复(GH013 + PAT 权限)与 v0.25.0 实发、部署双流首跑验证* diff --git "a/docs/versions/v0.23.0/00-\351\230\266\346\256\265\346\200\247\345\267\245\344\275\234\346\200\273\347\273\223\346\212\245\345\221\212.md" "b/docs/versions/v0.23.0/00-\351\230\266\346\256\265\346\200\247\345\267\245\344\275\234\346\200\273\347\273\223\346\212\245\345\221\212.md" new file mode 100644 index 00000000..1c9d20bc --- /dev/null +++ "b/docs/versions/v0.23.0/00-\351\230\266\346\256\265\346\200\247\345\267\245\344\275\234\346\200\273\347\273\223\346\212\245\345\221\212.md" @@ -0,0 +1,285 @@ +# Entropy Decrease(熵减 / 课伴)项目阶段性工作总结报告 + +> **报告基准**:仓库当前状态 · 版本 v0.23.0(2026-07-28)· 许可证 BUSL-1.1 +> **项目定位**:面向学生与终身学习者的 AI 智能学习桌面应用,践行费曼学习法与间隔重复,核心理念为 **本地优先(Local-First)+ AI 增强可选**。迁移后客户端包名已更名为 `entropy-decrease`("熵减 - 跨端学习平台")。 + +--- + +## 一、项目整体架构概览 + +### 1.1 四层 Monorepo 结构 + +| 模块 | 路径 | 职责 | 技术栈 | +|------|------|------|--------| +| 桌面客户端 | `client/` | Electron 主进程(IPC/数据库/截图/托盘/自动更新)+ React 渲染进程 | Electron 35 / React 18 / Vite 8 / TS 6 | +| AI 网关 | `server/ai-gateway/` | 多模型路由、限流、缓存、JWT 鉴权、降级链 | Python / FastAPI / Redis | +| 同步服务 | `server/sync-service/` | CRDT 增量同步 REST + WebSocket 实时通道 | Go 1.25 / Gin / PostgreSQL / Redis | +| 官网 | `website/` | 静态落地页与下载引导 | Next.js 16 / React 19 / Tailwind 4 | + +辅助设施:`scripts/`(semantic-release 版本管理、音效生成、学习会话检测)、`docs/`(全生命周期文档)、`.github/workflows/`(5 条 CI/CD 流水线)。 + +### 1.2 依赖方向与模块关系 + +``` +渲染进程 (React) ──IPC──▶ Electron 主进程 ──▶ SQLite / Ollama / MCP Bridge + │ │ + │ (Supabase 认证) └──▶ 远端 AI Gateway (FastAPI) + └──HTTP/WS──▶ sync-service (Go) ──▶ PostgreSQL + Redis +``` + +- 渲染进程按 **features 目录组织** 7 个业务模块(feynman、pomodoro、notes、flashcards、classroom、inspiration、dashboard),每个模块自包含 `pages / components / hooks / store`,通过路由懒加载形成天然模块边界(`client/src/routes/index.tsx`) +- AI 网关中间件链:`RequestId → SecurityHeaders → RateLimit → InputValidation → JWTAuth → CORS`,路由/链/提示词分层为 `routers/`(18 个)、`chains/`(22 个)、`prompts/`(15 个) + +--- + +## 二、核心功能模块总结 + +### 2.1 费曼学习法(feynman)— 成熟度:★★★★★ + +**关键文件**:`client/src/features/feynman/pages/FeynmanSessionPage.tsx` · `hooks/useFeynmanSession.tsx` · `store/useFeynmanStore.ts` + +- **四步流程状态机**:选择概念 → 讲解 → 标注薄弱 → 简化重述,数据库层面约束 `current_step BETWEEN 1 AND 4`、`status IN ('not_started','in_progress','completed')` +- **薄弱点系统**:`feynman_weak_points` 表记录文本位置与掌握状态,支持批量转化为闪卡 +- **AI 评估**:四维评分(准确度、完整度、逻辑、表达),走 `deepseek → glm → fallback` 降级链 +- **测试**:`useFeynmanStore.test.ts` 覆盖 store 逻辑 + +### 2.2 苏格拉底式学习(socratic)— 成熟度:★★★★☆ + +以三阶段状态机实现(`client/src/features/feynman/hooks/useSocraticFlow.tsx`),业务逻辑与 UI 完全分离: + +```tsx +export type Phase = 'brainstorm' | 'dialogue' | 'deepening'; +const MAX_ROUNDS = 4; + +/** 默认深化角度(AI 降级时使用) */ +const DEFAULT_DEEPENING_ANGLES: DeepeningAngle[] = [ + { key: 'analogy', label: '类比联想', question: '这个概念像什么?...' }, + { key: 'counter', label: '反例验证', question: '什么情况下这个概念不成立?...' }, + // ... apply / history / debate 共 5 个角度 +]; +``` + +- **流程**:头脑风暴选方向 → 多轮追问(最多 4 轮,上下文窗口保留最近 5 轮)→ 5 角度深化 +- **离线降级**:AI 不可用时使用 `DEFAULT_DEEPENING_ANGLES` 预设角度,贯彻本地优先原则 +- **服务端配套**:`server/ai-gateway/chains/socratic_chain.py` + `socratic_evaluate_chain.py`,配置了 `socratic_brainstorm / socratic_evaluate / socratic_deepening` 三条独立降级链 +- **待补**:该模块尚无专门测试文件 + +### 2.3 番茄工作法(pomodoro)— 成熟度:★★★★★ + +核心为 448 行的 Zustand store(`client/src/features/pomodoro/store/usePomodoroStore.ts`),采用 **动作信号模式** 解耦副作用: + +```ts +/** 番茄钟动作信号类型(供 usePomodoroEffects 监听) */ +export type PomodoroAction = + | 'start' | 'pause' | 'exit_immersive' + | 'tick_5min_warning' | 'tick_final' | 'phase_complete' | null; + +type Phase = 'work' | 'short_break' | 'long_break'; +type Mode = 'class' | 'self_study'; // 课程/自学双模式 +``` + +- **阶段流转**:work → short_break → …→ long_break(每 4 个番茄),支持自动开始 +- **音效体系**:开始/暂停/5 分钟警告/最后 10 秒滴答/完成提示 + 白噪音(`public/sounds/` 42 个音效文件) +- **沉浸模式**:`ImmersiveTimer.tsx` 全屏弧形光带进度 + 呼吸缩放 +- **AI 推荐时长**:`recommend` 功能走 `glm → deepseek → fallback`,失败降级本地策略 +- **测试**:`usePomodoroStore.test.ts` 覆盖充分 + +### 2.4 其余四个模块速览 + +| 模块 | 成熟度 | 亮点 | 短板 | +|------|--------|------|------| +| notes 智能笔记 | ★★★★☆ | TipTap 富文本 + 7 种模板(大纲/Cornell/思维导图等)+ 搜索索引 | AI 概念解释/要点提炼仍是 TODO(NoteEditPage.tsx L307) | +| flashcards 闪卡 | ★★★★★ | 自研 FSRS-5 算法(19 参数完整实现)+ 复习记录含信心等级/黄金错误 | — | +| classroom 课堂助手 | ★★★☆☆ | 屏幕捕获窗口智能评分、语音流式转写(Paraformer+GLM)、多模态笔记生成 | ScriptProcessor 已废弃待迁移 AudioWorklet | +| inspiration 灵感空间 | ★★★☆☆ | 3D 球面布局(`orbLayout.ts`)、AI 草稿、预测驱动学习 | 交互细节待打磨 | + +FSRS-5 算法实现片段(`client/src/lib/fsrs.ts`,287 行): + +```ts +/** FSRS-5 的 19 个核心参数 w[0]-w[18],来自 open-spaced-repetition/fsrs-rs 默认参数 */ +const W = [0.4872, 1.4003, 3.7145, 13.8206, /* ... */]; +const REQUESTED_RETENTION = 0.9; // 目标保留率 +const DECAY = -0.5; // 衰减因子 +``` + +与 SM-2 同构的接口设计支持 **策略模式热切换**,是算法层的一个良好架构决策。 + +--- + +## 三、技术栈和架构决策 + +### 3.1 选型清单(含实际版本) + +| 层级 | 技术 | 版本 | 决策依据 | +|------|------|------|---------| +| 渲染框架 | React + Vite (rolldown) | 18.3.1 / 8.1.1 | Vite 8 采用 rolldown bundler,构建性能 | +| 桌面容器 | Electron + electron-builder | 35.7.5 / 25.1.8 | 跨平台 + 自动更新(electron-updater) | +| 状态管理 | Zustand | 5.0.14 | 轻量、per-feature store | +| 本地数据 | Dexie (IndexedDB) + better-sqlite3 | 4.4.4 / 11.0.0 | 双库分工(见第六节) | +| 协同引擎 | @automerge/automerge | 3.3.2 | CRDT 多设备同步 | +| 编辑器 | TipTap 全家桶 | 3.27.3 | 富文本 + 表格/任务列表扩展 | +| 3D | three + @react-three/fiber | 0.185.1 / 8.18.0 | 双世界主题 3D 场景 | +| 认证 | @supabase/supabase-js | 2.110.1 | 用户体系 + JWT | +| AI 网关 | FastAPI + openai SDK + google-genai | ≥0.115 / ≥1.50 / ≥1.0 | OpenAI 兼容格式统一封装国产模型 | +| 同步服务 | Gin + GORM + gorilla/websocket | 1.10.0 / 1.31.2 / 1.5.3 | Go 高并发 WS | +| 测试/规范 | Vitest + Oxlint + semantic-release | 4.1.10 / 1.71 / 24.2.3 | Conventional Commits 全自动版本 | + +> 注:项目数据层并非单一 "DexieDB",而是 **Dexie(渲染进程)+ better-sqlite3(主进程)双库架构**,详见第六节。 + +### 3.2 关键架构决策:多模型路由与降级链 + +`server/ai-gateway/config.py` 集中定义了 **功能 → (provider, model)** 路由与 fallback 链: + +```python +PROVIDER_FALLBACK_CHAIN: dict[str, list[str]] = { + "summarize": ["glm", "qwen", "fallback"], # GLM(免费)优先 + "feynman_evaluate": ["deepseek", "glm", "fallback"], # DeepSeek 为主 + "socratic": ["qwen", "deepseek", "fallback"], + "vision_extract": ["qwen", "glm"], # Qwen-VL 优先, GLM-4V 备选 + "video_analyze": ["gemini", "qwen"], # 原生视频优先,抽帧降级 + ... +} +``` + +设计亮点: + +- 通过 `contextvars` 透传 feature 名,实现 **按功能独立超时预算** 而无需改动所有签名 +- 支持 **用户自带 API Key 优先** 于全局 Key +- `fallback` provider 提供兜底响应(如苏格拉底用预设问题),加上 Ollama 本地推理,形成"云端多模型 → 本地模型 → 静态兜底"三级降级 + +--- + +## 四、开发进度评估 + +### 4.1 版本节奏 + +CHANGELOG 显示 **1-2 天一个版本** 的高频迭代。里程碑: + +- **v0.16.0**:Ollama 本地模型接入、AI 处理器重构 +- **v0.17.x–v0.18.0**:课堂助手组件化、语音流式化、多模态分析 +- **v0.19.0**(重大):CRDT 协同引擎、FSRS 调度器、AI 流式传输 +- **v0.20–0.23**:官网 SEO/备案、许可证切换 BUSL 1.1(商业化前置动作) + +### 4.2 各模块完成度矩阵 + +| 模块 | 功能完整性 | 测试 | AI 降级 | 遗留 TODO | +|------|-----------|------|---------|-----------| +| pomodoro | 100% | ✅ store 测试 | ✅ | 无 | +| flashcards (FSRS) | 100% | — | ✅ | tags 字段兼容旧数据 | +| feynman | ~95% | ✅ store 测试 | ✅ | UI 优化项 | +| socratic | ~90% | ❌ | ✅ 预设角度降级 | 测试缺失 | +| notes | ~85% | — | ✅ | AI 解释/提炼待实现(v0.5.0-B1.4) | +| classroom | ~75% | — | ✅ merge 降级 | ScriptProcessor 迁移 | +| inspiration | ~70% | — | ✅ | 交互细节 | +| CRDT 同步 | ~80% | ❌ 后端无测试 | ✅ 离线队列 | E2E 缺失 | + +### 4.3 测试与质量现状 + +- 测试集中在 **store 层**(`useFeynmanStore.test.ts`、`usePomodoroStore.test.ts`)与 **AI 插件层**(`AIPluginLoader.test.ts`、`RemoteAIPlugin.test.ts`),共 6+ 文件 +- **明显缺口**:sync-service(Go)无测试、无 E2E、组件层测试稀少 +- 代码 TODO 仅 5 处且均为功能增强项,无 FIXME/HACK 级缺陷标记——代码健康度整体良好 + +--- + +## 五、文档体系梳理 + +`docs/` 覆盖 **软件全生命周期 6 阶段共 33 份规范文档 + 10 份设计文档 + 11 个模板 + 9 个版本归档**: + +| 阶段 | 文档数 | 覆盖主题 | +|------|--------|---------| +| phase-1-planning | 5 | 立项、MVP 验证、需求头脑风暴、估算、架构决策 | +| phase-2-design | 4 | 设计风格、UI/UX 标准、API 设计、数据库设计 | +| phase-3-development | 7 | Git 工作流、AI 编码规范、Code Review、Debug SOP、重构、环境配置、文档规范 | +| phase-4-quality | 5 | 测试策略、安全清单、安全事件响应、性能优化、日志可观测 | +| phase-5-delivery | 4 | CI/CD、发布回滚、备份容灾、服务器加固 | +| phase-6-operations | 8 | 维护迭代、技术债、事故复盘、知识管理、依赖管理、第三方集成、数据治理、用户反馈 | +| phase_design | 10 | 品牌故事、双世界主题、UI-UX 设计系统 v2、痛点图谱、需求池、项目迁移与重构规范 | + +**评价**:文档体系是本项目的显著优势,规范先行且与代码实践(Conventional Commits、semantic-release、AGENTS.md 代理上下文)形成闭环。相对薄弱的是:文档多为"规范类",**架构决策记录(ADR)与实际代码的双向追溯** 依赖 repowiki 补充,建议在 v1.0 前做一次文档-代码一致性核对。 + +--- + +## 六、数据管理和存储方案 + +### 6.1 双库分工 + +| 存储 | 进程 | 用途 | +|------|------|------| +| Dexie.js (IndexedDB) | 渲染进程 | 业务数据快速读写、Web 降级形态(`client/src/lib/storage/database.ts`) | +| better-sqlite3 | Electron 主进程 | 20+ 张结构化表:业务实体、操作日志、CRDT 元数据、搜索索引、离线队列(`client/electron/db/schema.ts`) | + +### 6.2 渐进式 Schema 迁移(当前 v4) + +```ts +export const SCHEMA_VERSION = 4; +export function initializeSchema(db: Database.Database): void { + db.exec(SCHEMA_DDL); // 幂等建表 + const v = db.pragma('user_version', { simple: true }) as number; + if (v < 2) { /* FSRS-5 扩展: stability/difficulty 列 */ } + if (v < 3) { db.exec(SCHEMA_V3_DDL); } // CRDT 元数据表 + if (v < 4) { /* search_index 增加 entity_id/entity_type */ } + db.pragma(`user_version = ${SCHEMA_VERSION}`); +} +``` + +### 6.3 CRDT 同步链路(Automerge v3) + +- **上行**:`getChangesSince()` 取增量 → `concatChanges()` 拼接 → Base64 → `POST /api/v1/sync/crdt/changes` +- **下行**:Base64 解码 → `loadIncremental()` 直接应用(原生支持多 chunk) +- **元数据**:`crdt_docs`(每表 snapshot + last_heads 版本向量)、`crdt_changes`(变更序列) +- **可靠性**:`offline_queue`(含 retry_count/next_retry_at 指数重试)+ `sync_conflicts`(四态冲突处理:pending/resolved-local/resolved-remote/resolved-manual) +- **服务端**:Go 服务提供 `push/pull/resolve/status` REST + WebSocket 实时通道(JWT 从 `?token=` 读取以绕过 WS 头限制),Redis 故障时优雅降级 + +整体形成"**本地先落盘 → 操作日志 → 离线队列 → CRDT 增量同步 → 冲突解决**"的完整 Local-First 链路。 + +--- + +## 七、存在的问题和改进建议 + +### 7.1 已识别问题(按优先级) + +| # | 问题 | 位置 | 风险 | +|---|------|------|------| +| P1 | **硬编码网关 IP** `http://101.37.70.235:8000` 写死在 `electron:dev` 脚本 | `client/package.json` L15 | 环境切换困难、IP 泄露到仓库 | +| P1 | **ScriptProcessor 已废弃**,需迁移 AudioWorklet | `useClassroomCapture.ts:499` | 未来 Chromium 版本课堂助手音频失效 | +| P2 | **测试盲区**:socratic 流程、sync-service 全部、E2E | — | v0.19 引入的 CRDT 链路无回归保障 | +| P2 | **any 类型扩散**:`ipcUtils.ts` TypedHandler、`OfflineQueue.ts` payload、`StorageAdapter.ts` where 参数 | 多处 | IPC 边界类型安全弱化 | +| P3 | `dist-electron/` 编译产物入库,与 `electron/` 源码并存 | client/ | 维护混淆、diff 噪音 | +| P3 | 三处 tsconfig 开启 `skipLibCheck` | client/website | 掩盖三方类型问题 | +| P3 | AI chains 22 个文件结构雷同、各 feature store CRUD 模板重复 | server/chains、src/features | 维护成本随功能数线性增长 | + +### 7.2 改进建议 + +1. **配置化清理(1 天内可完成)**:网关地址改由 `.env`/`build-config.json` 注入;`dist-electron/` 加入 `.gitignore` 并由 CI 产出 +2. **补齐测试三角(下一迭代重点)**: + - 为 `useSocraticFlow` 状态机补 Vitest 用例(brainstorm→dialogue→deepening 转换 + 降级路径) + - sync-service 用 Go `httptest` 覆盖 push/pull/resolve 与 CRDT 端点 + - 引入 Playwright 做番茄钟/费曼核心路径 E2E +3. **技术债专项**:AudioWorklet 迁移 + 逐步移除 `any`(可从 `env.d.ts` IPC 返回类型规范入手,与既有开发规范一致) +4. **代码复用**:AI chain 抽取基类(统一 prompt 装配/JSON 解析/降级);store 用工厂函数生成 CRUD 骨架 +5. **商业化前置**(BUSL 1.1 已切换,说明已在推进):完成额度计量、支付回调、隐私政策(面向学生群体需注意未成年人 PIPL 合规)、生成式 AI 算法备案后,重新启用 `AIProviderSettings.tsx` 中暂禁的云端模式 +6. **性能**:3D 场景补 LOD/实例化;`search_index` 增加过期清理任务 + +--- + +## 八、总体结论 + +项目已完成从 MVP 到 **功能完整、架构成型** 的阶段跨越:7 大学习模块闭环可用,Local-First + 三级 AI 降级的核心架构决策在代码中得到一致贯彻,FSRS-5、CRDT、多模型路由等硬核能力均为自研落地,文档体系完备度超过多数同规模项目。当前处于 **v0.x 冲刺 v1.0 / 商业化准备期**,下一阶段的主要矛盾已从"功能广度"转向 **测试覆盖、技术债清理与合规准备** 三件事。 + +--- + +## 附录:关键文件路径速查 + +| 功能 | 核心文件 | +|------|---------| +| 应用入口 | `client/src/main.tsx`、`client/electron/main.ts` | +| 配置管理 | `client/vite.config.ts`、`server/ai-gateway/config.py` | +| AI 网关 | `server/ai-gateway/main.py`、`routers/*`、`chains/*` | +| 同步服务 | `server/sync-service/main.go`、`handlers/*` | +| 费曼学习 | `client/src/features/feynman/{pages,hooks,store}/` | +| 番茄钟 | `client/src/features/pomodoro/store/usePomodoroStore.ts` | +| 笔记系统 | `client/src/features/notes/pages/NoteEditPage.tsx` | +| 闪卡系统 | `client/src/features/flashcards/store/useFlashcardStore.ts`、`client/src/lib/fsrs.ts` | +| 课堂助手 | `client/src/features/classroom/hooks/useClassroomCapture.ts` | +| 数据库 | `client/electron/db/schema.ts`、`client/src/lib/storage/database.ts` | +| 同步引擎 | `client/src/lib/sync/`、`server/sync-service/handlers/` | diff --git a/scripts/sound-gen/fx-defs.mjs b/scripts/sound-gen/fx-defs.mjs index cdcd0429..e9e334bb 100644 --- a/scripts/sound-gen/fx-defs.mjs +++ b/scripts/sound-gen/fx-defs.mjs @@ -43,10 +43,6 @@ export const FX_SOUNDS = [ /* ── 深潜类 ── */ { file: 'pomodoro_start', events: [G(0, 0.35, 'C5', 'G4', { env: { a: 0.015, tau: 0.18 } }), N(0, 0.35, 'brown', { lp: 500, gain: 0.12 })] }, { file: 'pomodoro_pause', events: [T(0, 0.12, 'E5', { harmonics: 1 })] }, - { - file: 'pomodoro_tick', peakScale: 0.55, // 极短低音量木鱼质感 - events: [T(0, 0.04, 'A4', { harmonics: 2, env: { a: 0.005, tau: 0.008 } }), N(0, 0.015, 'white', { lp: 2500, hp: 800, gain: 0.25 })], - }, { file: 'pomodoro_tick_final', peakScale: 0.65, // tick 提高小三度(A4→C5) events: [T(0, 0.04, 'C5', { harmonics: 2, env: { a: 0.005, tau: 0.008 } }), N(0, 0.015, 'white', { lp: 3000, hp: 900, gain: 0.25 })], @@ -54,6 +50,11 @@ export const FX_SOUNDS = [ { file: 'pomodoro_5min_warning', events: [T(0, 0.14, 'G5', { gain: 0.9, harmonics: 2 }), T(0.16, 0.2, 'E5', { harmonics: 2 })] }, { file: 'pomodoro_work_complete', events: [T(0, 0.1, 'C5'), T(0.09, 0.1, 'E5'), T(0.18, 0.2, 'G5')] }, { file: 'pomodoro_break_end', events: [T(0, 0.1, 'G4'), T(0.09, 0.18, 'C5')] }, + { + file: 'pomodoro_complete', reverb: { decay: 0.7, mix: 0.28 }, // 整轮深潜完成:work_complete 琶音上延八度 C6,庆祝感强于单轮 + events: [T(0, 0.12, 'C5', { harmonics: 3 }), T(0.1, 0.12, 'E5', { harmonics: 3 }), + T(0.2, 0.14, 'G5', { harmonics: 3 }), T(0.3, 0.42, 'C6', { harmonics: 4, env: { tau: 0.2 } })], + }, { file: 'rate_remember', events: [T(0, 0.09, 'C5'), T(0.08, 0.14, 'E5')] }, { file: 'rate_fuzzy', events: [T(0, 0.14, 'D5')] }, { file: 'rate_forgot', events: [T(0, 0.09, 'E5'), T(0.08, 0.14, 'C5')] }, diff --git a/server/.env.example b/server/.env.example index 013d3777..51598732 100644 --- a/server/.env.example +++ b/server/.env.example @@ -32,10 +32,14 @@ JWT_SECRET=change-this-to-a-random-string-in-production # --- AI 模型 API Key --- # 通义千问:https://dashscope.aliyun.com +# 主力 Provider:文本生成、视觉提取、ASR 语音转写主链路 +# (ASR 模型为 qwen3-asr-flash,DashScope OpenAI 兼容模式仅支持该系列做转写) QWEN_API_KEY= # DeepSeek:https://platform.deepseek.com DEEPSEEK_API_KEY= # 智谱 GLM:https://open.bigmodel.cn +# 用途:免费文本/视觉模型 glm-4.6v-flash,同时是 ASR 备选链路(模型 glm-asr)。 +# 留空时 GLM 链路不可用:Qwen ASR 失败后将直接降级为 fallback 空结果(客户端提示服务不可用) GLM_API_KEY= # Gemini(可选,用于视频分析):https://aistudio.google.com/app/apikey GEMINI_API_KEY= diff --git a/server/ai-gateway/cache/redis_cache.py b/server/ai-gateway/cache/redis_cache.py index bcf02e22..85397331 100644 --- a/server/ai-gateway/cache/redis_cache.py +++ b/server/ai-gateway/cache/redis_cache.py @@ -156,8 +156,15 @@ async def set_ai_cache( def get_cache() -> RedisCache: - """获取全局缓存实例""" + """ + 获取全局缓存实例 + + @ai-context: 必须用 APP_CONFIG["redis_url"](来自环境变量 REDIS_URL)构造, + 容器部署时 Redis 位于独立容器(redis:6379,带密码),若用无参默认值 + redis://localhost:6379/0 会永远连接失败,导致限流与响应缓存静默失效。 + """ global _cache_instance if _cache_instance is None: - _cache_instance = RedisCache() + from config import APP_CONFIG + _cache_instance = RedisCache(APP_CONFIG["redis_url"]) return _cache_instance diff --git a/server/ai-gateway/chains/multimodal_analyze_chain.py b/server/ai-gateway/chains/multimodal_analyze_chain.py index 91f85d5a..025185ce 100644 --- a/server/ai-gateway/chains/multimodal_analyze_chain.py +++ b/server/ai-gateway/chains/multimodal_analyze_chain.py @@ -17,9 +17,16 @@ from typing import Any from providers.base_provider import AIProvider +from chains.multimodal_analyze_utils import ( + format_timestamp, + strip_markdown_fence, + warn_if_truncated, +) from prompts.session_analyze import ( SESSION_ANALYZE_SYSTEM_PROMPT, + PARTIAL_ANALYZE_SYSTEM_PROMPT, build_session_prompt, + build_partial_prompt, build_course_context, ) @@ -29,13 +36,11 @@ _CHUNK_THRESHOLD = 20 # 每 chunk 最大帧数 _CHUNK_SIZE = 15 - - -def _format_timestamp(seconds: float) -> str: - """将秒数格式化为 MM:SS 时间戳""" - total = int(seconds) - mins, secs = divmod(total, 60) - return f"{mins:02d}:{secs:02d}" +# 全量分析生成上限 +_FULL_MAX_TOKENS = 4096 +# @ai-context partial 片段模式生成上限:GLM-4V-Flash 上限 1024 会被 provider +# clamp,此处 2000 是目标值(partial mode target max_tokens, provider may clamp) +_PARTIAL_MAX_TOKENS = 2000 class MultimodalAnalyzeChain: @@ -56,17 +61,20 @@ def _build_prompt( audio_text: str | None, duration: int, course_meta: dict | None = None, + mode: str = "full", ) -> tuple[list[str], str]: """ 组装多图分析所需的图片列表和文本 Prompt @ai-context 时间标注让模型感知帧间时序关系, "画面变化类型" 帮助模型区分板书切换 / PPT 翻页 / 板书手写等场景。 + partial 模式使用片段模板,仅输出该片段知识点(无课程概述等全局信息)。 Args: keyframes: 关键帧列表 [{timestamp, image_base64, change_type}] audio_text: 语音转写文本(可选,None 表示无语音) duration: 课程总时长(秒) + mode: 分析模式 full / partial Returns: tuple: (images_base64_list, full_prompt) @@ -76,7 +84,7 @@ def _build_prompt( for idx, kf in enumerate(keyframes): images.append(kf["image_base64"]) - ts = _format_timestamp(kf.get("timestamp", 0.0)) + ts = format_timestamp(kf.get("timestamp", 0.0)) change = kf.get("change_type", "scene_change") time_annotations.append( f"第 {idx + 1} 帧出现在 {ts},画面变化类型为 {change}" @@ -93,13 +101,16 @@ def _build_prompt( f"{audio_text.strip()}\n" ) - # 基础 Prompt 框架 - base_prompt = build_session_prompt( - keyframes_count=len(keyframes), - audio_segments_count=1 if audio_text else 0, - duration_seconds=duration, - course_meta=course_meta, - ) + # 基础 Prompt 框架:partial 模式使用增量片段模板 + if mode == "partial": + base_prompt = build_partial_prompt(keyframes_count=len(keyframes)) + else: + base_prompt = build_session_prompt( + keyframes_count=len(keyframes), + audio_segments_count=1 if audio_text else 0, + duration_seconds=duration, + course_meta=course_meta, + ) # 课程上下文注入(追加到 Prompt 末尾) course_context = build_course_context(course_meta) @@ -115,30 +126,16 @@ def _build_prompt( return images, full_prompt # ------------------------------------------------------------------ - # 响应解析 + # 响应解析 / 截断检测(委托 multimodal_analyze_utils 纯函数) # ------------------------------------------------------------------ def _parse_response(self, raw: str) -> str: - """ - 解析模型返回内容 + """解析模型返回内容(去除意外包裹的外层代码块围栏)""" + return strip_markdown_fence(raw) - 多模态分析直接输出 Markdown 文本,无需 JSON 解析。 - 若模型意外包裹在代码块中,去除外层围栏。 - """ - stripped = raw.strip() - # 去除模型常见的外层 Markdown 代码块围栏 - if stripped.startswith("```markdown"): - stripped = stripped[len("```markdown"):].strip() - if stripped.endswith("```"): - stripped = stripped[:-3].strip() - elif stripped.startswith("```") and stripped.endswith("```"): - inner = stripped[3:].strip() - if inner.endswith("```"): - inner = inner[:-3].strip() - # 仅当内层不含其他 ``` 时才剥离(避免破坏代码块) - if "```" not in inner: - stripped = inner - return stripped + def _warn_if_truncated(self, content: str, tokens_used: int, max_tokens: int) -> None: + """输出末尾疑似截断时记录告警""" + warn_if_truncated(content, tokens_used, max_tokens) # ------------------------------------------------------------------ # 单 chunk 执行 @@ -149,6 +146,8 @@ async def _run_chunk( images: list[str], prompt: str, chunk_label: str, + system_prompt: str = SESSION_ANALYZE_SYSTEM_PROMPT, + max_tokens: int = _FULL_MAX_TOKENS, ) -> dict[str, Any]: """执行单个 chunk 的多图分析调用""" logger.info( @@ -158,10 +157,10 @@ async def _run_chunk( result = await self.provider.generate_vision_multi( images_base64=images, prompt=prompt, - system_prompt=SESSION_ANALYZE_SYSTEM_PROMPT, + system_prompt=system_prompt, model=self.model, temperature=0.3, - max_tokens=4096, + max_tokens=max_tokens, _feature="multimodal_analyze", ) return result @@ -176,18 +175,21 @@ async def run( audio_text: str | None, duration: int, course_meta: dict | None = None, + mode: str = "full", ) -> dict[str, Any]: """ 执行多模态课堂分析 @ai-context 超过 _CHUNK_THRESHOLD 帧时拆分并行调用, 每 chunk ≤ _CHUNK_SIZE 帧,避免单次请求 token 超限。 + partial 模式使用片段模板 + _PARTIAL_MAX_TOKENS,仅输出片段知识点。 Args: keyframes: 关键帧列表 [{timestamp, image_base64, change_type}] audio_text: 语音转写文本(None 表示无语音) duration: 课程总时长(秒) course_meta: 课程元数据(可选) + mode: 分析模式 full(全量)/ partial(增量片段) Returns: dict: { @@ -207,14 +209,31 @@ async def run( "keyframes_analyzed": 0, } - images, full_prompt = self._build_prompt(keyframes, audio_text, duration, course_meta) + images, full_prompt = self._build_prompt( + keyframes, audio_text, duration, course_meta, mode=mode, + ) total_frames = len(images) + # partial 模式:片段系统提示词 + 较小的生成上限 + if mode == "partial": + system_prompt = PARTIAL_ANALYZE_SYSTEM_PROMPT + max_tokens = _PARTIAL_MAX_TOKENS + else: + system_prompt = SESSION_ANALYZE_SYSTEM_PROMPT + max_tokens = _FULL_MAX_TOKENS + # ---- 路径 A:单 chunk(≤ 阈值)---- if total_frames <= _CHUNK_THRESHOLD: - result = await self._run_chunk(images, full_prompt, "single") + result = await self._run_chunk( + images, full_prompt, "single", + system_prompt=system_prompt, max_tokens=max_tokens, + ) + content = self._parse_response(result["content"]) + # provider 可能 clamp max_tokens(如 GLM 上限 1024),优先取返回的实际值 + used_max = result.get("max_tokens", max_tokens) + self._warn_if_truncated(content, result.get("tokens_used", 0), used_max) return { - "content": self._parse_response(result["content"]), + "content": content, "tokens_used": result.get("tokens_used", 0), "model": result.get("model", self.model), "latency_ms": result.get("latency_ms", 0), @@ -235,7 +254,10 @@ async def run( # 每个 chunk 复用同一个 Prompt(模型可通过帧编号区分上下文) chunk_tasks = [ - self._run_chunk(chunk_imgs, full_prompt, f"chunk-{idx + 1}/{len(chunks)}") + self._run_chunk( + chunk_imgs, full_prompt, f"chunk-{idx + 1}/{len(chunks)}", + system_prompt=system_prompt, max_tokens=max_tokens, + ) for idx, chunk_imgs in enumerate(chunks) ] chunk_results = await asyncio.gather(*chunk_tasks, return_exceptions=True) @@ -253,7 +275,12 @@ async def run( f"\n> ⚠️ 第 {idx + 1} 段分析失败,该部分笔记可能不完整\n" ) else: - merged_parts.append(self._parse_response(res["content"])) + chunk_content = self._parse_response(res["content"]) + used_max = res.get("max_tokens", max_tokens) + self._warn_if_truncated( + chunk_content, res.get("tokens_used", 0), used_max, + ) + merged_parts.append(chunk_content) total_tokens += res.get("tokens_used", 0) total_latency = max(total_latency, res.get("latency_ms", 0)) used_model = res.get("model", used_model) diff --git a/server/ai-gateway/chains/multimodal_analyze_utils.py b/server/ai-gateway/chains/multimodal_analyze_utils.py new file mode 100644 index 00000000..4bf40ede --- /dev/null +++ b/server/ai-gateway/chains/multimodal_analyze_utils.py @@ -0,0 +1,65 @@ +""" +熵减 AI 网关 — 多模态课堂分析 Chain 的无状态工具函数 + +@ai-context 从 multimodal_analyze_chain.py 拆出(单文件 ≤300 行约束): +时间戳格式化 / 输出围栏剥离 / max_tokens 截断检测,均为纯函数无副作用。 +(Stateless helpers extracted from MultimodalAnalyzeChain: timestamp +formatting, markdown fence stripping, truncation detection.) +""" + +import logging + +logger = logging.getLogger(__name__) + +# 疑似截断判定用的结束标点集合(terminal punctuation for truncation check) +_TERMINAL_CHARS = "。!?.!?…))”\"'`*]】」』" + + +def format_timestamp(seconds: float) -> str: + """将秒数格式化为 MM:SS 时间戳""" + total = int(seconds) + mins, secs = divmod(total, 60) + return f"{mins:02d}:{secs:02d}" + + +def strip_markdown_fence(raw: str) -> str: + """ + 解析模型返回内容 + + 多模态分析直接输出 Markdown 文本,无需 JSON 解析。 + 若模型意外包裹在代码块中,去除外层围栏。 + """ + stripped = raw.strip() + # 去除模型常见的外层 Markdown 代码块围栏 + if stripped.startswith("```markdown"): + stripped = stripped[len("```markdown"):].strip() + if stripped.endswith("```"): + stripped = stripped[:-3].strip() + elif stripped.startswith("```") and stripped.endswith("```"): + inner = stripped[3:].strip() + if inner.endswith("```"): + inner = inner[:-3].strip() + # 仅当内层不含其他 ``` 时才剥离(避免破坏代码块) + if "```" not in inner: + stripped = inner + return stripped + + +def warn_if_truncated(content: str, tokens_used: int, max_tokens: int) -> None: + """ + 输出末尾疑似截断时记录告警 + + @ai-context tokens_used 达到请求上限且末尾无结束标点时,大概率是 + max_tokens 截断(如 GLM-4V-Flash clamp 到 1024),便于运维定位质量问题。 + (Warn on suspected max_tokens truncation: budget exhausted + no + terminal punctuation at the end.) + """ + stripped = content.rstrip() + if not stripped or tokens_used < max_tokens: + return + if stripped[-1] not in _TERMINAL_CHARS: + logger.warning( + "MultimodalAnalyzeChain: 输出疑似被 max_tokens 截断 " + "(tokens_used=%d, max_tokens=%d, tail=%r)", + tokens_used, max_tokens, stripped[-20:], + ) diff --git a/server/ai-gateway/chains/transcribe_chain.py b/server/ai-gateway/chains/transcribe_chain.py index ad2a2102..2701518d 100644 --- a/server/ai-gateway/chains/transcribe_chain.py +++ b/server/ai-gateway/chains/transcribe_chain.py @@ -20,7 +20,7 @@ class TranscribeChain: """语音转文字处理链""" - def __init__(self, provider: AIProvider, model: str = "paraformer-v2"): + def __init__(self, provider: AIProvider, model: str = "qwen3-asr-flash"): self.provider = provider self.model = model @@ -73,7 +73,7 @@ async def run( "segments": [{"start": 0.0, "end": 2.5, "text": "..."}], "language": "zh", "confidence": 0.95, - "model": "paraformer-v2", + "model": "qwen3-asr-flash", "latency_ms": 500, } """ diff --git a/server/ai-gateway/config/fallback.py b/server/ai-gateway/config/fallback.py index 0d54fb3e..08e0111e 100644 --- a/server/ai-gateway/config/fallback.py +++ b/server/ai-gateway/config/fallback.py @@ -25,7 +25,7 @@ "evaluate": ["glm", "deepseek", "fallback"], # GLM(免费)优先,DeepSeek 备选 "recommend": ["glm", "deepseek", "fallback"], # GLM(免费)优先,DeepSeek 备选 "vision_extract": ["qwen", "glm"], # Qwen-VL-Max(百炼)优先,GLM-4V 备选 - "transcribe": ["qwen", "glm", "fallback"], # Qwen Paraformer 优先,GLM 备选 + "transcribe": ["qwen", "glm", "fallback"], # Qwen3-ASR-Flash 优先,GLM-ASR 备选 "tag_content": ["glm", "deepseek", "fallback"], # GLM(免费)优先,DeepSeek 备选 "optimize_card": ["glm", "qwen", "fallback"], # GLM(免费)优先,Qwen 备选 "feynman_question": ["deepseek", "glm", "fallback"], # DeepSeek 为主,GLM 备选 diff --git a/server/ai-gateway/config/limits.py b/server/ai-gateway/config/limits.py index 383fa1ad..eb30732a 100644 --- a/server/ai-gateway/config/limits.py +++ b/server/ai-gateway/config/limits.py @@ -49,7 +49,9 @@ "evaluate": 10, "recommend": 15, "vision_extract": 20, - "transcribe": 30, + # 课堂实时转录为段级高频调用(VAD 每 5-30s 产生一段,一节课数百段), + # 对齐主流 ASR 按时长计费模式放宽次数限制;中间件对其豁免全局每日总量 + "transcribe": 600, "tag_content": 30, "optimize_card": 15, "feynman_question": 15, diff --git a/server/ai-gateway/config/providers.py b/server/ai-gateway/config/providers.py index 85231d27..87c3c97f 100644 --- a/server/ai-gateway/config/providers.py +++ b/server/ai-gateway/config/providers.py @@ -29,7 +29,7 @@ "summary": "qwen-plus", # 笔记摘要 "flashcard": "qwen-plus", # 闪卡生成(JSON Mode 稳定) "vision": "qwen2.5-vl-72b-instruct", # 多模态视觉提取(课堂助手主力,百炼标准标识符) - "asr": "paraformer-v2", # 语音转文字(Paraformer) + "asr": "qwen3-asr-flash", # 语音转文字(OpenAI 兼容模式仅支持 Qwen3-ASR-Flash 系列) "anchor": "qwen-plus", # 记忆锚点生成 "socratic": "qwen-plus", # 苏格拉底追问 "predict": "qwen-plus", # 预测驱动学习 @@ -52,7 +52,7 @@ "models": { "free": "glm-4.6v-flash", # 免费,多模态(文本+视觉),128K 上下文 "vision": "glm-4.6v-flash", # 多模态视觉(免费),128K 上下文 - "asr": "glm-4-audio", # 语音转文字 + "asr": "glm-asr", # 语音转文字(GLM-ASR,端点 audio/transcriptions) }, }, "gemini": { diff --git a/server/ai-gateway/errors.py b/server/ai-gateway/errors.py index 516fe040..f011e08c 100644 --- a/server/ai-gateway/errors.py +++ b/server/ai-gateway/errors.py @@ -29,11 +29,21 @@ def __init__(self, provider: str, reason: str = ""): class RateLimitExceededError(AIError): - """频率限制超限""" + """ + 频率限制超限 + + @ai-context: limit=0 表示上游服务商返回 429(其配额/并发受限), + 而非本项目的每日配额耗尽——两者文案必须区分,否则日志会出现 + "已达上限(0 次)"这类自相矛盾的误导信息。 + """ def __init__(self, feature: str, limit: int): + if limit > 0: + message = f"今日 {feature} 功能使用次数已达上限({limit} 次),请明天再试" + else: + message = f"{feature} 服务商当前访问量过大或配额受限(上游 429),请稍后重试" super().__init__( - message=f"今日 {feature} 功能使用次数已达上限({limit} 次),请明天再试", + message=message, status_code=429, detail={"feature": feature, "limit": limit}, ) diff --git a/server/ai-gateway/main.py b/server/ai-gateway/main.py index 7209f6ac..c5d1faa9 100644 --- a/server/ai-gateway/main.py +++ b/server/ai-gateway/main.py @@ -69,7 +69,15 @@ async def lifespan(app: FastAPI): # 初始化 Redis 连接 cache = get_cache() await cache.connect() - logger.info("Redis 连接已建立") + # connect 内部失败时会降级(_client=None),此处按实际结果记录, + # 避免"连接已建立"的误导日志掩盖限流/缓存静默失效 + if cache._client is not None: + logger.info("Redis 连接已建立") + else: + logger.error( + "Redis 连接失败,限流与响应缓存将全部降级失效(限流放行)。" + "请检查 REDIS_URL 环境变量与 redis 容器状态" + ) # 初始化各 Provider 并检查 API Key 配置 init_providers(app) diff --git a/server/ai-gateway/middleware/rate_limit.py b/server/ai-gateway/middleware/rate_limit.py index a40986f3..ae28794f 100644 --- a/server/ai-gateway/middleware/rate_limit.py +++ b/server/ai-gateway/middleware/rate_limit.py @@ -35,13 +35,17 @@ "/api/v1/ai/evaluate-explanation": "evaluate", "/api/v1/ai/recommend-duration": "recommend", "/api/v1/ai/vision": "vision_extract", - "/api/v1/ai/transcribe": "transcribe", + "/api/v1/asr/transcribe": "transcribe", "/api/v1/ai/tag-content": "tag_content", "/api/v1/ai/optimize-card": "optimize_card", "/api/v1/ai/feynman-question": "feynman_question", "/api/v1/ai/feynman-evaluate-answers": "feynman_evaluate", } +# 豁免全局每日总量的功能:段级高频调用(如课堂实时转录一节课数百段), +# 若计入 daily_total 会在几分钟内耗尽全部 AI 配额,仅受各自功能级上限约束 +GLOBAL_EXEMPT_FEATURES: frozenset[str] = frozenset({"transcribe"}) + class RateLimitMiddleware(BaseHTTPMiddleware): """频率限制中间件 — 基于 Redis 滑动窗口""" @@ -183,6 +187,10 @@ async def _check_rate_limit( "请明天再试,或升级套餐获取更多配额。" ) + # 段级高频功能豁免全局每日总量(仅受功能级上限约束) + if feature in GLOBAL_EXEMPT_FEATURES: + return True, "" + # ---- 第二层:全局每日总量限制(预检查,仅读取) ---- global_key = f"rate_limit:{user_id}:global:{today}" try: @@ -236,6 +244,10 @@ async def _increment_rate_limit(self, user_id: str, feature: str) -> None: except Exception as exc: logger.warning("频率限制计数失败(功能级): %s", exc) + # 段级高频功能不计入全局每日总量 + if feature in GLOBAL_EXEMPT_FEATURES: + return + # ---- 第二层:全局每日总量计数 ---- global_key = f"rate_limit:{user_id}:global:{today}" try: diff --git a/server/ai-gateway/prompts/session_analyze.py b/server/ai-gateway/prompts/session_analyze.py index bce8f5ed..4c7b765b 100644 --- a/server/ai-gateway/prompts/session_analyze.py +++ b/server/ai-gateway/prompts/session_analyze.py @@ -34,7 +34,9 @@ "2. **提取板书/PPT** 上的所有公式(LaTeX 格式)、定义、关键术语\n" "3. **结合语音内容**补充截屏中未完整展示的推导过程和重点解释\n" "4. 使用 Markdown 二级标题(##)分隔不同知识模块\n" - "5. 在笔记末尾添加 **「核心知识点摘要」** 部分,列出 3-5 个最重要的知识点\n\n" + "5. 在笔记末尾添加 **「核心知识点摘要」** 部分,列出 3-5 个最重要的知识点\n" + "6. 在讲解与某一帧强相关的知识点处,单独一行插入标记 [图:N]" + "(N 为帧编号,从 1 开始);仅在确有对应帧时插入\n\n" "请直接输出 Markdown 笔记内容,不要添加额外说明。" ) @@ -116,6 +118,58 @@ def build_course_context(course_meta: dict | None) -> str: return "\n".join(parts) +# ============================================================ +# 增量片段分析 Prompt(边采边析,每批约 5 帧) +# Partial (incremental) analysis prompts — per small keyframe batch +# ============================================================ + +# @ai-context 增量分析每批片段各自生成"课程概述+讲师+摘要"会导致合并后大量重复, +# 片段模板严格限定只输出该片段的知识点内容,全局信息由 merge-notes 阶段统一生成。 +PARTIAL_ANALYZE_SYSTEM_PROMPT = ( + "你是一个专业的课堂笔记助手,正在对一门课程录制过程中的一个片段进行增量分析。\n" + "你的输出必须使用 Markdown 格式,语言清晰、逻辑严密。\n" + "对于数学公式,使用 LaTeX 格式(行内用 $...$,独立公式用 $$...$$)。\n" + "对于代码,保留语言标注的代码块。\n" + "始终以中文输出,除非用户明确要求其他语言。\n" + "仅基于提供的截屏内容生成笔记,不确定的内容用 [?] 标记。\n" + "严禁输出任何全局性信息:课程概述、课程名称、讲师介绍、课程总时长、" + "核心知识点摘要、总结等一律不要生成,这些将在课后合并阶段统一处理。" +) + +PARTIAL_ANALYZE_USER_TEMPLATE = ( + "以下是这门课程其中一个片段的 {keyframes_count} 张关键帧截图(非完整课程)。\n\n" + "{keyframes_desc}\n\n" + "请仅针对该片段生成知识点笔记,要求:\n\n" + "1. 使用 Markdown 二级标题(##)按知识点分块,标题即知识点名称\n" + "2. 每个知识点下用要点列表描述内容,提取板书/PPT 上的公式(LaTeX)、定义、关键术语\n" + "3. 知识点标题后标注出现时间,格式统一为 MM:SS(如 ## 二叉树遍历(03:25))\n" + "4. **禁止**输出课程概述、课程名称、讲师、总时长、核心知识点摘要等全局信息\n" + "5. 不确定的内容用 [?] 标记\n" + "6. 在讲解与某一帧强相关的知识点处,单独一行插入标记 [图:N]" + "(N 为帧编号,从 1 开始);仅在确有对应帧时插入\n\n" + "请直接输出该片段的 Markdown 笔记内容,不要添加额外说明。" +) + + +def build_partial_prompt(keyframes_count: int) -> str: + """ + 组装增量片段分析的用户提示词 + + @ai-context 片段模式不注入课程时长与语音上下文(片段批次无音频), + 时间标注由 Chain 层追加到 Prompt 末尾。 + + Args: + keyframes_count: 本批关键帧数量 + + Returns: + 填充后的片段分析提示词字符串 + """ + return PARTIAL_ANALYZE_USER_TEMPLATE.format( + keyframes_count=keyframes_count, + keyframes_desc=f"截屏按时间顺序排列,共 {keyframes_count} 帧,每帧标注了课程内出现时间", + ) + + # ============================================================ # 片段笔记合并 Prompt(增量分析课后整理用) # ============================================================ @@ -131,11 +185,15 @@ def build_course_context(course_meta: dict | None) -> str: "以下是一门课程(总时长约 {duration_desc})的 {partials_count} 个片段笔记," "它们是在课堂进行中按时间顺序增量生成的。\n\n" "请将它们合并为一份完整的结构化课堂笔记,要求:\n\n" - "1. **去除重复**:相邻片段可能有重叠内容,请去重并保持连贯\n" - "2. **统一结构**:使用 Markdown 二级标题(##)按知识模块重新组织\n" - "3. **补充衔接**:在片段之间添加必要的过渡语句,使笔记读起来流畅自然\n" - "4. **保留细节**:不要丢失任何公式、定义、代码或关键术语\n" - "5. **末尾摘要**:在笔记最后添加「核心知识点摘要」部分,列出 3-5 个最重要的知识点\n\n" + "1. **只保留一个课程概述**:若多个片段各自含有课程概述/课程名称/讲师等全局信息," + "合并为开头唯一的一段概述,其余全部删除\n" + "2. **严禁重复知识点**:相邻片段的重叠内容必须去重,同一知识点只保留一处最完整的表述\n" + "3. **统一结构**:使用 Markdown 二级标题(##)按知识模块重新组织,标题层级保持一致\n" + "4. **统一时间标注**:所有时间标注统一为 MM:SS 格式\n" + "5. **保留细节**:不要丢失任何公式、定义、代码或关键术语\n" + "6. **唯一末尾摘要**:只在笔记最后输出一个「核心知识点摘要」部分(3-5 个知识点)," + "删除片段中间出现的任何摘要/总结\n" + "7. **保留图片标记**:保留原有 [图:N] 标记,不要删除或改写\n\n" "---\n\n{partials_content}\n\n---\n\n" "请直接输出合并后的 Markdown 笔记,不要添加额外说明。" ) diff --git a/server/ai-gateway/providers/fallback_provider.py b/server/ai-gateway/providers/fallback_provider.py index 62f5c0a5..bf4e2994 100644 --- a/server/ai-gateway/providers/fallback_provider.py +++ b/server/ai-gateway/providers/fallback_provider.py @@ -178,8 +178,16 @@ async def transcribe( sample_rate: int = 16000, channels: int = 1, model: str = "", + **kwargs: Any, ) -> dict[str, Any]: - """Fallback 不支持 ASR,返回友好降级结果""" + """ + Fallback 不支持 ASR,返回友好降级结果 + + @ai-context: 必须接受 **kwargs —— 云端 Provider 的 transcribe 由 + with_retry_and_timeout 装饰并在内部吞掉 _feature,而本方法无装饰器, + call_with_fallback 传入的 _feature 会直接透传到签名上。缺少 **kwargs + 会抛 TypeError 使 fallback 链最后一环也失败,请求最终返回 503 而非降级响应。 + """ import time as _time start = _time.monotonic() logger.warning("使用 FallbackProvider 降级 ASR 响应") diff --git a/server/ai-gateway/providers/glm_provider.py b/server/ai-gateway/providers/glm_provider.py index 3e64dedd..576cc83d 100644 --- a/server/ai-gateway/providers/glm_provider.py +++ b/server/ai-gateway/providers/glm_provider.py @@ -114,12 +114,15 @@ async def transcribe( language: str = "zh", sample_rate: int = 16000, channels: int = 1, - model: str = "glm-4-audio", + model: str = "glm-asr", ) -> dict[str, Any]: """ - 调用智谱 GLM-4-Audio 语音转文字 + 调用智谱 GLM-ASR 语音转文字 - 通过 OpenAI 兼容的 audio transcription 接口调用。 + @ai-context: 官方端点 POST {base_url}/audio/transcriptions(multipart), + 经 OpenAI 兼容 SDK 的 audio.transcriptions.create 调用。官方参数仅 + model/file(或 file_base64)/prompt/hotwords/stream,不支持 language, + 故不传;音频限制 wav/mp3、≤25MB、时长 ≤30 秒。 """ start_time = time.monotonic() @@ -132,7 +135,6 @@ async def transcribe( kwargs: dict[str, Any] = { "model": model, "file": audio_file, - "language": language if language != "auto" else "zh", } response = await self._client.audio.transcriptions.create(**kwargs) @@ -306,6 +308,8 @@ async def generate_vision_multi( "tokens_used": tokens_used, "model": model, "latency_ms": latency_ms, + # clamp 后实际生效的 max_tokens,供 chain 侧截断检测使用 + "max_tokens": max_tokens, } except Exception as e: diff --git a/server/ai-gateway/providers/qwen_provider.py b/server/ai-gateway/providers/qwen_provider.py index d834066a..8c736d64 100644 --- a/server/ai-gateway/providers/qwen_provider.py +++ b/server/ai-gateway/providers/qwen_provider.py @@ -11,7 +11,6 @@ _handle_provider_error 定义于 qwen_vision.py 并由本文件复用。 """ -import base64 import time import logging from typing import Any, AsyncGenerator @@ -105,31 +104,46 @@ async def transcribe( language: str = "zh", sample_rate: int = 16000, channels: int = 1, - model: str = "paraformer-v2", + model: str = "qwen3-asr-flash", ) -> dict[str, Any]: """ - 调用阿里云 DashScope Paraformer 语音转文字 + 调用阿里云百炼 Qwen3-ASR-Flash 语音转文字 - 通过 OpenAI 兼容的 audio transcription 接口调用 Paraformer 模型。 + @ai-context: DashScope 的 OpenAI 兼容模式仅 Qwen3-ASR-Flash 系列支持 + ASR(Paraformer 仅支持原生异步 API 且要求公网音频 URL,无法直传)。 + 官方调用规范:chat.completions + input_audio 内容块(Base64 Data URL, + 编码后 ≤10MB),语言经 extra_body.asr_options.language 指定, + language="auto" 时不传该字段由模型自动检测。 """ start_time = time.monotonic() try: - audio_bytes = base64.b64decode(audio_base64) - import io - audio_file = io.BytesIO(audio_bytes) - audio_file.name = "audio.wav" - - kwargs: dict[str, Any] = { - "model": model, - "file": audio_file, - "language": language if language != "auto" else "zh", - } - - response = await self._client.audio.transcriptions.create(**kwargs) + # 音频以 Data URL 内嵌(客户端上送 WAV/PCM base64) + data_uri = f"data:audio/wav;base64,{audio_base64}" + # ITN 开启:数字/单位规范化("三点一四"→"3.14"),对齐主流 ASR 默认行为, + # 课堂场景公式/数据密集,规范化文本对笔记质量至关重要 + asr_options: dict[str, Any] = {"enable_itn": True} + if language != "auto": + asr_options["language"] = language + + response = await self._client.chat.completions.create( + model=model, + messages=[ + { + "role": "user", + "content": [ + {"type": "input_audio", "input_audio": {"data": data_uri}}, + ], + } + ], + extra_body={"asr_options": asr_options}, + ) latency_ms = int((time.monotonic() - start_time) * 1000) - text = response.text if hasattr(response, "text") else str(response) + # ASR 转写文本在 message.content 中返回 + text = "" + if response.choices: + text = response.choices[0].message.content or "" logger.info( "QwenProvider.transcribe 调用成功: model=%s, text_length=%d, latency=%dms", diff --git a/server/ai-gateway/routers/multimodal.py b/server/ai-gateway/routers/multimodal.py index 1c6d8266..fc930d28 100644 --- a/server/ai-gateway/routers/multimodal.py +++ b/server/ai-gateway/routers/multimodal.py @@ -20,6 +20,7 @@ from config import call_with_fallback_for_request from chains.multimodal_analyze_chain import MultimodalAnalyzeChain from prompts.session_analyze import MERGE_NOTES_SYSTEM_PROMPT, build_merge_prompt +from utils.text_dedup import dedup_paragraphs from routers.multimodal_schemas import ( AnalyzeSessionRequest, AnalyzeSessionResponse, @@ -58,8 +59,8 @@ async def analyze_session( user_id = getattr(request.state, "user_id", "anonymous") logger.info( - "多模态分析请求: user=%s, keyframes=%d, audio_segments=%d, duration=%.1fs", - user_id, len(body.keyframes), len(body.audio_segments), body.duration, + "多模态分析请求: user=%s, keyframes=%d, audio_segments=%d, duration=%.1fs, mode=%s", + user_id, len(body.keyframes), len(body.audio_segments), body.duration, body.mode, ) # ---- 输入校验 ---- @@ -101,6 +102,7 @@ async def _run_chain(provider, model_name): audio_text=audio_text, duration=int(body.duration), course_meta=body.course_meta, + mode=body.mode, ) try: @@ -216,6 +218,10 @@ async def _run_merge(provider, model_name): if "```" not in inner: content = inner + # @ai-context 模型合并后仍可能残留重复段落,做一次段落级 Jaccard 去重兜底 + # (paragraph-level dedup as safety net after model merge) + content = dedup_paragraphs(content) + return MergeNotesResponse( content=content, model_used=result.get("model", "unknown"), diff --git a/server/ai-gateway/routers/multimodal_schemas.py b/server/ai-gateway/routers/multimodal_schemas.py index 1e552c9b..2d1889c5 100644 --- a/server/ai-gateway/routers/multimodal_schemas.py +++ b/server/ai-gateway/routers/multimodal_schemas.py @@ -6,6 +6,8 @@ 联合分析)、merge-notes(增量片段合并)、analyze-video(视频分析)。 """ +from typing import Literal + from pydantic import BaseModel, Field @@ -43,6 +45,10 @@ class AnalyzeSessionRequest(BaseModel): ) output_format: str = Field(default="markdown", description="输出格式:markdown") language: str = Field(default="zh-CN", description="输出语言:zh-CN / en-US") + mode: Literal["full", "partial"] = Field( + default="full", + description="分析模式:full 全量课程分析 / partial 增量片段分析(仅输出片段知识点,不含全局信息)", + ) course_meta: dict | None = Field( default=None, description="课程元数据(可选,含 course_name/subject/custom_terms)", diff --git a/server/ai-gateway/routers/transcribe.py b/server/ai-gateway/routers/transcribe.py index f5f21634..294349f2 100644 --- a/server/ai-gateway/routers/transcribe.py +++ b/server/ai-gateway/routers/transcribe.py @@ -2,9 +2,10 @@ 熵减 AI 网关 — ASR 语音转写路由 POST /api/v1/asr/transcribe -调用 Paraformer / GLM-4-Audio 等 ASR 模型将语音转写为文本。 +调用 Qwen3-ASR-Flash / GLM-ASR 等 ASR 模型将语音转写为文本。 -@ai-context: 语音转写路由:音频转文字(Paraformer/GLM-Audio),经 fallback 链。 +@ai-context: 语音转写路由:音频转文字(Qwen3-ASR-Flash/GLM-ASR),经 fallback 链。 +fallback 降级时通过 warning 字段透传提示,客户端据此识别失败而非静默当作"无语音"。 """ import time @@ -48,6 +49,7 @@ class TranscribeResponse(BaseModel): confidence: float = Field(..., description="置信度 0-1") model_used: str = Field(..., description="使用的模型名称") processing_time_ms: int = Field(..., description="请求耗时(毫秒)") + warning: str | None = Field(default=None, description="降级提示(fallback 时透传,客户端据此识别失败)") # ============================================================ @@ -60,8 +62,8 @@ async def transcribe_audio(request: Request, body: TranscribeRequest) -> Transcr """ 将音频数据转写为文本 - - 优先使用阿里云 Paraformer(低延迟中文 ASR) - - 备选 GLM-4-Audio + - 优先使用阿里云 Qwen3-ASR-Flash(OpenAI 兼容模式,低延迟中文 ASR) + - 备选 GLM-ASR - 返回转写文本、时间分段、置信度 """ start_time = time.monotonic() @@ -113,6 +115,11 @@ async def _run_chain(provider, model_name): len(result.get("text", "")), result.get("confidence", 0.0), ) + # fallback 降级时透传 warning,供客户端识别失败(而非静默当作"无语音") + warning = result.get("warning") or ( + "ASR 服务降级,转写结果不可用" if result.get("model") == "fallback" else None + ) + return TranscribeResponse( text=result.get("text", ""), segments=segments, @@ -120,4 +127,5 @@ async def _run_chain(provider, model_name): confidence=result.get("confidence", 0.0), model_used=result.get("model", "unknown"), processing_time_ms=latency_ms, + warning=warning, ) diff --git a/server/ai-gateway/tests/test_config.py b/server/ai-gateway/tests/test_config.py index cb0995fa..64a6b4fa 100644 --- a/server/ai-gateway/tests/test_config.py +++ b/server/ai-gateway/tests/test_config.py @@ -141,7 +141,7 @@ def test_primary_provider_uses_routing_slot(self): """主 Provider 使用 MODEL_ROUTING 指定的 slot""" assert _resolve_model_name("glm", "summarize") == "glm-4.6v-flash" assert _resolve_model_name("deepseek", "evaluate") == "deepseek-chat" - assert _resolve_model_name("qwen", "transcribe") == "paraformer-v2" + assert _resolve_model_name("qwen", "transcribe") == "qwen3-asr-flash" def test_fallback_provider_uses_feature_or_free_slot(self): """fallback 到非主 Provider 时,优先使用功能 slot,否则使用 free slot""" diff --git a/server/ai-gateway/tests/test_multimodal_partial.py b/server/ai-gateway/tests/test_multimodal_partial.py new file mode 100644 index 00000000..9c5d8f71 --- /dev/null +++ b/server/ai-gateway/tests/test_multimodal_partial.py @@ -0,0 +1,285 @@ +""" +测试多模态分析 partial 增量片段模式 + +覆盖: +- AnalyzeSessionRequest.mode 字段:默认 full / 接受 partial / 拒绝非法值 +- build_partial_prompt:包含片段限定与全局信息禁令 +- MultimodalAnalyzeChain partial 模式:片段系统提示词 + max_tokens=2000 +- 截断检测:tokens_used 达上限且末尾无结束标点时 logger.warning +- /analyze-session 路由透传 mode +- /merge-notes 路由对合并结果做段落级去重 +""" + +import sys +import logging +from pathlib import Path +from unittest.mock import patch, AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +GATEWAY_ROOT = str(Path(__file__).resolve().parent.parent) +if GATEWAY_ROOT not in sys.path: + sys.path.insert(0, GATEWAY_ROOT) + + +# ──────────────────────────────────────────────────────────── +# 辅助:mock 多模态 Provider 与关键帧数据 +# ──────────────────────────────────────────────────────────── + + +def _make_vision_provider(content: str = "## 知识点\n- 要点。", tokens_used: int = 100): + """构造带 generate_vision_multi 的 mock Provider""" + provider = MagicMock() + provider.provider_name = "qwen" + provider.api_key = "mock" + provider.generate_vision_multi = AsyncMock(return_value={ + "content": content, + "tokens_used": tokens_used, + "model": "qwen-vl-plus", + "latency_ms": 100, + }) + return provider + + +def _make_keyframes(count: int = 3) -> list[dict]: + return [ + {"timestamp": float(i * 30), "image_base64": "aW1n", "change_type": "slide_change"} + for i in range(count) + ] + + +# ──────────────────────────────────────────────────────────── +# Schema:mode 字段 +# ──────────────────────────────────────────────────────────── + + +class TestAnalyzeSessionRequestMode: + """AnalyzeSessionRequest.mode 字段校验""" + + def _base_payload(self) -> dict: + return { + "duration": 120, + "keyframes": [{"timestamp": 10.0, "image_base64": "aW1n"}], + } + + def test_mode_defaults_to_full(self): + """不传 mode 时默认 full(向后兼容)""" + from routers.multimodal_schemas import AnalyzeSessionRequest + req = AnalyzeSessionRequest(**self._base_payload()) + assert req.mode == "full" + + def test_mode_accepts_partial(self): + """mode=partial 合法""" + from routers.multimodal_schemas import AnalyzeSessionRequest + req = AnalyzeSessionRequest(**self._base_payload(), mode="partial") + assert req.mode == "partial" + + def test_mode_rejects_invalid_value(self): + """非法 mode 值被 Pydantic 拒绝""" + from pydantic import ValidationError + from routers.multimodal_schemas import AnalyzeSessionRequest + with pytest.raises(ValidationError): + AnalyzeSessionRequest(**self._base_payload(), mode="incremental") + + +# ──────────────────────────────────────────────────────────── +# Prompt:片段模板 +# ──────────────────────────────────────────────────────────── + + +class TestBuildPartialPrompt: + """build_partial_prompt 片段提示词""" + + def test_contains_fragment_scope_and_prohibition(self): + """片段提示词包含片段限定与全局信息禁令""" + from prompts.session_analyze import build_partial_prompt + prompt = build_partial_prompt(keyframes_count=5) + assert "5 张关键帧" in prompt + assert "禁止" in prompt + assert "课程概述" in prompt + assert "核心知识点摘要" in prompt + assert "MM:SS" in prompt + assert "[?]" in prompt + + def test_partial_system_prompt_forbids_global_info(self): + """片段系统提示词明确禁止全局信息""" + from prompts.session_analyze import PARTIAL_ANALYZE_SYSTEM_PROMPT + assert "严禁" in PARTIAL_ANALYZE_SYSTEM_PROMPT + assert "课程概述" in PARTIAL_ANALYZE_SYSTEM_PROMPT + + +# ──────────────────────────────────────────────────────────── +# Chain:partial 模式行为 +# ──────────────────────────────────────────────────────────── + + +class TestChainPartialMode: + """MultimodalAnalyzeChain 的 mode 分支""" + + @pytest.mark.asyncio + async def test_partial_mode_uses_partial_prompt_and_max_tokens(self): + """partial 模式使用片段系统提示词且 max_tokens=2000""" + from chains.multimodal_analyze_chain import MultimodalAnalyzeChain + from prompts.session_analyze import PARTIAL_ANALYZE_SYSTEM_PROMPT + + provider = _make_vision_provider() + chain = MultimodalAnalyzeChain(provider=provider, model="qwen-vl-plus") + result = await chain.run( + keyframes=_make_keyframes(3), audio_text=None, duration=90, mode="partial", + ) + + assert result["keyframes_analyzed"] == 3 + call_kwargs = provider.generate_vision_multi.call_args.kwargs + assert call_kwargs["max_tokens"] == 2000 + assert call_kwargs["system_prompt"] == PARTIAL_ANALYZE_SYSTEM_PROMPT + assert "禁止" in call_kwargs["prompt"] + + @pytest.mark.asyncio + async def test_full_mode_keeps_default_behavior(self): + """full 模式(默认)保持全量模板与 max_tokens=4096""" + from chains.multimodal_analyze_chain import MultimodalAnalyzeChain + from prompts.session_analyze import SESSION_ANALYZE_SYSTEM_PROMPT + + provider = _make_vision_provider() + chain = MultimodalAnalyzeChain(provider=provider, model="qwen-vl-plus") + await chain.run(keyframes=_make_keyframes(3), audio_text=None, duration=90) + + call_kwargs = provider.generate_vision_multi.call_args.kwargs + assert call_kwargs["max_tokens"] == 4096 + assert call_kwargs["system_prompt"] == SESSION_ANALYZE_SYSTEM_PROMPT + + @pytest.mark.asyncio + async def test_truncation_warning_logged(self, caplog): + """tokens_used 达上限且末尾无结束标点时记录截断告警""" + from chains.multimodal_analyze_chain import MultimodalAnalyzeChain + + provider = _make_vision_provider( + content="## 知识点\n- 这段输出在句子中间被截", tokens_used=2000, + ) + chain = MultimodalAnalyzeChain(provider=provider, model="qwen-vl-plus") + with caplog.at_level(logging.WARNING): + await chain.run( + keyframes=_make_keyframes(2), audio_text=None, duration=60, mode="partial", + ) + assert "截断" in caplog.text + + @pytest.mark.asyncio + async def test_no_truncation_warning_when_complete(self, caplog): + """输出以结束标点收尾且未达上限时不告警""" + from chains.multimodal_analyze_chain import MultimodalAnalyzeChain + + provider = _make_vision_provider(content="## 知识点\n- 完整的要点。", tokens_used=500) + chain = MultimodalAnalyzeChain(provider=provider, model="qwen-vl-plus") + with caplog.at_level(logging.WARNING): + await chain.run( + keyframes=_make_keyframes(2), audio_text=None, duration=60, mode="partial", + ) + assert "截断" not in caplog.text + + @pytest.mark.asyncio + async def test_truncation_uses_provider_clamped_max_tokens(self, caplog): + """provider clamp 后返回实际 max_tokens(如 GLM 1024)时截断检测仍生效""" + from chains.multimodal_analyze_chain import MultimodalAnalyzeChain + + # full 模式请求 4096,GLM provider clamp 到 1024 并在返回中携带实际值 + provider = _make_vision_provider( + content="## 知识点\n- 这段输出在句子中间被截", tokens_used=1024, + ) + provider.generate_vision_multi.return_value["max_tokens"] = 1024 + provider.generate_vision_multi.return_value["model"] = "glm-4.6v-flash" + chain = MultimodalAnalyzeChain(provider=provider, model="glm-4.6v-flash") + with caplog.at_level(logging.WARNING): + await chain.run(keyframes=_make_keyframes(2), audio_text=None, duration=60) + assert "截断" in caplog.text + + +# ──────────────────────────────────────────────────────────── +# 路由:mode 透传与 merge-notes 去重 +# ──────────────────────────────────────────────────────────── + + +def _create_multimodal_app() -> FastAPI: + """创建仅挂多模态路由的测试 app(不挂中间件)""" + from routers.multimodal import router as multimodal_router + + app = FastAPI() + mock_provider = MagicMock() + mock_provider.provider_name = "qwen" + mock_provider.api_key = "mock" + app.state.providers = {"qwen": mock_provider, "fallback": mock_provider} + app.include_router(multimodal_router) + return app + + +class TestAnalyzeSessionModeRoute: + """POST /api/v1/multimodal/analyze-session mode 透传""" + + @pytest.fixture + def client(self): + return TestClient(_create_multimodal_app()) + + def test_partial_mode_accepted(self, client): + """mode=partial 请求正常返回""" + mock_result = { + "content": "## 片段知识点\n- 要点。", + "tokens_used": 300, + "model": "qwen-vl-plus", + "latency_ms": 500, + "keyframes_analyzed": 2, + } + with patch("routers.multimodal.call_with_fallback_for_request", new_callable=AsyncMock) as mock_cwf: + mock_cwf.return_value = (mock_result, "qwen", False) + resp = client.post("/api/v1/multimodal/analyze-session", json={ + "duration": 60, + "keyframes": [ + {"timestamp": 5.0, "image_base64": "aW1n"}, + {"timestamp": 35.0, "image_base64": "aW1n"}, + ], + "mode": "partial", + }) + assert resp.status_code == 200 + assert resp.json()["content"] == "## 片段知识点\n- 要点。" + + def test_invalid_mode_rejected(self, client): + """非法 mode 返回 422""" + resp = client.post("/api/v1/multimodal/analyze-session", json={ + "duration": 60, + "keyframes": [{"timestamp": 5.0, "image_base64": "aW1n"}], + "mode": "bogus", + }) + assert resp.status_code == 422 + + +class TestMergeNotesDedup: + """POST /api/v1/multimodal/merge-notes 合并结果去重""" + + @pytest.fixture + def client(self): + return TestClient(_create_multimodal_app()) + + def test_duplicate_paragraphs_removed_from_merge_result(self, client): + """模型合并输出中的重复段落被兜底去重""" + duplicated = ( + "## 二叉树遍历\n\n" + "前序遍历的顺序是根左右,中序遍历的顺序是左根右,后序遍历的顺序是左右根。\n\n" + "前序遍历的顺序是根左右,中序遍历的顺序是左根右,后序遍历的顺序是左右根。\n\n" + "## 核心知识点摘要\n\n- 三种遍历方式的访问顺序不同。" + ) + mock_result = { + "content": duplicated, + "tokens_used": 400, + "model": "qwen-plus", + "latency_ms": 300, + } + with patch("routers.multimodal.call_with_fallback_for_request", new_callable=AsyncMock) as mock_cwf: + mock_cwf.return_value = (mock_result, "qwen", False) + resp = client.post("/api/v1/multimodal/merge-notes", json={ + "partials": ["## 片段一\n内容一", "## 片段二\n内容二"], + "duration": 600, + }) + assert resp.status_code == 200 + content = resp.json()["content"] + assert content.count("前序遍历的顺序是根左右") == 1 + assert "核心知识点摘要" in content diff --git a/server/ai-gateway/tests/test_providers.py b/server/ai-gateway/tests/test_providers.py index b5b36f5d..4707bbe1 100644 --- a/server/ai-gateway/tests/test_providers.py +++ b/server/ai-gateway/tests/test_providers.py @@ -183,6 +183,66 @@ def test_recommend_duration_fallback_no_history(self): assert result["recommended_minutes"] == 25 assert result["source"] == "local_rule" + @pytest.mark.asyncio + async def test_transcribe_accepts_feature_kwarg(self): + """ + 回归:transcribe 必须容忍 call_with_fallback 透传的 _feature 关键字。 + + 云端 Provider 的 transcribe 由 with_retry_and_timeout 装饰并吞掉 _feature, + 但 FallbackProvider 无装饰器,若签名不接受 **kwargs 会抛 TypeError, + 导致 fallback 链最后一环失败、ASR 请求返回 503 而非降级响应。 + """ + # Arrange + provider = FallbackProvider() + + # Act + result = await provider.transcribe( + audio_base64="ZmFrZQ==", language="zh", _feature="transcribe", + ) + + # Assert + assert result["model"] == "fallback" + assert result["text"] == "" + assert result["warning"] # 必须透传降级提示供客户端识别失败 + + @pytest.mark.asyncio + async def test_transcribe_fallback_reachable_when_cloud_asr_fails(self): + """ + 回归:云端 ASR 全失败时,fallback 链末端必须能返回降级响应。 + + 真实故障场景——Qwen 404 + GLM 400 后,若 FallbackProvider.transcribe + 因签名不接受 _feature 抛 TypeError,整个请求会变成 503。 + """ + # Arrange:模拟 qwen/glm 的 transcribe 均失败,链末端为真实 FallbackProvider + class FailingASRProvider: + def __init__(self, name): + self.provider_name = name + + async def transcribe(self, *args, **kwargs): + raise RuntimeError(f"{self.provider_name} ASR 上游故障") + + app = MagicMock() + app.state.providers = { + "qwen": FailingASRProvider("qwen"), + "glm": FailingASRProvider("glm"), + "fallback": FallbackProvider(), + } + + async def _run_chain(provider, model_name): + # 与 transcribe 路由一致:显式透传 _feature + return await provider.transcribe( + audio_base64="ZmFrZQ==", language="zh", model=model_name, + _feature="transcribe", + ) + + # Act + result, provider_key = await call_with_fallback(app, "transcribe", _run_chain) + + # Assert + assert provider_key == "fallback" + assert result["model"] == "fallback" + assert result["warning"] + def test_recommend_duration_fallback_short_history(self): history = [ {"duration_minutes": 10, "completed": True}, diff --git a/server/ai-gateway/tests/test_text_dedup.py b/server/ai-gateway/tests/test_text_dedup.py new file mode 100644 index 00000000..be353bac --- /dev/null +++ b/server/ai-gateway/tests/test_text_dedup.py @@ -0,0 +1,99 @@ +""" +测试文本段落去重工具(utils/text_dedup.py) + +覆盖: +- 完全重复段落被移除(保留首次出现) +- 高相似(>0.85)段落被移除 +- 不同内容段落全部保留且保序 +- threshold 参数生效 +- 空输入 / 单段落边界情况 +""" + +import sys +from pathlib import Path + +GATEWAY_ROOT = str(Path(__file__).resolve().parent.parent) +if GATEWAY_ROOT not in sys.path: + sys.path.insert(0, GATEWAY_ROOT) + +from utils.text_dedup import dedup_paragraphs, _jaccard, _tokenize + + +class TestJaccard: + """词集 Jaccard 相似度""" + + def test_identical_sets(self): + """完全相同的词集相似度为 1.0""" + a = _tokenize("二叉树的前序遍历是根左右") + assert _jaccard(a, a) == 1.0 + + def test_disjoint_sets(self): + """完全不同的词集相似度为 0.0""" + a = _tokenize("二叉树遍历") + b = _tokenize("动态规划") + assert _jaccard(a, b) == 0.0 + + def test_empty_set_returns_zero(self): + """空词集视为不相似""" + a = _tokenize("知识点") + assert _jaccard(a, frozenset()) == 0.0 + assert _jaccard(frozenset(), frozenset()) == 0.0 + + +class TestDedupParagraphs: + """dedup_paragraphs 段落级去重""" + + def test_exact_duplicate_removed(self): + """完全重复的段落只保留第一次出现""" + para = "## 二叉树遍历\n\n- 前序遍历:根 → 左 → 右\n- 中序遍历:左 → 根 → 右" + text = f"{para}\n\n{para}" + result = dedup_paragraphs(text) + assert result.count("前序遍历") == 1 + + def test_near_duplicate_removed(self): + """高相似段落(仅个别字不同)被移除""" + text = ( + "本节课讲解了二叉树的前序、中序、后序三种遍历方式及其递归实现方法。\n\n" + "本节课讲解了二叉树的前序、中序、后序三种遍历方式及其递归实现方式。" + ) + result = dedup_paragraphs(text) + paragraphs = [p for p in result.split("\n\n") if p.strip()] + assert len(paragraphs) == 1 + + def test_distinct_paragraphs_kept_in_order(self): + """不同内容的段落全部保留且顺序不变""" + text = "## 知识点一:栈\n\n## 知识点二:队列\n\n## 知识点三:动态规划详解" + result = dedup_paragraphs(text) + assert "栈" in result + assert "队列" in result + assert "动态规划" in result + assert result.index("栈") < result.index("队列") < result.index("动态规划") + + def test_threshold_parameter(self): + """降低阈值后中等相似段落也会被移除""" + text = ( + "梯度下降通过迭代更新参数使损失函数最小化,学习率控制每次更新的步长大小。\n\n" + "梯度下降通过迭代更新参数使损失函数逐步减小,学习率决定每次更新的幅度。" + ) + # 默认阈值 0.85 下两段视为不同(保留两段) + assert len(dedup_paragraphs(text).split("\n\n")) == 2 + # 阈值降到 0.5 时视为重复(只保留一段) + assert len(dedup_paragraphs(text, threshold=0.5).split("\n\n")) == 1 + + def test_empty_input_returned_as_is(self): + """空输入原样返回,不抛异常""" + assert dedup_paragraphs("") == "" + assert dedup_paragraphs(" ") == " " + + def test_single_paragraph_unchanged(self): + """单段落内容不变""" + text = "## 唯一的知识点\n- 要点一\n- 要点二" + assert dedup_paragraphs(text) == text + + def test_short_headings_not_falsely_deduped(self): + """内容不同的短标题不会被误判为重复""" + text = "## 片段 1\n\n## 片段 2\n\n## 片段 3" + result = dedup_paragraphs(text) + assert "片段 1" in result + assert "片段 2" in result + assert "片段 3" in result diff --git a/server/ai-gateway/utils/__init__.py b/server/ai-gateway/utils/__init__.py new file mode 100644 index 00000000..19a57a09 --- /dev/null +++ b/server/ai-gateway/utils/__init__.py @@ -0,0 +1 @@ +"""熵减 AI 网关 — 通用工具包(utils package for the AI gateway)""" diff --git a/server/ai-gateway/utils/text_dedup.py b/server/ai-gateway/utils/text_dedup.py new file mode 100644 index 00000000..caf8cadf --- /dev/null +++ b/server/ai-gateway/utils/text_dedup.py @@ -0,0 +1,69 @@ +""" +熵减 AI 网关 — 文本段落去重工具 + +@ai-context merge-notes 兜底去重:模型合并增量片段笔记后仍可能残留大量 +重复段落(实测重复率可达 60%+),按空行分段计算段落词集的 Jaccard 相似度, +相似度超过阈值时去除后出现的段落,保留首次出现的版本。 +(Paragraph-level dedup safety net for merge-notes: split by blank lines, +compute word-set Jaccard similarity, drop the later near-duplicate.) +""" + +import re + +# 分词:中文按单字、英文/数字按连续词(CJK char-level + latin word-level) +_TOKEN_RE = re.compile(r"[\u4e00-\u9fff]|[a-zA-Z0-9_]+") + +# 段落分隔:一个或多个空行(含仅空白字符的行) +_PARA_SPLIT_RE = re.compile(r"\n\s*\n") + + +def _tokenize(paragraph: str) -> frozenset[str]: + """将段落切分为词集(小写归一化),用于 Jaccard 相似度计算""" + return frozenset(tok.lower() for tok in _TOKEN_RE.findall(paragraph)) + + +def _jaccard(a: frozenset[str], b: frozenset[str]) -> float: + """计算两个词集的 Jaccard 相似度(空集视为不相似,返回 0.0)""" + if not a or not b: + return 0.0 + intersection = len(a & b) + if intersection == 0: + return 0.0 + return intersection / len(a | b) + + +def dedup_paragraphs(text: str, threshold: float = 0.85) -> str: + """ + 段落级去重:移除与前文高度相似的重复段落 + + @ai-context 保序策略——逐段与所有已保留段落比较,相似度 > threshold + 时丢弃当前段落(保留首次出现的版本),段落原文与顺序均不改动。 + 分隔符统一为一个空行(Markdown 语义等价)。 + + Args: + text: 待去重的 Markdown 文本 + threshold: Jaccard 相似度阈值,超过即视为重复(默认 0.85) + + Returns: + 去重后的文本;空输入原样返回 + """ + if not text or not text.strip(): + return text + + paragraphs = [p for p in _PARA_SPLIT_RE.split(text) if p.strip()] + + kept: list[str] = [] + kept_tokens: list[frozenset[str]] = [] + + for para in paragraphs: + tokens = _tokenize(para) + # 与所有已保留段落比较,命中即丢弃当前段落 + is_duplicate = any( + _jaccard(tokens, prev) > threshold for prev in kept_tokens + ) + if is_duplicate: + continue + kept.append(para.strip()) + kept_tokens.append(tokens) + + return "\n\n".join(kept)