Enterprise Meeting Knowledge Management System with: Backend (FastAPI): - Authentication proxy with JWT (pj-auth-api integration) - MySQL database with 4 tables (users, meetings, conclusions, actions) - Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX) - Dify LLM integration for AI summarization - Excel export with openpyxl - 20 unit tests (all passing) Client (Electron): - Login page with company auth - Meeting list with create/delete - Meeting detail with real-time transcription - Editable transcript textarea (single block, easy editing) - AI summarization with conclusions/action items - 5-second segment recording (efficient for long meetings) Sidecar (Python): - faster-whisper medium model with int8 quantization - ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB) - Chinese punctuation processing - OpenCC for Traditional Chinese conversion - Anti-hallucination parameters - Auto-cleanup of temp audio files OpenSpec: - add-meeting-assistant-mvp (47 tasks, archived) - add-realtime-transcription (29 tasks, archived) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
192 lines
6.4 KiB
Python
192 lines
6.4 KiB
Python
"""
|
|
Unit tests for AI summarization with mock Dify responses.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
import json
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
class TestDifyResponseParsing:
|
|
"""Tests for parsing Dify LLM responses."""
|
|
|
|
def test_parse_json_response(self):
|
|
"""Test parsing valid JSON response from Dify."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
response = '''Here is the summary:
|
|
```json
|
|
{
|
|
"conclusions": ["Agreed on Q1 budget", "New hire approved"],
|
|
"action_items": [
|
|
{"content": "Submit budget report", "owner": "John", "due_date": "2025-01-15"},
|
|
{"content": "Post job listing", "owner": "", "due_date": null}
|
|
]
|
|
}
|
|
```
|
|
'''
|
|
result = parse_dify_response(response)
|
|
|
|
assert len(result["conclusions"]) == 2
|
|
assert "Q1 budget" in result["conclusions"][0]
|
|
assert len(result["action_items"]) == 2
|
|
assert result["action_items"][0]["owner"] == "John"
|
|
|
|
def test_parse_inline_json_response(self):
|
|
"""Test parsing inline JSON without code blocks."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
response = '{"conclusions": ["Budget approved"], "action_items": []}'
|
|
result = parse_dify_response(response)
|
|
|
|
assert len(result["conclusions"]) == 1
|
|
assert result["conclusions"][0] == "Budget approved"
|
|
|
|
def test_parse_non_json_response(self):
|
|
"""Test fallback when response is not JSON."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
response = "The meeting discussed Q1 budget and hiring plans."
|
|
result = parse_dify_response(response)
|
|
|
|
# Should return the raw response as a single conclusion
|
|
assert len(result["conclusions"]) == 1
|
|
assert "Q1 budget" in result["conclusions"][0]
|
|
assert len(result["action_items"]) == 0
|
|
|
|
def test_parse_empty_response(self):
|
|
"""Test handling empty response."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
result = parse_dify_response("")
|
|
|
|
assert result["conclusions"] == []
|
|
assert result["action_items"] == []
|
|
|
|
|
|
class TestSummarizeEndpoint:
|
|
"""Tests for the AI summarization endpoint."""
|
|
|
|
@patch("app.routers.ai.httpx.AsyncClient")
|
|
@patch("app.routers.ai.settings")
|
|
async def test_summarize_success(self, mock_settings, mock_client_class):
|
|
"""Test successful summarization."""
|
|
mock_settings.DIFY_API_URL = "https://dify.test.com/v1"
|
|
mock_settings.DIFY_API_KEY = "test-key"
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"answer": json.dumps({
|
|
"conclusions": ["Decision made"],
|
|
"action_items": [{"content": "Follow up", "owner": "Alice", "due_date": "2025-01-20"}]
|
|
})
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.return_value = mock_response
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
from app.routers.ai import summarize_transcript
|
|
from app.models import SummarizeRequest, TokenPayload
|
|
|
|
mock_user = TokenPayload(email="test@test.com", role="user")
|
|
result = await summarize_transcript(
|
|
SummarizeRequest(transcript="Test meeting transcript"),
|
|
current_user=mock_user
|
|
)
|
|
|
|
assert len(result.conclusions) == 1
|
|
assert len(result.action_items) == 1
|
|
assert result.action_items[0].owner == "Alice"
|
|
|
|
@patch("app.routers.ai.httpx.AsyncClient")
|
|
@patch("app.routers.ai.settings")
|
|
async def test_summarize_handles_timeout(self, mock_settings, mock_client_class):
|
|
"""Test handling Dify timeout."""
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
mock_settings.DIFY_API_URL = "https://dify.test.com/v1"
|
|
mock_settings.DIFY_API_KEY = "test-key"
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post.side_effect = httpx.TimeoutException("Timeout")
|
|
mock_client.__aenter__.return_value = mock_client
|
|
mock_client.__aexit__.return_value = None
|
|
mock_client_class.return_value = mock_client
|
|
|
|
from app.routers.ai import summarize_transcript
|
|
from app.models import SummarizeRequest, TokenPayload
|
|
|
|
mock_user = TokenPayload(email="test@test.com", role="user")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await summarize_transcript(
|
|
SummarizeRequest(transcript="Test"),
|
|
current_user=mock_user
|
|
)
|
|
|
|
assert exc_info.value.status_code == 504
|
|
|
|
@patch("app.routers.ai.settings")
|
|
async def test_summarize_no_api_key(self, mock_settings):
|
|
"""Test error when Dify API key is not configured."""
|
|
from fastapi import HTTPException
|
|
|
|
mock_settings.DIFY_API_KEY = ""
|
|
|
|
from app.routers.ai import summarize_transcript
|
|
from app.models import SummarizeRequest, TokenPayload
|
|
|
|
mock_user = TokenPayload(email="test@test.com", role="user")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await summarize_transcript(
|
|
SummarizeRequest(transcript="Test"),
|
|
current_user=mock_user
|
|
)
|
|
|
|
assert exc_info.value.status_code == 503
|
|
|
|
|
|
class TestPartialDataHandling:
|
|
"""Tests for handling partial data from AI."""
|
|
|
|
def test_action_item_with_empty_owner(self):
|
|
"""Test action items with empty owner are handled."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
response = json.dumps({
|
|
"conclusions": [],
|
|
"action_items": [
|
|
{"content": "Task 1", "owner": "", "due_date": None},
|
|
{"content": "Task 2", "owner": "Bob", "due_date": "2025-02-01"}
|
|
]
|
|
})
|
|
|
|
result = parse_dify_response(response)
|
|
|
|
assert result["action_items"][0]["owner"] == ""
|
|
assert result["action_items"][1]["owner"] == "Bob"
|
|
|
|
def test_action_item_with_missing_fields(self):
|
|
"""Test action items with missing fields."""
|
|
from app.routers.ai import parse_dify_response
|
|
|
|
response = json.dumps({
|
|
"conclusions": ["Done"],
|
|
"action_items": [
|
|
{"content": "Task only"}
|
|
]
|
|
})
|
|
|
|
result = parse_dify_response(response)
|
|
|
|
# Should have content but other fields may be missing
|
|
assert result["action_items"][0]["content"] == "Task only"
|