from datetime import datetime, timezone from pgvector.sqlalchemy import Vector from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint from sqlalchemy.types import TypeDecorator from .db import Base def now(): return datetime.now(timezone.utc) class Embedding(TypeDecorator): impl = JSON cache_ok = True def load_dialect_impl(self, dialect): return dialect.type_descriptor(Vector(1024) if dialect.name == "postgresql" else JSON()) class Course(Base): __tablename__ = "courses" id = Column(Integer, primary_key=True) name = Column(String(200), unique=True, nullable=False) code = Column(String(100), unique=True, nullable=False) created_at = Column(DateTime(timezone=True), default=now) class CourseVersion(Base): __tablename__ = "course_versions" id = Column(Integer, primary_key=True) course_id = Column(Integer, ForeignKey("courses.id"), nullable=False) version = Column(Integer, nullable=False) title = Column(String(200), nullable=False) objectives = Column(JSON, default=list, nullable=False) indicators = Column(JSON, default=list, nullable=False) directions = Column(JSON, default=list, nullable=False) prompts = Column(JSON, default=dict, nullable=False) templates = Column(JSON, default=list, nullable=False) published = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime(timezone=True), default=now) __table_args__ = (UniqueConstraint("course_id", "version"),) class CourseObjective(Base): __tablename__ = "course_objectives" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) code = Column(String(50), nullable=False) name = Column(String(300), nullable=False) description = Column(Text, default="") indicator_codes = Column(JSON, default=list) __table_args__ = (UniqueConstraint("course_version_id", "code"),) class GraduationIndicator(Base): __tablename__ = "graduation_indicators" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) code = Column(String(50), nullable=False) description = Column(Text, nullable=False) __table_args__ = (UniqueConstraint("course_version_id", "code"),) class TopicDirection(Base): __tablename__ = "topic_directions" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) name = Column(String(200), nullable=False) examples = Column(JSON, default=list) __table_args__ = (UniqueConstraint("course_version_id", "name"),) class RubricVersion(Base): __tablename__ = "rubric_versions" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) version = Column(Integer, nullable=False) criteria = Column(JSON, default=list, nullable=False) weights = Column(JSON, default=dict, nullable=False) published = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime(timezone=True), default=now) __table_args__ = (UniqueConstraint("course_version_id", "version"),) class RubricCriterion(Base): __tablename__ = "rubric_criteria" id = Column(Integer, primary_key=True) rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False) criterion_id = Column(String(100), nullable=False) category = Column(String(50), nullable=False) name = Column(String(300), nullable=False) description = Column(Text, nullable=False) max_score = Column(Float, nullable=False) linked_objectives = Column(JSON, default=list) linked_outcomes = Column(JSON, default=list) required_evidence = Column(JSON, default=list) __table_args__ = (UniqueConstraint("rubric_version_id", "criterion_id"),) class RubricScoreLevel(Base): __tablename__ = "rubric_score_levels" id = Column(Integer, primary_key=True) rubric_criterion_id = Column(Integer, ForeignKey("rubric_criteria.id"), nullable=False) min_score = Column(Float, nullable=False) max_score = Column(Float, nullable=False) description = Column(Text, nullable=False) class Student(Base): __tablename__ = "students" id = Column(Integer, primary_key=True) student_no = Column(String(80), unique=True, nullable=False) name = Column(String(100), nullable=False) class Project(Base): __tablename__ = "projects" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) student_id = Column(Integer, ForeignKey("students.id"), nullable=False) topic = Column(String(500), nullable=False) direction = Column(String(200), default="") source_dir = Column(String(1000), nullable=False) status = Column(String(40), default="imported") facts = Column(JSON, default=dict, nullable=False) created_at = Column(DateTime(timezone=True), default=now) __table_args__ = (UniqueConstraint("course_version_id", "source_dir"),) class Submission(Base): __tablename__ = "submissions" id = Column(Integer, primary_key=True) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) category = Column(String(50), nullable=False) status = Column(String(40), nullable=False) __table_args__ = (UniqueConstraint("project_id", "category"),) class SubmissionFile(Base): __tablename__ = "submission_files" id = Column(Integer, primary_key=True) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=True) category = Column(String(40), nullable=False) relative_path = Column(String(1200), nullable=False) mime_type = Column(String(100), nullable=False) size = Column(Integer, nullable=False) mtime = Column(Float, nullable=False) sha256 = Column(String(64), nullable=False) recognition_status = Column(String(40), nullable=False) parse_status = Column(String(40), default="pending") parse_error = Column(Text, default="") parsed = Column(JSON, default=dict, nullable=False) __table_args__ = (UniqueConstraint("project_id", "relative_path"),) class KnowledgeDocument(Base): __tablename__ = "knowledge_documents" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True) title = Column(String(500), nullable=False) document_type = Column(String(80), nullable=False) source_hash = Column(String(64), nullable=False) index_version = Column(Integer, nullable=False, default=5, server_default="5") revision = Column(Integer, nullable=False, default=1, server_default="1") status = Column(String(40), default="ready") __table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash", "revision", name="uq_knowledge_document_revision"),) class KnowledgePage(Base): __tablename__ = "knowledge_pages" document_id = Column(Integer, ForeignKey("knowledge_documents.id", ondelete="CASCADE"), primary_key=True) page = Column(Integer, primary_key=True) text = Column(Text, nullable=False, default="") image_path = Column(String(1200), nullable=True) # Relative to processed_path. # Page-level classification: cover, abstract, toc, body, references or unknown. page_type = Column(String(20), nullable=False, default="unknown", server_default="unknown") mixed = Column(Boolean, nullable=False, default=False, server_default="false") section = Column(String(500), default="") chapter = Column(String(500), default="") confidence = Column(Float, nullable=True) # Bounded classifier evidence: which signals decided the type, kept for audit. classification = Column(JSON, default=dict, nullable=False) class KnowledgeChunk(Base): __tablename__ = "knowledge_chunks" id = Column(Integer, primary_key=True) document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=False) course_version_id = Column(Integer, nullable=False) project_id = Column(Integer, nullable=True) document_type = Column(String(80), nullable=False) page = Column(Integer, nullable=False) section = Column(String(500), default="") chapter = Column(String(500), default="") page_type = Column(String(20), nullable=False, default="unknown", server_default="unknown") block_type = Column(String(20), nullable=False, default="body", server_default="body") text = Column(Text, nullable=False) embedding = Column(Embedding, nullable=True) class ScoringBatch(Base): __tablename__ = "scoring_batches" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) rubric_version_id = Column(Integer, ForeignKey("rubric_versions.id"), nullable=False) course_snapshot = Column(JSON, nullable=False) rubric_snapshot = Column(JSON, nullable=False) model_config_snapshot = Column(JSON, nullable=False) embedding_model_name = Column(String(200), nullable=False) embedding_model_version = Column(String(100), nullable=False) status = Column(String(40), default="created") project_ids = Column(JSON, nullable=True) # NULL identifies a legacy batch with unknown original membership. progress_done = Column(Integer, default=0) progress_total = Column(Integer, default=0) error = Column(Text, default="") created_at = Column(DateTime(timezone=True), default=now) finished_at = Column(DateTime(timezone=True), nullable=True) class CriterionScore(Base): __tablename__ = "criterion_scores" id = Column(Integer, primary_key=True) batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) criterion_id = Column(String(100), nullable=False) model_score = Column(Float, nullable=True) teacher_score = Column(Float, nullable=True) model_comment = Column(Text, default="") teacher_comment = Column(Text, nullable=True) confidence = Column(Float, nullable=True) missing_evidence = Column(JSON, default=list) suggestion = Column(Text, default="") status = Column(String(40), default="pending") error = Column(Text, default="") #: Content targets that still lacked body evidence, with the search that was run. #: Kept after teacher review so the original retrieval problem stays auditable. evidence_gaps = Column(JSON, default=list) __table_args__ = (UniqueConstraint("batch_id", "project_id", "criterion_id"),) @property def final_score(self): return self.teacher_score if self.teacher_score is not None else self.model_score class ScoreEvidence(Base): __tablename__ = "score_evidences" id = Column(Integer, primary_key=True) score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False) document_id = Column(Integer, ForeignKey("knowledge_documents.id"), nullable=True) submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True) page = Column(Integer, nullable=False) quote = Column(Text, nullable=False) image_path = Column(String(1200), nullable=True) # Which required-evidence target this quotation is offered to support. target_id = Column(String(50), default="", server_default="") target_label = Column(String(300), default="", server_default="") # Page type of the source passage, and its role in the judgement. page_type = Column(String(20), default="unknown", server_default="unknown") source_role = Column(String(20), default="content", server_default="content") # True when the quotation was written before this retrieval policy existed. historical = Column(Boolean, default=False, nullable=False, server_default="false") class TeacherRevision(Base): __tablename__ = "teacher_revisions" id = Column(Integer, primary_key=True) score_id = Column(Integer, ForeignKey("criterion_scores.id"), nullable=False) old_score = Column(Float, nullable=True) new_score = Column(Float, nullable=True) old_comment = Column(Text, nullable=True) new_comment = Column(Text, nullable=True) reason = Column(Text, default="") created_at = Column(DateTime(timezone=True), default=now) class ExportTask(Base): __tablename__ = "export_tasks" id = Column(Integer, primary_key=True) batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) kind = Column(String(20), nullable=False) path = Column(String(1200), nullable=True) status = Column(String(40), default="queued") error = Column(Text, default="") created_at = Column(DateTime(timezone=True), default=now) class AppSetting(Base): __tablename__ = "app_settings" key = Column(String(100), primary_key=True) encrypted_value = Column(LargeBinary, nullable=False) updated_at = Column(DateTime(timezone=True), default=now) class AuditLog(Base): __tablename__ = "audit_logs" id = Column(Integer, primary_key=True) action = Column(String(100), nullable=False) entity_type = Column(String(100), nullable=False) entity_id = Column(Integer, nullable=True) details = Column(JSON, default=dict) created_at = Column(DateTime(timezone=True), default=now) class IndexTask(Base): __tablename__ = "index_tasks" id = Column(Integer, primary_key=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=False) submission_file_id = Column(Integer, ForeignKey("submission_files.id"), nullable=True) kind = Column(String(30), nullable=False) status = Column(String(30), nullable=False, default="queued") result = Column(JSON, nullable=False, default=dict) error = Column(Text, default="") created_at = Column(DateTime(timezone=True), default=now) updated_at = Column(DateTime(timezone=True), default=now, onupdate=now) class Conversation(Base): __tablename__ = "conversations" id = Column(Integer, primary_key=True) title = Column(String(200), nullable=False, default="新会话") first_request_id = Column(String(36), nullable=True, unique=True) course_version_id = Column(Integer, ForeignKey("course_versions.id"), nullable=True) batch_id = Column(Integer, ForeignKey("scoring_batches.id"), nullable=True) project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) created_at = Column(DateTime(timezone=True), default=now) updated_at = Column(DateTime(timezone=True), default=now, onupdate=now) class ChatMessage(Base): __tablename__ = "chat_messages" id = Column(Integer, primary_key=True) conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) role = Column(String(20), nullable=False) content = Column(Text, nullable=False, default="") citations = Column(JSON, nullable=False, default=list) tool_calls = Column(JSON, nullable=False, default=list) created_at = Column(DateTime(timezone=True), default=now) class AgentRun(Base): __tablename__ = "agent_runs" id = Column(Integer, primary_key=True) conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) status = Column(String(30), nullable=False, default="running") error = Column(Text, default="") tool_calls = Column(JSON, nullable=False, default=list) events = Column(JSON, nullable=False, default=list) created_at = Column(DateTime(timezone=True), default=now) finished_at = Column(DateTime(timezone=True), nullable=True) class PendingAction(Base): __tablename__ = "pending_actions" id = Column(Integer, primary_key=True) conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False) kind = Column(String(40), nullable=False) payload = Column(JSON, nullable=False) preview = Column(JSON, nullable=False) expected = Column(JSON, nullable=False) idempotency_key = Column(String(64), nullable=False, unique=True) status = Column(String(30), nullable=False, default="pending") result = Column(JSON, nullable=True) created_at = Column(DateTime(timezone=True), default=now) expires_at = Column(DateTime(timezone=True), nullable=False)