diff --git a/app/observability/tracing.py b/app/observability/tracing.py index 3886724..addd611 100644 --- a/app/observability/tracing.py +++ b/app/observability/tracing.py @@ -77,7 +77,8 @@ async def ainvoke_screen( from langfuse.langchain import CallbackHandler from langgraph.types import Command - handler = CallbackHandler() + # 明确回调所属项目,避免进程中存在多个客户端时跳过追踪 + handler = CallbackHandler(public_key=os.getenv("LANGFUSE_PUBLIC_KEY")) # 在当前异步上下文执行回调,手动记录的 Jev generation 才会挂到所属节点。 handler.run_inline = True resume = isinstance(payload, Command) and payload.resume is not None @@ -156,16 +157,16 @@ def observe_decision(request: Any) -> Iterator[Any]: 只有图回调已经挂上当前 span 时才记。没有父 span 就跳过,避免 Jev 单独变成一条 trace 这里不吞异常:失败时标 ERROR 后原样抛出,调用方的 DecisionError 类型不变 """ - if not tracing_enabled() or _ensure_client() is None or not _has_active_span(): + client = _ensure_client() if tracing_enabled() else None + if client is None or not _has_active_span(): yield None return - from langfuse import get_client - model = request.model or settings.typesafe_model # 意图判断用固定名字,方便以后按名字做评测。别的决策题不要都叫 classify-intent name = "classify-intent" if set(request.decisions) == {"intent"} else "run-decision" - with get_client().start_as_current_observation( + # 复用本应用已经选定的客户端 + with client.start_as_current_observation( as_type="generation", name=name, model=model, diff --git a/app/processor/graph.py b/app/processor/graph.py index ac0f756..cc84aa5 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -6,19 +6,19 @@ from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.graph import END, StateGraph +from app.processor.nodes.action_node import ActionNode, can_generate_actions from app.processor.nodes.clarify_node import ClarifyNode +from app.processor.nodes.component_resource_node import ComponentResourceNode +from app.processor.nodes.fallback_node import FallbackNode from app.processor.nodes.input_node import InputNode from app.processor.nodes.intent_node import IntentNode -from app.processor.nodes.requirement_node import RequirementNode -from app.processor.nodes.wait_for_user_node import WaitForUserNode -from app.processor.nodes.qa_node import QANode -from app.processor.nodes.scope_node import ScopeNode -from app.processor.nodes.action_node import ActionNode, can_generate_actions -from app.processor.nodes.validator_node import ValidatorNode -from app.processor.nodes.reflection_node import ReflectionNode -from app.processor.nodes.fallback_node import FallbackNode from app.processor.nodes.planner_node import PlannerNode -from app.processor.nodes.component_resource_node import ComponentResourceNode +from app.processor.nodes.qa_node import QANode +from app.processor.nodes.reflection_node import ReflectionNode +from app.processor.nodes.requirement_node import RequirementNode +from app.processor.nodes.scope_node import ScopeNode +from app.processor.nodes.validator_node import ValidatorNode +from app.processor.nodes.wait_for_user_node import WaitForUserNode from app.processor.state import QueryState @@ -207,6 +207,7 @@ query_app = build_graph() if __name__ == "__main__": + import argparse import asyncio import json from uuid import uuid4 @@ -217,11 +218,25 @@ if __name__ == "__main__": from app.clients.decision import close_decision_engine from app.observability.tracing import ainvoke_screen, flush_tracing + # 选择场景时只切换用户需求,共用同一份画布,方便比较执行结果 + examples = { + "move": "把销售额统计向右移动 50px,其他内容保持不变", + "layout": ( + "重新排列现有两个图表,销售额统计放在左侧,销售占比放在右侧," + "统一尺寸为 600×400,画布改为 1600×900,不新增或删除组件" + ), + "add": "保留现有组件,在下方新增一个柱状图,使用物料示例数据,不需要真实数据绑定", + "clarify": "这个图不太对", + } + parser = argparse.ArgumentParser(description="运行大屏 Agent 完整流程示例") + parser.add_argument("--case", choices=examples, default="move", help="选择联调场景") + args = parser.parse_args() + async def main(): - # 1. 修改请求,测试完整流程 + # 1. 创建请求,每次运行使用独立会话,避免混入之前的澄清历史 state: QueryState = { "request": { - "prompt": "帮我修改销售额统计这个图,但具体改哪里我还没说,请先问我", + "prompt": examples[args.case], "screen": { "canvas": {"width": 1920, "height": 1080}, "nodes": [ @@ -231,7 +246,19 @@ if __name__ == "__main__": "name": "销售额统计", "layout": {"x": 26, "y": 34.8, "width": 400, "height": 260}, "props": {"option": {"title": {"text": "销售额统计"}}}, - } + }, + { + "id": "sales-share", + "type": "pie-chart", + "name": "销售占比", + "layout": { + "x": 500, + "y": 34.8, + "width": 400, + "height": 260, + }, + "props": {"option": {"title": {"text": "销售占比"}}}, + }, ], "dataSource": [], }, @@ -245,10 +272,15 @@ if __name__ == "__main__": state["request"].get("context", {}).get("conversation_id") or thread_id ) + print(f"场景:{args.case}\n需求:{examples[args.case]}") + if args.case == "clarify": + print("澄清回答示例:把销售额统计向右移动 50px,其他内容保持不变") + try: final_state = await ainvoke_screen( query_app, state, thread_id=thread_id, session_id=session_id ) + # 3. 展示真实澄清问题,用同一个 thread_id 恢复流程 while final_state.get("__interrupt__"): pending = final_state["__interrupt__"][0].value print(pending["question"]) @@ -277,15 +309,24 @@ if __name__ == "__main__": await close_decision_engine() flush_tracing() - # 3. 打印完整 state + # 4. 聚焦最终结果,校验失败时 simulated_screen 应为 None + result = { + "status": final_state.get("status"), + "intent": final_state.get("intent"), + # 保留需求与规划,方便定位模型输出和执行能力之间的不一致 + "requirement": final_state.get("requirement"), + "screen_plan": final_state.get("screen_plan"), + "validation_passed": final_state.get("validation_passed"), + "retry_count": final_state.get("retry_count", 0), + "failure_reason": final_state.get("failure_reason"), + "validation_errors": final_state.get("validation_errors", []), + "actions": final_state.get("actions", []), + "simulated_screen": final_state.get("simulated_screen"), + "answer": final_state.get("answer"), + } print( json.dumps( - { - key: value.model_dump(mode="json", exclude_unset=True) - if isinstance(value, BaseModel) - else value - for key, value in final_state.items() - }, + result, ensure_ascii=False, indent=2, default=lambda value: ( @@ -295,5 +336,8 @@ if __name__ == "__main__": ), ) ) + # 原始坐标单独展示,确认后端试执行没有修改请求中的画布 + original = state["request"]["screen"]["nodes"][0]["layout"] + print(f"原始销售额坐标:{original['x']},{original['y']}") asyncio.run(main()) diff --git a/app/processor/nodes/clarify_node.py b/app/processor/nodes/clarify_node.py index 21f61f6..2061ea9 100644 --- a/app/processor/nodes/clarify_node.py +++ b/app/processor/nodes/clarify_node.py @@ -2,7 +2,9 @@ import json +from langchain_core.exceptions import OutputParserException from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import ValidationError from app.clients.llm import get_llm from app.processor.base import BaseNode @@ -28,14 +30,39 @@ class ClarifyNode(BaseNode): payload["requirement"] = state["requirement"].model_dump(mode="json") llm_client = get_llm(Clarification) - clarification = await llm_client.ainvoke( - [ - SystemMessage(content=CLARIFY_SYSTEM_PROMPT), - HumanMessage(content=json.dumps(payload, ensure_ascii=False)), - ] - ) + messages = [ + SystemMessage(content=CLARIFY_SYSTEM_PROMPT), + HumanMessage(content=json.dumps(payload, ensure_ascii=False)), + ] + + # 格式错误重试一次 + for attempt in range(2): + try: + clarification = await llm_client.ainvoke(messages) + break + except (ValidationError, OutputParserException): + if attempt == 0: + # 要求重新生成完整问题,避免截断选项改变原意 + messages.append( + HumanMessage( + content=( + "上次输出未通过 Clarification 格式校验,请重新生成完整结果:" + "question 为非空问题,options 为空或含 2–3 项," + "每项包含唯一 id、非空 label 和 description,最多一个 recommended=true" + ) + ) + ) + continue + # 连续格式失败时退回自由回答,需求阶段沿用已确定的问题 + requirement = state.get("requirement") if intent != "unclear" else None + question = ( + requirement.clarification_question if requirement else None + ) or "你希望我对当前大屏做什么?可以描述想修改的内容,或想了解的问题" + clarification = Clarification(question=question) updated_state["clarification"] = clarification + # 在进入 interrupt 前保存等待状态,暂停中的节点尚不会返回更新 + updated_state["status"] = "waiting_for_input" return updated_state diff --git a/app/processor/nodes/input_node.py b/app/processor/nodes/input_node.py index ba5f7c2..37a3d0a 100644 --- a/app/processor/nodes/input_node.py +++ b/app/processor/nodes/input_node.py @@ -68,6 +68,8 @@ class InputNode(BaseNode): updated_state["retry_count"] = 0 updated_state["repair_plan"] = None updated_state["failure_reason"] = None + # 首次请求进入处理状态,恢复澄清时也使用同一状态 + updated_state["status"] = "running" return updated_state diff --git a/app/processor/nodes/qa_node.py b/app/processor/nodes/qa_node.py index 9fbc204..43f9009 100644 --- a/app/processor/nodes/qa_node.py +++ b/app/processor/nodes/qa_node.py @@ -28,6 +28,8 @@ class QANode(BaseNode): if not answer: raise ValueError("问答模型未返回文本回答") updated_state["answer"] = answer + # 回答生成完成,问答分支在此结束 + updated_state["status"] = "completed" return updated_state diff --git a/app/processor/nodes/validator_node.py b/app/processor/nodes/validator_node.py index dff6448..2353585 100644 --- a/app/processor/nodes/validator_node.py +++ b/app/processor/nodes/validator_node.py @@ -15,6 +15,8 @@ class ValidatorNode(BaseNode): def process(self, state: QueryState) -> QueryState: updated_state: QueryState = state.copy() + # 校验失败仍可能进入修正循环,最终失败由 Fallback 标记 + updated_state["status"] = "running" actions = state["actions"] try: # 重新校验模型内容,避免赋值后绕过字段校验 @@ -130,6 +132,8 @@ class ValidatorNode(BaseNode): updated_state["validation_errors"] = [] # 保存整批动作执行后的 JSON,供后续检查,不代表前端已经完成更新 updated_state["simulated_screen"] = screen + # 仅表示后端动作已通过校验,前端执行结果需另行反馈 + updated_state["status"] = "completed" return updated_state diff --git a/app/processor/state.py b/app/processor/state.py index 813f10f..994ab4d 100644 --- a/app/processor/state.py +++ b/app/processor/state.py @@ -49,5 +49,5 @@ class QueryState(TypedDict): ] # 待合并的用户回答或取消指令 answer: NotRequired[str | None] # 问答节点生成的回答或建议 status: NotRequired[ - Literal["running", "cancelled", "failed"] - ] # 流程继续、用户取消或动作修正失败 + Literal["running", "waiting_for_input", "completed", "cancelled", "failed"] + ] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行 diff --git a/app/prompt/clarify.py b/app/prompt/clarify.py index 11aae26..779855b 100644 --- a/app/prompt/clarify.py +++ b/app/prompt/clarify.py @@ -9,7 +9,7 @@ CLARIFY_SYSTEM_PROMPT = """## 任务 结合 clarification_history,不重复询问已回答的信息 ## 回答方式 -适合选择时提供互斥选项,简短说明各选项的影响,有依据才推荐 +适合选择时提供 2–3 个互斥选项,简短说明各选项的影响,有依据才推荐 需要具体数值或自由描述时,options 留空;自由输入由客户端提供,不生成“其他”选项 用户没有思路时,可提供先分析并给建议的选项,不将其视为授权修改 diff --git a/app/prompt/intent.py b/app/prompt/intent.py index 4b147fb..98f1803 100644 --- a/app/prompt/intent.py +++ b/app/prompt/intent.py @@ -8,7 +8,9 @@ def build_intent_question() -> ChoiceDecision: # 区分意图模糊与修改细节缺失,后者交给需求理解 instructions=( "Classify the user's primary intent. " - "Read `prompt` together with `clarification_history`; later answers clarify or correct earlier requests. " + "Classify the latest user answer in `clarification_history`, or `prompt` when there is no history. " + "Use earlier turns and the selected option to resolve references; the latest answer overrides earlier conflicting intent. " + "Once the requested action or response is clear, earlier ambiguity does not require another confirmation. " "Choose modify_screen only when the user explicitly requests an actual edit. " "Choose qa for clear requests for a textual answer or general conversation, including information, explanation, diagnosis, advice, and assistant identity or capabilities. " "Requests need not be phrased as questions; being unrelated to screens does not make an intent unclear. " @@ -24,6 +26,6 @@ def build_intent_question() -> ChoiceDecision: # 明确的文字回答或交流请求,包括助手身份和能力,但没有要求实际操作大屏 "qa": "Clearly request a textual answer or general conversation without requesting screen creation or an actual edit; includes assistant identity and capabilities, information, reasons, diagnosis, and suggestions, e.g. 这个图为什么没有数据 or 这个图怎么优化", # 语义上没有明确表达要问什么或做什么,不等同于模型分数低 - "unclear": "The user's desired response or action cannot be determined from the request and clarification history, so clarification is needed to decide what to do; includes vague feedback, references, or an unspecified request to look, e.g. 这个图不太对, 这个有点问题, 帮我看看这个", + "unclear": "The user's desired response or action is still undetermined after incorporating the latest answer and selected option; vague feedback such as 这个图不太对 is unclear only while subsequent answers have not resolved what to do", }, )