Files
AgentLab/backend/app/chat/launch.py
T
ThearchyHelios 46a2988dc1 问数据双轨重构:快速直查与深度分析分流、可点击数字证据闭环及双语文档
- 重构问数据执行架构为「快速直查(fast)」与「深度分析(deep)」双模式,内置语义预命中(Grounding)注入指标卡片、标准实体编码与表目录血缘
- 快速直查免工作流生成单节点执行,新增零额外模型调用的数字证据归一与封存(direct_evidence),打通可点击数字溯源与 /verify 校验
- 深度分析与助手编排接入跨源本体穿透(自动升级重跑与一键沿本体排查关联数据源),优化数据源范围智能收敛与往返缓存
- 补齐端到端回归测试矩阵与中英文全量文档说明
2026-11-12 12:58:42 +08:00

117 lines
6.6 KiB
Python

"""问数据的运行怎么发起:开登录后 user 角色的那几条规则,HTTP 发起(api/runs.start_run)和服务端跑的一轮(chat/job.py)共用。
以前只有 start_run 一处(浏览器发 POST /api/runs)。一轮问数据搬到服务端之后,发起运行的是这一轮的后台任务,规则不能另抄一份——
使用者的问数据运行只许白名单里的节点和工具、记忆固定为自己的范围、查询结果在工具边界遮、数据源一律只读,这几条一处说清。
请求体能不能带记忆范围、知识库,能不能发正式运行,这些是 HTTP 请求本身的事,仍在 start_run 里判。
**口径查询工具是服务端补上的(K14)。** 使用者的图里不用自己画一个「按口径取数」:准备发起时在这里替它绑——有授权的
口径至少有一份时,把 `caliber_query`(只读、按资产授权收窄)加进这张图里各个 Agent 的 `tools`;一份也没有就原样不动,
模型不会拿到一个一调就被拒的工具。模型因此在工具清单里看得到它(chat_policy 的白名单里有它、copilot 的工具清单按白名单
列),能自己从 look 给出的**有授权的**口径里挑一张调用(look/calibers 只列这些)。绑上不等于放开数据:真正执行时
QueryCore 统一按资产授权核(querycore 的 `_require_caliber`,没有授权当场 403 `querycore_not_authorized`)。
"""
from __future__ import annotations
import copy
from typing import Any
class ChatRunRefused(Exception):
"""这张图使用者在问数据里不能运行:str(e) 就是给人看的原因(HTTP 发起时是 403 的 detail)。"""
async def user_chat_run(session: Any, graph: Any, actor: str | None) -> tuple[str, dict[str, Any], dict[str, Any]]:
"""开登录后 user 角色发起问数据运行:交回 (记忆范围, 运行选项, 这次要运行的图)。图里有白名单之外的节点、工具抛 ChatRunRefused。
记忆固定为这位使用者自己的范围、知识库取设置里的默认值(记忆、知识库的内容不经遮罩,见 auth/chat_policy 开头);查询结果
在工具边界就遮(事件、回答里只有遮过的值——它读得到自己这次运行的事件,api/deps.py 的 _run_scope);数据源查询一律只读,
写语句不交给审批。交回的图是 `bind_caliber_tool` 补过口径查询工具的那一份(调用方拿它发起,见模块说明)。`session` 只用来
读这个人有授权的口径(apps/access.usable_calibers),发起本身仍由调用方负责。"""
from app.auth.chat_policy import actor_name, chat_graph_problem, chat_memory_scope
from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION
if problem := chat_graph_problem(graph):
raise ChatRunRefused(problem)
# 主体归一成名字:记进记忆范围、判「是不是普通使用者」都按同一个名字算(调用方给的可能是 Principal)
name = actor_name(actor)
bound = await bind_caliber_tool(session, graph, name)
return chat_memory_scope(name), {MASK_RESULTS_OPTION: True, READ_ONLY_OPTION: True}, bound
async def bind_caliber_tool(session: Any, graph: Any, actor: str | None) -> dict[str, Any]:
"""普通使用者的图里补上口径查询工具,交回这次要运行的图(搭建者及以上、没有可用口径、图不成形都原样交回)。
- 只补 **Agent 节点的 tools**:调用工具节点一次只调它写死的那个工具,补不进去(要按口径取数就把 tool 写成 caliber_query);
- 只在**这份授权确实有口径可用**时补(apps/access.usable_calibers):一份也没有就原样交回,免得模型拿到一个一调就被拒
的工具;builder / admin / 关登录不补(auth/chat_policy.restricted_actor)——它们本来就能自己在画布上绑;
- 改的是**副本**(deepcopy):调用方的请求体、这一轮的图都不动。"""
from app.auth.chat_policy import restricted_actor
from app.engine.launch import node_setting
from app.engine.schema import GraphSpec
from app.ledger.querycore import CALIBER_QUERY
if not isinstance(graph, dict) or not graph.get("nodes"):
return graph
try:
spec = GraphSpec.model_validate(graph)
except ValueError:
return graph # 图不成形:交给 launch_run 照常报校验问题
if not any(str(node.type) == "agent" for node in spec.nodes):
return graph
if not await restricted_actor(session, actor):
return graph
from app.apps.access import usable_calibers
from sqlalchemy import select
from app.db.models import Workflow, WorkflowVersion
from app.ledger.ontology import ACTIVE_LINK_STATUSES, workflow_ontology_index
from app.tools.ontology import ONTOLOGY_NEIGHBORS, SOURCE_HEALTH
usable = await usable_calibers(session, actor)
if not usable:
return graph
has_ontology_edges = False
for wf_id, ver in usable:
snap = (
await session.execute(
select(WorkflowVersion.graph).where(
WorkflowVersion.workflow_id == wf_id,
WorkflowVersion.version == int(ver),
)
)
).scalar_one_or_none()
if not isinstance(snap, dict):
wf = await session.get(Workflow, wf_id)
snap = wf.graph if wf is not None and isinstance(wf.graph, dict) else {}
idx = workflow_ontology_index(workflow_id=wf_id, version=int(ver), graph=snap)
if any(e.get("status") in ACTIVE_LINK_STATUSES for e in idx["edges"]):
has_ontology_edges = True
break
nodes = copy.deepcopy(graph["nodes"])
for node in nodes:
if not isinstance(node, dict) or str(node.get("type")) != "agent":
continue
here = node.get("data")
if not isinstance(here, dict):
continue
config = here.setdefault("config", {})
if not isinstance(config, dict):
continue
spec_node = spec.node_map()[str(node.get("id"))]
tools = [str(t) for t in (node_setting(spec, spec_node, "tools") or [])]
additions = [CALIBER_QUERY]
if has_ontology_edges or bool(node_setting(spec, spec_node, "investigation")):
additions.extend([ONTOLOGY_NEIGHBORS, SOURCE_HEALTH])
changed = False
for extra_tool in additions:
if extra_tool not in tools:
tools.append(extra_tool)
changed = True
if changed:
config["tools"] = tools
return {**graph, "nodes": nodes}
__all__ = ["ChatRunRefused", "bind_caliber_tool", "user_chat_run"]