"""知识域模型(M3):knowledge_doc。 三条设计约束(对应 PRODUCT-PLAN 的 D1/D3): 1. **知识也是 `item`**。标题、正文、标签、评论、事件流全部复用;`knowledge_doc` 只补"从哪来的、是什么类型、什么时候被人认可过"。同一套列表、搜索、详情、权限对知识依然成立。 2. **知识必须有来源**(D3)。`source_kind` 为 `task` / `ticket` 时必须给出 `source_item_id`, 只有 `manual`(自己记的笔记)允许留空。这条写在数据库 CHECK 里,不靠 API 自律—— 将来接 LLM 自动归档时,没有来源的知识等于"没人能核对的断言",比没有更糟。 3. **状态机是 `draft → in_review → published → archived`**。草稿可以随时改,发布的要有 人来点头(`published_at` 记时间),归档的不删只藏(历史结论不该消失,只该退出检索)。 """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( CheckConstraint, DateTime, Float, ForeignKey, Index, String, Text, UniqueConstraint, ) from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import UUID as PgUUID from sqlalchemy.orm import Mapped, mapped_column from .models import Base, Timestamps, new_uuid # 知识状态机(与任务/工单共用 item.status 一列,取值集合不同) KNOWLEDGE_STATUSES: tuple[str, ...] = ("draft", "in_review", "published", "archived") DOC_TYPES: tuple[str, ...] = ("note", "howto", "faq", "postmortem", "decision") SOURCE_KINDS: tuple[str, ...] = ("manual", "task", "ticket") GENERATED_BY: tuple[str, ...] = ("human", "rule") class KnowledgeDoc(Base, Timestamps): """知识的附加信息,与 item 一比一。""" __tablename__ = "knowledge_doc" __table_args__ = ( Index("ix_knowledge_ws_status", "workspace_id", "status_hint"), Index("ix_knowledge_ws_source", "workspace_id", "source_item_id"), CheckConstraint( "source_kind = 'manual' OR source_item_id IS NOT NULL", name="ck_knowledge_source_required", ), CheckConstraint("chunk_count >= 0", name="ck_knowledge_chunk_count"), ) # item_id 既是主键又是外键:一条 item 最多一条 knowledge_doc,天然 1:1 item_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), primary_key=True ) workspace_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False ) author_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="RESTRICT"), nullable=False ) # 知识的来源。NULL 只允许出现在 source_kind='manual' 上(见 CHECK) source_item_id: Mapped[uuid.UUID | None] = mapped_column( PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL") ) source_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") doc_type: Mapped[str] = mapped_column(String(16), nullable=False, default="note") # 冗余一份状态:列表页要按知识自己的口径筛,不想每次都回 item 表 status_hint: Mapped[str] = mapped_column(String(16), nullable=False, default="draft") summary: Mapped[str] = mapped_column(Text, nullable=False, default="") generated_by: Mapped[str] = mapped_column(String(16), nullable=False, default="human") published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) chunk_count: Mapped[int] = mapped_column(nullable=False, default=0) class Chunk(Base): """知识正文的切片。 为什么切片与文档同库:检索要按工作区过滤,跨库做权限校验就得把两边数据捞到应用里, 既不安全也不快。向量本身是普通数组,换 pgvector 时只换列类型与索引。 """ __tablename__ = "chunk" __table_args__ = ( UniqueConstraint("item_id", "idx", name="uq_chunk_item_idx"), Index("ix_chunk_ws_item", "workspace_id", "item_id"), {"comment": "知识正文切片,检索的最小单位"}, ) id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) workspace_id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), nullable=False) item_id: Mapped[uuid.UUID] = mapped_column( PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="CASCADE"), nullable=False ) # 片内顺序,用于把命中片段按原顺序还原上下文 idx: Mapped[int] = mapped_column(nullable=False) heading: Mapped[str | None] = mapped_column(String(200)) content: Mapped[str] = mapped_column(Text, nullable=False) char_count: Mapped[int] = mapped_column(nullable=False, default=0) # 向量。hash 提供方是 256 维,OpenAI 兼容端点通常是 1024/1536 维, # 所以不固定维度——换模型时老切片要么重算,要么维度不匹配(检索会拒绝并提示重建)。 embedding: Mapped[list[float] | None] = mapped_column(ARRAY(Float)) embed_model: Mapped[str | None] = mapped_column(String(64)) KNOWLEDGE_TENANT_TABLES: tuple[str, ...] = ("knowledge_doc", "chunk")