第一次提交
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""模型共用的小工具。"""
|
||||
import uuid
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
"""主键默认值:UUID4 字符串。"""
|
||||
return str(uuid.uuid4())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user