feat: Meeting Assistant MVP - Complete implementation

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>
This commit is contained in:
egg
2025-12-10 20:17:44 +08:00
commit 8b6184ecc5
65 changed files with 10510 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Tests package

48
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
"""
Pytest configuration and fixtures.
"""
import pytest
import sys
import os
# Add the backend directory to the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(autouse=True)
def mock_env(monkeypatch):
"""Set up mock environment variables for all tests."""
monkeypatch.setenv("DB_HOST", "localhost")
monkeypatch.setenv("DB_PORT", "3306")
monkeypatch.setenv("DB_USER", "test")
monkeypatch.setenv("DB_PASS", "test")
monkeypatch.setenv("DB_NAME", "test_db")
monkeypatch.setenv("AUTH_API_URL", "https://auth.test.com/login")
monkeypatch.setenv("DIFY_API_URL", "https://dify.test.com/v1")
monkeypatch.setenv("DIFY_API_KEY", "test-api-key")
monkeypatch.setenv("ADMIN_EMAIL", "admin@test.com")
monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
@pytest.fixture
def sample_meeting():
"""Sample meeting data for tests."""
return {
"subject": "Test Meeting",
"meeting_time": "2025-01-15T10:00:00",
"location": "Conference Room A",
"chairperson": "John Doe",
"recorder": "Jane Smith",
"attendees": "alice@test.com, bob@test.com",
}
@pytest.fixture
def sample_transcript():
"""Sample transcript for AI tests."""
return """
今天的會議主要討論了Q1預算和新員工招聘計劃。
決定將行銷預算增加10%
小明負責在下週五前提交最終報告。
"""

191
backend/tests/test_ai.py Normal file
View File

@@ -0,0 +1,191 @@
"""
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"

138
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Unit tests for authentication functionality.
"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi.testclient import TestClient
from jose import jwt
pytestmark = pytest.mark.asyncio
class TestAdminRoleDetection:
"""Tests for admin role detection."""
def test_admin_email_gets_admin_role(self):
"""Test that admin email is correctly identified."""
from app.config import settings
admin_email = settings.ADMIN_EMAIL
test_email = "regular@example.com"
# Admin email should be set (either from env or default)
assert admin_email is not None
assert len(admin_email) > 0
assert test_email != admin_email
@patch("app.routers.auth.settings")
def test_create_token_includes_role(self, mock_settings):
"""Test that created tokens include the role."""
mock_settings.JWT_SECRET = "test-secret"
mock_settings.ADMIN_EMAIL = "admin@test.com"
from app.routers.auth import create_token
# Test admin token
admin_token = create_token("admin@test.com", "admin")
admin_payload = jwt.decode(admin_token, "test-secret", algorithms=["HS256"])
assert admin_payload["role"] == "admin"
# Test user token
user_token = create_token("user@test.com", "user")
user_payload = jwt.decode(user_token, "test-secret", algorithms=["HS256"])
assert user_payload["role"] == "user"
class TestTokenValidation:
"""Tests for JWT token validation."""
@patch("app.routers.auth.settings")
def test_decode_valid_token(self, mock_settings):
"""Test decoding a valid token."""
mock_settings.JWT_SECRET = "test-secret"
from app.routers.auth import create_token, decode_token
token = create_token("test@example.com", "user")
payload = decode_token(token)
assert payload.email == "test@example.com"
assert payload.role == "user"
@patch("app.routers.auth.settings")
def test_decode_invalid_token_raises_error(self, mock_settings):
"""Test that invalid tokens raise an error."""
mock_settings.JWT_SECRET = "test-secret"
from app.routers.auth import decode_token
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
decode_token("invalid-token")
assert exc_info.value.status_code == 401
class TestLoginEndpoint:
"""Tests for the login endpoint."""
@pytest.fixture
def client(self):
"""Create test client."""
from app.main import app
# Skip lifespan for tests
app.router.lifespan_context = None
return TestClient(app, raise_server_exceptions=False)
@patch("app.routers.auth.httpx.AsyncClient")
@patch("app.routers.auth.settings")
async def test_login_success(self, mock_settings, mock_client_class):
"""Test successful login."""
mock_settings.AUTH_API_URL = "https://auth.test.com/login"
mock_settings.ADMIN_EMAIL = "admin@test.com"
mock_settings.JWT_SECRET = "test-secret"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"token": "external-token"}
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.auth import login
from app.models import LoginRequest
result = await login(LoginRequest(email="user@test.com", password="password"))
assert result.email == "user@test.com"
assert result.role == "user"
assert result.token is not None
@patch("app.routers.auth.httpx.AsyncClient")
@patch("app.routers.auth.settings")
async def test_login_admin_gets_admin_role(self, mock_settings, mock_client_class):
"""Test that admin email gets admin role."""
mock_settings.AUTH_API_URL = "https://auth.test.com/login"
mock_settings.ADMIN_EMAIL = "admin@test.com"
mock_settings.JWT_SECRET = "test-secret"
mock_response = MagicMock()
mock_response.status_code = 200
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.auth import login
from app.models import LoginRequest
result = await login(LoginRequest(email="admin@test.com", password="password"))
assert result.role == "admin"

View File

@@ -0,0 +1,95 @@
"""
Unit tests for database connection and table initialization.
"""
import pytest
from unittest.mock import patch, MagicMock
class TestDatabaseConnection:
"""Tests for database connectivity."""
@patch("mysql.connector.pooling.MySQLConnectionPool")
def test_init_db_pool_success(self, mock_pool):
"""Test successful database pool initialization."""
mock_pool.return_value = MagicMock()
from app.database import init_db_pool
pool = init_db_pool()
assert pool is not None
mock_pool.assert_called_once()
@patch("mysql.connector.pooling.MySQLConnectionPool")
def test_init_db_pool_with_correct_config(self, mock_pool):
"""Test database pool is created with correct configuration."""
from app.database import init_db_pool
from app.config import settings
init_db_pool()
call_args = mock_pool.call_args
assert call_args.kwargs["host"] == settings.DB_HOST
assert call_args.kwargs["port"] == settings.DB_PORT
assert call_args.kwargs["user"] == settings.DB_USER
assert call_args.kwargs["database"] == settings.DB_NAME
class TestTableInitialization:
"""Tests for table creation."""
@patch("app.database.get_db_cursor")
def test_init_tables_creates_required_tables(self, mock_cursor_context):
"""Test that all required tables are created."""
mock_cursor = MagicMock()
mock_cursor_context.return_value.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor_context.return_value.__exit__ = MagicMock(return_value=False)
from app.database import init_tables
init_tables()
# Verify execute was called for each table
assert mock_cursor.execute.call_count == 4
# Check table names in SQL
calls = mock_cursor.execute.call_args_list
sql_statements = [call[0][0] for call in calls]
assert any("meeting_users" in sql for sql in sql_statements)
assert any("meeting_records" in sql for sql in sql_statements)
assert any("meeting_conclusions" in sql for sql in sql_statements)
assert any("meeting_action_items" in sql for sql in sql_statements)
class TestDatabaseHelpers:
"""Tests for database helper functions."""
@patch("app.database.connection_pool")
def test_get_db_connection_returns_connection(self, mock_pool):
"""Test that get_db_connection returns a valid connection."""
mock_conn = MagicMock()
mock_pool.get_connection.return_value = mock_conn
from app.database import get_db_connection
with get_db_connection() as conn:
assert conn == mock_conn
mock_conn.close.assert_called_once()
@patch("app.database.connection_pool")
def test_get_db_cursor_with_commit(self, mock_pool):
"""Test that get_db_cursor commits when specified."""
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_pool.get_connection.return_value = mock_conn
mock_conn.cursor.return_value = mock_cursor
from app.database import get_db_cursor
with get_db_cursor(commit=True) as cursor:
cursor.execute("SELECT 1")
mock_conn.commit.assert_called_once()
mock_cursor.close.assert_called_once()