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