"""documents 테이블 ORM""" from datetime import date, datetime from pgvector.sqlalchemy import Vector from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column # Note: file_type='note' (메모) 문서는 file_path=NULL, file_hash=content SHA-256 from core.database import Base class Document(Base): __tablename__ = "documents" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # 1계층: 원본 파일 file_path: Mapped[str | None] = mapped_column(Text, nullable=True) file_hash: Mapped[str] = mapped_column(String(64), nullable=False) file_format: Mapped[str] = mapped_column(String(20), nullable=False) file_size: Mapped[int | None] = mapped_column(BigInteger) file_type: Mapped[str] = mapped_column( Enum("immutable", "editable", "note", name="doc_type"), default="immutable" ) import_source: Mapped[str | None] = mapped_column(Text) # 1계층: 원본명 + 중복검사 (S1-ADD, migration 287) # original_filename = 업로드 원본 파일명(다운로드 라벨용). file_path 는 충돌 시 _N 리네임됨. # cf. original_format(ODF 변환용) / original_path·original_hash(007 legacy dead) 와 의미 구분. # duplicate_of = canonical doc id (자기 자신이 canonical 이면 NULL). FK ON DELETE SET NULL. # duplicate_count = canonical 행에 담는 '본인 제외 동일 판정 사본 수' (group_size-1). 업로드/backfill 가 갱신. original_filename: Mapped[str | None] = mapped_column(Text) duplicate_of: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("documents.id", ondelete="SET NULL") ) duplicate_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0" ) # 2계층: 텍스트 추출 extracted_text: Mapped[str | None] = mapped_column(Text) extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) extractor_version: Mapped[str | None] = mapped_column(String(50)) # 2계층: 추출 메타 (OCR 판정/실행) extract_meta: Mapped[dict | None] = mapped_column(JSONB, default=dict) ocr_derived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) # 2계층: AI 가공 ai_summary: Mapped[str | None] = mapped_column(Text) ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[]) ai_domain: Mapped[str | None] = mapped_column(String(100)) ai_sub_group: Mapped[str | None] = mapped_column(String(100)) ai_model_version: Mapped[str | None] = mapped_column(String(50)) ai_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) document_type: Mapped[str | None] = mapped_column(String(50)) importance: Mapped[str | None] = mapped_column(String(20), default="medium") ai_confidence: Mapped[float | None] = mapped_column() # Memo Intake Upgrade PR-2B — Gemma 4B triage 가 추론한 메모 의도 분류 hint # ('note' | 'task' | 'calendar_event' | 'activity_log' | 'reference') # AI 자동 events 생성 X — 사용자 1-click promote 시점에만 events row 생성 (안전 boundary). ai_event_kind: Mapped[str | None] = mapped_column( Enum("note", "task", "calendar_event", "activity_log", "reference", name="event_kind_hint") ) ai_event_confidence: Mapped[float | None] = mapped_column() # 3계층: 벡터 임베딩 embedding = mapped_column(Vector(1024), nullable=True) embed_model_version: Mapped[str | None] = mapped_column(String(50)) embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 사용자 메모 user_note: Mapped[str | None] = mapped_column(Text) # 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력) user_tags: Mapped[list | None] = mapped_column(JSONB, default=[]) # 핀 고정 pinned: Mapped[bool] = mapped_column(Boolean, default=False) # /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외) ask_includable: Mapped[bool] = mapped_column(Boolean, default=True) # 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음) archived: Mapped[bool] = mapped_column(Boolean, default=False) # 메모 체크박스별 메타 — {"": {"checked_at": ""}} # UI에서 체크 후 10초 경과 항목 숨김 판정에 사용. file_type='note'에서만 의미 있음. memo_task_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) # ODF 변환 derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/) original_format: Mapped[str | None] = mapped_column(String(20)) conversion_status: Mapped[str | None] = mapped_column(String(20), default="none") # 읽음 상태 (뉴스용) is_read: Mapped[bool | None] = mapped_column(Boolean, default=False) # 승인/삭제 review_status: Mapped[str | None] = mapped_column(String(20), default="pending") deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 외부 편집 URL edit_url: Mapped[str | None] = mapped_column(Text) # 미리보기 preview_status: Mapped[str | None] = mapped_column(String(20), default="none") preview_hash: Mapped[str | None] = mapped_column(String(64)) preview_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 메타데이터 source_channel: Mapped[str | None] = mapped_column( Enum("law_monitor", "devonagent", "email", "web_clip", "tksafety", "inbox_route", "manual", "drive_sync", "news", "memo", "voice", "hermes", "crawl", name="source_channel") ) # 외부 채널 (Hermes Discord 등) 의 channel/user/message_id/timestamp 메타. # extract_meta (OCR 전용) 와 분리. source_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) data_origin: Mapped[str | None] = mapped_column( Enum("work", "external", name="data_origin") ) # 용도 구분 (우선순위: 수동 수정 > 업로드 명시값 > AI 추론) doc_purpose: Mapped[str | None] = mapped_column( Enum("business", "knowledge", name="document_purpose") ) title: Mapped[str | None] = mapped_column(Text) # 카테고리 (1차 진입점 — UI 탭/라우트 분기) # 7 활성: document / library / news / memo / audio / video / law # 3 유보: mail / calendar / plex category: Mapped[str | None] = mapped_column( Enum("document", "library", "news", "memo", "audio", "video", "law", "mail", "calendar", "plex", name="doc_category", create_type=False) ) # AI 가 제안했지만 미승인된 변경 후보 (category / path / doctype) # /accept-suggestion 승인 시에만 category / user_tags 반영 (자동 전이 금지) ai_suggestion: Mapped[dict | None] = mapped_column(JSONB) # === 안전 자료실 분류 축 (plan safety-library-1, migrations 340~345) === # 자료유형 — law/paper/book/incident/manual/standard/guide (TEXT+CHECK, enum 아님). # 수집기 ingest 시점 deterministic 부여 (classify-skip 경로 다수 — classify_worker 의존 금지). # AI 라우팅(subject_domain) 매칭 키 사용 금지 (axis separation — category 와 동일 불변식). material_type: Mapped[str | None] = mapped_column(Text) # 관할 — KR/US/EU/JP/GB/INT. law 는 CHECK 로 jurisdiction NOT NULL 구조 강제 (migration 344). jurisdiction: Mapped[str | None] = mapped_column(Text) # 유형별 대표 날짜 — 법령=COALESCE(시행일, 공포일) / 논문=발행일 / 재해=발생일 published_date: Mapped[date | None] = mapped_column(Date) # PR-B B-1: summary_triage (4B, 상시) / summary_deep (26B, 에스컬레이션) 분할 산출 ai_tldr: Mapped[str | None] = mapped_column(Text) # ≤60자 TL;DR ai_bullets: Mapped[list | None] = mapped_column(JSONB) # 3~5개 핵심 bullets ai_detail_summary: Mapped[str | None] = mapped_column(Text) # 26B 2~3문단 ai_inconsistencies: Mapped[list | None] = mapped_column(JSONB) # [{kind, desc}] # 'triage' | 'deep' | NULL — 현재 문서가 어느 tier 까지 분석 완료됐는지 ai_analysis_tier: Mapped[str | None] = mapped_column(String(10)) # 비디오 썸네일 (§3) — ffmpeg 50% 지점 1장. PKM/Videos/.thumbs/{id}.jpg 절대경로. thumbnail_path: Mapped[str | None] = mapped_column(Text) # NAS 드롭된 mov/mkv/avi quarantine 플래그 (§3). true 면 재생 불가 안내만 표시. needs_conversion: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # facet 탐색 축 (Phase 2) facet_company: Mapped[str | None] = mapped_column(Text) facet_topic: Mapped[str | None] = mapped_column(Text) facet_year: Mapped[int | None] = mapped_column(Integer) facet_doctype: Mapped[str | None] = mapped_column(Text) # === Phase 1A canonical Markdown layer columns (migrations 211~219) === # plan: ~/.claude/plans/plan-idempotent-sundae.md md_content: Mapped[str | None] = mapped_column(Text) md_frontmatter: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) md_format_version: Mapped[str] = mapped_column(Text, nullable=False, default='1.0') md_status: Mapped[str] = mapped_column(Text, nullable=False, default='pending') md_extraction_engine: Mapped[str | None] = mapped_column(Text) md_extraction_engine_version: Mapped[str | None] = mapped_column(Text) md_extraction_quality: Mapped[dict | None] = mapped_column(JSONB) md_extraction_error: Mapped[str | None] = mapped_column(Text) md_content_hash: Mapped[str | None] = mapped_column(Text) md_source_hash: Mapped[str | None] = mapped_column(Text) md_generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) content_origin: Mapped[str] = mapped_column(Text, nullable=False, default='extracted') md_draft_status: Mapped[str | None] = mapped_column(Text) # 타임스탬프 created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.now, onupdate=datetime.now )