diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..923912b --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +*.db +*.db-journal diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..0389d88 --- /dev/null +++ b/api/README.md @@ -0,0 +1,37 @@ +# MeetNote API + +FastAPI 后端,端口 4491。 + +## 启动 + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # 填 GROQ_API_KEY / POE_API_KEY +uvicorn app.main:app --reload --port 4491 +``` + +## 路由 + +- `GET /health` +- `POST /api/upload-url` — 申请 presigned URL 创建会议 +- `POST /api/upload-complete` — 通知后端上传完成,触发后台处理 +- `GET /api/meetings` — 会议列表 +- `GET /api/meetings/{id}` — 单个会议 +- `GET /api/meetings/{id}/transcript` +- `GET /api/meetings/{id}/summary` +- `DELETE /api/meetings/{id}` + +## 数据库 +- 默认 `sqlite+aiosqlite:///./meetnote.db`(本地开发) +- 生产改 `DATABASE_URL=postgresql+asyncpg://...` + +## 状态机 +`pending → uploading → uploaded → splitting → transcribing → summarizing → done | failed` + +## A3 vs A5 +A3(本提交)只搭好骨架 + state-machine stub。A5 会接上: +- ffmpeg silencedetect 切片 +- Groq Whisper 真实转写 +- Poe Claude 真实总结 diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..1f316b2 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,31 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .db import init_db +from .routers import meetings as meetings_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(title="MeetNote API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(meetings_router.router) + + +@app.get("/health") +async def health(): + return {"ok": True, "service": "meetnote-api", "version": "0.1.0"} diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/meetings.py b/api/app/routers/meetings.py new file mode 100644 index 0000000..e297703 --- /dev/null +++ b/api/app/routers/meetings.py @@ -0,0 +1,114 @@ +import uuid +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from ..db import get_session +from ..models import Meeting, MeetingStatus, TranscriptSegment, Summary +from ..schemas import ( + MeetingRead, + UploadCompleteRequest, + UploadUrlRequest, + UploadUrlResponse, +) +from ..services.storage import presign_put +from ..services.transcribe import process_meeting + +router = APIRouter(prefix="/api", tags=["meetings"]) + + +@router.post("/upload-url", response_model=UploadUrlResponse) +async def request_upload_url( + body: UploadUrlRequest, session: AsyncSession = Depends(get_session) +): + object_key = f"audio/{uuid.uuid4().hex}-{body.filename}" + + meeting = Meeting( + title=body.title or body.filename, + participants=body.participants, + object_key=object_key, + status=MeetingStatus.uploading, + ) + session.add(meeting) + await session.commit() + await session.refresh(meeting) + + upload_url = presign_put(object_key, body.content_type) + return UploadUrlResponse( + meeting_id=meeting.id, + upload_url=upload_url, + object_key=object_key, + ) + + +@router.post("/upload-complete") +async def upload_complete( + body: UploadCompleteRequest, + background: BackgroundTasks, + session: AsyncSession = Depends(get_session), +): + meeting = await session.get(Meeting, body.meeting_id) + if not meeting: + raise HTTPException(404, "meeting not found") + + meeting.file_size = body.file_size + meeting.status = MeetingStatus.uploaded + await session.commit() + + background.add_task(process_meeting, meeting.id) + return {"ok": True, "meeting_id": meeting.id} + + +@router.get("/meetings", response_model=list[MeetingRead]) +async def list_meetings(session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Meeting).order_by(Meeting.created_at.desc())) + return result.scalars().all() + + +@router.get("/meetings/{meeting_id}", response_model=MeetingRead) +async def get_meeting(meeting_id: int, session: AsyncSession = Depends(get_session)): + meeting = await session.get(Meeting, meeting_id) + if not meeting: + raise HTTPException(404, "meeting not found") + return meeting + + +@router.get("/meetings/{meeting_id}/transcript") +async def get_transcript(meeting_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(TranscriptSegment) + .where(TranscriptSegment.meeting_id == meeting_id) + .order_by(TranscriptSegment.start) + ) + segments = result.scalars().all() + return [ + {"start": s.start, "end": s.end, "speaker": s.speaker, "text": s.text} + for s in segments + ] + + +@router.get("/meetings/{meeting_id}/summary") +async def get_summary(meeting_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(Summary).where(Summary.meeting_id == meeting_id) + ) + summary = result.scalar_one_or_none() + if not summary: + return None + return { + "key_points": summary.key_points, + "todos": summary.todos, + "decisions": summary.decisions, + "keywords": summary.keywords, + "preview": summary.preview, + } + + +@router.delete("/meetings/{meeting_id}") +async def delete_meeting(meeting_id: int, session: AsyncSession = Depends(get_session)): + meeting = await session.get(Meeting, meeting_id) + if not meeting: + raise HTTPException(404, "meeting not found") + await session.delete(meeting) + await session.commit() + return {"ok": True} diff --git a/api/app/schemas.py b/api/app/schemas.py new file mode 100644 index 0000000..9cf6713 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel +from .models import MeetingStatus + + +class UploadUrlRequest(BaseModel): + filename: str + content_type: str = "audio/mp4" + title: Optional[str] = None + participants: Optional[str] = None + + +class UploadUrlResponse(BaseModel): + meeting_id: int + upload_url: str + object_key: str + + +class UploadCompleteRequest(BaseModel): + meeting_id: int + file_size: int + + +class MeetingRead(BaseModel): + id: int + title: str + participants: Optional[str] = None + duration: Optional[int] = None + file_size: Optional[int] = None + status: MeetingStatus + chunks_done: int + chunks_total: int + error: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True diff --git a/api/app/services/transcribe.py b/api/app/services/transcribe.py new file mode 100644 index 0000000..493a04e --- /dev/null +++ b/api/app/services/transcribe.py @@ -0,0 +1,46 @@ +"""Stub for the transcribe + summarize pipeline. + +This module is intentionally a placeholder for A3. The real implementation +lands in A5 and will: + 1. Download the object from MinIO + 2. Probe duration and size + 3. If file > 24 MB, ffmpeg silencedetect → split into <20 MB chunks + 4. Call Groq Whisper for each chunk, shift timestamps, merge + 5. Call Poe Claude with map-reduce for long audio + 6. Persist segments + summary, update meeting.status to done +""" + +import asyncio +from sqlmodel import select +from ..db import AsyncSessionLocal +from ..models import Meeting, MeetingStatus + + +async def process_meeting(meeting_id: int) -> None: + """Background task launched after upload-complete. + + For A3 we just walk the state machine so the frontend can see status + transitions; A5 swaps in real Groq/Poe calls. + """ + async with AsyncSessionLocal() as session: + meeting = await session.get(Meeting, meeting_id) + if not meeting: + return + + try: + for status, delay in [ + (MeetingStatus.splitting, 1), + (MeetingStatus.transcribing, 2), + (MeetingStatus.summarizing, 1), + (MeetingStatus.done, 0), + ]: + meeting.status = status + if status == MeetingStatus.transcribing: + meeting.chunks_total = 1 + meeting.chunks_done = 1 + await session.commit() + await asyncio.sleep(delay) + except Exception as exc: + meeting.status = MeetingStatus.failed + meeting.error = str(exc) + await session.commit()