第一次提交

This commit is contained in:
2026-08-01 16:50:55 +08:00
commit c8f9e39039
68 changed files with 6016 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
"""集中导入所有模型,确保 Base.metadata 完整(Alembic autogenerate 需要)。"""
from app.models.image import Image
from app.models.job import JobStatus, ProcessingJob
from app.models.practice import AiGeneration, JudgedBy, PracticeRecord
from app.models.question import (
OBJECTIVE_TYPES,
Question,
QuestionTag,
QuestionType,
Tag,
)
from app.models.user import User, UserSettings
__all__ = [
"User",
"UserSettings",
"Question",
"QuestionType",
"OBJECTIVE_TYPES",
"Tag",
"QuestionTag",
"Image",
"ProcessingJob",
"JobStatus",
"PracticeRecord",
"AiGeneration",
"JudgedBy",
]
+7
View File
@@ -0,0 +1,7 @@
"""模型共用的小工具。"""
import uuid
def new_uuid() -> str:
"""主键默认值:UUID4 字符串。"""
return str(uuid.uuid4())
+22
View File
@@ -0,0 +1,22 @@
"""上传图片元数据(文件本体存 S3)。"""
from sqlalchemy import BigInteger, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import new_uuid
from app.db import Base, TimestampMixin
class Image(Base, TimestampMixin):
__tablename__ = "images"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
object_key: Mapped[str] = mapped_column(String(512)) # S3 中的 key
mime: Mapped[str] = mapped_column(String(64))
size_bytes: Mapped[int] = mapped_column(BigInteger)
sha256: Mapped[str] = mapped_column(String(64), index=True) # 去重
# 缓存原始 OCR 文本:一张图可能拆成多条笔记,留着便于追溯与重新拆分
ocr_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
+38
View File
@@ -0,0 +1,38 @@
"""OCR / AI 后台任务状态,前端轮询。"""
import enum
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.models.base import new_uuid
from app.db import Base, TimestampMixin
class JobStatus(str, enum.Enum):
pending = "pending"
ocr_running = "ocr_running"
ai_running = "ai_running"
done = "done"
failed = "failed"
class ProcessingJob(Base, TimestampMixin):
__tablename__ = "processing_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
image_id: Mapped[str | None] = mapped_column(
ForeignKey("images.id", ondelete="SET NULL"), nullable=True
)
kind: Mapped[str] = mapped_column(String(32), default="ocr_extract")
status: Mapped[JobStatus] = mapped_column(
SAEnum(JobStatus), default=JobStatus.pending
)
# 一张图可能拆成多条笔记
result_question_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
engine_used: Mapped[str | None] = mapped_column(String(32), nullable=True)
+49
View File
@@ -0,0 +1,49 @@
"""答题 / 练习记录,以及 AI 调用审计。"""
import enum
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.models.base import new_uuid
from app.db import Base, TimestampMixin
class JudgedBy(str, enum.Enum):
auto = "auto" # 客观题本地判分
ai = "ai" # 主观题 AI 判分
self_ = "self" # 用户自评
class PracticeRecord(Base, TimestampMixin):
__tablename__ = "practice_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[str] = mapped_column(
ForeignKey("questions.id", ondelete="CASCADE"), index=True
)
user_answer: Mapped[list | None] = mapped_column(JSON, nullable=True)
is_correct: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
judged_by: Mapped[JudgedBy] = mapped_column(SAEnum(JudgedBy), default=JudgedBy.auto)
ai_feedback_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
class AiGeneration(Base, TimestampMixin):
"""记录每次 AI 调用,便于复用缓存、成本观察、排查。"""
__tablename__ = "ai_generations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
task: Mapped[str] = mapped_column(String(32)) # extract/judge/summarize/explain
model: Mapped[str | None] = mapped_column(String(128), nullable=True)
request_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
response_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
content_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
+101
View File
@@ -0,0 +1,101 @@
"""题目、标签、题目-标签关联。"""
import enum
from sqlalchemy import (
Boolean,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.models.base import new_uuid
from app.db import Base, TimestampMixin
class QuestionType(str, enum.Enum):
# 拍照入库的默认值:先收进来,想练习时再补题型/答案
unclassified = "unclassified"
single_choice = "single_choice"
multiple_choice = "multiple_choice"
true_false = "true_false"
fill_blank = "fill_blank"
short_answer = "short_answer"
# 客观题:有标准答案时可本地精确判分;其余交给 AI 判
OBJECTIVE_TYPES = {
QuestionType.single_choice,
QuestionType.multiple_choice,
QuestionType.true_false,
}
class Tag(Base, TimestampMixin):
__tablename__ = "tags"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tag_user_name"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(128))
questions: Mapped[list["Question"]] = relationship(
secondary="question_tags", back_populates="tags"
)
class QuestionTag(Base):
__tablename__ = "question_tags"
question_id: Mapped[str] = mapped_column(
ForeignKey("questions.id", ondelete="CASCADE"), primary_key=True
)
tag_id: Mapped[str] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True
)
class Question(Base, TimestampMixin):
"""一条笔记。
只有 type 和 stem_markdown 是必填,其中 type 可以是 unclassified
所以"拍照识别完直接存"不需要用户补任何东西。
"""
__tablename__ = "questions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[QuestionType] = mapped_column(
SAEnum(QuestionType), default=QuestionType.unclassified
)
stem_markdown: Mapped[str] = mapped_column(Text)
# 选项:[{"key": "A", "text_markdown": "..."}],选择/判断题用
options: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 标准答案:选择题 ["A","C"],填空/简答 ["文本"]
correct_answer: Mapped[list | None] = mapped_column(JSON, nullable=True)
# AI 生成的讲解(每次 /ai/explain 会覆写,别往这里写用户内容)
explanation_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
# 用户自己写的笔记 / 错因反思,AI 永不触碰
my_note_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
# 手动标记"待复习";复习清单 = 这个标记 ∪ 最近一次作答判错
needs_review: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
difficulty: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1-5
source_image_id: Mapped[str | None] = mapped_column(
ForeignKey("images.id", ondelete="SET NULL"), nullable=True, index=True
)
ocr_engine: Mapped[str | None] = mapped_column(String(32), nullable=True)
tags: Mapped[list["Tag"]] = relationship(
secondary="question_tags", back_populates="questions"
)
# 便于详情页把原图和笔记一起展示
source_image: Mapped["Image | None"] = relationship("Image")
+38
View File
@@ -0,0 +1,38 @@
"""用户与用户 AI 配置。"""
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import new_uuid
from app.db import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
settings: Mapped["UserSettings"] = relationship(
back_populates="user", uselist=False, cascade="all, delete-orphan"
)
class UserSettings(Base, TimestampMixin):
"""每个用户可在 UI 里配置自己的 OpenAI 兼容 AI 接口。api_key 用 Fernet 加密存储。"""
__tablename__ = "user_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
)
llm_base_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
llm_api_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
llm_text_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
llm_vision_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
user: Mapped["User"] = relationship(back_populates="settings")