Files
AgentLab/backend/app/api/copilot_blueprint.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

556 lines
33 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.
"""蓝图助手和填关节的流式接口(docs/design/normal-user-apps.md §2.2、§2.3、§2.11)。
- POST /api/copilot/blueprint-stream:蓝图对话。模型输出 bp_* 操作(BlueprintApplier 落地)、look、bp_ask(这一轮就此结束);
- POST /api/copilot/fill-joints-stream:按模板填生成关节(sql、metric_expr)。TemplateApplier 只收 fill_joint、只收模板声明过的
关节;不同 group 的关节并行填。
都复用 api/copilot_gen.Generation。权限按蓝图判(blueprints/store.ensure_access):共享蓝图要搭建者,且是拥有者、它发布出来
的应用的管理者或管理员;个人蓝图只给拥有者,且管理员开了个人应用。
**调用次数的预算**(N11):起草蓝图 1 次主调用加最多 2 次查看;填关节每组 1 次调用、各自最多 1 次查看(系统先代为查看
这一组要用的表,不占次数),不同的组并行。并行调用出错(多半是模型服务限流)的组,等别的组做完再按顺序试一次。
**留痕**(P2):蓝图对话每轮落一条对话记录(kind=blueprint 的 Conversation,问题和回答都进历史;助手反问的那一轮回答
记成反问,meta.asked 同问数据和画布的写法),蓝图的每个 bp_* 操作、每个填上的关节各写一条 blueprint_events。
"""
from __future__ import annotations
import asyncio
import copy
import json
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import get_session
router = APIRouter(prefix="/api/copilot", tags=["copilot-blueprint"])
logger = logging.getLogger(__name__)
#: 起草蓝图最多查看几次;填关节每组最多查看几次(N11)
BLUEPRINT_LOOK_LIMIT = 2
FILL_LOOK_LIMIT = 1
# ==========================================================================
# 写给模型的文字
# ==========================================================================
BLUEPRINT_SYSTEM_PROMPT = """\
你是应用蓝图助手。用户说想要一个应用,你把他的话整理成蓝图:几张白话卡片,每张是用户能判断对错的一件事。蓝图之后按场景模板
确定性地生成工作流和应用,你不写工作流、不写 SQL。
卡片(路径是 JSON 指针,值照下面的写法):
- /purpose 目的:一句话,这个应用解决什么问题、给谁用
- 场景:用 bp_template 选模板(见下面的模板清单),选了之后模板会预置几项默认值
- /data 看哪些数,每一项:{"id":"gmv","label":"销售额","unit":"元","table":{"source":"库名","table":"表名"},
"measure":"怎么算(白话或简单聚合)","date":{"column":"业务日期的列","rule":"按支付日"},"filter":"筛选条件","grain":"粒度"}
对到已发布的口径卡时写 "reuse"(照抄 look 给的复用写法),不写 table。table 的 source 只能是上面概览里列出的数据源名,
不要自己起名;填报登记汇总这个应用自己收上来的记录时,写 {"source":"@records","table":"records"}(记录表建好后系统换成真名)
- /cadence 多久更新:{"preset":"daily|weekly|monthly","weekday":1,"day":1,"time":"09:00","tz":"Asia/Shanghai",
"period":"yesterday|last_iso_week|last_month|today|this_iso_week|this_month"}(weekly 写 weekday,1 是周一;monthly 写 day)
- /people 谁来做,每一步:{"id":"finance","label":"财务会签","who":[{"kind":"user|group|slot|initiator","name":"…"}],
"policy":{"kind":"any|all|quorum","m":2},"due":"48 小时","on_timeout":{"action":"remind|escalate|reject","to":[…]},
"threshold":{"field":"amount","op":">","value":5000}};threshold 只许「字段 比较 常量」,字段要在「填什么」里
- /fields 填什么,每个字段:{"name":"amount","spec":{"type":"number","title":"金额(元)","minimum":0.01,"required":true},
"sensitive":false};type 只能是 string、number、integer、boolean、array,x-widget 可写 textarea、select、date 这类控件
- /writeback 写回哪里:{"capability":"能力登记里的写入能力","mapping":{"参数":"form.字段 | metric.指标 id | rows | '常量'"},
"confirm":true}
- 谁能看(/access)、写回的结果不明时谁去核实(/writeback/reconciler)由用户自己在卡片上定,你不填
规则:
- 只提议:你写的每一项都是「待确认」,用户确认了才算数;用户已经确认过的项不要改,除非他这一轮明确要改
- 只改要改的:已有的项用 bp_set 改(写到项上,例如 /data/0/table),列表用 bp_add 加、bp_remove 删
- 对到数据:先 look 数据目录里的表(kind table),对到表和业务日期的列,用 bp_set …/why 写一句为什么对到这张表;
有已发布的口径能直接用的(kind caliber),优先复用,不重写怎么算
- 缺了只有用户知道的事(按下单日还是支付日、金额线多少、谁来审批),用 bp_ask 问:一次只问一件事、给出候选,这一轮就此结束
- 不要编表名、列名、人名、能力名;看不到的就问"""
BLUEPRINT_PROTOCOL_PROMPT = """\
输出格式(严格遵守):
- 每行一个完整的 JSON 对象,除此之外不输出任何东西——不要 markdown 围栏、不要解释文字,也不要写成工具调用的格式(这里没有工具调用接口)
- 可用操作:
{"op":"plan","summary":"一句话说明打算怎么整理"} ← 第一行
{"op":"bp_template","template":{"id":"periodic_board","version":1}}
{"op":"bp_set","path":"/purpose","value":"…"}
{"op":"bp_add","path":"/data","value":{"id":"gmv","label":"销售额"}}
{"op":"bp_remove","path":"/data/1"}
{"op":"bp_set","path":"/data/0/table/why","value":"一句话说为什么对到这张表"}
{"op":"bp_ask","question":"销售额按下单日还是支付日算?","options":["按下单日","按支付日"],"path":"/data/0/date"} ← 问了就停
{"op":"done","explanation":"两三句话说明整理了什么,还缺什么"} ← 最后一行
- 这句话不需要改蓝图(只是问个问题)时,第一行也是最后一行输出 {"op":"reply","text":"直接回答的内容"}
- 要看表、口径、能力时输出一行 look(用法见下),输出之后就停下等查看结果,拿到结果再接着写"""
BLUEPRINT_LOOK_WHEN_PROMPT = """\
什么时候看:对「看哪些数」先看数据目录里的表(kind table);用户说的指标可能已经有人做过时看口径(kind caliber);
定「写回哪里」时看能力(kind capability),只能选写入能力。只改目的、谁来做、填什么的不看"""
TEMPLATES_HEAD_PROMPT = "场景模板(bp_template 选一个;卡片照约束写):"
PERSONAL_PROMPT = """\
这是使用者给自己做的个人应用:只读、只有自己看得见、自己点刷新。不能有审批步骤(不写「谁来做」),不能写回别的系统(不写
「写回哪里」),只能选下面列出的场景、用模板里「个人应用能用」的那一部分。想给别人用,告诉用户做好之后提交给搭建者审核"""
TEMPLATE_PERSONAL_PROMPT = "\n 个人应用能用:{personal}"
TEMPLATE_LINE_PROMPT = "- {id} v{version} {label}:适用于{applies}\n 卡片:{cards}"
BLUEPRINT_REQUEST_PROMPT = "这是蓝图现在的样子:\n{spec}\n\n{issues}用户说:{instruction}"
BLUEPRINT_ISSUES_PROMPT = "试运行前还要补齐的:\n{lines}\n\n"
ANSWERED_PROMPT = "(这是在回答你上一轮的反问:{question})"
FILL_SYSTEM_PROMPT = """\
你在给场景模板「{template}」填关节。模板的骨架(节点、连线、别的配置)已经按用户确认的蓝图定好、锁住了,你只能填下面列出的
这几个位置:取数的 SQL(只读的一条 SELECT)、口径卡的指标式(只许 cell() 取格加算术)。别的位置改不了,改了也不会被采纳。
输出格式(严格遵守):
- 每行一个完整的 JSON 对象,除此之外不输出任何东西
- 可用操作:
{{"op":"plan","summary":"一句话说明打算怎么写"}} ← 第一行
{{"op":"fill_joint","joint":"关节 id","value":"SQL 或指标式"}} ← 每个关节一行,整段写完
{{"op":"done","explanation":"一两句话说明写了什么、有什么拿不准的"}} ← 最后一行
- 要看表结构时输出一行 look(用法见下),输出之后就停下等查看结果;要用的表系统已经代为查看过一次,下面就有
- 拿不准的按最合理的写,在 done 的说明里写清楚;不要问用户"""
FILL_REQUEST_PROMPT = "这一组要填的关节:\n{joints}\n\n说明:\n{brief}\n\n模板的骨架(只读):\n{graph}"
FILL_JOINT_LINE_PROMPT = "- {id}:{label}({kind})"
FILL_SYSTEM_LOOK_NEED = "这一组关节要用的表"
FILL_KIND_PROMPT = {"sql": "SQL", "metric_expr": "指标式"}
#: 交付时写进关节状态的说明(界面上照原话显示)
FILL_FAILED_NOTE = "助手填的没通过检查:{reason}。请补充说明或换个说法再试"
FILL_ERROR_NOTE = "助手这一组没填完:{reason}。可以重试"
FILL_STALE_NOTE = "填的时候卡片又改过了,请重新填"
class BlueprintStreamIn(BaseModel):
blueprint_id: str
instruction: str = Field(min_length=1, description="用自然语言说想要什么、要怎么改,或者回答助手的反问")
provider: str | None = None
model: str | None = None
class FillJointsIn(BaseModel):
blueprint_id: str
#: 只填这几组(不给就填还没填上的那几组)
groups: list[str] | None = None
provider: str | None = None
model: str | None = None
def _sse(obj: dict[str, Any]) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False, default=str)}\n\n"
def _streaming(gen: Any) -> Any:
from fastapi.responses import StreamingResponse
return StreamingResponse(gen, media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
async def _model(session: AsyncSession, provider: str | None, model: str | None) -> tuple[Any, str, Any]:
"""按操作流写的模型(同改图助手的额度和退回),和挑选模型的规格。没配接入 400。"""
from app.api import copilot
request = copilot.GenerateIn(instruction="-", provider=provider, model=model)
try:
op_model, model_id = await copilot._op_model(session, request)
except copilot.ProviderNotConfigured as e:
raise HTTPException(400, copilot._unconfigured(e)) from e
spec = await copilot.copilot_model_spec(session, request, max_tokens=copilot.copilot_context.PICK_MAX_TOKENS)
return op_model, model_id, spec
def _templates_text(*, personal: bool = False) -> str:
"""模板清单(给助手选场景)。个人蓝图只列能做成个人应用的那几个,并写明能用哪一部分。"""
from app.blueprints.templates import list_templates
lines = [PERSONAL_PROMPT, TEMPLATES_HEAD_PROMPT] if personal else [TEMPLATES_HEAD_PROMPT]
for t in list_templates():
if personal and t.personal is None:
continue
lines.append(TEMPLATE_LINE_PROMPT.format(id=t.id, version=t.version, label=t.label, applies=t.applies_when,
cards=";".join(f"{k} {v}" for k, v in t.cards.items()))
+ (TEMPLATE_PERSONAL_PROMPT.format(personal=t.personal) if personal else ""))
return "\n".join(lines)
def _actor(x_actor: str | None) -> tuple[str | None, bool]:
from app.api.deps import actor_of, actor_verified
return actor_of(x_actor), actor_verified()
# ==========================================================================
# 蓝图对话
# ==========================================================================
async def _conversation(session: AsyncSession, blueprint: Any, actor: str | None, title: str) -> Any:
"""这张蓝图的对话(kind=blueprint):没有就建一段挂上去。对话按人隔离的规则照旧(api/conversations)。"""
from app.api.deps import ensure_conversation_access
from app.db.models import Conversation
if blueprint.conversation_id:
row = await ensure_conversation_access(session, blueprint.conversation_id)
if row is not None:
return row
from app.api.conversations import title_from
row = Conversation(kind="blueprint", title=blueprint.name or title_from(title), created_by=actor)
session.add(row)
await session.flush()
blueprint.conversation_id = row.id
await session.commit()
return row
async def _open_turn(session: AsyncSession, conversation: Any, question: str) -> str:
from sqlalchemy import func, select
from app.chat.job import hold_conversation
from app.db.base import utcnow
from app.db.models import ConversationTurn
# 先占住这段对话再取最大值往下接(同 api/conversations 开一轮):多进程时同时两次开轮不撞号
await hold_conversation(session, conversation.id)
seq = int((await session.execute(select(func.coalesce(func.max(ConversationTurn.seq), -1))
.where(ConversationTurn.conversation_id == conversation.id))).scalar_one()) + 1
turn = ConversationTurn(conversation_id=conversation.id, seq=seq, question=question, status="running")
session.add(turn)
conversation.last_active_at = utcnow()
await session.commit()
return turn.id
async def _close_turn(turn_id: str, *, status: str, answer: str = "", error: str = "",
meta: dict[str, Any] | None = None) -> None:
"""回填这一轮(另开短会话:流里请求的会话已经不能用了)。"""
from app.db.base import SessionLocal
from app.db.models import ConversationTurn
async with SessionLocal() as session:
turn = await session.get(ConversationTurn, turn_id)
if turn is None:
return
turn.status, turn.answer, turn.error = status, answer, error
if meta is not None:
turn.meta = meta
await session.commit()
def _open_ask(spec: dict[str, Any]) -> tuple[int, dict[str, Any]] | None:
"""上一轮的反问还没回答:这一轮用户的话就是在回答它。只看最后一个(更早的反问后面已经有过一轮了)。"""
asks = spec.get("asks") or []
if asks and not asks[-1].get("answer"):
return len(asks) - 1, asks[-1]
return None
def _as_bp_ask(op: dict[str, Any]) -> dict[str, Any]:
"""模型写成了通用的 ask(画布、问数据的写法)也认:换成 bp_ask 记进蓝图的反问。"""
options = op.get("options") if isinstance(op.get("options"), list) else []
return {"op": "bp_ask", "question": str(op.get("question") or ""),
"options": [str(o) for o in options if isinstance(o, (str, int, float))][:6],
**({"path": op["path"]} if isinstance(op.get("path"), str) else {})}
@router.post("/blueprint-stream")
async def blueprint_stream(payload: BlueprintStreamIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""蓝图对话:SSE 推送,形状同 /generate-stream。
事件:model;模型的 plan、bp_* 操作(已经落到服务端维护的那一份蓝图上,来源记 assistant)、look、thinking、heartbeat;
bp_ask(反问,这一轮就此结束);自查的 check;最后 final {blueprint, explanation, issues, asked?}——blueprint 是存好的
蓝图(typesBlueprint.Blueprint),issues 是这一轮没采纳的操作(越界、和卡片冲突)。只回答了一句的发 reply 收尾。
"""
from app.api import copilot
from app.blueprints import store
from app.blueprints.generation import BlueprintApplier, BlueprintChecker
from app.blueprints.ops import blueprint_issues
from app.look.calibers import CaliberKind
from app.look.capabilities import CapabilityKind
from app.look.pick import Picker
from app.look.service import LookService
from app.look.tables import TableKind
row = await store.load(session, payload.blueprint_id)
actor, verified = _actor(x_actor)
op_model, model_id, pick_spec = await _model(session, payload.provider, payload.model)
conversation = await _conversation(session, row, actor, payload.instruction)
history = await copilot._history_section(session, conversation.id)
# 用户这一轮的话是在回答上一轮的反问:先记下回答(来源 user),助手看到的就是答过的那一份
instruction = payload.instruction.strip()
if (open_ask := _open_ask(row.spec or {})) is not None:
await store.apply_ops_and_save(session, row, [{"op": "bp_set", "path": f"/asks/{open_ask[0]}/answer",
"value": instruction[:500]}], actor, verified, "user")
instruction = f"{instruction}\n{ANSWERED_PROMPT.format(question=open_ask[1]['question'])}"
turn_id = await _open_turn(session, conversation, payload.instruction)
blueprint_id = row.id
spec = copy.deepcopy(row.spec or {})
picker = Picker(pick_spec)
personal = row.scope == "personal"
calibers = await CaliberKind.load(session, picker=picker)
# 个人蓝图不写回别的系统:能力只列只读的(同使用者的问数据,P4)
looks = LookService([await TableKind.load(await copilot._sources(session, None), picker=picker), calibers,
await CapabilityKind.load(picker=picker, read_only=personal)])
system = "\n\n".join(part for part in (
BLUEPRINT_SYSTEM_PROMPT, _templates_text(personal=personal), await looks.overview(), BLUEPRINT_PROTOCOL_PROMPT,
looks.guide(), BLUEPRINT_LOOK_WHEN_PROMPT) if part)
missing = [i for i in blueprint_issues(spec, for_="trial", graph=row.graph) if i["level"] == "error"]
human = copilot._with_history(BLUEPRINT_REQUEST_PROMPT.format(
spec=json.dumps(spec, ensure_ascii=False), instruction=instruction,
issues=BLUEPRINT_ISSUES_PROMPT.format(lines="\n".join(f"- {i['path']} {i['message']}" for i in missing))
if missing else ""), history)
applier = BlueprintApplier(spec, calibers=calibers)
checker = BlueprintChecker(applier, template_id=row.template_id)
started = time.monotonic()
gen = copilot.Generation(op_model, system=system, applier=applier, checker=checker, looks=looks, started=started,
look_limit=BLUEPRINT_LOOK_LIMIT)
async def event_stream():
from app.db.base import SessionLocal
from app.db.models_blueprints import Blueprint
yield _sse({"op": "model", "model": model_id})
try:
async for op in copilot._with_heartbeat(gen.draft(human), started=started):
yield _sse(op)
if applier.replied is not None and not applier.effective and gen.asked is None:
yield _sse(applier.replied)
await _close_turn(turn_id, status="done", answer=str(applier.replied.get("text") or ""),
meta={"looks": looks.records})
return
if gen.asked is None and gen.done is None and not applier.effective:
raise gen.empty(hint="蓝图未修改。可以重试,或把需求描述得更具体")
async for op in copilot._with_heartbeat(gen.repair(), phase="repairing", started=started):
yield _sse(op)
remaining = await checker.issues() if gen.asked is None else []
yield _sse({"op": "check", "status": "failed" if remaining else "passed", "repaired": gen.repaired,
**({"issues": remaining} if remaining else {})})
asked = _as_bp_ask(gen.asked) if gen.asked is not None else None
async with SessionLocal() as own:
target = await own.get(Blueprint, blueprint_id)
saved = await store.apply_ops_and_save(own, target, [*applier.effective, *([asked] if asked else [])],
actor, verified, "assistant", partial=True)
out = await store.blueprint_view(own, target)
conflicts = [{"code": r["code"], "level": "warning", "path": r["op"].get("path"),
"message": f"助手提的这一处没有采纳:{r['message']}"} for r in saved.refused]
answer = applier.explanation or (asked["question"] if asked else "")
await _close_turn(turn_id, status="done", answer=answer, meta={
"looks": looks.records, "calls": gen.calls,
**({"asked": {"question": asked["question"], "options": asked["options"]}} if asked else {})})
yield _sse({"op": "final", "blueprint": out, "explanation": applier.explanation,
"issues": [*applier.refusals(), *conflicts],
**({"asked": {"question": asked["question"], "options": asked["options"]}} if asked else {})})
except copilot.CopilotIncomplete as e:
await _close_turn(turn_id, status="error", error=str(e))
yield _sse({"op": "error", "message": f"助手本轮未完成:{e}", "hint": e.hint, "detail": e.detail})
except Exception as e: # noqa: BLE001
reason, hint = copilot.explain_error(e)
logger.exception("蓝图助手这一轮没完成")
await _close_turn(turn_id, status="error", error=reason)
yield _sse({"op": "error", "message": f"助手本轮未完成:{reason}", "hint": hint,
"detail": copilot.raw_error(e)})
return _streaming(event_stream())
# ==========================================================================
# 填关节
# ==========================================================================
def _slim(graph: dict[str, Any]) -> dict[str, Any]:
"""给模型看的骨架:节点的 id、类型、标题、配置和连线,不带坐标。"""
return {"nodes": [{"id": n["id"], "type": n["type"], "label": (n.get("data") or {}).get("label"),
"config": (n.get("data") or {}).get("config")} for n in graph.get("nodes") or []],
"edges": [{k: e.get(k) for k in ("source", "target", "sourceHandle") if e.get(k)}
for e in graph.get("edges") or []]}
def _group_tables(spec: dict[str, Any], joints: list[Any], graph: dict[str, Any]) -> list[dict[str, str]]:
"""这一组要用的表(系统代为查看):组里取数节点查的库 × 「看哪些数」里对到这个库的表。"""
from app.blueprints.instantiate import node_config
sources = {str((node_config(graph, j.node_id) or {}).get("tool") or "").removeprefix("db_query__")
for j in joints if j.kind == "sql"}
out: list[dict[str, str]] = []
for item in spec.get("data") or []:
table = (item.get("table") or {}).get("value")
if isinstance(table, dict) and table.get("source") in sources:
target = {"kind": "table", "source": table["source"], "name": table["table"]}
if target not in out:
out.append(target)
for source in sources:
if not any(t["source"] == source for t in out):
out.append({"kind": "table", "source": source})
return out
@router.post("/fill-joints-stream")
async def fill_joints_stream(payload: FillJointsIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> Any:
"""按模板填生成关节:SSE 推送。不同 group 的关节并行填,每组各自一个 Generation,事件都带 group。
事件:model;每组的 group {status: started}、系统代为查看的 look(by=system)、plan、fill_joint、look、check、
group {status: filled | failed, issues?, message?};最后 final {blueprint, groups, problems}——blueprint 是存好的蓝图,
图和关节状态都更新了;失败的组照实报,留占位(标「待填」),不挡别的组。"""
from app.api import copilot
from app.blueprints import store
from app.blueprints.generation import JOINT_REFUSED, TemplateApplier, TemplateChecker
from app.blueprints.instantiate import filled_from, generated_groups, instantiate
from app.blueprints.templates import get_template
from app.data.sqlcheck import load_checkers
from app.look.pick import Picker
from app.look.service import LookService
from app.look.tables import TableKind
row = await store.load(session, payload.blueprint_id)
if not ((row.spec or {}).get("template") or None):
raise HTTPException(409, "这张蓝图还没选场景,没有要填的关节:先说说要做什么,或者从场景模板开始")
actor, verified = _actor(x_actor)
op_model, model_id, pick_spec = await _model(session, payload.provider, payload.model)
spec = copy.deepcopy(row.spec)
# 填报登记要按记录表的结构写 SQL:起草这边的那一版还没建的先建(和试运行同一个入口;草稿不影响线上那一版)
from app.api.coded import CodedHTTPException
from app.records.store import RecordsRefused
try:
ctx = await store.instantiate_context(session, row, create_records=True)
except RecordsRefused as e:
await session.rollback()
raise CodedHTTPException(422, e.message, e.code) from e
await session.commit()
keep = filled_from(spec, row.joints or {}, graph=ctx.get("graph"), records=ctx.get("records"))
inst = instantiate(spec, filled=keep, **ctx)
groups = generated_groups(inst)
wanted = [g for g in (payload.groups or []) if g in groups] if payload.groups else \
[g for g, joints in groups.items() if any(inst.joints[j.id]["status"] != "filled" for j in joints)]
if payload.groups and not wanted:
raise HTTPException(400, f"这张蓝图没有这些关节组:{'、'.join(payload.groups)}(有的是:{'、'.join(groups) or '没有'})")
sources = await copilot._sources(session, None)
checkers = await load_checkers(session, sources)
tables = await TableKind.load(sources, picker=Picker(pick_spec))
template = get_template(str(spec["template"]["id"]))
system = FILL_SYSTEM_PROMPT.format(template=template.label if template else spec["template"]["id"]) \
+ await copilot.copilot_context.section(LookService([tables])) + "\n\n" + LookService([tables]).guide()
blueprint_id = row.id
started = time.monotonic()
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
results: dict[str, dict[str, Any]] = {}
async def fill(group: str) -> None:
joints = groups[group]
applier = TemplateApplier(copy.deepcopy(inst.graph), inst.joint_defs, joints)
checker = TemplateChecker(applier, skeleton=inst.skeleton, sources=sources, checkers=checkers)
looks = LookService([tables])
gen = copilot.Generation(op_model, system=system, applier=applier, checker=checker, looks=looks,
started=started, look_limit=FILL_LOOK_LIMIT)
await queue.put({"op": "group", "group": group, "status": "started", "joints": [j.id for j in joints]})
try:
request = FILL_REQUEST_PROMPT.format(
joints="\n".join(FILL_JOINT_LINE_PROMPT.format(id=j.id, label=j.label, kind=FILL_KIND_PROMPT[j.kind])
for j in joints),
brief=inst.briefs.get(group, ""), graph=json.dumps(_slim(inst.skeleton), ensure_ascii=False))
targets = _group_tables(spec, joints, inst.graph)
if targets:
looked = await looks.look({"op": "look", "need": FILL_SYSTEM_LOOK_NEED, "targets": targets},
by="system")
if looked.record is not None:
await queue.put({**looked.record, "group": group})
request = f"{request}\n\n{looked.text}"
async for op in gen.draft(request):
await queue.put({**op, "group": group})
async for op in gen.repair():
await queue.put({**op, "group": group})
# 越界被拒的不算这一组没填好:没采纳的那一处骨架原样留着,照实列给人看(refusals)
remaining = [i for i in await checker.issues() if i["code"] != JOINT_REFUSED]
results[group] = {"values": dict(applier.values), "issues": remaining, "error": None,
"refusals": applier.refusals(), "calls": gen.calls}
await queue.put({"op": "group", "group": group, "status": "failed" if remaining else "filled",
**({"issues": remaining} if remaining else {})})
except Exception as e: # noqa: BLE001 - 一组失败不挡别的组,照实报
reason, _ = copilot.explain_error(e)
logger.exception("填关节的一组没完成:%s", group)
results[group] = {"values": {}, "issues": [], "error": reason, "refusals": [], "calls": gen.calls}
await queue.put({"op": "group", "group": group, "status": "failed", "message": reason})
async def run_all() -> None:
try:
await asyncio.gather(*(fill(g) for g in wanted))
# 并行调用出错的组(多半是模型服务限流)退回串行:等别的组做完,再按顺序各试一次
for g in [g for g in wanted if results.get(g, {}).get("error")]:
await queue.put({"op": "group", "group": g, "status": "retrying"})
await fill(g)
finally:
await queue.put(None)
async def drained():
while (item := await queue.get()) is not None:
yield item
async def event_stream():
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal
from app.db.models_blueprints import Blueprint
from app.plain import with_plain
yield _sse({"op": "model", "model": model_id})
task = asyncio.create_task(run_all())
try:
async for item in copilot._with_heartbeat(drained(), phase="building", started=started):
yield _sse(item)
await task
filled = dict(keep)
notes: dict[str, str] = {}
for g in wanted:
result = results.get(g) or {"values": {}, "issues": [], "error": "没有结果"}
ids = [j.id for j in groups[g]]
if result["error"] is None and not result["issues"]:
filled.update({jid: result["values"][jid] for jid in ids if jid in result["values"]})
continue
why = (FILL_ERROR_NOTE.format(reason=result["error"]) if result["error"] else
FILL_FAILED_NOTE.format(reason=result["issues"][0].get("message") or ""))
notes.update({jid: why for jid in ids if jid not in keep})
async with SessionLocal() as own:
target = await own.get(Blueprint, blueprint_id)
ctx_now = await store.instantiate_context(own, target)
done = instantiate(target.spec or {}, filled=filled, **ctx_now)
for g in wanted:
if done.basis.get(g) != inst.basis.get(g):
# 填的时候卡片又改过:这一组按旧的说明填的,不算数
filled = {k: v for k, v in filled.items() if k not in {j.id for j in groups[g]}}
notes.update({j.id: FILL_STALE_NOTE for j in groups[g]})
done = instantiate(target.spec or {}, filled=filled, **ctx_now)
joints = {jid: ({**state, "note": notes[jid]} if jid in notes and state["status"] != "filled" else state)
for jid, state in done.joints.items()}
for g in wanted:
for j in groups[g]:
state = joints.get(j.id) or {}
await store.record_event(own, target, op="fill_joint", actor=actor, verified=verified, data={
"joint": j.id, "group": g, "by": "assistant", "status": state.get("status"),
**({"note": state["note"]} if state.get("note") else {})})
target.graph = done.graph
target.graph_hash = graph_hash(done.graph)
target.joints = joints
target.updated_by = actor
await own.commit()
out = await store.blueprint_view(own, target)
yield _sse({"op": "final", "blueprint": out,
"groups": {g: {"status": "filled" if not (results.get(g) or {}).get("error")
and not (results.get(g) or {}).get("issues") else "failed",
"calls": (results.get(g) or {}).get("calls", 0),
"refusals": (results.get(g) or {}).get("refusals", [])} for g in wanted},
"problems": [with_plain(p) for p in done.problems]})
except Exception as e: # noqa: BLE001
reason, hint = copilot.explain_error(e)
logger.exception("填关节没完成")
yield _sse({"op": "error", "message": f"助手本轮未完成:{reason}", "hint": hint,
"detail": copilot.raw_error(e)})
finally:
if not task.done():
task.cancel()
return _streaming(event_stream())