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