bugfix:修复部分提示词导致的格式错误问题及langfuse客户端复用bug
This commit is contained in:
@@ -77,7 +77,8 @@ async def ainvoke_screen(
|
|||||||
from langfuse.langchain import CallbackHandler
|
from langfuse.langchain import CallbackHandler
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
|
|
||||||
handler = CallbackHandler()
|
# 明确回调所属项目,避免进程中存在多个客户端时跳过追踪
|
||||||
|
handler = CallbackHandler(public_key=os.getenv("LANGFUSE_PUBLIC_KEY"))
|
||||||
# 在当前异步上下文执行回调,手动记录的 Jev generation 才会挂到所属节点。
|
# 在当前异步上下文执行回调,手动记录的 Jev generation 才会挂到所属节点。
|
||||||
handler.run_inline = True
|
handler.run_inline = True
|
||||||
resume = isinstance(payload, Command) and payload.resume is not None
|
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
|
只有图回调已经挂上当前 span 时才记。没有父 span 就跳过,避免 Jev 单独变成一条 trace
|
||||||
这里不吞异常:失败时标 ERROR 后原样抛出,调用方的 DecisionError 类型不变
|
这里不吞异常:失败时标 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
|
yield None
|
||||||
return
|
return
|
||||||
|
|
||||||
from langfuse import get_client
|
|
||||||
|
|
||||||
model = request.model or settings.typesafe_model
|
model = request.model or settings.typesafe_model
|
||||||
# 意图判断用固定名字,方便以后按名字做评测。别的决策题不要都叫 classify-intent
|
# 意图判断用固定名字,方便以后按名字做评测。别的决策题不要都叫 classify-intent
|
||||||
name = "classify-intent" if set(request.decisions) == {"intent"} else "run-decision"
|
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",
|
as_type="generation",
|
||||||
name=name,
|
name=name,
|
||||||
model=model,
|
model=model,
|
||||||
|
|||||||
+63
-19
@@ -6,19 +6,19 @@ from langgraph.checkpoint.memory import InMemorySaver
|
|||||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||||
from langgraph.graph import END, StateGraph
|
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.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.input_node import InputNode
|
||||||
from app.processor.nodes.intent_node import IntentNode
|
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.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
|
from app.processor.state import QueryState
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +207,7 @@ query_app = build_graph()
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -217,11 +218,25 @@ if __name__ == "__main__":
|
|||||||
from app.clients.decision import close_decision_engine
|
from app.clients.decision import close_decision_engine
|
||||||
from app.observability.tracing import ainvoke_screen, flush_tracing
|
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():
|
async def main():
|
||||||
# 1. 修改请求,测试完整流程
|
# 1. 创建请求,每次运行使用独立会话,避免混入之前的澄清历史
|
||||||
state: QueryState = {
|
state: QueryState = {
|
||||||
"request": {
|
"request": {
|
||||||
"prompt": "帮我修改销售额统计这个图,但具体改哪里我还没说,请先问我",
|
"prompt": examples[args.case],
|
||||||
"screen": {
|
"screen": {
|
||||||
"canvas": {"width": 1920, "height": 1080},
|
"canvas": {"width": 1920, "height": 1080},
|
||||||
"nodes": [
|
"nodes": [
|
||||||
@@ -231,7 +246,19 @@ if __name__ == "__main__":
|
|||||||
"name": "销售额统计",
|
"name": "销售额统计",
|
||||||
"layout": {"x": 26, "y": 34.8, "width": 400, "height": 260},
|
"layout": {"x": 26, "y": 34.8, "width": 400, "height": 260},
|
||||||
"props": {"option": {"title": {"text": "销售额统计"}}},
|
"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": [],
|
"dataSource": [],
|
||||||
},
|
},
|
||||||
@@ -245,10 +272,15 @@ if __name__ == "__main__":
|
|||||||
state["request"].get("context", {}).get("conversation_id") or thread_id
|
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:
|
try:
|
||||||
final_state = await ainvoke_screen(
|
final_state = await ainvoke_screen(
|
||||||
query_app, state, thread_id=thread_id, session_id=session_id
|
query_app, state, thread_id=thread_id, session_id=session_id
|
||||||
)
|
)
|
||||||
|
# 3. 展示真实澄清问题,用同一个 thread_id 恢复流程
|
||||||
while final_state.get("__interrupt__"):
|
while final_state.get("__interrupt__"):
|
||||||
pending = final_state["__interrupt__"][0].value
|
pending = final_state["__interrupt__"][0].value
|
||||||
print(pending["question"])
|
print(pending["question"])
|
||||||
@@ -277,15 +309,24 @@ if __name__ == "__main__":
|
|||||||
await close_decision_engine()
|
await close_decision_engine()
|
||||||
flush_tracing()
|
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(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
result,
|
||||||
key: value.model_dump(mode="json", exclude_unset=True)
|
|
||||||
if isinstance(value, BaseModel)
|
|
||||||
else value
|
|
||||||
for key, value in final_state.items()
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
indent=2,
|
indent=2,
|
||||||
default=lambda value: (
|
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())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from langchain_core.exceptions import OutputParserException
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.clients.llm import get_llm
|
from app.clients.llm import get_llm
|
||||||
from app.processor.base import BaseNode
|
from app.processor.base import BaseNode
|
||||||
@@ -28,14 +30,39 @@ class ClarifyNode(BaseNode):
|
|||||||
payload["requirement"] = state["requirement"].model_dump(mode="json")
|
payload["requirement"] = state["requirement"].model_dump(mode="json")
|
||||||
|
|
||||||
llm_client = get_llm(Clarification)
|
llm_client = get_llm(Clarification)
|
||||||
clarification = await llm_client.ainvoke(
|
messages = [
|
||||||
[
|
|
||||||
SystemMessage(content=CLARIFY_SYSTEM_PROMPT),
|
SystemMessage(content=CLARIFY_SYSTEM_PROMPT),
|
||||||
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
|
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
|
updated_state["clarification"] = clarification
|
||||||
|
# 在进入 interrupt 前保存等待状态,暂停中的节点尚不会返回更新
|
||||||
|
updated_state["status"] = "waiting_for_input"
|
||||||
return updated_state
|
return updated_state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ class InputNode(BaseNode):
|
|||||||
updated_state["retry_count"] = 0
|
updated_state["retry_count"] = 0
|
||||||
updated_state["repair_plan"] = None
|
updated_state["repair_plan"] = None
|
||||||
updated_state["failure_reason"] = None
|
updated_state["failure_reason"] = None
|
||||||
|
# 首次请求进入处理状态,恢复澄清时也使用同一状态
|
||||||
|
updated_state["status"] = "running"
|
||||||
return updated_state
|
return updated_state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class QANode(BaseNode):
|
|||||||
if not answer:
|
if not answer:
|
||||||
raise ValueError("问答模型未返回文本回答")
|
raise ValueError("问答模型未返回文本回答")
|
||||||
updated_state["answer"] = answer
|
updated_state["answer"] = answer
|
||||||
|
# 回答生成完成,问答分支在此结束
|
||||||
|
updated_state["status"] = "completed"
|
||||||
return updated_state
|
return updated_state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class ValidatorNode(BaseNode):
|
|||||||
|
|
||||||
def process(self, state: QueryState) -> QueryState:
|
def process(self, state: QueryState) -> QueryState:
|
||||||
updated_state: QueryState = state.copy()
|
updated_state: QueryState = state.copy()
|
||||||
|
# 校验失败仍可能进入修正循环,最终失败由 Fallback 标记
|
||||||
|
updated_state["status"] = "running"
|
||||||
actions = state["actions"]
|
actions = state["actions"]
|
||||||
try:
|
try:
|
||||||
# 重新校验模型内容,避免赋值后绕过字段校验
|
# 重新校验模型内容,避免赋值后绕过字段校验
|
||||||
@@ -130,6 +132,8 @@ class ValidatorNode(BaseNode):
|
|||||||
updated_state["validation_errors"] = []
|
updated_state["validation_errors"] = []
|
||||||
# 保存整批动作执行后的 JSON,供后续检查,不代表前端已经完成更新
|
# 保存整批动作执行后的 JSON,供后续检查,不代表前端已经完成更新
|
||||||
updated_state["simulated_screen"] = screen
|
updated_state["simulated_screen"] = screen
|
||||||
|
# 仅表示后端动作已通过校验,前端执行结果需另行反馈
|
||||||
|
updated_state["status"] = "completed"
|
||||||
return updated_state
|
return updated_state
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,5 +49,5 @@ class QueryState(TypedDict):
|
|||||||
] # 待合并的用户回答或取消指令
|
] # 待合并的用户回答或取消指令
|
||||||
answer: NotRequired[str | None] # 问答节点生成的回答或建议
|
answer: NotRequired[str | None] # 问答节点生成的回答或建议
|
||||||
status: NotRequired[
|
status: NotRequired[
|
||||||
Literal["running", "cancelled", "failed"]
|
Literal["running", "waiting_for_input", "completed", "cancelled", "failed"]
|
||||||
] # 流程继续、用户取消或动作修正失败
|
] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ CLARIFY_SYSTEM_PROMPT = """## 任务
|
|||||||
结合 clarification_history,不重复询问已回答的信息
|
结合 clarification_history,不重复询问已回答的信息
|
||||||
|
|
||||||
## 回答方式
|
## 回答方式
|
||||||
适合选择时提供互斥选项,简短说明各选项的影响,有依据才推荐
|
适合选择时提供 2–3 个互斥选项,简短说明各选项的影响,有依据才推荐
|
||||||
需要具体数值或自由描述时,options 留空;自由输入由客户端提供,不生成“其他”选项
|
需要具体数值或自由描述时,options 留空;自由输入由客户端提供,不生成“其他”选项
|
||||||
用户没有思路时,可提供先分析并给建议的选项,不将其视为授权修改
|
用户没有思路时,可提供先分析并给建议的选项,不将其视为授权修改
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ def build_intent_question() -> ChoiceDecision:
|
|||||||
# 区分意图模糊与修改细节缺失,后者交给需求理解
|
# 区分意图模糊与修改细节缺失,后者交给需求理解
|
||||||
instructions=(
|
instructions=(
|
||||||
"Classify the user's primary intent. "
|
"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 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. "
|
"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. "
|
"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 这个图怎么优化",
|
"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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user