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,177 @@
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment, Border, Side
import io
import os
from ..database import get_db_cursor
from ..models import TokenPayload
from .auth import get_current_user, is_admin
router = APIRouter()
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates")
def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook:
"""Create Excel workbook with meeting data."""
wb = Workbook()
ws = wb.active
ws.title = "Meeting Record"
# Styles
header_font = Font(bold=True, size=14)
label_font = Font(bold=True)
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# Title
ws.merge_cells("A1:F1")
ws["A1"] = "Meeting Record"
ws["A1"].font = Font(bold=True, size=16)
ws["A1"].alignment = Alignment(horizontal="center")
# Metadata section
row = 3
metadata = [
("Subject", meeting.get("subject", "")),
("Date/Time", str(meeting.get("meeting_time", ""))),
("Location", meeting.get("location", "")),
("Chairperson", meeting.get("chairperson", "")),
("Recorder", meeting.get("recorder", "")),
("Attendees", meeting.get("attendees", "")),
]
for label, value in metadata:
ws[f"A{row}"] = label
ws[f"A{row}"].font = label_font
ws.merge_cells(f"B{row}:F{row}")
ws[f"B{row}"] = value
row += 1
# Conclusions section
row += 1
ws.merge_cells(f"A{row}:F{row}")
ws[f"A{row}"] = "Conclusions"
ws[f"A{row}"].font = header_font
row += 1
ws[f"A{row}"] = "Code"
ws[f"B{row}"] = "Content"
ws[f"A{row}"].font = label_font
ws[f"B{row}"].font = label_font
row += 1
for c in conclusions:
ws[f"A{row}"] = c.get("system_code", "")
ws.merge_cells(f"B{row}:F{row}")
ws[f"B{row}"] = c.get("content", "")
row += 1
# Action Items section
row += 1
ws.merge_cells(f"A{row}:F{row}")
ws[f"A{row}"] = "Action Items"
ws[f"A{row}"].font = header_font
row += 1
headers = ["Code", "Content", "Owner", "Due Date", "Status"]
for col, header in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=header)
cell.font = label_font
cell.border = thin_border
row += 1
for a in actions:
ws.cell(row=row, column=1, value=a.get("system_code", "")).border = thin_border
ws.cell(row=row, column=2, value=a.get("content", "")).border = thin_border
ws.cell(row=row, column=3, value=a.get("owner", "")).border = thin_border
ws.cell(row=row, column=4, value=str(a.get("due_date", "") or "")).border = thin_border
ws.cell(row=row, column=5, value=a.get("status", "")).border = thin_border
row += 1
# Adjust column widths
ws.column_dimensions["A"].width = 18
ws.column_dimensions["B"].width = 40
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 12
ws.column_dimensions["E"].width = 12
ws.column_dimensions["F"].width = 12
return wb
@router.get("/meetings/{meeting_id}/export")
async def export_meeting(
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
):
"""Export meeting to Excel file."""
with get_db_cursor() as cursor:
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
meeting = cursor.fetchone()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check access
if not is_admin(current_user):
if (
meeting["created_by"] != current_user.email
and meeting["recorder"] != current_user.email
and current_user.email not in (meeting["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 = cursor.fetchall()
# Get action items
cursor.execute(
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
)
actions = cursor.fetchall()
# Check for custom template
template_path = os.path.join(TEMPLATE_DIR, "template.xlsx")
if os.path.exists(template_path):
wb = load_workbook(template_path)
ws = wb.active
# Replace placeholders
for row in ws.iter_rows():
for cell in row:
if cell.value and isinstance(cell.value, str):
cell.value = (
cell.value.replace("{{subject}}", meeting.get("subject", ""))
.replace("{{time}}", str(meeting.get("meeting_time", "")))
.replace("{{location}}", meeting.get("location", ""))
.replace("{{chair}}", meeting.get("chairperson", ""))
.replace("{{recorder}}", meeting.get("recorder", ""))
.replace("{{attendees}}", meeting.get("attendees", ""))
)
else:
# Use default template
wb = create_default_workbook(meeting, conclusions, actions)
# Save to bytes buffer
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx"
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)