CI / backend (push) Canceled after 0s
分类之前只能新增,建错了只能干看着。补上三件事: - knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB), PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。 - 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。 - 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈, 不再一动不动等请求回来。 顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
154 lines
7.4 KiB
Python
154 lines
7.4 KiB
Python
"""知识域模型(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")
|