Files
AgentLab/backend/app/look/service.py
T
ThearchyHelios 6b68961c65 助手把操作写成工具调用格式时照操作读;一个能落地的操作都没有时当场纠正一次;报错按诊断照实说
部署上画布助手连着几轮报「助手本轮未完成:模型没有给出任何修改,也没有说明原因」,又试了一次才成。本地用同一网关、同一模型、同一底图和需求重放复现:模型思考里写着「先输出 look」,正文却是一段 DeepSeek 的 DSML 工具调用(invoke name="look",参数 need、targets),意图和参数都对,只是不是约定的单行 JSON。读流只认 JSON 和原生工具调用,于是一个操作都没认出、整轮报错;画布不处理 next=retry,点重试原样再发,几率不变。提示词里「输出这一行就停下,系统把结果交回来」很像工具调用,却从没说这里没有工具调用接口。

- engine/toolcalls:take_leaked_calls 读写成文字的工具调用(DSML 竖线一到两个、带不带空格,<function_calls><invoke>,<tool_call>{name, arguments},DeepSeek 特殊 token 两种排法),按出现先后交出已写完的调用和没写完的那一段;final 时参数写完整、只差结尾的补上结尾再读,写到一半的不硬读。_MARKUP 补认 DSML 结尾标签和 function_calls / tool_calls 包装(agent 节点的识别跟着受益)。has_markup 只认标记本身
- copilot_gen._iter_ops:从有标记的那一行起攒,一次调用写完就按名字读成操作(名字是操作名的才认,规矩同原生工具调用 _call_op;op 写在最前,接着写时交回模型的是约定写法),读到 look 照样停下去查看;读不出的记「工具调用标记的名字不认识 / 参数解析不了 / 没写完」;raw 交出正文开头一段
- Generation:起草时一次调用读完、到这时还没有能落地的操作(plan、look 不算——查看完接着写什么都没认出的整轮照样是空的),把原话交回去、附 StreamDiagnosis.correction 那句纠正,当场重写一次(FORMAT_NUDGE_PROMPT,每轮一次);什么都没写的把纠正接在最后那条消息后面。诊断按次收(absorb),记 nudged。写成标记被读出、当场纠正都打 info 日志
- 报错说法:Generation.empty 默认用 StreamDiagnosis.headline(写成工具调用格式 / 只写了思考 / 什么都没有输出 / 格式不对 / 只说了打算),不再一律「也没有说明原因」;摘要注明当场纠正过
- 提示词:look 用法(四个入口共用)和画布、应用、蓝图的输出格式都写明这里没有工具调用接口
- 问数据失败原因 meta.cause(kind=copilot)带上 code、diagnostics

验证:本地真模型重放,修后 DSML 没再出现、都正常交付;把第一次调用换成读不出的 DSML 后交给真模型,都被当场纠正救回来(先 look 再出操作);原先抓到的真实 DSML 原文离线读成 look。测试:新增 tests/test_copilot_markup_ops.py(各写法、流式切碎、和 JSON 行混写、读不出的诊断、DSML look 驱动真查看、说明文字 / 只写思考 / 只写 plan / 查看后空续写的纠正、只纠正一次、画布流不报错、报错说法、提示词);空生成相关的旧测试按「一次生成先纠正一次」更新(基线夹具 no_ops 的说法)。后端全量通过。
2026-10-19 22:48:23 +08:00

246 lines
14 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.
"""查看服务(look 文档 §2.5.3,总方案 L2、L3):一行 look → 交回给模型的文字 + 发给前端的记录。
处理顺序:
1. need 没写:回「look 要写 need」,不算次数;次数用完:回「按已查看的结构完成」,不再查看。
2. targets 按类型分组(旧写法 tables:[{source, table}] 读进来换成 kind=table)。写了 name 的点名直取,不调挑选模型;
只写了 kind(和 source)的、或者整个没写 targets 的,按 need 在那一类里挑(没写时挑这个入口的第一类)。
3. 挑选失败或挑出来是空的:交回那一类的目录(status=index),写明原因,请模型点名再看。点名的一个都没认出来时,
按 need 在那几个来源里挑(同只写类别),没认出来的照样写「没找到」;候选里一个能查看的都没有(库还没探查表
结构)时没有目录可交,status=failed,照实说原因。
4. 这一批对象这一轮都已经看过:回「已在上面」,不重新排一遍。
5. 各类给详情,合计受 LOOK_RESULT_CHARS 约束;记一条记录(id、by、need、status、objects、用时)。
系统代为查看(look 文档 D5:发布前修复、升级证据、应用润色)也走这里,by=system,不占模型的次数,记录照样发、照样存。
查看本身出错(status=failed)不挡生成:照实交回原因,模型按已有的信息接着写。
"""
from __future__ import annotations
import logging
import time
from typing import Any
from app.look.base import LOOK_LIMIT, LOOK_RESULT_CHARS, LookBudget, LookKind, LookResult, LookTarget
logger = logging.getLogger(__name__)
# ==========================================================================
# 写给模型的文字
# ==========================================================================
NEED_MISSING_PROMPT = "look 要写 need:一句话说明要查什么。这一次没有查看,也不算次数"
LIMIT_PROMPT = "这一轮的查看次数已用完(最多 {limit} 次),按已经查看的结构完成,不要再 look"
REPEAT_PROMPT = "这些{what}已经在上面的查看结果里了,不再重复给出,直接用上面的"
HEAD_MODEL_PROMPT = "查看结果(第 {n} 次,这一轮最多 {limit} 次)"
HEAD_SYSTEM_PROMPT = "系统已代为查看(不占你的查看次数)"
NEED_PROMPT = "需求:{need}"
INDEX_PROMPT = ("没能按需求挑出{what}:{reason}。下面是候选的{what}目录,从里面点名(targets 写 name)再 look 一次:\n"
"{index}")
EMPTY_PROMPT = ("按这个需求没有挑出相关的{what}。这一步确实要用到{what}的话,从下面的目录里点名再 look;"
"不需要就接着写:\n{index}")
FAILED_PROMPT = "这一次查看出错了:{reason}。按已有的信息接着写"
UNAVAILABLE_PROMPT = "没有可查看的{what}:{reason}。按已有的信息接着写,不要猜{what}的名字和字段"
#: 按需求挑出来是空的:写进 look 事件的 reason,界面上照原话显示
EMPTY_REASON = "按这个需求没有挑出相关的{what}"
UNKNOWN_KIND_PROMPT = "这一轮不能查看这一类对象:{kinds}(能查看的:{allowed})"
GUIDE_HEAD_PROMPT = (
"按需查看(look):要看{labels}的详情时,单独输出一行\n"
' {{"op":"look","need":"一句话说要查什么","targets":[{example}]}}\n'
" 输出这一行就停下,系统把查看结果作为下一条消息交回来,你接着往下写(已经输出的不要重复)。\n"
" · 这里没有工具调用接口:look 和别的操作一样写成正文里单独一行的 JSON,不要写成工具调用的格式"
"(<invoke>、<tool_call>、DSML 这类标记)——那样系统认不出\n"
" · need 必填。targets 里写了 name 的直接取那几个;只写 kind(可以加 source)不写 name 的,按 need 在这一类里挑;"
"整个不写 targets 时按 need 挑{default}。sources 可选:限定在哪几个来源(数据源名、工作流名……)里挑\n"
" · kind 只能是:{kinds}\n"
" · 每轮最多查看 {limit} 次,一次把要用的都点上;这一轮已经看过的不要再看,这次对话里看过的直接点名看"
)
GUIDE_EXAMPLE_PROMPT = {
"table": '{"kind":"table","source":"数据源名","name":"表名"}',
"capability": '{"kind":"capability","name":"能力名"}',
"workflow_version": '{"kind":"workflow_version","source":"工作流名","name":"v3"}',
}
CONTINUE_PROMPT = "\n\n接着输出操作。已经输出、已经落地的操作不要重复{landed}。"
LANDED_PROMPT = "(已落地:{ids})"
def parse_targets(line: dict[str, Any]) -> list[LookTarget]:
"""look 行里的 targets(L2)。旧写法 tables:[{source, table}](look 文档 §2.5.2 的初稿)读进来换成 kind=table。"""
out: list[LookTarget] = []
for t in line.get("targets") or []:
if isinstance(t, dict) and isinstance(t.get("kind"), str):
target: LookTarget = {"kind": t["kind"].strip()}
for key in ("source", "name"):
if isinstance(t.get(key), (str, int)) and str(t[key]).strip():
target[key] = str(t[key]).strip()
out.append(target)
for t in line.get("tables") or []:
if isinstance(t, str) and t.strip():
out.append({"kind": "table", "name": t.strip()})
elif isinstance(t, dict) and isinstance(t.get("table", t.get("name")), str):
target = {"kind": "table", "name": str(t.get("table", t.get("name"))).strip()}
if isinstance(t.get("source"), str) and t["source"].strip():
target["source"] = t["source"].strip()
out.append(target)
return out
def _sources(line: dict[str, Any]) -> list[str] | None:
raw = line.get("sources")
names = [str(s).strip() for s in raw if isinstance(s, str) and s.strip()] if isinstance(raw, list) else []
return names or None
class LookService:
"""一轮生成里的查看:各类对象(LookKind)、这一轮的记录、看过的对象。起草和自查修正共用一份。"""
def __init__(self, kinds: list[LookKind]) -> None:
self.kinds: dict[str, LookKind] = {k.kind: k for k in kinds}
self.records: list[dict[str, Any]] = []
self.texts: list[str] = []
self._seen: set[str] = set()
self._n = 0
@property
def default_kind(self) -> str | None:
return next(iter(self.kinds), None)
async def overview(self) -> str:
"""system 里的概览:每类各自几行,没有内容的类不占地方。"""
blocks = []
for kind in self.kinds.values():
lines = await kind.overview()
if lines:
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
def guide(self) -> str:
"""写给模型的 look 用法(只列这个入口能看的类)。什么时候该看由调用方另写(各入口不一样)。"""
if not self.kinds:
return ""
labels = "、".join(k.label for k in self.kinds.values())
default = self.kinds[self.default_kind].label if self.default_kind else ""
return GUIDE_HEAD_PROMPT.format(labels=labels, example=GUIDE_EXAMPLE_PROMPT.get(self.default_kind or "", ""),
default=default, kinds="、".join(self.kinds), limit=LOOK_LIMIT)
def looked(self) -> str:
"""这一轮所有查看交回过的文字,修正请求里原样带上(look 文档 D8:和起草时看到的是同一份)。"""
return "\n\n".join(self.texts)
def continue_prompt(self, landed: list[str]) -> str:
return CONTINUE_PROMPT.format(landed=LANDED_PROMPT.format(ids="、".join(landed)) if landed else "")
async def look(self, line: dict[str, Any], budget: LookBudget | None = None, *, by: str = "model") -> LookResult:
need = str(line.get("need") or "").strip()
if not need:
return LookResult(NEED_MISSING_PROMPT, None, counted=False)
if budget is not None and budget.used >= budget.limit:
return LookResult(LIMIT_PROMPT.format(limit=budget.limit), None, counted=False)
if budget is not None:
budget.used += 1
self._n += 1
started = time.monotonic()
record: dict[str, Any] = {"op": "look", "id": f"L{self._n}", "by": by, "need": need, "status": "ok"}
head = HEAD_SYSTEM_PROMPT if by == "system" else HEAD_MODEL_PROMPT.format(
n=budget.used if budget else self._n, limit=budget.limit if budget else LOOK_LIMIT)
try:
body, objects, keys = await self._run(line, need, record)
except Exception as e: # noqa: BLE001 - 查看是辅助步骤,出错不挡生成(P3)
from app.core.errors import explain
logger.exception("查看出错:%s", need)
record.update(status="failed", reason=explain(e)[0])
body, objects, keys = FAILED_PROMPT.format(reason=record["reason"]), [], set()
if record["status"] == "ok" and keys and keys <= self._seen and not any(o.get("missing") for o in objects):
# 这一批这一轮都看过了:不重排一遍,也不发事件(模型没看到新东西),编号留给下一次真正的查看
self._n -= 1
what = "、".join(dict.fromkeys(self.kinds[k.split(":", 1)[0]].label for k in keys))
return LookResult(REPEAT_PROMPT.format(what=what), None, counted=True)
self._seen |= keys
record["elapsed_ms"] = int((time.monotonic() - started) * 1000)
record["objects"] = objects
text = "\n".join([head, NEED_PROMPT.format(need=need), "", body])
self.records.append(record)
self.texts.append(text)
return LookResult(text, record)
async def _run(self, line: dict[str, Any], need: str,
record: dict[str, Any]) -> tuple[str, list[dict[str, Any]], set[str]]:
"""真正查看:(交回的正文, objects[], 这一次给出详情的对象的身份)。status / reason 写进 record。"""
targets = parse_targets(line)
sources = _sources(line)
for kind in self.kinds.values():
# 这一次查看开始:按需加入的上限按次数算、留痕要记需求原文和查看编号(只有表这一类用得着)
begin = getattr(kind, "begin", None)
if callable(begin):
begin(need=need, ref=str(record.get("id") or ""))
unknown = [t["kind"] for t in targets if t["kind"] not in self.kinds]
named: dict[str, list[LookTarget]] = {}
picks: dict[str, list[str] | None] = {}
for t in targets:
if t["kind"] not in self.kinds:
continue
if t.get("name"):
named.setdefault(t["kind"], []).append(t)
else:
# 只写了类别:按 need 在这一类里挑;写了 source 的只在那几个来源里挑
scope = picks.setdefault(t["kind"], [])
if scope is not None and t.get("source"):
scope.append(t["source"])
elif not t.get("source"):
picks[t["kind"]] = None
if not targets and self.default_kind:
picks[self.default_kind] = None
parts: list[str] = []
objects: list[dict[str, Any]] = []
keys: set[str] = set()
indexed: list[str] = []
empty: list[str] = []
unavailable: list[str] = []
budget = LOOK_RESULT_CHARS
for kind_name in dict.fromkeys([*named, *picks]):
kind = self.kinds[kind_name]
found, missing = await kind.resolve(named.get(kind_name, [])) if kind_name in named else ([], [])
fallback = bool(missing) and not found and kind_name not in picks
if fallback:
# 点名的一个都没认出来(常见的是拿说明里的中文词当表名):按 need 在那几个来源里挑,和只写类别时一样,
# 不让模型为了拿到真名再 look 一次。没认出来的照样写进「没找到」,不拿挑出来的冒充点名的
picks[kind_name] = list(dict.fromkeys(str(t["source"]) for t in missing if t.get("source"))) or None
if kind_name in picks:
within = picks[kind_name] or sources
chosen, reason = await kind.pick(need, within)
if reason is not None or not chosen:
index = await kind.index(within)
if not index and reason is not None:
# 候选里一个能查看的都没有(库还没探查表结构):没有目录可交,照实说原因,算查看没完成
unavailable.append(UNAVAILABLE_PROMPT.format(what=kind.label, reason=reason))
record["reason"] = reason
elif index or not fallback:
# 点名的来源本身不在这一轮里(目录是空的):「没找到数据源」已经说清楚了,不再补一段空目录
indexed.append(INDEX_PROMPT.format(what=kind.label, reason=reason, index=index) if reason
else EMPTY_PROMPT.format(what=kind.label, index=index))
if reason is not None:
record["reason"] = reason
else:
empty.append(kind.label)
found += [o for o in chosen if kind.key(o) not in {kind.key(f) for f in found}]
if not found and not missing:
continue
text, blocks = await kind.detail(found, budget, missing=missing)
budget = max(budget - len(text), 0)
parts.append(text)
objects += blocks
keys |= {kind.key(o) for o in found}
if unknown:
parts.append(UNKNOWN_KIND_PROMPT.format(kinds="、".join(dict.fromkeys(unknown)),
allowed="、".join(self.kinds)))
parts += indexed + unavailable
if unavailable and not indexed and not keys:
record["status"] = "failed"
elif indexed and not keys:
# 一个对象都没给出详情、交回的是目录:挑选失败(有原因)或按需求没挑出来(返回空)
record["status"] = "index"
record.setdefault("reason", EMPTY_REASON.format(what="、".join(empty)))
else:
# 给出了详情:status 是 ok,reason 只在不是 ok 时带
record.pop("reason", None)
return "\n\n".join(p for p in parts if p), objects, keys