From 06f761e75079c740ceb003b7e947a6e9a566c3de Mon Sep 17 00:00:00 2001 From: Kevin Carter Date: Thu, 21 May 2026 12:21:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(cis):=20add=20Keywords=20tab=20=E2=80=94?= =?UTF-8?q?=20watches,=20real-time=20detection,=20match=20log=20(Phase=203?= =?UTF-8?q?C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeywordWatch and KeywordMatch interfaces in transcriptionStore - loadKeywordWatches, addKeywordWatch, deleteKeywordWatch actions via API with JWT auth - checkSegmentForKeywords: client-side detection on each final segment, logs events to backend - clearKeywordMatches: resets session-scoped match list - Keywords tab in CCTranscriptionPanel: add/delete watches, match log with timestamp - Match count badge on Keywords tab when hits exist during recording - Also fixes missing Close import that was present in summary modal --- src/stores/transcriptionStore.ts | 153 +++++++++++++ .../shared/CCTranscriptionPanel.tsx | 206 +++++++++++++++++- 2 files changed, 355 insertions(+), 4 deletions(-) diff --git a/src/stores/transcriptionStore.ts b/src/stores/transcriptionStore.ts index 74368d7..469ce0e 100644 --- a/src/stores/transcriptionStore.ts +++ b/src/stores/transcriptionStore.ts @@ -39,6 +39,24 @@ export interface LLMConfig { export type ExportFormat = 'srt' | 'txt' | 'json'; +export interface KeywordWatch { + id: string; + user_id: string; + keyword: string; + match_type: string; + action: string; + is_active: boolean; + created_at: string; +} + +export interface KeywordMatch { + keyword: string; + watch_id: string | null; + segment_text: string; + elapsed_seconds: number; + matched_at: string; +} + const LLM_CONFIG_STORAGE_KEY = 'cc_llm_config'; function loadLLMConfig(): LLMConfig { @@ -97,6 +115,10 @@ interface TranscriptionState { isExporting: boolean; exportError: string | null; + // Keyword state + keywordWatches: KeywordWatch[]; + keywordMatches: KeywordMatch[]; + // Actions startSession: (timetableTag?: TimetablePeriod) => Promise; stopSession: () => Promise; @@ -120,6 +142,13 @@ interface TranscriptionState { // Export actions exportSession: (sessionId: string, format: ExportFormat) => Promise; setExportError: (error: string | null) => void; + + // Keyword actions + loadKeywordWatches: () => Promise; + addKeywordWatch: (keyword: string) => Promise; + deleteKeywordWatch: (watchId: string) => Promise; + checkSegmentForKeywords: (text: string, elapsedSeconds: number) => Promise; + clearKeywordMatches: () => void; } export const useTranscriptionStore = create((set, get) => ({ @@ -145,6 +174,10 @@ export const useTranscriptionStore = create((set, get) => ({ isExporting: false, exportError: null, + // Keyword state + keywordWatches: [], + keywordMatches: [], + setTimetableContext: (context) => { set({ timetableContext: context }); }, @@ -265,6 +298,7 @@ export const useTranscriptionStore = create((set, get) => ({ activeSession: null, pendingCanvasEvents: [], timetableContext: null, + keywordMatches: [], }); }, @@ -405,4 +439,123 @@ export const useTranscriptionStore = create((set, get) => ({ setExportError: (error: string | null) => { set({ exportError: error }); }, + + loadKeywordWatches: async () => { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + headers: { 'Authorization': `Bearer ${session.access_token}` }, + }); + if (!response.ok) return; + const watches = await response.json(); + set({ keywordWatches: watches }); + } catch (error) { + console.error('Failed to load keyword watches:', error); + } + }, + + addKeywordWatch: async (keyword: string) => { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) return; + const user = await supabase.auth.getUser(); + if (!user.data.user) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + const response = await fetch(`${apiBaseUrl}/transcribe/keywords`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ + user_id: user.data.user.id, + keyword: keyword.trim(), + match_type: 'contains', + action: 'alert', + }), + }); + if (!response.ok) return; + const newWatch = await response.json(); + set((state) => ({ keywordWatches: [...state.keywordWatches, newWatch] })); + } catch (error) { + console.error('Failed to add keyword watch:', error); + } + }, + + deleteKeywordWatch: async (watchId: string) => { + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.access_token) return; + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/${watchId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${session.access_token}` }, + }); + set((state) => ({ keywordWatches: state.keywordWatches.filter((w) => w.id !== watchId) })); + } catch (error) { + console.error('Failed to delete keyword watch:', error); + } + }, + + checkSegmentForKeywords: async (text: string, elapsedSeconds: number) => { + const { keywordWatches, activeSession } = get(); + if (keywordWatches.length === 0) return; + + const lowerText = text.toLowerCase(); + const matches: KeywordMatch[] = []; + + for (const watch of keywordWatches) { + if (!watch.is_active) continue; + const lowerKeyword = watch.keyword.toLowerCase(); + const matched = + watch.match_type === 'exact' + ? lowerText === lowerKeyword + : watch.match_type === 'starts_with' + ? lowerText.startsWith(lowerKeyword) + : lowerText.includes(lowerKeyword); + + if (matched) { + matches.push({ + keyword: watch.keyword, + watch_id: watch.id, + segment_text: text, + elapsed_seconds: elapsedSeconds, + matched_at: new Date().toISOString(), + }); + + if (activeSession) { + try { + const { data: { session } } = await supabase.auth.getSession(); + const apiBaseUrl = import.meta.env.VITE_API_BASE || 'https://api.classroomcopilot.ai'; + await fetch(`${apiBaseUrl}/transcribe/keywords/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session?.access_token ? { 'Authorization': `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ + session_id: activeSession.id, + keyword_watch_id: watch.id, + keyword_text: watch.keyword, + matched_in_text: text, + session_elapsed_seconds: elapsedSeconds, + }), + }); + } catch (error) { + console.error('Failed to log keyword event:', error); + } + } + } + } + + if (matches.length > 0) { + set((state) => ({ keywordMatches: [...state.keywordMatches, ...matches] })); + } + }, + + clearKeywordMatches: () => { + set({ keywordMatches: [] }); + }, })); diff --git a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx index a96cbf2..c846cc0 100644 --- a/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx +++ b/src/utils/tldraw/ui-overrides/components/shared/CCTranscriptionPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; -import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; -import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod } from "../../../../../stores/transcriptionStore"; +import { Mic as MicIcon, Stop as StopIcon, History as HistoryIcon, Settings as SettingsIcon, AutoAwesome as AutoAwesomeIcon, NotificationsActive as KeywordIcon, Close } from "@mui/icons-material"; +import { useTranscriptionStore, TranscriptionSegment, TranscriptionSession, TimetablePeriod, KeywordWatch, KeywordMatch } from "../../../../../stores/transcriptionStore"; import { TranscriptionService } from "../../../cc-base/cc-transcription/transcriptionService"; import { CanvasEventLogger } from "../../../cc-base/canvas-event-logger/CanvasEventLogger"; import LLMConfigModal from "./LLMConfigModal"; @@ -17,7 +17,7 @@ const formatDateTime = (isoString: string): string => { return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; -type TabType = "live" | "sessions"; +type TabType = "live" | "sessions" | "keywords"; const SUMMARY_TYPES = [ { value: 'full_lesson', label: 'Full Lesson Summary' }, @@ -52,6 +52,13 @@ export const CCTranscriptionPanel: React.FC = () => { setSummaryText, setIsGeneratingSummary, setSummaryError, + keywordWatches, + keywordMatches, + loadKeywordWatches, + addKeywordWatch, + deleteKeywordWatch, + checkSegmentForKeywords, + clearKeywordMatches, } = useTranscriptionStore(); const [activeTab, setActiveTab] = useState("live"); @@ -66,9 +73,14 @@ export const CCTranscriptionPanel: React.FC = () => { const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryType, setSummaryType] = useState('full_lesson'); - // Load sessions on mount + // Keyword tab state + const [newKeyword, setNewKeyword] = useState(''); + const [isAddingKeyword, setIsAddingKeyword] = useState(false); + + // Load sessions and keyword watches on mount useEffect(() => { loadSessions().then(setSessions); + loadKeywordWatches(); }, []); // Auto-detect timetable context on mount @@ -117,6 +129,10 @@ export const CCTranscriptionPanel: React.FC = () => { const service = new TranscriptionService(); service.setTranscriptionCallback((text, isFinal, metadata) => { saveSegment(text, isFinal, metadata); + if (isFinal) { + const { elapsedSeconds: elapsed } = useTranscriptionStore.getState(); + checkSegmentForKeywords(text, elapsed); + } }); await service.startTranscription(); serviceRef.current = service; @@ -247,6 +263,37 @@ export const CCTranscriptionPanel: React.FC = () => { Sessions + {/* Live Tab */} @@ -600,6 +647,157 @@ export const CCTranscriptionPanel: React.FC = () => { )} + {/* Keywords Tab */} + {activeTab === "keywords" && ( + <> +
+
+ Keyword Watches +
+
+ setNewKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && newKeyword.trim()) { + setIsAddingKeyword(true); + addKeywordWatch(newKeyword.trim()).finally(() => { + setNewKeyword(''); + setIsAddingKeyword(false); + }); + } + }} + placeholder="Add keyword..." + style={{ + flex: 1, + padding: "6px 8px", + border: "1px solid var(--color-divider)", + borderRadius: "4px", + fontSize: "13px", + backgroundColor: "var(--color-panel)", + color: "var(--color-text)", + outline: "none", + }} + /> + +
+
+ +
+ +
+ {keywordWatches.length === 0 ? ( +
+ No keyword watches yet +
+ ) : ( + keywordWatches.filter((w) => w.is_active).map((watch) => ( +
+ + {watch.keyword} + + +
+ )) + )} +
+ + {keywordMatches.length > 0 && ( + <> +
+
+
+
+ Matches this session +
+ +
+
+ {[...keywordMatches].reverse().map((match, i) => ( +
+
+ "{match.keyword}" at {formatTime(Math.floor(match.elapsed_seconds))} +
+
+ {match.segment_text.length > 80 ? match.segment_text.slice(0, 80) + '…' : match.segment_text} +
+
+ ))} +
+
+ + )} + + )} + {/* LLM Config Settings Modal */}