feat: implement backend knowledge management

This commit is contained in:
2026-10-15 19:24:21 +08:00
parent b6adddee42
commit 6eb1d12580
33 changed files with 1604 additions and 55 deletions
+26
View File
@@ -0,0 +1,26 @@
from alembic import context
from sqlalchemy import create_engine, pool
from app import models # noqa: F401
from app.core.config import settings
from app.db.session import Base
config = context.config
def run_migrations(connection):
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
raise RuntimeError("迁移需要连接数据库,以检查并保留已有表;请使用 alembic upgrade head")
elif config.attributes.get("connection") is not None:
run_migrations(config.attributes["connection"])
else:
engine = create_engine(
settings.database_url, poolclass=pool.NullPool, connect_args={"connect_timeout": 5}
)
with engine.connect() as connection:
run_migrations(connection)
+17
View File
@@ -0,0 +1,17 @@
"""${message}"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,48 @@
"""Register the original knowledge table without replacing existing data."""
import sqlalchemy as sa
from alembic import op
revision = "0001_baseline"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
inspector = sa.inspect(bind)
if inspector.has_table("knowledge_items"):
columns = {column["name"] for column in inspector.get_columns("knowledge_items")}
expected = {
"id",
"title",
"category",
"content",
"source_url",
"verification_status",
"created_at",
}
if columns != expected:
raise RuntimeError("已有 knowledge_items 结构与基础版本不同,请先核对结构并备份")
return
op.create_table(
"knowledge_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(250), nullable=False),
sa.Column("category", sa.String(80), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source_url", sa.String(1000)),
sa.Column("verification_status", sa.String(30), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_knowledge_items_id", "knowledge_items", ["id"])
op.create_index("ix_knowledge_items_title", "knowledge_items", ["title"])
def downgrade():
op.drop_table("knowledge_items")
# 扩展可能由其他项目共享,不在回滚时删除。
+110
View File
@@ -0,0 +1,110 @@
"""Add source, document, review and audit management."""
import sqlalchemy as sa
from alembic import op
revision = "0002_catalog"
down_revision = "0001_baseline"
branch_labels = None
depends_on = None
def version_columns():
return [
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column("is_deleted", sa.Boolean(), server_default=sa.false(), nullable=False),
]
def upgrade():
op.create_table(
"sources",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(250), nullable=False),
sa.Column("source_url", sa.String(1000)),
sa.Column("notes", sa.Text(), nullable=False),
*version_columns(),
)
op.create_table(
"documents",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("source_id", sa.Integer(), sa.ForeignKey("sources.id"), nullable=False),
sa.Column("title", sa.String(250), nullable=False),
sa.Column("category", sa.String(80), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("collected_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("notes", sa.Text(), nullable=False),
*version_columns(),
)
op.create_index("ix_documents_source_id", "documents", ["source_id"])
op.create_table(
"audit_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=False),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("actor", sa.String(100)),
sa.Column("notes", sa.Text(), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
)
op.create_index("ix_audit_events_entity_type", "audit_events", ["entity_type"])
op.create_index("ix_audit_events_entity_id", "audit_events", ["entity_id"])
for column in version_columns():
if column.name != "created_at":
op.add_column("knowledge_items", column)
for name, kind in [
("source_id", sa.Integer()),
("document_id", sa.Integer()),
("collected_at", sa.DateTime(timezone=True)),
("reviewer", sa.String(100)),
("reviewed_at", sa.DateTime(timezone=True)),
("verification_notes", sa.Text()),
]:
op.add_column("knowledge_items", sa.Column(name, kind))
for name, parent in [("source_id", "sources"), ("document_id", "documents")]:
op.create_foreign_key(f"fk_knowledge_{name}", "knowledge_items", parent, [name], ["id"])
op.create_index(f"ix_knowledge_items_{name}", "knowledge_items", [name])
op.create_check_constraint(
"ck_knowledge_verification_status",
"knowledge_items",
"verification_status IN ('pending', 'verified', 'rejected')",
)
op.execute("""INSERT INTO audit_events (entity_type, entity_id, action, notes, snapshot)
SELECT 'knowledge_items', id, 'import', '保留基础版本数据及原状态',
json_build_object('item', row_to_json(k)) FROM knowledge_items k""")
op.execute("""UPDATE knowledge_items SET verification_status = 'pending', version = version + 1
WHERE verification_status = 'verified'""")
op.execute("""INSERT INTO audit_events (entity_type, entity_id, action, notes, snapshot)
SELECT 'knowledge_items', id, 'invalidate', '旧版本没有完整审核记录,需要重新核验',
json_build_object('item', row_to_json(k)) FROM knowledge_items k WHERE version = 2""")
def downgrade():
op.drop_constraint("ck_knowledge_verification_status", "knowledge_items", type_="check")
for name in ["source_id", "document_id"]:
op.drop_index(f"ix_knowledge_items_{name}", table_name="knowledge_items")
op.drop_constraint(f"fk_knowledge_{name}", "knowledge_items", type_="foreignkey")
for name in [
"source_id",
"document_id",
"collected_at",
"reviewer",
"reviewed_at",
"verification_notes",
"version",
"updated_at",
"is_deleted",
]:
op.drop_column("knowledge_items", name)
op.drop_table("audit_events")
op.drop_table("documents")
op.drop_table("sources")