diff --git a/backends/advanced/docker-compose.yml b/backends/advanced/docker-compose.yml index f46a23fa..31b7892f 100644 --- a/backends/advanced/docker-compose.yml +++ b/backends/advanced/docker-compose.yml @@ -76,6 +76,27 @@ services: condition: service_started restart: unless-stopped + cron: + build: + context: . + dockerfile: Dockerfile + command: ["uv", "run", "python", "-m", "advanced_omi_backend.cron"] + env_file: + - .env + volumes: + - ./src:/app/src + - ./data:/app/data + - ../../config/config.yml:/app/config.yml + environment: + - REDIS_URL=redis://redis:6379/0 + - MONGO_URL=${MONGO_URL:-mongodb://mongo:27017} + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + webui: build: context: ./webui diff --git a/backends/advanced/src/advanced_omi_backend/app_factory.py b/backends/advanced/src/advanced_omi_backend/app_factory.py index 7ccda184..192e7fac 100644 --- a/backends/advanced/src/advanced_omi_backend/app_factory.py +++ b/backends/advanced/src/advanced_omi_backend/app_factory.py @@ -56,10 +56,11 @@ async def lifespan(app: FastAPI): from advanced_omi_backend.models.conversation import Conversation from advanced_omi_backend.models.audio_file import AudioFile from advanced_omi_backend.models.user import User + from advanced_omi_backend.models.annotation import TranscriptAnnotation await init_beanie( database=config.db, - document_models=[User, Conversation, AudioFile], + document_models=[User, Conversation, AudioFile, TranscriptAnnotation], ) application_logger.info("Beanie initialized for all document models") except Exception as e: diff --git a/backends/advanced/src/advanced_omi_backend/cron.py b/backends/advanced/src/advanced_omi_backend/cron.py new file mode 100644 index 00000000..d4ab697e --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/cron.py @@ -0,0 +1,72 @@ +import asyncio +import logging +import os +from datetime import datetime +import signal +import sys + +from advanced_omi_backend.workers.annotation_jobs import surface_error_suggestions, finetune_hallucination_model +from advanced_omi_backend.database import init_db + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + stream=sys.stdout +) +logger = logging.getLogger("cron_scheduler") + +# Frequency configuration (in seconds) +SUGGESTION_INTERVAL = 24 * 60 * 60 # Daily +TRAINING_INTERVAL = 7 * 24 * 60 * 60 # Weekly + +# For testing purposes, we can check more frequently if ENV var is set +if os.getenv("DEV_MODE", "false").lower() == "true": + SUGGESTION_INTERVAL = 60 # 1 minute + TRAINING_INTERVAL = 300 # 5 minutes + +async def run_scheduler(): + logger.info("Starting Cron Scheduler...") + + # Initialize DB connection + await init_db() + + last_suggestion_run = datetime.min + last_training_run = datetime.min + + while True: + now = datetime.utcnow() + + # Check Suggestions Job + if (now - last_suggestion_run).total_seconds() >= SUGGESTION_INTERVAL: + logger.info("Running scheduled job: surface_error_suggestions") + try: + await surface_error_suggestions() + last_suggestion_run = now + except Exception as e: + logger.error(f"Error in surface_error_suggestions: {e}", exc_info=True) + + # Check Training Job + if (now - last_training_run).total_seconds() >= TRAINING_INTERVAL: + logger.info("Running scheduled job: finetune_hallucination_model") + try: + await finetune_hallucination_model() + last_training_run = now + except Exception as e: + logger.error(f"Error in finetune_hallucination_model: {e}", exc_info=True) + + # Sleep for a bit before next check (e.g. 1 minute) + await asyncio.sleep(60) + +def handle_shutdown(signum, frame): + logger.info("Shutting down Cron Scheduler...") + sys.exit(0) + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, handle_shutdown) + signal.signal(signal.SIGINT, handle_shutdown) + + try: + asyncio.run(run_scheduler()) + except KeyboardInterrupt: + pass diff --git a/backends/advanced/src/advanced_omi_backend/models/__init__.py b/backends/advanced/src/advanced_omi_backend/models/__init__.py index a19fa0db..38d1a230 100644 --- a/backends/advanced/src/advanced_omi_backend/models/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/models/__init__.py @@ -7,4 +7,5 @@ # Models can be imported directly from their files # e.g. from .job import TranscriptionJob -# e.g. from .conversation import Conversation, create_conversation \ No newline at end of file +# e.g. from .conversation import Conversation, create_conversation +from .annotation import TranscriptAnnotation \ No newline at end of file diff --git a/backends/advanced/src/advanced_omi_backend/models/annotation.py b/backends/advanced/src/advanced_omi_backend/models/annotation.py new file mode 100644 index 00000000..998cd45b --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/models/annotation.py @@ -0,0 +1,39 @@ +from datetime import datetime, timezone +from typing import Optional, List +from pydantic import Field +from beanie import Document, Indexed +from enum import Enum +import uuid + +class TranscriptAnnotation(Document): + """Model for transcript annotations/corrections.""" + + class AnnotationStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + + class AnnotationSource(str, Enum): + USER = "user" + MODEL_SUGGESTION = "model_suggestion" + + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + conversation_id: Indexed(str) + segment_index: int + original_text: str + corrected_text: str + user_id: Indexed(str) + + status: AnnotationStatus = Field(default=AnnotationStatus.ACCEPTED) # User edits are accepted by default + source: AnnotationSource = Field(default=AnnotationSource.USER) + + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class Settings: + name = "transcript_annotations" + indexes = [ + "conversation_id", + "user_id", + "status" + ] diff --git a/backends/advanced/src/advanced_omi_backend/routers/api_router.py b/backends/advanced/src/advanced_omi_backend/routers/api_router.py index 9e761f8e..513ab2d9 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/api_router.py +++ b/backends/advanced/src/advanced_omi_backend/routers/api_router.py @@ -19,6 +19,7 @@ queue_router, system_router, user_router, + annotation_router, ) from .modules.health_routes import router as health_router @@ -38,6 +39,7 @@ router.include_router(obsidian_router) router.include_router(system_router) router.include_router(queue_router) +router.include_router(annotation_router, prefix="/annotations", tags=["annotations"]) router.include_router(health_router) # Also include under /api for frontend compatibility diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py index 21f89991..b8df9926 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/__init__.py @@ -25,6 +25,7 @@ from .system_routes import router as system_router from .user_routes import router as user_router from .websocket_routes import router as websocket_router +from .annotation_routes import router as annotation_router __all__ = [ "audio_router", @@ -38,4 +39,5 @@ "system_router", "user_router", "websocket_router", + "annotation_router", ] diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py new file mode 100644 index 00000000..49a48f3f --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/annotation_routes.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, HTTPException, Depends +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime + +from advanced_omi_backend.models.annotation import TranscriptAnnotation +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.auth import current_active_user +from advanced_omi_backend.models.user import User +from advanced_omi_backend.workers.memory_jobs import enqueue_memory_processing +from advanced_omi_backend.models.job import JobPriority + +router = APIRouter() + +class AnnotationCreate(BaseModel): + conversation_id: str + segment_index: int + original_text: str + corrected_text: str + status: Optional[TranscriptAnnotation.AnnotationStatus] = TranscriptAnnotation.AnnotationStatus.ACCEPTED + +class AnnotationResponse(BaseModel): + id: str + conversation_id: str + segment_index: int + original_text: str + corrected_text: str + status: str + created_at: datetime + +@router.post("/", response_model=AnnotationResponse) +async def create_annotation( + annotation: AnnotationCreate, + current_user: User = Depends(current_active_user) +): + # Verify conversation exists and belongs to user + conversation = await Conversation.find_one({ + "conversation_id": annotation.conversation_id, + "user_id": str(current_user.id) + }) + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Create annotation + new_annotation = TranscriptAnnotation( + conversation_id=annotation.conversation_id, + segment_index=annotation.segment_index, + original_text=annotation.original_text, + corrected_text=annotation.corrected_text, + user_id=str(current_user.id), + status=annotation.status, + source=TranscriptAnnotation.AnnotationSource.USER + ) + + await new_annotation.insert() + + # Update the actual transcript in the conversation + # We need to find the active transcript version and update the segment + if conversation.active_transcript: + version = conversation.active_transcript + if 0 <= annotation.segment_index < len(version.segments): + version.segments[annotation.segment_index].text = annotation.corrected_text + + # Save the conversation with the updated segment + # We need to update the specific version in the list + for i, v in enumerate(conversation.transcript_versions): + if v.version_id == version.version_id: + conversation.transcript_versions[i] = version + break + + await conversation.save() + + # Trigger memory reprocessing + enqueue_memory_processing( + client_id=conversation.client_id, + user_id=str(current_user.id), + user_email=current_user.email, + conversation_id=conversation.conversation_id, + priority=JobPriority.NORMAL + ) + else: + raise HTTPException(status_code=400, detail="Segment index out of range") + else: + raise HTTPException(status_code=400, detail="No active transcript found") + + return AnnotationResponse( + id=str(new_annotation.id), + conversation_id=new_annotation.conversation_id, + segment_index=new_annotation.segment_index, + original_text=new_annotation.original_text, + corrected_text=new_annotation.corrected_text, + status=new_annotation.status, + created_at=new_annotation.created_at + ) + +@router.get("/{conversation_id}", response_model=List[AnnotationResponse]) +async def get_annotations( + conversation_id: str, + current_user: User = Depends(current_active_user) +): + annotations = await TranscriptAnnotation.find({ + "conversation_id": conversation_id, + "user_id": str(current_user.id) + }).to_list() + + return [ + AnnotationResponse( + id=str(a.id), + conversation_id=a.conversation_id, + segment_index=a.segment_index, + original_text=a.original_text, + corrected_text=a.corrected_text, + status=a.status, + created_at=a.created_at + ) + for a in annotations + ] diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py index 85ee200a..13bb781c 100644 --- a/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/vector_stores.py @@ -175,15 +175,17 @@ async def search_memories(self, query_embedding: List[float], user_id: str, limi "query_filter": search_filter, "limit": limit } - + if score_threshold > 0.0: search_params["score_threshold"] = score_threshold memory_logger.debug(f"Using similarity threshold: {score_threshold}") - + + # Use query_points instead of search (AsyncQdrantClient v1.10+ compat) response = await self.client.query_points(**search_params) - + results = response.points + memories = [] - for result in response.points: + for result in results: memory = MemoryEntry( id=str(result.id), content=result.payload.get("content", ""), diff --git a/backends/advanced/src/advanced_omi_backend/workers/annotation_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/annotation_jobs.py new file mode 100644 index 00000000..cd08b8b9 --- /dev/null +++ b/backends/advanced/src/advanced_omi_backend/workers/annotation_jobs.py @@ -0,0 +1,98 @@ +import asyncio +import logging +import random +from datetime import datetime, timedelta +from typing import List + +from advanced_omi_backend.models.conversation import Conversation +from advanced_omi_backend.models.annotation import TranscriptAnnotation +from advanced_omi_backend.database import get_db + +logger = logging.getLogger(__name__) + +async def surface_error_suggestions(): + """ + Cron job to surface potential errors in transcripts. + Mocks the behavior of an ML model identifying low-confidence segments. + """ + logger.info("Starting surface_error_suggestions job...") + + # Get conversations from the last 24 hours + since = datetime.utcnow() - timedelta(days=1) + conversations = await Conversation.find( + {"created_at": {"$gte": since}} + ).to_list() + + logger.info(f"Found {len(conversations)} recent conversations to scan.") + + count = 0 + for conv in conversations: + if not conv.active_transcript or not conv.segments: + continue + + # Mock logic: Randomly pick a segment to "flag" as potential error + # In reality, this would use a "speech-understanding" model to find inconsistencies + if random.random() < 0.3: # 30% chance per conversation + segment_idx = random.randint(0, len(conv.segments) - 1) + segment = conv.segments[segment_idx] + + # Check if annotation already exists + existing = await TranscriptAnnotation.find_one({ + "conversation_id": conv.conversation_id, + "segment_index": segment_idx + }) + + if not existing: + # Create a suggestion + suggestion = TranscriptAnnotation( + conversation_id=conv.conversation_id, + segment_index=segment_idx, + original_text=segment.text, + corrected_text=segment.text + " [SUGGESTED CORRECTION]", # Placeholder + user_id=conv.user_id, + status=TranscriptAnnotation.AnnotationStatus.PENDING, + source=TranscriptAnnotation.AnnotationSource.MODEL_SUGGESTION + ) + await suggestion.insert() + count += 1 + if count >= 6: # Surface 5-6 places as requested + break + if count >= 6: + break + + logger.info(f"Surfaced {count} new suggestions.") + +async def finetune_hallucination_model(): + """ + Cron job to finetune a LORA model on corrections. + """ + logger.info("Starting finetune_hallucination_model job...") + + # Gather accepted corrections + corrections = await TranscriptAnnotation.find({ + "status": TranscriptAnnotation.AnnotationStatus.ACCEPTED.value + }).to_list() + + if not corrections: + logger.info("No corrections found for training.") + return + + logger.info(f"Found {len(corrections)} corrections for training.") + + # Prepare training data (Mock) + training_pairs = [] + for c in corrections: + training_pairs.append({ + "input": c.original_text, + "output": c.corrected_text + }) + + # Mock Training Process + logger.info("Initiating LORA fine-tuning process...") + # In a real scenario, this would call a training service or script + # e.g., train_lora(model="speech-understanding", data=training_pairs) + + # Simulate time taken + await asyncio.sleep(2) + + logger.info("Fine-tuning job completed successfully (Mock).") diff --git a/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py index 8b64d690..8347dc9c 100644 --- a/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py +++ b/backends/advanced/src/advanced_omi_backend/workers/memory_jobs.py @@ -16,7 +16,9 @@ ) from advanced_omi_backend.models.job import BaseRQJob, JobPriority, async_job from advanced_omi_backend.services.memory.base import MemoryEntry - +from advanced_omi_backend.controllers.queue_controller import default_queue +from advanced_omi_backend.workers.conversation_jobs import generate_title_summary_job + logger = logging.getLogger(__name__) @@ -281,4 +283,23 @@ def enqueue_memory_processing( ) logger.info(f"📥 RQ: Enqueued memory job {job.id} for conversation {conversation_id}") + + # Also enqueue title/summary generation to ensure summaries reflect any transcript changes + try: + # Use a timestamp in job_id to avoid conflicts if re-run frequently + summary_job_id = f"title_summary_{conversation_id[:8]}_{int(time.time())}" + + default_queue.enqueue( + generate_title_summary_job, + conversation_id, + job_timeout=300, + result_ttl=JOB_RESULT_TTL, + job_id=summary_job_id, + description=f"Generate title and summary for conversation {conversation_id[:8]}", + ) + logger.info(f"📥 RQ: Enqueued summary job {summary_job_id} for conversation {conversation_id}") + except Exception as e: + logger.error(f"Failed to enqueue summary job: {e}") + raise e + return job diff --git a/backends/advanced/tests/integration/test_annotation_flow.py b/backends/advanced/tests/integration/test_annotation_flow.py new file mode 100644 index 00000000..b30ecdd7 --- /dev/null +++ b/backends/advanced/tests/integration/test_annotation_flow.py @@ -0,0 +1,108 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from unittest.mock import patch, MagicMock, AsyncMock +from datetime import datetime + +from advanced_omi_backend.main import create_app +from advanced_omi_backend.models.user import User +from advanced_omi_backend.auth import current_active_user + +# Mock data +MOCK_USER_ID = "test-user-id" +MOCK_CONVERSATION_ID = "test-conversation-id" + +@pytest.fixture +def mock_user(): + user = MagicMock(spec=User) + user.id = MOCK_USER_ID + user.email = "test@example.com" + return user + +@pytest.fixture +def app(mock_user): + application = create_app() + # Override authentication dependency + application.dependency_overrides[current_active_user] = lambda: mock_user + return application + +@pytest.fixture +async def client(app): + async with AsyncClient(app=app, base_url="http://test") as c: + yield c + +@pytest.mark.asyncio +async def test_annotation_flow(app, mock_user): + # Mock DB interactions + with patch("advanced_omi_backend.routers.modules.annotation_routes.Conversation") as MockConversation, \ + patch("advanced_omi_backend.routers.modules.annotation_routes.TranscriptAnnotation") as MockAnnotation, \ + patch("advanced_omi_backend.routers.modules.annotation_routes.enqueue_memory_processing") as mock_enqueue: + + # Setup mock conversation + mock_conv = MagicMock() + mock_conv.conversation_id = MOCK_CONVERSATION_ID + mock_conv.user_id = MOCK_USER_ID + mock_conv.client_id = "test-client" + + # Setup active transcript + mock_version = MagicMock() + mock_version.version_id = "v1" + mock_version.segments = [MagicMock(text="Original text")] + mock_conv.active_transcript = mock_version + mock_conv.transcript_versions = [mock_version] + + # Make save awaitable + mock_conv.save = AsyncMock() + + # Configure find_one to return our mock conversation (awaitable) + MockConversation.find_one.return_value = AsyncMock(return_value=mock_conv)() # Calling AsyncMock returns an awaitable coroutine + + # Mock Annotation insert (awaitable) + mock_annotation_instance = MagicMock() + mock_annotation_instance.insert = AsyncMock() + mock_annotation_instance.id = "test-annotation-id" + mock_annotation_instance.conversation_id = MOCK_CONVERSATION_ID + mock_annotation_instance.segment_index = 0 + mock_annotation_instance.original_text = "Original text" + mock_annotation_instance.corrected_text = "Corrected text" + mock_annotation_instance.status = "accepted" + mock_annotation_instance.created_at = datetime.now() + + MockAnnotation.return_value = mock_annotation_instance + + # Define the annotation payload + annotation_data = { + "conversation_id": MOCK_CONVERSATION_ID, + "segment_index": 0, + "original_text": "Original text", + "corrected_text": "Corrected text", + "status": "accepted" + } + + # Make the API call using AsyncClient with ASGITransport + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/annotations/", json=annotation_data) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data["conversation_id"] == MOCK_CONVERSATION_ID + assert data["corrected_text"] == "Corrected text" + + # Verify DB interaction + # 1. Verify conversation lookup was called + MockConversation.find_one.assert_called() + + # 2. Verify annotation creation (MockAnnotation constructor called) + MockAnnotation.assert_called() + mock_annotation_instance.insert.assert_called_once() + + # 3. Verify transcript update + assert mock_version.segments[0].text == "Corrected text" + mock_conv.save.assert_called_once() + + # 4. Verify memory job enqueuing + mock_enqueue.assert_called_once() + call_kwargs = mock_enqueue.call_args.kwargs + assert call_kwargs['conversation_id'] == MOCK_CONVERSATION_ID + assert call_kwargs['user_id'] == MOCK_USER_ID \ No newline at end of file diff --git a/backends/advanced/tests/test_annotation_models.py b/backends/advanced/tests/test_annotation_models.py new file mode 100644 index 00000000..fa332f4d --- /dev/null +++ b/backends/advanced/tests/test_annotation_models.py @@ -0,0 +1,90 @@ +import pytest +from datetime import datetime +from advanced_omi_backend.models.annotation import TranscriptAnnotation +from beanie import init_beanie +from mongomock_motor import AsyncMongoMockClient +import uuid + +async def initialize_beanie(): + client = AsyncMongoMockClient() + await init_beanie(database=client.db_name, document_models=[TranscriptAnnotation]) + +class TestAnnotationModel: + """Test TranscriptAnnotation Pydantic/Beanie model.""" + + @pytest.mark.asyncio + async def test_create_annotation_defaults(self): + """Test creating an annotation with default values.""" + await initialize_beanie() + + annotation = TranscriptAnnotation( + conversation_id="conv-123", + segment_index=5, + original_text="Hello world", + corrected_text="Hello, world!", + user_id="user-456" + ) + + # Check required fields + assert annotation.conversation_id == "conv-123" + assert annotation.segment_index == 5 + assert annotation.original_text == "Hello world" + assert annotation.corrected_text == "Hello, world!" + assert annotation.user_id == "user-456" + + # Check defaults + assert isinstance(annotation.id, str) + assert len(annotation.id) > 0 + assert annotation.status == TranscriptAnnotation.AnnotationStatus.ACCEPTED + assert annotation.source == TranscriptAnnotation.AnnotationSource.USER + assert isinstance(annotation.created_at, datetime) + assert isinstance(annotation.updated_at, datetime) + + @pytest.mark.asyncio + async def test_annotation_status_enum(self): + """Test that status enum works as expected.""" + await initialize_beanie() + + # Test valid statuses + for status in ["pending", "accepted", "rejected"]: + annotation = TranscriptAnnotation( + conversation_id="c", segment_index=0, original_text="o", corrected_text="c", user_id="u", + status=status + ) + assert annotation.status == status + + # Test validation error (Pydantic validates enums) + with pytest.raises(ValueError): + TranscriptAnnotation( + conversation_id="c", segment_index=0, original_text="o", corrected_text="c", user_id="u", + status="invalid_status" + ) + + @pytest.mark.asyncio + async def test_annotation_source_enum(self): + """Test that source enum works as expected.""" + await initialize_beanie() + + # Test valid sources + for source in ["user", "model_suggestion"]: + annotation = TranscriptAnnotation( + conversation_id="c", segment_index=0, original_text="o", corrected_text="c", user_id="u", + source=source + ) + assert annotation.source == source + + @pytest.mark.asyncio + async def test_custom_id(self): + """Test that ID can be overridden.""" + await initialize_beanie() + + custom_id = str(uuid.uuid4()) + annotation = TranscriptAnnotation( + id=custom_id, + conversation_id="c", + segment_index=0, + original_text="o", + corrected_text="c", + user_id="u" + ) + assert annotation.id == custom_id diff --git a/backends/advanced/webui/src/pages/Conversations.tsx b/backends/advanced/webui/src/pages/Conversations.tsx index d4b76ed3..735df9ec 100644 --- a/backends/advanced/webui/src/pages/Conversations.tsx +++ b/backends/advanced/webui/src/pages/Conversations.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' -import { MessageSquare, RefreshCw, Calendar, User, Play, Pause, MoreVertical, RotateCcw, Zap, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' -import { conversationsApi, BACKEND_URL } from '../services/api' +import { MessageSquare, RefreshCw, Calendar, User, Play, Pause, MoreVertical, RotateCcw, Zap, ChevronDown, ChevronUp, Trash2, Check, X as XIcon, Loader2 } from 'lucide-react' +import { conversationsApi, annotationsApi, queueApi, BACKEND_URL } from '../services/api' import ConversationVersionHeader from '../components/ConversationVersionHeader' import { getStorageKey } from '../utils/storage' @@ -26,6 +26,15 @@ interface Conversation { end: number confidence?: number }> // From detail endpoint (loaded on expand) + annotations?: Array<{ + id: string + conversation_id: string + segment_index: number + original_text: string + corrected_text: string + status: 'pending' | 'accepted' | 'rejected' + created_at: string + }> active_transcript_version?: string active_memory_version?: string transcript_version_count?: number @@ -49,6 +58,49 @@ const SPEAKER_COLOR_PALETTE = [ 'text-cyan-600 dark:text-cyan-400', ]; +interface AutoResizingTextareaProps { + value: string + onChange: (e: React.ChangeEvent) => void + onSave: () => void + onCancel: () => void +} + +const AutoResizingTextarea = ({ value, onChange, onSave, onCancel }: AutoResizingTextareaProps) => { + const textareaRef = useRef(null) + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto' + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px' + } + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + onSave() + } + if (e.key === 'Escape') { + onCancel() + } + } + + return ( +
+