590 lines
13 KiB
Python
590 lines
13 KiB
Python
"""API 请求 / 响应模型。"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
|
||
class ORMModel(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class KnowledgeOut(ORMModel):
|
||
id: int
|
||
knowledge_id: int
|
||
name: str
|
||
mastery: int
|
||
position: int
|
||
status: str = ""
|
||
domain: str = ""
|
||
category: str = ""
|
||
|
||
|
||
class KnowledgeResourceOut(BaseModel):
|
||
id: int
|
||
kind: str
|
||
title: str
|
||
url: str
|
||
source_name: str = ""
|
||
|
||
|
||
class KnowledgeRelatedOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
relation_type: str
|
||
direction: str = "out" # out: 当前节点指向对方;in: 对方指向当前节点
|
||
mastery: int | None = None
|
||
|
||
|
||
class KnowledgeChapterOut(BaseModel):
|
||
chapter_id: int
|
||
chapter_name: str
|
||
textbook_id: int
|
||
textbook_name: str
|
||
question_count: int = 0
|
||
# 电子书定位:has_ebook 为假时前端不显示「读教材」入口
|
||
ebook_page: int = 0
|
||
has_ebook: bool = False
|
||
|
||
|
||
class KnowledgeDetailOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
domain: str
|
||
category: str
|
||
description: str
|
||
mastery: int
|
||
status: str
|
||
related_knowledge_count: int
|
||
chapter_count: int
|
||
question_count: int
|
||
related: list[KnowledgeRelatedOut] = []
|
||
chapters: list[KnowledgeChapterOut] = []
|
||
resources: list[KnowledgeResourceOut] = []
|
||
|
||
|
||
class KnowledgeResourceIn(BaseModel):
|
||
kind: str = "video"
|
||
title: str = ""
|
||
url: str = ""
|
||
textbook_id: int | None = None
|
||
course_id: int | None = None
|
||
|
||
|
||
class QuestionOut(BaseModel):
|
||
id: int
|
||
stem: str
|
||
options: list[str]
|
||
knowledge_name: str
|
||
|
||
|
||
class PracticeSessionOut(BaseModel):
|
||
mode: str
|
||
title: str
|
||
chapter_id: int | None = None
|
||
questions: list[QuestionOut]
|
||
|
||
|
||
class AnswerIn(BaseModel):
|
||
question_id: int
|
||
selected: int
|
||
|
||
|
||
class SubmitIn(BaseModel):
|
||
mode: str
|
||
chapter_id: int | None = None
|
||
answers: list[AnswerIn] = Field(min_length=1)
|
||
|
||
|
||
class PerQuestionOut(BaseModel):
|
||
question_id: int
|
||
correct: bool
|
||
selected: int
|
||
correct_index: int
|
||
explanation: str
|
||
|
||
|
||
class SubmitResultOut(BaseModel):
|
||
mode: str
|
||
title: str
|
||
total: int
|
||
score: int
|
||
accuracy: float
|
||
per_question: list[PerQuestionOut]
|
||
knowledge: list[KnowledgeOut]
|
||
|
||
|
||
class ErrorOut(BaseModel):
|
||
id: int
|
||
source_label: str
|
||
knowledge_name: str
|
||
diagnosis: str
|
||
status: str
|
||
created_on: date
|
||
attempt_id: int | None = None
|
||
question_id: int | None = None
|
||
stem: str = ""
|
||
options: list[str] = []
|
||
selected: int | None = None
|
||
correct_index: int | None = None
|
||
explanation: str = ""
|
||
knowledge_names: list[str] = []
|
||
|
||
|
||
class RecordOut(BaseModel):
|
||
id: int
|
||
mode: str
|
||
title: str
|
||
total: int
|
||
correct: int
|
||
happened_on: date
|
||
|
||
|
||
class AttemptItemOut(BaseModel):
|
||
question_index: int
|
||
question_id: int | None
|
||
stem: str
|
||
options: list[str]
|
||
selected: int | None
|
||
correct_index: int
|
||
correct: bool
|
||
knowledge_name: str
|
||
explanation: str
|
||
knowledge_names: list[str] = []
|
||
|
||
|
||
class RecordDetailOut(RecordOut):
|
||
items: list[AttemptItemOut] = []
|
||
|
||
|
||
class DailyOut(BaseModel):
|
||
today: date
|
||
completed: bool
|
||
streak: int
|
||
chapter_id: int | None = None
|
||
chapter_name: str = ""
|
||
book_name: str = ""
|
||
# 今日章节对应的电子书定位,没有电子书时 has_ebook 为假
|
||
textbook_id: int | None = None
|
||
ebook_page: int = 0
|
||
has_ebook: bool = False
|
||
rating: str = ""
|
||
knowledge_names: list[str] = []
|
||
reason: str = ""
|
||
|
||
|
||
class DailyCompleteIn(BaseModel):
|
||
chapter_id: int
|
||
rating: str = ""
|
||
|
||
|
||
class MetricItem(BaseModel):
|
||
label: str
|
||
value: str
|
||
|
||
|
||
class PlanOut(BaseModel):
|
||
notice: str
|
||
focus_names: list[str]
|
||
steps: list[str]
|
||
|
||
|
||
class OverviewOut(BaseModel):
|
||
metrics: list[MetricItem]
|
||
knowledge: list[KnowledgeOut]
|
||
plan: PlanOut
|
||
|
||
|
||
class DiagnosisOut(BaseModel):
|
||
name: str
|
||
mastery: int
|
||
error_count: int
|
||
action: str
|
||
|
||
|
||
class AnalyticsOut(BaseModel):
|
||
metrics: list[MetricItem]
|
||
diagnosis: list[DiagnosisOut]
|
||
|
||
|
||
class ImportPayload(BaseModel):
|
||
version: int = 1
|
||
knowledge: list[dict[str, Any]] = []
|
||
records: list[Any] = []
|
||
errors: list[Any] = []
|
||
daily_completions: list[date] = []
|
||
|
||
|
||
class ImportResult(BaseModel):
|
||
imported: bool
|
||
knowledge: int
|
||
records: int
|
||
errors: int
|
||
items: int = 0
|
||
message: str
|
||
|
||
|
||
class ChapterOut(ORMModel):
|
||
id: int
|
||
textbook_id: int
|
||
name: str
|
||
position: int
|
||
summary: str = ""
|
||
ebook_page: int = 0
|
||
question_count: int = 0
|
||
knowledge_names: list[str] = []
|
||
knowledge_ids: list[int] = []
|
||
|
||
|
||
class EbookProgressOut(ORMModel):
|
||
textbook_id: int
|
||
locator: str = ""
|
||
page: int = 0
|
||
total: int = 0
|
||
position: float = 0.0
|
||
updated_at: datetime | None = None
|
||
|
||
|
||
class EbookProgressIn(BaseModel):
|
||
locator: str = Field(default="", max_length=512)
|
||
page: int = 0
|
||
total: int = 0
|
||
position: float = 0.0
|
||
|
||
|
||
class TextbookOut(ORMModel):
|
||
id: int
|
||
name: str
|
||
author: str
|
||
publisher: str
|
||
isbn: str
|
||
edition: str
|
||
grade: str
|
||
link: str
|
||
description: str
|
||
position: int
|
||
chapters: list[ChapterOut] = []
|
||
# 在线阅读:文件本身走 /api/textbooks/{id}/ebook/file/{file}
|
||
ebook_file: str = ""
|
||
ebook_format: str = ""
|
||
ebook_name: str = ""
|
||
ebook_size: int = 0
|
||
ebook_pages: int = 0
|
||
ebook_uploaded_at: datetime | None = None
|
||
ebook_progress: EbookProgressOut | None = None
|
||
|
||
|
||
class CourseOut(ORMModel):
|
||
id: int
|
||
name: str
|
||
provider: str
|
||
url: str
|
||
grade: str
|
||
description: str
|
||
position: int
|
||
|
||
|
||
class QuestionBankItemOut(BaseModel):
|
||
id: int
|
||
chapter_id: int | None
|
||
chapter_name: str = ""
|
||
textbook_name: str = ""
|
||
knowledge_name: str
|
||
stem: str
|
||
options: list[str]
|
||
correct_index: int
|
||
explanation: str
|
||
difficulty: int
|
||
is_generated: bool
|
||
used_count: int
|
||
knowledge_names: list[str] = []
|
||
knowledge_ids: list[int] = []
|
||
|
||
|
||
class BankSummaryOut(BaseModel):
|
||
"侧栏计数用:全库题目总数与几个切片,避免把整库题目拉回前端数数。"
|
||
total: int
|
||
unattached: int
|
||
generated: int
|
||
used: int
|
||
|
||
|
||
class QuestionUpdate(BaseModel):
|
||
stem: str = Field(min_length=1)
|
||
options: list[str] = Field(min_length=4, max_length=4)
|
||
correct_index: int = Field(ge=0, le=3)
|
||
explanation: str = ""
|
||
knowledge_name: str = Field(min_length=1, max_length=64)
|
||
difficulty: int = Field(default=1, ge=1, le=3)
|
||
chapter_id: int | None = None
|
||
knowledge_names: list[str] = []
|
||
knowledge_ids: list[int] = []
|
||
|
||
|
||
class GenerateIn(BaseModel):
|
||
chapter_id: int
|
||
count: int = Field(default=3, ge=1, le=10)
|
||
difficulty: int = Field(default=2, ge=1, le=3)
|
||
knowledge_name: str = ""
|
||
instructions: str = ""
|
||
channel_id: int | None = None
|
||
knowledge_names: list[str] = []
|
||
knowledge_ids: list[int] = []
|
||
need_figure: bool = False
|
||
|
||
|
||
class LlmProviderOut(BaseModel):
|
||
id: str
|
||
label: str
|
||
base_url: str
|
||
model: str
|
||
|
||
|
||
class LlmChannelOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
provider: str
|
||
provider_label: str
|
||
base_url: str
|
||
model: str
|
||
temperature: float
|
||
max_tokens: int | None = None
|
||
has_api_key: bool
|
||
api_key_preview: str
|
||
is_default: bool
|
||
|
||
|
||
class LlmSettingsOut(BaseModel):
|
||
channels: list[LlmChannelOut] = []
|
||
providers: list[LlmProviderOut]
|
||
|
||
|
||
class LlmChannelCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=128)
|
||
provider: str = "openai"
|
||
base_url: str = ""
|
||
model: str = ""
|
||
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
|
||
max_tokens: int | None = Field(default=None, ge=64, le=12000)
|
||
api_key: str | None = None
|
||
|
||
|
||
class LlmChannelUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||
provider: str | None = None
|
||
base_url: str | None = None
|
||
model: str | None = None
|
||
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
|
||
max_tokens: int | None = Field(default=None, ge=64, le=12000)
|
||
clear_max_tokens: bool = False
|
||
api_key: str | None = None
|
||
clear_api_key: bool = False
|
||
|
||
|
||
class LlmTestResult(BaseModel):
|
||
ok: bool
|
||
message: str
|
||
|
||
|
||
class LlmTaskCreate(BaseModel):
|
||
kind: str
|
||
params: dict[str, Any] = {}
|
||
|
||
|
||
class LlmTaskOut(BaseModel):
|
||
id: int
|
||
kind: str
|
||
status: str
|
||
progress: int
|
||
message: str
|
||
result: dict[str, Any] = {}
|
||
|
||
|
||
class LoginIn(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
class TokenOut(BaseModel):
|
||
access_token: str
|
||
token_type: str = "bearer"
|
||
role: str
|
||
display_name: str
|
||
|
||
|
||
class ChangePasswordIn(BaseModel):
|
||
old_password: str
|
||
new_password: str = Field(min_length=6, max_length=128)
|
||
|
||
|
||
class RoleOut(ORMModel):
|
||
id: int
|
||
code: str
|
||
name: str
|
||
description: str
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
id: int
|
||
username: str
|
||
display_name: str
|
||
active: bool
|
||
roles: list[RoleOut] = []
|
||
created_at: datetime | None = None
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
username: str = Field(min_length=2, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||
display_name: str = Field(default="", max_length=64)
|
||
password: str = Field(min_length=6, max_length=128)
|
||
role_code: str = "student"
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
display_name: str | None = Field(default=None, max_length=64)
|
||
active: bool | None = None
|
||
role_code: str | None = None
|
||
|
||
|
||
class ResetPasswordIn(BaseModel):
|
||
password: str = Field(min_length=6, max_length=128)
|
||
|
||
|
||
class TextbookCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=128)
|
||
author: str = ""
|
||
publisher: str = ""
|
||
isbn: str = ""
|
||
edition: str = ""
|
||
grade: str = ""
|
||
link: str = ""
|
||
description: str = ""
|
||
|
||
|
||
class TextbookUpdate(BaseModel):
|
||
name: str | None = Field(default=None, max_length=128)
|
||
author: str | None = None
|
||
publisher: str | None = None
|
||
isbn: str | None = None
|
||
edition: str | None = None
|
||
grade: str | None = None
|
||
link: str | None = None
|
||
description: str | None = None
|
||
|
||
|
||
class CourseCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=128)
|
||
provider: str = ""
|
||
url: str = ""
|
||
grade: str = ""
|
||
description: str = ""
|
||
|
||
|
||
class CourseUpdate(BaseModel):
|
||
name: str | None = Field(default=None, max_length=128)
|
||
provider: str | None = None
|
||
url: str | None = None
|
||
grade: str | None = None
|
||
description: str | None = None
|
||
|
||
|
||
class ChapterCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=128)
|
||
summary: str = ""
|
||
knowledge_names: list[str] = []
|
||
knowledge_ids: list[int] = []
|
||
|
||
|
||
class ChapterUpdate(BaseModel):
|
||
name: str | None = Field(default=None, max_length=128)
|
||
summary: str | None = None
|
||
ebook_page: int | None = None
|
||
knowledge_names: list[str] | None = None
|
||
knowledge_ids: list[int] | None = None
|
||
|
||
|
||
class ChaptersGenerateIn(BaseModel):
|
||
count: int | None = Field(default=None, ge=1, le=12)
|
||
channel_id: int | None = None
|
||
instructions: str = ""
|
||
knowledge_ids: list[int] = []
|
||
|
||
|
||
class ChaptersGenerateOut(BaseModel):
|
||
created: list[ChapterOut] = []
|
||
skipped_duplicates: list[str] = []
|
||
message: str = ""
|
||
|
||
|
||
class ChaptersOrganizeIn(BaseModel):
|
||
reference_text: str = Field(min_length=1)
|
||
channel_id: int | None = None
|
||
|
||
|
||
class ProfileUpdate(BaseModel):
|
||
display_name: str = Field(min_length=1, max_length=64)
|
||
|
||
|
||
class KnowledgeGraphNode(BaseModel):
|
||
id: int
|
||
name: str
|
||
domain: str
|
||
category: str
|
||
description: str
|
||
chapter_count: int = 0
|
||
question_count: int = 0
|
||
resources: list[KnowledgeResourceOut] = []
|
||
|
||
|
||
class KnowledgeGraphEdge(BaseModel):
|
||
source: int
|
||
target: int
|
||
relation_type: str
|
||
|
||
|
||
class KnowledgeGraphOut(BaseModel):
|
||
nodes: list[KnowledgeGraphNode] = []
|
||
edges: list[KnowledgeGraphEdge] = []
|
||
|
||
|
||
class KnowledgeMapNode(BaseModel):
|
||
"""学生视角的知识节点:结构 + 本人掌握度,一次给全量。"""
|
||
id: int
|
||
name: str
|
||
domain: str
|
||
category: str
|
||
description: str = ""
|
||
mastery: int = 0
|
||
status: str = "重点"
|
||
# 有没有练过:没练过的不该被当成「薄弱」,界面上单独表达
|
||
practiced: bool = False
|
||
chapter_count: int = 0
|
||
question_count: int = 0
|
||
|
||
|
||
class KnowledgeMapOut(BaseModel):
|
||
nodes: list[KnowledgeMapNode] = []
|
||
edges: list[KnowledgeGraphEdge] = []
|
||
|
||
|
||
class KnowledgeNodeCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=64)
|
||
domain: str = "初等"
|
||
category: str = ""
|
||
description: str = ""
|
||
|
||
|
||
class KnowledgeNodeUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=64)
|
||
domain: str | None = None
|
||
category: str | None = None
|
||
description: str | None = None
|
||
|
||
|
||
class KnowledgeRelationIn(BaseModel):
|
||
source_id: int
|
||
target_id: int
|
||
relation_type: str = "前置"
|