Files
AgentLab/backend/app/main.py
T
ThearchyHelios eddeded2a4 全栈安全加固、性能提速与架构演进:连接池与沙箱止血、双路混合检索、多版本归档及前端分包治理
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费
- 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定
- 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
2026-11-13 14:51:36 +08:00

475 lines
24 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 __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import (
artifacts, bundle, catalog, compare, conversations, copilot, datasources, evidence, governance, inquiry, knowledge,
runs, sandbox, schedules, settings as settings_api, source_versions, table_imports, tools, whatif, workflows,
)
# 应用平台(docs/design/app-platform-and-look.md)。权限按 §3.12 在这里挂路由级依赖(api/deps.require_role),
# 关登录时全部放行
from app.api import (
actions as actions_api, apps as apps_api, auth as auth_api, capabilities as capabilities_api, hooks as hooks_api,
identity as identity_api, ledger as ledger_api, notify as notify_api, tasks as tasks_api,
)
# 下一轮第 2 波(docs/design/next-round):评测、访问密钥
from app.api import access_keys as access_keys_api, evals as evals_api
# 第 3 波:分享链接、数据源的变化记录
from app.api import shares as shares_api, source_changes as source_changes_api
# 普通用户建应用(docs/design/normal-user-apps.md §3.10):蓝图、模板、文件、个人应用。权限在接口里按蓝图的范围和拥有者判
from app.api import (
blueprints as blueprints_api, copilot_blueprint, files as files_api, personal as personal_api,
templates as templates_api,
)
# 编排完善第 1 波(docs/design/orchestration-round §1):问数据的一轮在服务端跑
from app.api import chat_turns as chat_turns_api
# 增量重导入(K11):建一次导入、看工作项和冲突、接受选定字段、拒绝、人工对齐、恢复
from app.api import caliber_import as caliber_import_api
# 指标本体穿透(W1-a):标准业务实体与跨口径指标关系网
from app.api import ontology as ontology_api
# 独立语义指标库(即存即用指标卡片与在线试算)
from app.api import semantic_metrics as semantic_metrics_api
from app.api.coded import CodedHTTPException, coded_handler
from app.api.deps import chat_scope, evidence_scope, private_run_scope, require_role, role_by_method, run_scope
from app.auth.keys import key_gate
from app.auth.middleware import AuthMiddleware
from app.core.config import settings
from app.db.base import init_db
from app.engine.runner import run_manager
from app.seed import seed_defaults
from app.web_security import SecurityHeaders
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("agentlab")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
from app import cluster
from app.cluster import guard, mutex
from app.data.engine import sqlite_dqs_self_check
sqlite_dqs_self_check() # 写错的双引号列名必须报错;做不到就记日志并让启动失败
settings.ensure_dirs()
# SQLite 只支持单进程:同一个数据目录已经有一个进程在用(uvicorn --workers、两个服务指着同一个目录)就拒绝启动、照实说
# (app/cluster/guard.py)。PostgreSQL 可以多进程,什么都不做
guard.acquire()
# PgBouncer 事务模式(AGENTLAB_DB_PGBOUNCER=1)要另给直连地址,没给就拒绝启动(db/base.check_pgbouncer)
from app.db.base import check_pgbouncer, wait_for_database
check_pgbouncer()
# 库还没起来(和 PostgreSQL 容器一起起、库还在初始化)时先按退避等它:下面的版本检查就要连库,不等的话一上来就崩
await wait_for_database()
# 不许新旧版本混跑(app/cluster/version.py):库里还有活着的、版本不同的进程就不启动——在补列之前看
from app.cluster import version
await version.preflight()
await init_db()
# 多进程协调(app/cluster):登记这个工作进程、开始心跳和进程间通知、争调度当班。要在下面的启动整理之前:整理只碰属主不在了的
# 行,要先知道别的进程谁还活着
await cluster.start()
from app.cluster.relay import relay
await relay.start()
_register_reapers()
_register_cluster_hooks()
# 下面这一段是全站的一次性整理(补默认数据、补记级别、存量密钥加密、上传版本的清理和迁移……),各项都是幂等的;多进程同时
# 启动时一个接一个做(跨进程的锁),后起的看得到先起的做完的样子,不会重复插、互相删
async with mutex.lock("startup"):
await seed_defaults()
# 记级别之前发布过的老版本:有正式运行凭据的补记为已发布,免得子工作流、口径卡钉住它们的正式运行被误判成「没发布过」
from app.apps.versions import backfill_legacy_levels
from app.db.base import SessionLocal as _Sessions
async with _Sessions() as _s:
if levelled := await backfill_legacy_levels(_s):
logger.info("给 %d 个老的发布版本补记了级别(有正式运行凭据)", levelled)
await _s.commit()
await _platform_startup()
# 上传表格的版本底座:清理上次没发布完的文件、给老上传补初始版本、回收没人用的版本。
# 出错只记日志:最坏是留下几个多余的文件,不该让服务起不来
from app.data import table_versions
from app.db.base import SessionLocal
async with SessionLocal() as _s:
try:
await table_versions.startup(_s)
except Exception: # noqa: BLE001
logger.exception("上传表格的版本整理失败,服务照常启动")
# 把存的 embedding 配置读进来。不读的话重启后会悄悄退回本地哈希向量,
# 而库里的向量是上次那个模型建的——检索会整体退回关键词,
# 用户只看到"重启之后搜得不准了"
from app.memory.embeddings import load_setting
async with SessionLocal() as _session:
try:
await load_setting(_session)
except Exception as e: # noqa: BLE001 - key 没了不该拦住整个服务起不来
logger.warning("embedding 配置没能生效,暂用本地向量:%s", e)
# 打开断点库;上次被强杀时留下的运行(只碰属主不在了的:多进程时别的活着的进程在跑的一概不碰)——在跑的标中断、排队的标失败
await run_manager.setup()
# 接手属主不在了的:处理中的文档标失败;数据目录起草、剖析、探查、评测、影子起草标中断(不自动续跑:拷来的数据目录启动时不能
# 替人去调模型、查业务库,界面上点「重试」接着做);问数据的一轮(在 run_manager 之后:在跑的运行这时已经标成挂起)还在建图的
# 标中断,发起了运行的接着等它、照常复核收尾;待审批补建待办、停在 resolving 的收拾;结果未知的写入安排核实;执行中断的
# 发布单改成停下。各项的规矩见 _register_reapers 登记的各模块。启动时每个进程都做一次(同样一个接一个),之后当班进程隔一会儿
# 再做一次(别的进程没了的时候)
async with mutex.lock("startup"):
await cluster.service.startup_reap()
from app.sandbox.manager import sandbox_manager as _sbm
await _sbm.start_reaper()
# 定时任务的调度循环放在 run_manager 之后起:它到点要发起运行。拷来的数据目录(检查脚本的
# --stacks)用 AGENTLAB_SCHEDULER=0 关掉,免得替原库把定时任务再执行一遍。每个进程都起,只有调度当班的进程真的做
from app import scheduler
if scheduler.scheduler_enabled():
await scheduler.start()
logger.info("AgentLab 就绪 · 数据目录 %s", settings.data_dir)
try:
yield
finally:
# 先停调度,再停运行:停运行的途中不该再有新运行被发起。起草、剖析任务停下来标成中断,下次启动不会再续
await scheduler.stop()
from app.data import catalog_jobs, catalog_profile_jobs, entity_discovery, introspect_jobs, semantic_decompose
from app.chat import job as chat_job
from app.eval import runner as eval_runner, snapshots as eval_snapshots
from app.ledger import jobs as draft_jobs, reimport as import_jobs
await draft_jobs.manager.shutdown()
await import_jobs.manager.shutdown()
await catalog_jobs.manager.shutdown()
await catalog_profile_jobs.manager.shutdown()
await introspect_jobs.manager.shutdown()
await entity_discovery.shutdown()
await semantic_decompose.shutdown()
# 问数据的一轮停下不改那几行(下次启动、或者别的进程照「属主不在了」的规矩接手),也要在运行之前停
await chat_job.shutdown()
# 评测要在运行之前停:它停下时会取消自己发起的作答、算期望的运行,再标中断
await eval_runner.shutdown()
await eval_snapshots.manager.shutdown()
await run_manager.shutdown()
from app.sandbox.manager import sandbox_manager
await sandbox_manager.close()
# 交班、删掉本进程的登记:别的进程马上就知道本进程不在了,接手它留下的行(要用库,在关连接之前)
await relay.stop()
await cluster.stop()
# 应用库的连接全关掉:SQLite 最后一个连接关掉时才把 -wal 并回、删掉(copy-to-postgres 按它认服务停没停),
# PostgreSQL 的连接池也正常关;外部数据源缓存的连接池一并释放
from app.data.engine import engines
from app.db.base import dispose_session_engine, engine as db_engine
await engines.close()
await db_engine.dispose()
await dispose_session_engine()
logger.info("已停止")
def _register_cluster_hooks() -> None:
"""心跳里本进程自己的活、接班之后要补做的事(app/cluster/service.py)。"""
from app import cluster
from app.auth import keys
# 访问密钥的请求记录攒在每个进程自己的内存里(每个请求同步写一行会和运行抢 SQLite 的写锁):每个进程隔一拍心跳写自己的。
# 过了保留期的清理只在当班进程(调度循环的那一拍)
cluster.on_beat("访问密钥的请求记录", keys.flush)
def on_lead(now_leading: bool) -> None:
if not now_leading:
return
from app.ledger import match as ledger_match, recall as ledger_recall
from app.scheduler.loop import runner
# 接了调度班:马上补一拍;口径比对的补比、自动召回的续发也归当班进程(同 run_manager.setup 里启动时的那一次)
runner.poke()
ledger_match.start_backfill()
ledger_recall.start_resume()
cluster.on_leadership(on_lead)
_register_cache_handlers()
def _register_cache_handlers() -> None:
"""别的进程改了、本进程缓存着的东西怎么重读(app/cluster/caches.py:改的进程 broadcast,这里照着清掉、重读)。"""
from app.cluster import caches
async def auth_settings(_key: str | None) -> None:
from app.auth import load_settings
await load_settings(force=True)
async def public_address(_key: str | None) -> None:
from app.core import public_url
await public_url.refresh()
async def embeddings(_key: str | None) -> None:
from app.db.base import SessionLocal
from app.memory.embeddings import load_setting
async with SessionLocal() as session:
await load_setting(session)
async def capabilities(_key: str | None) -> None:
from app.capabilities import registry
from app.db.base import SessionLocal
async with SessionLocal() as session:
await registry.refresh_cache(session, broadcast=False)
async def datasource_engine(key: str | None) -> None:
from app.data.engine import engines
if key:
await engines.invalidate(key, broadcast=False)
def app_pages(_key: str | None) -> None:
from app.apps.resolve import clear_cache
clear_cache()
for name, fn in (("auth_settings", auth_settings), ("public_url", public_address), ("embeddings", embeddings),
("capabilities", capabilities), ("datasource_engine", datasource_engine), ("app_pages", app_pages)):
caches.on(name, fn)
def _register_reapers() -> None:
"""接手属主不在了的行的各种规矩(app/cluster/reaper.py),按这个先后:运行在问数据之前(问数据接手时要看运行的状态)。"""
from app.blueprints import publish_plan
from app.chat import job as chat_job
from app.cluster import reaper
from app.data import catalog_jobs, catalog_profile_jobs, entity_discovery, introspect_jobs, semantic_decompose
from app.eval import runner as eval_runner, snapshots as eval_snapshots
from app.process import actions, tasks
from app.ledger import jobs as draft_jobs, reimport as import_jobs
async def documents(live: Any) -> int:
return await _reap_documents(live)
for name, fn in (
("运行", run_manager.reap),
("处理中的文档", documents),
("口径起草任务", lambda live: draft_jobs.startup(live=live)),
("口径增量导入", lambda live: import_jobs.startup(live=live)),
("数据目录起草任务", lambda live: catalog_jobs.startup(live=live)),
("数据剖析任务", lambda live: catalog_profile_jobs.startup(live=live)),
("探查任务", lambda live: introspect_jobs.startup(live=live)),
("候选实体扫描任务", lambda live: entity_discovery.startup(live=live)),
("口径拆解任务", lambda live: semantic_decompose.startup(live=live)),
("影子起草", lambda live: eval_snapshots.startup(live=live)),
("评测任务", lambda live: eval_runner.startup(live=live)),
("问数据的一轮", _chat_reaper(chat_job)),
("待办", lambda live: tasks.startup(live=live)),
("结果未知的写入", lambda live: actions.startup(live=live)),
("发布单", lambda live: publish_plan.startup(live=live)),
):
reaper.register(name, fn)
def _chat_reaper(chat_job: Any) -> Any:
async def reap(live: Any) -> Any:
counts = await chat_job.startup(live=live)
return {k: v for k, v in counts.items() if v} or 0
return reap
async def _reap_documents(live: Any) -> int:
"""上次被强杀时留下的「处理中」文档(只看属主不在了的)。后台任务不会自己回来,留着它只会在界面上永远转圈——和 runner
对 running 的 run 是同一个道理。"""
from sqlalchemy import update as _update
from app.cluster import claims
from app.db.base import SessionLocal
from app.db.models import Document as _Doc
ids = [doc_id for doc_id, _ in await claims.orphans(_Doc, live, running=lambda _id: False,
statuses=("processing",))]
if ids:
async with SessionLocal() as _s:
await _s.execute(
_update(_Doc).where(_Doc.id.in_(ids), _Doc.status == "processing")
.values(status="failed", error="服务重启,这份文档没有处理完,请重新上传")
)
await _s.commit()
return len(ids)
async def _platform_startup() -> None:
"""应用平台的启动整理(BE-1):登录设置、对外访问地址读进缓存;自定义工具请求头、MCP env 和请求头里的存量明文加密
一遍(幂等);存量的内网 MCP 服务改为绑定登记端点(只在升级后第一次启动时做);能力登记同步一次。后三样出错只记
日志:最坏是登记暂时不全,不该让服务起不来。"""
from app.auth import current_mode, load_settings
from app.capabilities import registry as capability_registry
from app.capabilities.secrets import seal_existing
from app.core import public_url
from app.db.base import SessionLocal
await load_settings(force=True)
logger.info("登录方式:%s", current_mode())
# 对外访问地址(core/public_url.py)同样读进缓存:中间件核对来源时不查库
if address := await public_url.refresh():
logger.info("对外访问地址:%s(%s)", address, "环境变量" if public_url.env_locked() else "设置")
try:
if sealed := await seal_existing():
logger.info("已加密 %d 处存量的明文密钥(自定义工具请求头、MCP 环境变量和请求头)", sealed)
except Exception: # noqa: BLE001
logger.exception("存量密钥加密失败,服务照常启动")
try:
from app.tools.mcp_manager import adopt_intranet_servers
if adopted := await adopt_intranet_servers():
logger.info("已为 %d 个指向内网的存量 MCP 服务登记端点并绑定:%s", len(adopted),
"、".join(a["server"] for a in adopted))
except Exception: # noqa: BLE001
logger.exception("存量 MCP 服务的端点登记失败,服务照常启动")
try:
async with SessionLocal() as session:
await capability_registry.sync_all(session)
except Exception: # noqa: BLE001
logger.exception("能力登记同步失败,服务照常启动")
# 访问密钥的门禁(app/auth/keys.py 的 key_gate,docs/design/next-round/04-对外集成与触达.md A2)挂在全站一次:每个路由都先过它,
# 按访问密钥认出来的请求只放行 KEY_ROUTES 里的接口;别的认法(会话、关登录、钩子)一律放过,由各组路由自己的依赖判
app = FastAPI(
title="AgentLab",
description="可视化 Agent 编排实验台",
version="0.1.0",
lifespan=lifespan,
dependencies=[Depends(key_gate)],
)
# 身份(app/auth/middleware.py):按请求认出是谁、核对 CSRF。加在 CORS 之前,CORS 就在它外面一层:
# 跨域预检由 CORS 先答,不经过登录检查
app.add_middleware(AuthMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 安全响应头(app/web_security.py):最后加、在最外层,CORS 预检和登录检查自己回的 401 / 403 也带上
app.add_middleware(SecurityHeaders)
# 路由级权限(§3.12)。关登录时这些依赖全部放行;开了登录,中间件先挡掉没登录的,这里再按角色和运行挡
_BUILDER = [require_role("builder")]
_ADMIN = [require_role("admin")]
_USER = [require_role("user")]
for router, guard in (
(workflows.router, _BUILDER),
# 这几个也挂在 /api/runs 下面,要在 runs.router 之前登记:以后加的固定路径(/api/runs/compare 之类)
# 才不会被 runs 的 /{run_id} 当成运行 id 吃掉。/{run_id}/… 的子路径放在哪边都不冲突。
# 运行对比、可验证报告、质询、假设分析交的是查询原文,只给 builder 及以上;别人的个人应用刷新、个人蓝图试运行只给
# 拥有者(private_run_scope),管理员也不例外
(compare.router, [*_BUILDER, private_run_scope]),
(bundle.router, [*_BUILDER, private_run_scope]),
(inquiry.router, [*_BUILDER, private_run_scope]),
(whatif.router, [*_BUILDER, private_run_scope]),
# 口径账本按运行读的接口(/api/runs/{run_id}/ledger…):和证据同一道门
(ledger_api.runs_router, [evidence_scope]),
(runs.router, [run_scope]),
(runs.approvals_router, _BUILDER),
(evidence.router, [evidence_scope]),
# 模型接入、运行默认值:搭建者只读,改动只给管理员
(settings_api.router, [role_by_method(read="builder", write="admin")]),
(tools.router, _BUILDER),
(tools.custom_router, _BUILDER),
# MCP 的 stdio 只有管理员能建、能改(api/tools.py 里按传输方式判断)
(tools.mcp_router, _BUILDER),
(knowledge.memory_router, _BUILDER),
(knowledge.kb_router, _BUILDER),
(knowledge.skills_router, _BUILDER),
(sandbox.router, _BUILDER),
(copilot.router, [chat_scope]),
(conversations.router, [chat_scope]),
# 问数据的一轮(发起、看、停):同问数据的门,接口里再按这一轮所属的对话核对归属
(chat_turns_api.router, [chat_scope]),
# 工件:user 角色只能取能读的运行引用的、遮罩后的(api/artifacts.py)
(artifacts.router, _USER),
(governance.router, _BUILDER),
(schedules.router, _BUILDER),
# 问数据页选数据源用的名单(只有名字、说明、种类):开了问数据的使用者也能读,同样要在通配 /{source_id} 之前登记
(datasources.names_router, [chat_scope]),
# 按配方导入的 /api/datasources/imports/… 要在 datasources 的通配 /{source_id} 之前登记
(table_imports.router, _BUILDER),
(source_versions.router, _BUILDER),
(catalog.router, _BUILDER),
# 数据源的变化记录(/api/datasources/{id}/changes…):在通配 /{source_id} 之前登记
(source_changes_api.router, _BUILDER),
(datasources.router, _BUILDER),
# 应用平台。登录、登出、OIDC、JWKS 匿名可用,设置在接口里要管理员;入站钩子只认签名
(auth_api.router, []),
(identity_api.router, _ADMIN),
# 选人用的名录(用户名、显示名、是否停用、组名):岗位绑定、应用授权由搭建者做,只读
(identity_api.directory_router, _BUILDER),
(identity_api.candidates_router, _USER),
# 能力登记、端点:搭建者只读,改动在接口里要管理员
(capabilities_api.router, _BUILDER),
# 普通用户建应用:个人应用的接口共用 /api/apps 前缀,要在 apps_api 之前登记;蓝图助手共用 /api/copilot 前缀,
# 在接口里按蓝图的范围判(共享要 builder,个人要拥有者且开了 apps.user_personal_apps)
(personal_api.router, _USER),
(blueprints_api.router, _USER),
(templates_api.router, _USER),
(files_api.router, _USER),
(copilot_blueprint.router, _USER),
# 应用、待办、生效记录、通知:各自按应用授权、派给谁再判断
(apps_api.router, _USER),
(tasks_api.router, _USER),
(actions_api.router, _USER),
(hooks_api.router, []),
# 分享链接的读接口只认分享 key(不用登录,中间件放行 /api/shares/)
(shares_api.router, []),
(notify_api.router, _USER),
(ledger_api.router, _BUILDER),
# 增量重导入(K11):和 /api/ledger 其余写接口同一道门(搭建者);访问密钥不开放(不登记 KEY_ROUTES)
(caliber_import_api.router, _BUILDER),
# 指标本体穿透(W1-a):标准业务实体与指标本体图谱(搭建者及以上)
(ontology_api.router, _BUILDER),
# 独立语义指标库(即存即用指标卡片):同口径与本体工作台权限(搭建者及以上)
(semantic_metrics_api.router, _BUILDER),
# 下一轮第 2 波:评测只给搭建者;访问密钥登录即可,接口里再按本人 / 管理员判
(evals_api.router, _BUILDER),
# 口径账本里按答案出具、对账的(第 3 波):登录即可,接口里按运行和应用授权判
(ledger_api.answers_router, _USER),
(access_keys_api.router, _USER),
):
app.include_router(router, dependencies=guard)
app.add_exception_handler(CodedHTTPException, coded_handler)
@app.exception_handler(ValueError)
async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse:
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "agentlab"}
# 生产部署:前端构建产物由服务端一并托管(见 app/web.py)。页面回退是通配路由,
# 必须在所有 /api 路由之后注册。没设 AGENTLAB_WEB_DIST 时什么都不挂,开发模式照旧
if settings.web_dist is not None:
from app.web import mount_web
mount_web(app, settings.web_dist)