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,372 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import List
import uuid
from datetime import date
from ..database import get_db_cursor
from ..models import (
MeetingCreate,
MeetingUpdate,
MeetingResponse,
MeetingListResponse,
ConclusionResponse,
ActionItemResponse,
ActionItemUpdate,
TokenPayload,
)
from .auth import get_current_user, is_admin
router = APIRouter()
def generate_system_code(prefix: str, meeting_date: date, sequence: int) -> str:
"""Generate system code like C-20251210-01 or A-20251210-01."""
date_str = meeting_date.strftime("%Y%m%d")
return f"{prefix}-{date_str}-{sequence:02d}"
def get_next_sequence(cursor, prefix: str, date_str: str) -> int:
"""Get next sequence number for a given prefix and date."""
pattern = f"{prefix}-{date_str}-%"
cursor.execute(
"""
SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s
UNION
SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s
ORDER BY system_code DESC LIMIT 1
""",
(pattern, pattern),
)
result = cursor.fetchone()
if result:
last_code = result["system_code"]
last_seq = int(last_code.split("-")[-1])
return last_seq + 1
return 1
@router.post("/meetings", response_model=MeetingResponse)
async def create_meeting(
meeting: MeetingCreate, current_user: TokenPayload = Depends(get_current_user)
):
"""Create a new meeting with optional conclusions and action items."""
meeting_uuid = str(uuid.uuid4())
recorder = meeting.recorder or current_user.email
meeting_date = meeting.meeting_time.date()
date_str = meeting_date.strftime("%Y%m%d")
with get_db_cursor(commit=True) as cursor:
# Insert meeting record
cursor.execute(
"""
INSERT INTO meeting_records
(uuid, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
meeting_uuid,
meeting.subject,
meeting.meeting_time,
meeting.location,
meeting.chairperson,
recorder,
meeting.attendees,
meeting.transcript_blob,
current_user.email,
),
)
meeting_id = cursor.lastrowid
# Insert conclusions
conclusions = []
seq = get_next_sequence(cursor, "C", date_str)
for conclusion in meeting.conclusions or []:
system_code = generate_system_code("C", meeting_date, seq)
cursor.execute(
"""
INSERT INTO meeting_conclusions (meeting_id, content, system_code)
VALUES (%s, %s, %s)
""",
(meeting_id, conclusion.content, system_code),
)
conclusions.append(
ConclusionResponse(
conclusion_id=cursor.lastrowid,
meeting_id=meeting_id,
content=conclusion.content,
system_code=system_code,
)
)
seq += 1
# Insert action items
actions = []
seq = get_next_sequence(cursor, "A", date_str)
for action in meeting.actions or []:
system_code = generate_system_code("A", meeting_date, seq)
cursor.execute(
"""
INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code)
VALUES (%s, %s, %s, %s, %s)
""",
(meeting_id, action.content, action.owner, action.due_date, system_code),
)
actions.append(
ActionItemResponse(
action_id=cursor.lastrowid,
meeting_id=meeting_id,
content=action.content,
owner=action.owner,
due_date=action.due_date,
status="Open",
system_code=system_code,
)
)
seq += 1
# Fetch created meeting
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
record = cursor.fetchone()
return MeetingResponse(
meeting_id=record["meeting_id"],
uuid=record["uuid"],
subject=record["subject"],
meeting_time=record["meeting_time"],
location=record["location"],
chairperson=record["chairperson"],
recorder=record["recorder"],
attendees=record["attendees"],
transcript_blob=record["transcript_blob"],
created_by=record["created_by"],
created_at=record["created_at"],
conclusions=conclusions,
actions=actions,
)
@router.get("/meetings", response_model=List[MeetingListResponse])
async def list_meetings(current_user: TokenPayload = Depends(get_current_user)):
"""List meetings. Admin sees all, users see only their own."""
with get_db_cursor() as cursor:
if is_admin(current_user):
cursor.execute(
"""
SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at
FROM meeting_records ORDER BY meeting_time DESC
"""
)
else:
cursor.execute(
"""
SELECT meeting_id, uuid, subject, meeting_time, chairperson, created_at
FROM meeting_records
WHERE created_by = %s OR recorder = %s OR attendees LIKE %s
ORDER BY meeting_time DESC
""",
(
current_user.email,
current_user.email,
f"%{current_user.email}%",
),
)
records = cursor.fetchall()
return [MeetingListResponse(**record) for record in records]
@router.get("/meetings/{meeting_id}", response_model=MeetingResponse)
async def get_meeting(
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
):
"""Get meeting details with conclusions and action items."""
with get_db_cursor() as cursor:
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
record = cursor.fetchone()
if not record:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check access
if not is_admin(current_user):
if (
record["created_by"] != current_user.email
and record["recorder"] != current_user.email
and current_user.email not in (record["attendees"] or "")
):
raise HTTPException(status_code=403, detail="Access denied")
# Get conclusions
cursor.execute(
"SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
)
conclusions = [ConclusionResponse(**c) for c in cursor.fetchall()]
# Get action items
cursor.execute(
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
)
actions = [ActionItemResponse(**a) for a in cursor.fetchall()]
return MeetingResponse(
meeting_id=record["meeting_id"],
uuid=record["uuid"],
subject=record["subject"],
meeting_time=record["meeting_time"],
location=record["location"],
chairperson=record["chairperson"],
recorder=record["recorder"],
attendees=record["attendees"],
transcript_blob=record["transcript_blob"],
created_by=record["created_by"],
created_at=record["created_at"],
conclusions=conclusions,
actions=actions,
)
@router.put("/meetings/{meeting_id}", response_model=MeetingResponse)
async def update_meeting(
meeting_id: int,
meeting: MeetingUpdate,
current_user: TokenPayload = Depends(get_current_user),
):
"""Update meeting details."""
with get_db_cursor(commit=True) as cursor:
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
record = cursor.fetchone()
if not record:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check access
if not is_admin(current_user) and record["created_by"] != current_user.email:
raise HTTPException(status_code=403, detail="Access denied")
# Build update query dynamically
updates = []
values = []
for field in ["subject", "meeting_time", "location", "chairperson", "recorder", "attendees", "transcript_blob"]:
value = getattr(meeting, field)
if value is not None:
updates.append(f"{field} = %s")
values.append(value)
if updates:
values.append(meeting_id)
cursor.execute(
f"UPDATE meeting_records SET {', '.join(updates)} WHERE meeting_id = %s",
values,
)
# Update conclusions if provided
if meeting.conclusions is not None:
cursor.execute(
"DELETE FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
)
meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today()
date_str = meeting_date.strftime("%Y%m%d")
seq = get_next_sequence(cursor, "C", date_str)
for conclusion in meeting.conclusions:
system_code = generate_system_code("C", meeting_date, seq)
cursor.execute(
"""
INSERT INTO meeting_conclusions (meeting_id, content, system_code)
VALUES (%s, %s, %s)
""",
(meeting_id, conclusion.content, system_code),
)
seq += 1
# Update action items if provided
if meeting.actions is not None:
cursor.execute(
"DELETE FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
)
meeting_date = (meeting.meeting_time or record["meeting_time"]).date() if hasattr(meeting.meeting_time or record["meeting_time"], 'date') else date.today()
date_str = meeting_date.strftime("%Y%m%d")
seq = get_next_sequence(cursor, "A", date_str)
for action in meeting.actions:
system_code = generate_system_code("A", meeting_date, seq)
cursor.execute(
"""
INSERT INTO meeting_action_items (meeting_id, content, owner, due_date, system_code)
VALUES (%s, %s, %s, %s, %s)
""",
(meeting_id, action.content, action.owner, action.due_date, system_code),
)
seq += 1
# Return updated meeting
return await get_meeting(meeting_id, current_user)
@router.delete("/meetings/{meeting_id}")
async def delete_meeting(
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
):
"""Delete meeting and all related data (cascade)."""
with get_db_cursor(commit=True) as cursor:
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
record = cursor.fetchone()
if not record:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check access - admin or creator can delete
if not is_admin(current_user) and record["created_by"] != current_user.email:
raise HTTPException(status_code=403, detail="Access denied")
# Delete (cascade will handle conclusions and action items)
cursor.execute("DELETE FROM meeting_records WHERE meeting_id = %s", (meeting_id,))
return {"message": "Meeting deleted successfully"}
@router.put("/meetings/{meeting_id}/actions/{action_id}")
async def update_action_item(
meeting_id: int,
action_id: int,
action: ActionItemUpdate,
current_user: TokenPayload = Depends(get_current_user),
):
"""Update a specific action item's status, owner, or due date."""
with get_db_cursor(commit=True) as cursor:
cursor.execute(
"SELECT * FROM meeting_action_items WHERE action_id = %s AND meeting_id = %s",
(action_id, meeting_id),
)
record = cursor.fetchone()
if not record:
raise HTTPException(status_code=404, detail="Action item not found")
updates = []
values = []
for field in ["content", "owner", "due_date", "status"]:
value = getattr(action, field)
if value is not None:
updates.append(f"{field} = %s")
values.append(value.value if hasattr(value, "value") else value)
if updates:
values.append(action_id)
cursor.execute(
f"UPDATE meeting_action_items SET {', '.join(updates)} WHERE action_id = %s",
values,
)
cursor.execute(
"SELECT * FROM meeting_action_items WHERE action_id = %s", (action_id,)
)
updated = cursor.fetchone()
return ActionItemResponse(**updated)