Files
yawan-workbench/server/app/db.py
T

503 lines
28 KiB
Python
Raw Normal View History

"""数据库引擎与初始化"""
from pathlib import Path
import sqlite3
from sqlmodel import SQLModel, Session, create_engine, select
from sqlalchemy.pool import NullPool
from sqlalchemy import event
from .config import DB_PATH
import app.models # noqa: F401 确保所有表注册
from .models import Agent, Artifact, Campaign, KnowledgeUsage, QCReport, Segment, RoleDefinition
from .auth import ROLE_LABELS, ROLE_PERMISSIONS
engine = create_engine(f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False, "timeout": 30},
poolclass=NullPool)
@event.listens_for(engine, "connect")
def _configure_sqlite_connection(dbapi_connection, _connection_record):
"""Apply connection-level SQLite tuning for concurrent API workers.
The application uses ``NullPool`` so every request may open a fresh
connection. Applying these pragmas at connect time keeps the settings
consistent without requiring callers to remember to configure them.
WAL and a bounded busy timeout let readers continue while a writer is
committing; ``synchronous=NORMAL`` provides a useful throughput gain while
retaining SQLite's normal crash-safety guarantees.
"""
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA busy_timeout=30000")
# ``:memory:`` databases (used by tests) do not support WAL. Ignore
# that specific SQLite error while still applying the other pragmas.
try:
cursor.execute("PRAGMA journal_mode=WAL")
except sqlite3.DatabaseError:
pass
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA foreign_keys=ON")
finally:
cursor.close()
def _apply_column_migrations(conn):
"""Add missing columns without rebuilding tables or touching existing rows."""
migrations_by_table = {
"resourcerevision": {
"fork_resource_id": "ALTER TABLE resourcerevision ADD COLUMN fork_resource_id INTEGER DEFAULT 0",
"base_fingerprint": "ALTER TABLE resourcerevision ADD COLUMN base_fingerprint VARCHAR DEFAULT ''",
},
"trainingmap": {
"kb_category_ids": "ALTER TABLE trainingmap ADD COLUMN kb_category_ids JSON DEFAULT '{}'",
"coverage": "ALTER TABLE trainingmap ADD COLUMN coverage JSON DEFAULT '{}'",
"assessment_cancel_requested": "ALTER TABLE trainingmap ADD COLUMN assessment_cancel_requested BOOLEAN DEFAULT 0",
"agent_id": "ALTER TABLE trainingmap ADD COLUMN agent_id INTEGER",
},
"systemlog": {
"level": "ALTER TABLE systemlog ADD COLUMN level VARCHAR DEFAULT 'INFO'",
"source": "ALTER TABLE systemlog ADD COLUMN source VARCHAR DEFAULT 'app'",
"message": "ALTER TABLE systemlog ADD COLUMN message VARCHAR DEFAULT ''",
"trace_id": "ALTER TABLE systemlog ADD COLUMN trace_id VARCHAR DEFAULT ''",
"status": "ALTER TABLE systemlog ADD COLUMN status VARCHAR DEFAULT 'ok'",
"created_at": "ALTER TABLE systemlog ADD COLUMN created_at VARCHAR DEFAULT ''",
},
"campaignrun": {
"pause_requested": "ALTER TABLE campaignrun ADD COLUMN pause_requested BOOLEAN DEFAULT 0",
},
"contenttag": {
"organization_id": "ALTER TABLE contenttag ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE contenttag ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE contenttag ADD COLUMN visibility VARCHAR DEFAULT 'global'",
},
"agentskill": {
"organization_id": "ALTER TABLE agentskill ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE agentskill ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE agentskill ADD COLUMN visibility VARCHAR DEFAULT 'global'",
},
"agentprompttemplate": {
"organization_id": "ALTER TABLE agentprompttemplate ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE agentprompttemplate ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE agentprompttemplate ADD COLUMN visibility VARCHAR DEFAULT 'global'",
},
"knowledgefile": {
"ai_category_id": "ALTER TABLE knowledgefile ADD COLUMN ai_category_id INTEGER",
"classification_status": "ALTER TABLE knowledgefile ADD COLUMN classification_status VARCHAR DEFAULT 'unclassified'",
"ai_confidence": "ALTER TABLE knowledgefile ADD COLUMN ai_confidence FLOAT DEFAULT 0",
"ai_reason": "ALTER TABLE knowledgefile ADD COLUMN ai_reason VARCHAR DEFAULT ''",
"content_hash": "ALTER TABLE knowledgefile ADD COLUMN content_hash VARCHAR DEFAULT ''",
"revision": "ALTER TABLE knowledgefile ADD COLUMN revision INTEGER DEFAULT 1",
},
"knowledgebase": {
"organization_id": "ALTER TABLE knowledgebase ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE knowledgebase ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE knowledgebase ADD COLUMN visibility VARCHAR DEFAULT 'global'",
"chunk_target": "ALTER TABLE knowledgebase ADD COLUMN chunk_target INTEGER DEFAULT 0",
"chunk_mode": "ALTER TABLE knowledgebase ADD COLUMN chunk_mode VARCHAR DEFAULT 'auto'",
},
"knowledgeusage": {
"banned": "ALTER TABLE knowledgeusage ADD COLUMN banned BOOLEAN DEFAULT 0",
},
"agent": {
"kb_category_ids": "ALTER TABLE agent ADD COLUMN kb_category_ids JSON DEFAULT '[]'",
"fusion_agent_ids": "ALTER TABLE agent ADD COLUMN fusion_agent_ids JSON DEFAULT '[]'",
"prompt_template_id": "ALTER TABLE agent ADD COLUMN prompt_template_id INTEGER",
"model_catalog_id": "ALTER TABLE agent ADD COLUMN model_catalog_id INTEGER",
"allowed_tools": "ALTER TABLE agent ADD COLUMN allowed_tools JSON DEFAULT '[]'",
"organization_id": "ALTER TABLE agent ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE agent ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE agent ADD COLUMN visibility VARCHAR DEFAULT 'global'",
"runtime_snapshot": "ALTER TABLE agent ADD COLUMN runtime_snapshot JSON",
"persona_json": "ALTER TABLE agent ADD COLUMN persona_json JSON",
"digital_human_id": "ALTER TABLE agent ADD COLUMN digital_human_id INTEGER",
},
"digitalhuman": {
"visibility": "ALTER TABLE digitalhuman ADD COLUMN visibility VARCHAR DEFAULT 'private'",
"persona_meta": "ALTER TABLE digitalhuman ADD COLUMN persona_meta JSON DEFAULT '{}'",
},
"modelprovider": {
"organization_id": "ALTER TABLE modelprovider ADD COLUMN organization_id INTEGER DEFAULT 1",
},
"digitalhumangrant": {
"enabled": "ALTER TABLE digitalhumangrant ADD COLUMN enabled BOOLEAN DEFAULT 1",
},
"canvas": {
"organization_id": "ALTER TABLE canvas ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE canvas ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE canvas ADD COLUMN visibility VARCHAR DEFAULT 'global'",
"supervisor_agent_id": "ALTER TABLE canvas ADD COLUMN supervisor_agent_id INTEGER",
"supervisor_mode": "ALTER TABLE canvas ADD COLUMN supervisor_mode VARCHAR DEFAULT 'on_drift'",
},
"campaign": {
"approved_plan_version_id": "ALTER TABLE campaign ADD COLUMN approved_plan_version_id INTEGER",
"organization_id": "ALTER TABLE campaign ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE campaign ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE campaign ADD COLUMN visibility VARCHAR DEFAULT 'private'",
"objective": "ALTER TABLE campaign ADD COLUMN objective VARCHAR DEFAULT ''",
"success_criteria": "ALTER TABLE campaign ADD COLUMN success_criteria VARCHAR DEFAULT ''",
"duration_minutes": "ALTER TABLE campaign ADD COLUMN duration_minutes INTEGER DEFAULT 0",
"qc_mode": "ALTER TABLE campaign ADD COLUMN qc_mode VARCHAR DEFAULT 'full'",
},
"pendingmemory": {
"organization_id": "ALTER TABLE pendingmemory ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE pendingmemory ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE pendingmemory ADD COLUMN visibility VARCHAR DEFAULT 'private'",
},
"memory": {
"organization_id": "ALTER TABLE memory ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE memory ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE memory ADD COLUMN visibility VARCHAR DEFAULT 'private'",
"title": "ALTER TABLE memory ADD COLUMN title VARCHAR DEFAULT ''",
"description": "ALTER TABLE memory ADD COLUMN description VARCHAR DEFAULT ''",
"privacy": "ALTER TABLE memory ADD COLUMN privacy VARCHAR DEFAULT 'internal'",
"tags": "ALTER TABLE memory ADD COLUMN tags JSON DEFAULT '[]'",
"timestamp": "ALTER TABLE memory ADD COLUMN timestamp VARCHAR DEFAULT ''",
"index_status": "ALTER TABLE memory ADD COLUMN index_status VARCHAR DEFAULT 'indexed'",
"usage_count": "ALTER TABLE memory ADD COLUMN usage_count INTEGER DEFAULT 0",
"last_used_at": "ALTER TABLE memory ADD COLUMN last_used_at VARCHAR DEFAULT ''",
"supersedes_id": "ALTER TABLE memory ADD COLUMN supersedes_id INTEGER DEFAULT 0",
},
"appuser": {
"organization_id": "ALTER TABLE appuser ADD COLUMN organization_id INTEGER DEFAULT 1",
},
"roledefinition": {
"organization_id": "ALTER TABLE roledefinition ADD COLUMN organization_id INTEGER DEFAULT 1",
},
"segment": {
"agent_assignment_mode": "ALTER TABLE segment ADD COLUMN agent_assignment_mode VARCHAR DEFAULT 'auto'",
"generation_history": "ALTER TABLE segment ADD COLUMN generation_history JSON DEFAULT '[]'",
"fusion_agent_ids": "ALTER TABLE segment ADD COLUMN fusion_agent_ids JSON DEFAULT '[]'",
"runtime_snapshot": "ALTER TABLE segment ADD COLUMN runtime_snapshot JSON DEFAULT '{}'",
"kb_category_ids": "ALTER TABLE segment ADD COLUMN kb_category_ids JSON DEFAULT '[]'",
"kb_category_recommendation": "ALTER TABLE segment ADD COLUMN kb_category_recommendation JSON DEFAULT '{}'",
},
"llmcall": {
"run_id": "ALTER TABLE llmcall ADD COLUMN run_id INTEGER DEFAULT 0",
"trace_id": "ALTER TABLE llmcall ADD COLUMN trace_id VARCHAR DEFAULT ''",
"span_id": "ALTER TABLE llmcall ADD COLUMN span_id VARCHAR DEFAULT ''",
"attempt": "ALTER TABLE llmcall ADD COLUMN attempt INTEGER DEFAULT 1",
"prompt_hash": "ALTER TABLE llmcall ADD COLUMN prompt_hash VARCHAR DEFAULT ''",
"output_hash": "ALTER TABLE llmcall ADD COLUMN output_hash VARCHAR DEFAULT ''",
"user_id": "ALTER TABLE llmcall ADD COLUMN user_id INTEGER DEFAULT 0",
"organization_id": "ALTER TABLE llmcall ADD COLUMN organization_id INTEGER DEFAULT 1",
"usage_event_id": "ALTER TABLE llmcall ADD COLUMN usage_event_id INTEGER",
"billing_status": "ALTER TABLE llmcall ADD COLUMN billing_status VARCHAR DEFAULT 'unbilled'",
},
"campaignrun": {
"user_id": "ALTER TABLE campaignrun ADD COLUMN user_id INTEGER DEFAULT 0",
"organization_id": "ALTER TABLE campaignrun ADD COLUMN organization_id INTEGER DEFAULT 1",
"trace_id": "ALTER TABLE campaignrun ADD COLUMN trace_id VARCHAR DEFAULT ''",
"runtime_version": "ALTER TABLE campaignrun ADD COLUMN runtime_version VARCHAR DEFAULT 'v1'",
"pause_requested": "ALTER TABLE campaignrun ADD COLUMN pause_requested BOOLEAN DEFAULT 0",
},
"runevent": {
"trace_id": "ALTER TABLE runevent ADD COLUMN trace_id VARCHAR DEFAULT ''",
"span_id": "ALTER TABLE runevent ADD COLUMN span_id VARCHAR DEFAULT ''",
"parent_span_id": "ALTER TABLE runevent ADD COLUMN parent_span_id VARCHAR DEFAULT ''",
"actor_agent_id": "ALTER TABLE runevent ADD COLUMN actor_agent_id INTEGER DEFAULT 0",
"actor_name": "ALTER TABLE runevent ADD COLUMN actor_name VARCHAR DEFAULT ''",
"tool_name": "ALTER TABLE runevent ADD COLUMN tool_name VARCHAR DEFAULT ''",
},
"qcreport": {
"run_id": "ALTER TABLE qcreport ADD COLUMN run_id INTEGER DEFAULT 0",
"trace_id": "ALTER TABLE qcreport ADD COLUMN trace_id VARCHAR DEFAULT ''",
"runtime_snapshot": "ALTER TABLE qcreport ADD COLUMN runtime_snapshot JSON DEFAULT '{}'",
},
"artifact": {
"organization_id": "ALTER TABLE artifact ADD COLUMN organization_id INTEGER DEFAULT 1",
"owner_user_id": "ALTER TABLE artifact ADD COLUMN owner_user_id INTEGER DEFAULT 0",
"visibility": "ALTER TABLE artifact ADD COLUMN visibility VARCHAR DEFAULT 'private'",
"run_id": "ALTER TABLE artifact ADD COLUMN run_id INTEGER DEFAULT 0",
"trace_id": "ALTER TABLE artifact ADD COLUMN trace_id VARCHAR DEFAULT ''",
"runtime_snapshot": "ALTER TABLE artifact ADD COLUMN runtime_snapshot JSON DEFAULT '{}'",
},
}
for table, migrations in migrations_by_table.items():
columns = {row[1] for row in conn.exec_driver_sql(f"PRAGMA table_info({table})").fetchall()}
for name, statement in migrations.items():
if name not in columns:
conn.exec_driver_sql(statement)
def init_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
SQLModel.metadata.create_all(engine)
with engine.begin() as conn:
_apply_column_migrations(conn)
# 用户对全局资源的修改/删除提案。该表在旧生产库中可能不存在;
# SQLModel.metadata.create_all 只会覆盖新表,因此这里显式补建并建立
# 常用筛选索引,保证版本审核接口升级后可直接使用。
conn.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS resourcerevision ("
"id INTEGER PRIMARY KEY,"
"resource_type VARCHAR NOT NULL,"
"resource_id INTEGER NOT NULL,"
"fork_resource_id INTEGER NOT NULL DEFAULT 0,"
"owner_user_id INTEGER NOT NULL,"
"organization_id INTEGER NOT NULL DEFAULT 1,"
"operation VARCHAR NOT NULL DEFAULT 'update',"
"base_version INTEGER NOT NULL DEFAULT 1,"
"base_fingerprint VARCHAR NOT NULL DEFAULT '',"
"version INTEGER NOT NULL DEFAULT 1,"
"payload JSON NOT NULL DEFAULT '{}',"
"status VARCHAR NOT NULL DEFAULT 'pending',"
"reviewer_user_id INTEGER NOT NULL DEFAULT 0,"
"review_note VARCHAR NOT NULL DEFAULT '',"
"created_at VARCHAR NOT NULL DEFAULT '',"
"reviewed_at VARCHAR NOT NULL DEFAULT ''"
")"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resourcerevision_resource "
"ON resourcerevision (resource_type, resource_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resourcerevision_status "
"ON resourcerevision (status, organization_id)"
)
conn.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS resourcefork ("
"id INTEGER PRIMARY KEY, resource_type VARCHAR NOT NULL, source_id INTEGER NOT NULL,"
"copy_id INTEGER NOT NULL, owner_user_id INTEGER NOT NULL, organization_id INTEGER NOT NULL DEFAULT 1,"
"base_version INTEGER NOT NULL DEFAULT 1, base_fingerprint VARCHAR NOT NULL DEFAULT '', created_at VARCHAR NOT NULL DEFAULT '')"
)
# 历史记忆没有沉淀日期时,沿用创建时间
conn.exec_driver_sql("UPDATE memory SET timestamp = created_at WHERE COALESCE(timestamp, '') = ''")
# Memory 使用本地文本召回,没有外部向量索引。旧版本编辑元数据留下的
# needs_reindex 没有后台消费者,恢复这些记忆,避免被永久静默排除。
conn.exec_driver_sql("UPDATE memory SET index_status = 'indexed' WHERE index_status = 'needs_reindex'")
# 所有历史列补齐后再读取/写入角色种子,避免升级期间按新模型查询旧表结构。
with Session(engine) as session:
for role, permissions in ROLE_PERMISSIONS.items():
if not session.exec(select(RoleDefinition).where(RoleDefinition.role == role)).first():
session.add(RoleDefinition(role=role, label=ROLE_LABELS.get(role, role),
description=f"{ROLE_LABELS.get(role, role)}默认权限",
permissions=sorted(permissions), organization_id=1))
session.commit()
# 工作台拥有并幂等播种内置工作流模板;DSH Planner 只能在模板边界内提案。
with Session(engine) as session:
from .planner import ensure_default_workflow_template
ensure_default_workflow_template(session)
# 同一 Agent 对同一知识库片段只能保留一条状态记录,作为并发认领的数据库约束。
with engine.begin() as conn:
# 兼容旧版本删除 Provider 后遗留的模型目录,避免用户侧读取到无来源的模型。
conn.exec_driver_sql(
"DELETE FROM modelcatalog WHERE provider_id NOT IN (SELECT id FROM modelprovider)"
)
# 早期版本没有唯一约束,先保留最早一条记录再创建索引,避免升级时因历史重复数据失败。
duplicate_groups = conn.exec_driver_sql(
"SELECT agent_id, kb_id, chunk_id, MIN(id) AS keep_id "
"FROM knowledgeusage GROUP BY agent_id, kb_id, chunk_id HAVING COUNT(*) > 1"
).fetchall()
for agent_id, kb_id, chunk_id, keep_id in duplicate_groups:
conn.exec_driver_sql(
"DELETE FROM knowledgeusage WHERE agent_id = ? AND kb_id = ? AND chunk_id = ? AND id <> ?",
(agent_id, kb_id, chunk_id, keep_id),
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_knowledge_usage_agent_chunk "
"ON knowledgeusage (agent_id, kb_id, chunk_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_campaign_active_run "
"ON campaignrun (campaign_id) WHERE status IN ('queued', 'running')"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_agentsession_run_segment "
"ON agentsession (run_id, segment_id, created_at)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_qcreport_run_trace "
"ON qcreport (run_id, trace_id, created_at)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_artifact_run_trace "
"ON artifact (run_id, trace_id, created_at)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_agent_prompt_template "
"ON agent (prompt_template_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kb_grant_user "
"ON knowledgebasegrant (kb_id, user_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_memory_grant_user "
"ON memorygrant (memory_id, user_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_canvas_grant_user "
"ON canvasgrant (canvas_id, user_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resource_scope_agent "
"ON agent (organization_id, owner_user_id, visibility)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resource_scope_kb "
"ON knowledgebase (organization_id, owner_user_id, visibility)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resource_scope_tag "
"ON contenttag (organization_id, owner_user_id, visibility)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resource_scope_skill "
"ON agentskill (organization_id, owner_user_id, visibility)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_resource_scope_prompt "
"ON agentprompttemplate (organization_id, owner_user_id, visibility)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_model_catalog_provider_name "
"ON modelcatalog (provider_id, model_name)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_model_catalog_enabled "
"ON modelcatalog (enabled, provider_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_usage_event_idempotency "
"ON usageevent (idempotency_key)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_usage_event_owner_created "
"ON usageevent (user_id, organization_id, created_at)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_usage_quota_owner_period "
"ON usagequota (user_id, organization_id, period)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_point_account_owner "
"ON pointaccount (user_id, organization_id)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_point_transaction_idempotency "
"ON pointtransaction (idempotency_key)"
)
# 高频列表与租户筛选的复合索引。单列索引虽可用,但无法覆盖常见的
# ``WHERE ... ORDER BY ...`` 组合,数据量增大后会退化为全表扫描。
for statement in (
"CREATE INDEX IF NOT EXISTS ix_systemlog_created_level "
"ON systemlog (created_at DESC, level)",
"CREATE INDEX IF NOT EXISTS ix_auditlog_created_actor "
"ON auditlog (created_at DESC, actor_user_id)",
"CREATE INDEX IF NOT EXISTS ix_kbfile_kb_category "
"ON knowledgefile (kb_id, category_id)",
"CREATE INDEX IF NOT EXISTS ix_kbcategory_tree "
"ON knowledgecategory (kb_id, parent_id, sort_order)",
"CREATE INDEX IF NOT EXISTS ix_kbusage_kb_status_time "
"ON knowledgeusage (kb_id, status, used_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_kbusage_agent_status_time "
"ON knowledgeusage (agent_id, status, used_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_agentskillbinding_agent_skill "
"ON agentskillbinding (agent_id, skill_id)",
"CREATE INDEX IF NOT EXISTS ix_agentgrant_agent_user "
"ON agentgrant (agent_id, user_id, enabled)",
"CREATE INDEX IF NOT EXISTS ix_kbgrant_kb_user "
"ON knowledgebasegrant (kb_id, user_id, enabled)",
"CREATE INDEX IF NOT EXISTS ix_memorygrant_memory_user "
"ON memorygrant (memory_id, user_id, enabled)",
"CREATE INDEX IF NOT EXISTS ix_canvasgrant_canvas_user "
"ON canvasgrant (canvas_id, user_id, enabled)",
"CREATE INDEX IF NOT EXISTS ix_runevent_run_time "
"ON runevent (run_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_taskqueue_status_lease "
"ON taskqueueitem (status, lease_until, created_at)",
"CREATE INDEX IF NOT EXISTS ix_llmcall_run_time "
"ON llmcall (run_id, created_at DESC)",
):
conn.exec_driver_sql(statement)
# 兼容旧数据:没有知识库的生产 Agent 不可参与生成,直到管理员完成挂接。
with Session(engine) as session:
invalid_agents = session.exec(select(Agent).where(
Agent.role == "production", Agent.status == "active"
)).all()
changed = False
for agent in invalid_agents:
if not agent.kb_ids:
agent.status = "disabled"
session.add(agent)
changed = True
if changed:
session.commit()
# 旧版本曾把 checks=[] 的模型返回当作 PASS。保留原始成稿,但一律改为需复检,
# 防止它们继续作为已验证产物被交付或复用。
empty_reports = session.exec(select(QCReport).where(QCReport.verdict == "PASS")).all()
affected_campaign_ids: set[int] = set()
for report in empty_reports:
if report.checks:
continue
report.verdict = "FAIL"
report.checks = [{
"id": "qc-empty-output", "name": "质检返回完整性", "status": "FAIL",
"issues": [{"desc": "历史质检返回空检查列表,系统已自动标记为需要复检", "suggestion": "重新运行本段质检"}],
}]
segment = session.get(Segment, report.segment_id)
if segment:
affected_campaign_ids.add(segment.campaign_id)
if segment.status == "approved":
segment.status = "human_review"
session.add(segment)
session.add(report)
if affected_campaign_ids:
for campaign_id in affected_campaign_ids:
campaign = session.get(Campaign, campaign_id)
if campaign and campaign.status == "done":
campaign.status = "human_review"
campaign.finished_at = ""
session.add(campaign)
for artifact in session.exec(select(Artifact).where(Artifact.campaign_id == campaign_id)).all():
artifact.final_status = "needs_recheck"
report = dict(artifact.compose_report or {})
report["safety_recheck"] = "历史段质检为空,组合产物需在重新审查后再交付"
artifact.compose_report = report
session.add(artifact)
# 进程在模型请求中断时,reserved 片段从未产出正文,启动后应自动归还。
for usage in session.exec(select(KnowledgeUsage).where(KnowledgeUsage.status == "reserved")).all():
usage.status = "available"
usage.released_at = usage.released_at or "startup-recovery"
session.add(usage)
session.commit()
# P0-4 租户边界数据库级强制:业务表 organization_id 必须 >= 0(0=全局资源),
# user 归属 organization_id 必须 >= 1。CHECK 约束让越权数据无法落库。
with engine.begin() as conn:
tenant_checks = {
"campaign": "campaign_tenant_check",
"knowledgebase": "knowledgebase_tenant_check",
"memory": "memory_tenant_check",
"agent": "agent_tenant_check",
"artifact": "artifact_tenant_check",
"canvas": "canvas_tenant_check",
"contenttag": "contenttag_tenant_check",
"agentskill": "agentskill_tenant_check",
"agentprompttemplate": "agentprompttemplate_tenant_check",
}
for table, constraint in tenant_checks.items():
exists = conn.exec_driver_sql(
"SELECT 1 FROM sqlite_master WHERE type='check_constraint' AND name=?",
(constraint,),
).fetchone()
if not exists:
# SQLite 不支持 ADD CONSTRAINT,重建表代价高;改用触发器实现同等级强制。
trigger = f"trg_{table}_tenant_ins"
trg_exists = conn.exec_driver_sql(
"SELECT 1 FROM sqlite_master WHERE type='trigger' AND name=?",
(trigger,),
).fetchone()
if not trg_exists:
conn.exec_driver_sql(
f"CREATE TRIGGER {trigger} BEFORE INSERT ON {table} "
f"WHEN NEW.organization_id IS NULL OR NEW.organization_id < 0 "
f"BEGIN SELECT RAISE(ABORT, '{table}.organization_id 必须为 0(全局)或正整数(租户)'); END"
)
def get_session():
with Session(_current_engine()) as s:
yield s
def _current_engine():
"""动态引擎:测试可以替换模块级 engine 变量,所有 Session 跟随。"""
return engine