feat(api): A3 backend skeleton with FastAPI + SQLModel

- 3 tables: Meeting / TranscriptSegment / Summary (with state machine)
- Routes: /api/upload-url + /api/upload-complete + meetings CRUD
- MinIO presigned PUT for direct browser upload
- BackgroundTasks state-machine stub for A5 to flesh out
- SQLite for local dev, PostgreSQL+asyncpg for prod
- CORS configured for frontend on 4490

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:14:16 +08:00
parent 4f064bb470
commit 3111c854c5
7 changed files with 272 additions and 0 deletions

6
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.env
*.db
*.db-journal

37
api/README.md Normal file
View File

@@ -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 真实总结

31
api/app/main.py Normal file
View File

@@ -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"}

View File

114
api/app/routers/meetings.py Normal file
View File

@@ -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}

38
api/app/schemas.py Normal file
View File

@@ -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

View File

@@ -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()