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:
372
backend/app/routers/meetings.py
Normal file
372
backend/app/routers/meetings.py
Normal 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)
|
||||
Reference in New Issue
Block a user