367 lines
17 KiB
Python
367 lines
17 KiB
Python
"""screen agent 的本地工具:读取当前大屏、在当前大屏上提交一批修改
|
||
|
||
它们读写的是本次请求的大屏和 agent 状态,所以放在本地;物料等能力由 MCP 提供
|
||
读取、修改和校验都基于同一份草稿:原始大屏加上之前写入草稿的修改;回复 done 时草稿交付给用户
|
||
"""
|
||
|
||
import json
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass, field
|
||
from typing import Annotated, NotRequired
|
||
|
||
from langchain.agents import AgentState
|
||
from langchain.tools import ToolRuntime, tool
|
||
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
|
||
from langgraph.types import Command
|
||
from pydantic import Field
|
||
|
||
from app.actions.batch import BatchError, misplaced_props, run_batch
|
||
from app.agent.data_tools import DataContext
|
||
from app.agent.query_values import (
|
||
EmbeddedQuery,
|
||
encode_problems,
|
||
fill_queries,
|
||
rebound_nodes,
|
||
)
|
||
from app.resources.material_fields import paragraph_texts, retained_examples
|
||
from app.schemas.actions import (
|
||
Action,
|
||
AddAction,
|
||
AgentAction,
|
||
DeleteAction,
|
||
UpdateAction,
|
||
)
|
||
from app.schemas.component_resource import ComponentCatalog
|
||
from app.schemas.screen import Screen
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScreenAgentContext:
|
||
screen: Screen # 本次请求的原始大屏,只读,交付的动作从它开始执行
|
||
catalog: ComponentCatalog # 当前物料目录,只用于补全和校验
|
||
data: DataContext = field(default_factory=DataContext) # 本会话可用的数据表
|
||
|
||
|
||
# 两个字段只由 apply_actions 写,同一轮只执行第一次提交,所以一步里最多一次写入,不需要合并规则
|
||
# 提交是否失败不另存字段,由 last_write_failed 从消息推出:参数解析、参数校验、查询、执行各处的失败都只体现在消息上
|
||
class ScreenAgentState(AgentState):
|
||
# 草稿:最近一次写入后的大屏;还没有写入时不存在,用原始大屏
|
||
working_screen: NotRequired[Screen]
|
||
# 交付记录:各次写入的动作(补全、展开后)按顺序拼接,在原始大屏上依次执行得到草稿
|
||
actions: NotRequired[list[Action]]
|
||
|
||
|
||
def current_screen(runtime: ToolRuntime[ScreenAgentContext]) -> Screen:
|
||
return runtime.state.get("working_screen") or runtime.context.screen
|
||
|
||
|
||
@tool
|
||
def get_nodes(node_ids: list[str], runtime: ToolRuntime[ScreenAgentContext]) -> dict:
|
||
"""读取组件的完整配置(layout、props、style 等)。修改属性或迁移数据前先读取
|
||
|
||
读取的是草稿:原始大屏加上之前写入草稿的修改;和 apply_actions 在同一轮调用时读不到这次提交的结果,要核对就在下一轮读取
|
||
"""
|
||
nodes = {node.id: node for node in current_screen(runtime).nodes}
|
||
return {
|
||
"nodes": [
|
||
nodes[item].model_dump(mode="json") for item in node_ids if item in nodes
|
||
],
|
||
"missing": [item for item in node_ids if item not in nodes],
|
||
}
|
||
|
||
|
||
def _result_text(
|
||
before: Screen,
|
||
after: Screen,
|
||
actions: list[Action],
|
||
catalog: ComponentCatalog | None = None,
|
||
queries: Sequence[EmbeddedQuery] = (),
|
||
unchanged: bool = False,
|
||
repeated: bool = False,
|
||
) -> str:
|
||
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
|
||
|
||
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正;
|
||
这一批动过布局或新增的组件前面标 *,这一批删除的单独列出
|
||
没有带来变化时也照样给出布局:只说"已经写入"会让模型以为达到了目标,可能当前大屏本来就是这样、并不符合要求
|
||
统一说"写入草稿"、"回复 done 时交付":说"已生效"时模型回复 unable 也会说已经改了什么,
|
||
不说怎么交付时它会再交一批空的或重复的来"确认"
|
||
"""
|
||
if unchanged and repeated:
|
||
# 之前的提交已经改过:模型不确定是否写入才会重复提交,要写明已经在草稿里、不要重复,并说怎么结束,
|
||
# 只说"没有变化"时它以为修改没成功,会换着字段反复提交直到调用次数用尽
|
||
lines = [
|
||
(
|
||
"执行成功,但这一批没有改变草稿(0 个动作):提交的内容和草稿里已有的一样,之前的提交已经改过。"
|
||
"对照要求和下面的布局:已经达到就调用 AgentReply 回复 done,草稿会一起交付;"
|
||
"没达到就提交不同的修改,不要原样重复提交"
|
||
)
|
||
]
|
||
elif unchanged:
|
||
# 这次运行还没有改过大屏:原始大屏本来就是这样,模型的方案并没有改动任何东西,只陈述事实;
|
||
# 加上"完成就回复"之类的提示时,大屏并不符合要求它也会当成完成
|
||
lines = [
|
||
"执行成功,共 0 个动作写入草稿:提交的修改和原始大屏已有的一样,大屏原本就是这样"
|
||
]
|
||
else:
|
||
lines = [
|
||
f"执行成功,共 {len(actions)} 个动作已写入草稿;回复 done 时草稿交付给用户,不需要再提交确认"
|
||
]
|
||
if declared := [
|
||
action.node.id
|
||
for action in actions
|
||
if isinstance(action, AddAction) and action.sample_data
|
||
]:
|
||
lines.append(
|
||
f"声明为自己编写数据的新增组件,交付时标为示例数据:{'、'.join(declared)}"
|
||
)
|
||
# 新增组件没写到的字段沿用默认配置,其中的标题、系列名、数据是演示内容;不说出来时模型以为都按自己的意思写好了,
|
||
# 结果真实数据的卡片带着示例的前缀和环比、图例还叫"访问量"。只陈述事实和后果,由模型决定改不改
|
||
if examples := _retained_example_notes(actions, after, catalog):
|
||
lines.append(
|
||
"新增组件里这些值还是物料的示例内容:示例文字和组件要展示的内容不符就改写;"
|
||
"示例数据会让组件交付时标为示例数据,要展示真实数据就换掉(数据来自上传的文件时用 $query),"
|
||
"没有真实数据时可以保留并在回复里说明"
|
||
)
|
||
lines.extend(examples)
|
||
# 问答之后"把结果放到大屏上",模型会把分析结论抄成文本组件;skill 里写了规则仍会发生,写入时再陈述一次,
|
||
# 用户明确要在大屏上放这段文字时由模型保留。要说清空出的位置怎么办:只说"改成简短的标题"时,
|
||
# 模型把结论块缩成一行小标题,留下一大块空位,反复加删组件去填,直到调用次数用尽
|
||
if paragraphs := _paragraph_notes(actions, after, catalog):
|
||
lines.append(
|
||
"这一批写入的标题或文字是一段话:大屏上的文字只放标题和简短标签,"
|
||
"分析结论、洞察、数据说明和注意事项写在回复里。用户明确要求在大屏上放这段文字时保留;"
|
||
"否则只放这段文字的文本组件直接删除,原来的位置给图表或让相邻组件占满,"
|
||
"图表标题、指标名这类改成简短的名称"
|
||
)
|
||
lines.extend(paragraphs)
|
||
# 模型提交时只写了 SQL,看不到结果;给出列名和行数,用来核对 encode 和数据量
|
||
if queries:
|
||
lines.append("$query 已替换为查询结果:")
|
||
for item in queries:
|
||
note = "(结果为空,组件会没有数据)" if not item.rows else ""
|
||
result = (
|
||
f"取值 {item.value}"
|
||
if item.value is not None
|
||
else f"{item.rows} 行{note},列 {'、'.join(item.columns)}"
|
||
)
|
||
lines.append(
|
||
f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:{result}"
|
||
)
|
||
|
||
old_layouts = {node.id: node.layout for node in before.nodes}
|
||
lines.append(
|
||
f"执行后画布 {after.canvas.width:g}x{after.canvas.height:g},组件布局(* 为本次改动):"
|
||
)
|
||
for node in after.nodes:
|
||
layout = node.layout
|
||
mark = "*" if old_layouts.get(node.id) != layout else " "
|
||
lines.append(
|
||
f"{mark} {node.id} {node.type} "
|
||
f"x={layout.x:g} y={layout.y:g} width={layout.width:g} height={layout.height:g}"
|
||
)
|
||
if removed := [
|
||
item for item in old_layouts if item not in {n.id for n in after.nodes}
|
||
]:
|
||
lines.append(f"已删除:{'、'.join(removed)}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _retained_example_notes(
|
||
actions: Sequence[Action], after: Screen, catalog: ComponentCatalog | None
|
||
) -> list[str]:
|
||
"""这一批新增的组件上还和默认配置一样的示例内容,每个组件一行"""
|
||
nodes = {node.id: node for node in after.nodes}
|
||
notes = []
|
||
for action in actions:
|
||
if not isinstance(action, AddAction) or action.node.id not in nodes:
|
||
continue
|
||
node = nodes[action.node.id].model_dump(mode="json")
|
||
if retained := retained_examples(catalog, node):
|
||
notes.append(
|
||
f"- {action.node.id}:"
|
||
+ "、".join(
|
||
f"{field.path}({field.label}){_brief(value)}"
|
||
for field, value in retained
|
||
)
|
||
)
|
||
return notes
|
||
|
||
|
||
def _paragraph_notes(
|
||
actions: Sequence[Action], after: Screen, catalog: ComponentCatalog | None
|
||
) -> list[str]:
|
||
"""这一批写入的识别字段里成段的文字,每个组件一行
|
||
|
||
新增的组件看全部识别字段,修改只看这一批写到的字段:只改颜色时不提原有的说明文字
|
||
"""
|
||
written: dict[str, set[str] | None] = {}
|
||
for action in actions:
|
||
if isinstance(action, AddAction):
|
||
written[action.node.id] = None
|
||
elif (
|
||
isinstance(action, UpdateAction)
|
||
and written.get(action.target_id, set()) is not None
|
||
):
|
||
written.setdefault(action.target_id, set()).add(action.field_path)
|
||
nodes = {node.id: node for node in after.nodes}
|
||
notes = []
|
||
for node_id, paths in written.items():
|
||
if node_id not in nodes:
|
||
continue
|
||
node = nodes[node_id].model_dump(mode="json")
|
||
if found := paragraph_texts(catalog, node, paths):
|
||
notes.append(
|
||
f"- {node_id}:"
|
||
+ "、".join(
|
||
f"{field.path}({field.label}){len(text.strip())} 字 {_brief(' '.join(text.split()))}"
|
||
for field, text in found
|
||
)
|
||
)
|
||
return notes
|
||
|
||
|
||
def _brief(value) -> str:
|
||
if isinstance(value, list):
|
||
return f"示例数据 {len(value)} 行"
|
||
if isinstance(value, str):
|
||
return f"“{value if len(value) <= 20 else value[:20] + '…'}”"
|
||
return json.dumps(value, ensure_ascii=False)
|
||
|
||
|
||
def _reply(
|
||
runtime: ToolRuntime, text: str, *, error: bool = False, **update
|
||
) -> Command:
|
||
"""返回工具消息;状态字段由调用方显式传入,没传的保持不变"""
|
||
status = "error" if error else "success"
|
||
message = ToolMessage(text, tool_call_id=runtime.tool_call_id, status=status)
|
||
return Command(update={**update, "messages": [message]})
|
||
|
||
|
||
def _first_write(message: AIMessage) -> str | None:
|
||
"""一条模型消息里第一个 apply_actions 调用的 ID;同一轮只执行它"""
|
||
return next(
|
||
(call["id"] for call in message.tool_calls if call["name"] == "apply_actions"),
|
||
None,
|
||
)
|
||
|
||
|
||
def _is_first_submission(runtime: ToolRuntime) -> bool:
|
||
"""按模型消息里 tool_calls 的顺序,这次调用是不是第一个 apply_actions
|
||
|
||
同一轮的多个调用都从同一份草稿出发,都执行会互相覆盖;只执行第一个,与并发谁先跑完无关
|
||
直接调用工具、找不到发起调用的模型消息时,视为第一个
|
||
"""
|
||
message = next(
|
||
(
|
||
item
|
||
for item in reversed(runtime.state.get("messages", []))
|
||
if isinstance(item, AIMessage)
|
||
and any(call["id"] == runtime.tool_call_id for call in item.tool_calls)
|
||
),
|
||
None,
|
||
)
|
||
return message is None or _first_write(message) == runtime.tool_call_id
|
||
|
||
|
||
def last_write_failed(messages: Sequence[BaseMessage]) -> bool:
|
||
"""最后一次被接受的提交是否失败:最后一条带 apply_actions 的模型消息里,第一个调用的结果是错误
|
||
|
||
参数解析、参数校验、查询、执行任何一处失败,结果都是 status="error" 的工具消息,都在这里判断,
|
||
不靠各处各自记状态,以后多一种失败也不会漏;同一轮被拒绝的后续调用不是第一个,不算
|
||
没有结果时也算失败:这一批没有执行,最后想做的修改没有写入草稿
|
||
"""
|
||
for position in range(len(messages) - 1, -1, -1):
|
||
message = messages[position]
|
||
if isinstance(message, AIMessage) and (call_id := _first_write(message)):
|
||
reply = next(
|
||
(
|
||
item
|
||
for item in messages[position + 1 :]
|
||
if isinstance(item, ToolMessage) and item.tool_call_id == call_id
|
||
),
|
||
None,
|
||
)
|
||
return reply is None or reply.status == "error"
|
||
return False
|
||
|
||
|
||
@tool
|
||
def apply_actions(
|
||
actions: Annotated[
|
||
list[AgentAction],
|
||
# 不限制至少一个:以前整批替换时空的一批会清掉之前的结果,增量执行下空的一批只是没有变化,
|
||
# 按普通的无变化回复,比参数校验的英文报错更能让模型知道该结束了
|
||
Field(description="这一批要执行的动作;不需要修改时不要调用,直接回复"),
|
||
],
|
||
runtime: ToolRuntime[ScreenAgentContext],
|
||
) -> Command:
|
||
"""把一批修改写进这次的草稿;回复 done 时草稿交付给用户,不需要再提交确认
|
||
|
||
第一次提交从原始大屏开始,之后每一批都在草稿上继续修改;回复 unable 或 clarify 时草稿整个丢弃
|
||
成功时返回这一批执行后的画布和各组件布局,用它核对结果;结果不对再提交一批修正,已经写入的动作不用重复提交
|
||
一批里任何一个动作失败,这一批都不写入草稿,之前写入的保留;按带序号的错误修正这一批后再提交
|
||
同一轮只执行第一次调用,同一轮的 get_nodes 也读不到这次的结果;看到执行结果后再调用 AgentReply
|
||
"""
|
||
if not _is_first_submission(runtime):
|
||
# 被拒绝的调用不写任何状态字段,不会覆盖第一次提交的结果
|
||
return _reply(
|
||
runtime,
|
||
"同一轮只执行第一次 apply_actions,这一批没有执行;需要的话在下一轮根据执行结果再提交",
|
||
error=True,
|
||
)
|
||
before = current_screen(runtime)
|
||
misplaced = misplaced_props(actions, runtime.context.catalog)
|
||
# 写错层级的属性不生效,失败和成功时都说明;它常是 encode 对不上的真正原因
|
||
hint = (
|
||
"\n新增组件写了物料没有的属性,前端不会使用:"
|
||
+ ";".join(misplaced)
|
||
+ ";检查是不是写错了层级"
|
||
if misplaced
|
||
else ""
|
||
)
|
||
try:
|
||
actions, queries = fill_queries(
|
||
actions, runtime.context.data, runtime.context.catalog, before
|
||
)
|
||
completed, after = run_batch(before, runtime.context.catalog, actions)
|
||
# 这一批改过数据或映射的组件都核对,不论数据来自这一批的 $query、之前的提交还是直接写入
|
||
problems = encode_problems(after, rebound_nodes(before, after))
|
||
except BatchError as error:
|
||
problems = [str(error)]
|
||
if problems:
|
||
# 失败只体现在工具消息上,由 last_write_failed 判断,不写状态字段
|
||
message = "执行失败,这一批未写入草稿,之前写入的修改保留:\n" + "\n".join(
|
||
f"- {item}" for item in problems
|
||
)
|
||
return _reply(runtime, message + hint, error=True)
|
||
log = runtime.state.get("actions", [])
|
||
delivered = [*log, *completed]
|
||
# 按交付结果判断有没有变化,不比较参数:同样的 move 再执行一次会继续移动,同样的 update 再执行一次没有变化
|
||
# 交付结果除了大屏还有只在动作上的"自己编写的数据"声明,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉
|
||
unchanged = after == before and _sample_marks(delivered) == _sample_marks(log)
|
||
# 这次运行之前已有成功提交时,没有变化多半是在重复自己的修改;还没有时说明原始大屏本来就是这样
|
||
text = _result_text(
|
||
before,
|
||
after,
|
||
completed,
|
||
runtime.context.catalog,
|
||
queries,
|
||
unchanged,
|
||
repeated=bool(log),
|
||
)
|
||
if unchanged:
|
||
return _reply(runtime, text + hint)
|
||
return _reply(runtime, text + hint, working_screen=after, actions=delivered)
|
||
|
||
|
||
def _sample_marks(actions: Sequence[Action]) -> dict[str, bool]:
|
||
"""按交付记录,每个新增组件是否声明为自己编写的数据;这个声明只在 add 动作上,不在大屏里"""
|
||
marks: dict[str, bool] = {}
|
||
for action in actions:
|
||
if isinstance(action, AddAction):
|
||
marks[action.node.id] = action.sample_data
|
||
elif isinstance(action, DeleteAction):
|
||
marks.pop(action.target_id, None)
|
||
return marks
|