From 4f064bb470543611026f020da2c7a37a5e095902 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 13 Apr 2026 19:12:14 +0800 Subject: [PATCH] auto-save 2026-04-13 19:12 (+1) --- api/.env.example | 25 +++++++++++++++ api/app/__init__.py | 0 api/app/config.py | 30 ++++++++++++++++++ api/app/db.py | 19 ++++++++++++ api/app/models.py | 51 ++++++++++++++++++++++++++++++ api/app/services/__init__.py | 0 api/app/services/storage.py | 60 ++++++++++++++++++++++++++++++++++++ api/requirements.txt | 12 ++++++++ 8 files changed, 197 insertions(+) create mode 100644 api/.env.example create mode 100644 api/app/__init__.py create mode 100644 api/app/config.py create mode 100644 api/app/db.py create mode 100644 api/app/models.py create mode 100644 api/app/services/__init__.py create mode 100644 api/app/services/storage.py create mode 100644 api/requirements.txt diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..9409951 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,25 @@ +# MeetNote API config + +# 数据库 - 本地 SQLite,生产 PostgreSQL +DATABASE_URL=sqlite+aiosqlite:///./meetnote.db +# DATABASE_URL=postgresql+asyncpg://user:pass@127.0.0.1:5432/meetnote + +# 对象存储 (MinIO) +MINIO_ENDPOINT=127.0.0.1:9000 +MINIO_REGION=us-east-1 +MINIO_BUCKET=meetnote +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_SECURE=false +MINIO_PUBLIC_ENDPOINT=http://127.0.0.1:9000 + +# Groq Whisper +GROQ_API_KEY= +GROQ_MODEL=whisper-large-v3 + +# Poe Claude (复用全局) +POE_API_KEY= +POE_MODEL=Claude-Sonnet-4.6 + +# CORS +CORS_ORIGINS=http://localhost:4490,http://192.168.2.69:4490 diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..e2765cb --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "sqlite+aiosqlite:///./meetnote.db" + + minio_endpoint: str = "127.0.0.1:9000" + minio_region: str = "us-east-1" + minio_bucket: str = "meetnote" + minio_access_key: str = "minioadmin" + minio_secret_key: str = "minioadmin" + minio_secure: bool = False + minio_public_endpoint: str = "http://127.0.0.1:9000" + + groq_api_key: str = "" + groq_model: str = "whisper-large-v3" + + poe_api_key: str = "" + poe_model: str = "Claude-Sonnet-4.6" + + cors_origins: str = "http://localhost:4490" + + @property + def cors_origins_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/api/app/db.py b/api/app/db.py new file mode 100644 index 0000000..2030a1a --- /dev/null +++ b/api/app/db.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlmodel import SQLModel +from .config import settings + +engine = create_async_engine(settings.database_url, echo=False, future=True) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def init_db() -> None: + # Import models so SQLModel.metadata sees them + from . import models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session diff --git a/api/app/models.py b/api/app/models.py new file mode 100644 index 0000000..ec2c178 --- /dev/null +++ b/api/app/models.py @@ -0,0 +1,51 @@ +from datetime import datetime +from enum import Enum +from typing import Optional +from sqlmodel import SQLModel, Field, Column +from sqlalchemy import JSON + + +class MeetingStatus(str, Enum): + pending = "pending" + uploading = "uploading" + uploaded = "uploaded" + splitting = "splitting" + transcribing = "transcribing" + summarizing = "summarizing" + done = "done" + failed = "failed" + + +class Meeting(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + title: str + participants: Optional[str] = None # comma-separated + object_key: Optional[str] = None # MinIO key + file_size: Optional[int] = None + duration: Optional[int] = None # seconds + status: MeetingStatus = Field(default=MeetingStatus.pending) + chunks_done: int = 0 + chunks_total: int = 0 + error: Optional[str] = None + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class TranscriptSegment(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + meeting_id: int = Field(foreign_key="meeting.id", index=True) + start: float # seconds + end: float + speaker: Optional[str] = None + text: str + + +class Summary(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + meeting_id: int = Field(foreign_key="meeting.id", unique=True, index=True) + key_points: list = Field(default_factory=list, sa_column=Column(JSON)) + todos: list = Field(default_factory=list, sa_column=Column(JSON)) + decisions: list = Field(default_factory=list, sa_column=Column(JSON)) + keywords: list = Field(default_factory=list, sa_column=Column(JSON)) + preview: str = "" + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/services/storage.py b/api/app/services/storage.py new file mode 100644 index 0000000..3b1a47b --- /dev/null +++ b/api/app/services/storage.py @@ -0,0 +1,60 @@ +"""MinIO / S3 presigned URL helper. + +We use boto3 because MinIO speaks the S3 protocol and boto3 is the de-facto +client. Generating presigned multipart upload URLs lets the browser upload +files >100MB directly to MinIO without going through the FastAPI process, +which avoids body-size limits and streaming-into-RAM issues. +""" + +import boto3 +from botocore.client import Config +from ..config import settings + + +def s3_client(): + return boto3.client( + "s3", + endpoint_url=("https" if settings.minio_secure else "http") + + "://" + + settings.minio_endpoint, + aws_access_key_id=settings.minio_access_key, + aws_secret_access_key=settings.minio_secret_key, + region_name=settings.minio_region, + config=Config(signature_version="s3v4", s3={"addressing_style": "path"}), + ) + + +def ensure_bucket() -> None: + client = s3_client() + try: + client.head_bucket(Bucket=settings.minio_bucket) + except Exception: + client.create_bucket(Bucket=settings.minio_bucket) + + +def presign_put(object_key: str, content_type: str, expires: int = 3600) -> str: + """Single-PUT presigned URL — for files small enough not to need multipart. + + For multipart (files >100MB), the frontend should use the AWS SDK's + @aws-sdk/lib-storage Upload helper, which can sign each part itself once + we hand it the credentials. For MVP we keep things simple with single-PUT + + a 500 MB cap. + """ + return s3_client().generate_presigned_url( + "put_object", + Params={ + "Bucket": settings.minio_bucket, + "Key": object_key, + "ContentType": content_type, + }, + ExpiresIn=expires, + HttpMethod="PUT", + ) + + +def presign_get(object_key: str, expires: int = 3600) -> str: + return s3_client().generate_presigned_url( + "get_object", + Params={"Bucket": settings.minio_bucket, "Key": object_key}, + ExpiresIn=expires, + ) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..733167c --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlmodel==0.0.22 +aiosqlite==0.20.0 +asyncpg==0.30.0 +greenlet==3.1.1 +python-multipart==0.0.20 +python-dotenv==1.0.1 +httpx==0.28.1 +boto3==1.35.93 +pydantic==2.10.4 +pydantic-settings==2.7.0