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:
6
api/.gitignore
vendored
Normal file
6
api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
37
api/README.md
Normal file
37
api/README.md
Normal 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
31
api/app/main.py
Normal 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"}
|
||||
0
api/app/routers/__init__.py
Normal file
0
api/app/routers/__init__.py
Normal file
114
api/app/routers/meetings.py
Normal file
114
api/app/routers/meetings.py
Normal 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
38
api/app/schemas.py
Normal 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
|
||||
46
api/app/services/transcribe.py
Normal file
46
api/app/services/transcribe.py
Normal 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()
|
||||
Reference in New Issue
Block a user