Files
DadaoTask/backend/app/models_knowledge.py
T
steven 71982f9fa4
CI / backend (push) Canceled after 0s
feat(knowledge): 分类可改名/配色/删除,选中态与加载态更清楚
分类之前只能新增,建错了只能干看着。补上三件事:

- knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB),
  PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。
- 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。
- 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈,
  不再一动不动等请求回来。

顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
2026-11-02 19:24:44 +08:00

154 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识域模型(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")
# 分类树最多三级。再深就不是"归档"而是"迷宫"了:找东西的人记不住第四层叫什么。
MAX_CATEGORY_DEPTH = 3
class KnowledgeCategory(Base, Timestamps):
"""知识分类:一级大类 → 二级 → 三级,深不过三。
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
`color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让
"这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色),
但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。
"""
__tablename__ = "knowledge_category"
__table_args__ = (
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"),
{"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), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(40), nullable=False)
depth: Mapped[int] = mapped_column(nullable=False, default=1)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
color: Mapped[str | None] = mapped_column(String(7))
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"),
Index("ix_knowledge_ws_category", "workspace_id", "category_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")
# 分类可以为空("未分类");分类被删时知识不跟着消失,只是回到未分类
category_id: Mapped[uuid.UUID | None] = mapped_column(
PgUUID(as_uuid=True), ForeignKey("knowledge_category.id", ondelete="SET NULL")
)
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_category", "knowledge_doc", "chunk")