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

503 lines
28 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.
"""数据库引擎与初始化"""
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