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>
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
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}"'},
|
|
)
|