feat:意图识别优化

This commit is contained in:
2026-09-30 08:57:39 -07:00
parent 4d7c91cc41
commit 3e88b339a0
21 changed files with 443 additions and 88 deletions
+13 -3
View File
@@ -55,10 +55,17 @@ def route_after_intent(state: QueryState) -> str:
def route_after_requirement(state: QueryState) -> str:
# 需求输出校验失败直接收尾;需要澄清进入澄清,否则进入修改范围判断
# 需求输出校验失败直接收尾,其余按落地策略的决定分流
if state["status"] == "failed":
return "fallback_node"
return "clarify_node" if state["requirement"].needs_clarification else "scope_node"
outcome = state["requirement_outcome"]
if outcome == "inform":
# 目标不存在时直接告知用户,不执行也不追问
return "qa_node"
if outcome == "clarify":
# 候选选项已由策略生成时直接等待用户,否则由澄清节点组织问题
return "wait_for_user_node" if state.get("clarification") else "clarify_node"
return "scope_node"
def route_after_wait(state: QueryState) -> str:
@@ -70,7 +77,8 @@ def route_after_wait(state: QueryState) -> str:
# 接入后把 needs_planning 与 resource_needs 拆成两个独立开关:
# 规划 -> 按需查资源 -> planner;无需规划但有 resource_needs -> 查资源 -> action
# 并让 component_resource_node 按 resource_needs 决定查什么,而不是固定全量目录
# 在此之前,无需规划且带 resource_needs 的请求会由 action_block_reason 判为失败
# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 ScopeNode 和 PlannerNode
# 会清空资源需求并改用物料默认配置和示例数据,不因资源需求失败
def route_after_scope(state: QueryState) -> str:
# 范围输出校验失败或已判定无法执行,直接失败收尾
if state["status"] == "failed":
@@ -170,6 +178,8 @@ def build_graph(checkpointer=None):
path=route_after_requirement,
path_map={
"clarify_node": "clarify_node",
"wait_for_user_node": "wait_for_user_node",
"qa_node": "qa_node",
"scope_node": "scope_node",
"fallback_node": "fallback_node",
},
+16
View File
@@ -7,6 +7,22 @@ from app.schemas.requirement import Intent
ACTION_INTENTS = ("create_screen", "modify_screen")
def resolve_offer_response(
decision: ChoiceResult,
*,
accept_min_probability: float,
decline_min_probability: float,
) -> Intent | None:
"""用户回应上一轮提议时直接确定意图:同意即执行提议,拒绝即简短回复;其余交给常规意图判断"""
choice = decision.choice
probability = decision.probabilities.get(choice, decision.confidence)
if choice == "accept" and probability >= accept_min_probability:
return "modify_screen"
if choice == "decline" and probability >= decline_min_probability:
return "qa"
return None
def resolve_intent(
decision: ChoiceResult,
*,
+4
View File
@@ -60,6 +60,10 @@ class InputNode(BaseNode):
updated_state["intent_decision"] = None
updated_state["intent"] = None
updated_state["requirement"] = None
updated_state["requirement_outcome"] = None
updated_state["notice"] = None
updated_state["offer"] = None
updated_state["accepted_offer"] = None
updated_state["change_scope"] = None
updated_state["screen_plan"] = None
updated_state["component_resources"] = None
+43 -20
View File
@@ -2,9 +2,9 @@
from app.clients.decision import close_decision_engine, get_decision_engine
from app.processor.base import BaseNode
from app.processor.intent_policy import resolve_intent
from app.processor.intent_policy import resolve_intent, resolve_offer_response
from app.processor.state import QueryState
from app.prompt.intent import build_intent_question
from app.prompt.intent import build_intent_question, build_offer_question
from app.schemas.base import JsonObject
from app.schemas.decision import DecisionRequest
from app.schemas.input_context import InputContext
@@ -78,18 +78,29 @@ class IntentNode(BaseNode):
],
}
@staticmethod
def pending_offer(context: InputContext) -> str | None:
"""只有紧接着的上一轮留下的提议才等待确认;澄清回答针对的是澄清问题,不是提议"""
if context.clarification_history or not context.history:
return None
offer = context.history[-1].get("offer")
return offer if isinstance(offer, str) and offer.strip() else None
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取输入节点已校验的上下文,包含最新大屏和澄清历史
context = state["input_context"]
# 构建 Jev 决策请求,将上下文摘要与意图判断问题一起提交
request = DecisionRequest(
# 将 InputNode 的上下文整理为意图判断摘要
state=self.build_decision_state(context),
# 使用 intent 作为决策名称,后续通过同名键读取判断结果
decisions={"intent": build_intent_question()},
)
# 上一轮助手提出了待确认的修改,且本轮不是澄清回答时,额外判断用户是否同意
offer = self.pending_offer(context)
decision_state = self.build_decision_state(context)
# 使用 intent 作为决策名称,后续通过同名键读取判断结果
decisions = {"intent": build_intent_question()}
if offer is not None:
decision_state["pending_offer"] = offer
decisions["offer_response"] = build_offer_question()
# 构建 Jev 决策请求,同一次调用提交全部判断
request = DecisionRequest(state=decision_state, decisions=decisions)
# 获取决策引擎,用于执行本次意图判断
decision_engine = get_decision_engine()
@@ -99,17 +110,29 @@ class IntentNode(BaseNode):
# 从响应中取出 intent 对应的判断结果
decision = decision_res.results["intent"]
# 用户刚点选了澄清选项时,选择本身已表达明确意图
history = context.clarification_history
# 按选中项概率、风险和澄清轮数确定最终意图,规则集中在 intent_policy
intent = resolve_intent(
decision,
action_min_probability=self.config.intent_min_probability,
qa_min_probability=self.config.intent_qa_min_probability,
clarified_rounds=len(history),
max_clarify_rounds=self.config.intent_max_clarify_rounds,
option_selected=bool(history and history[-1].get("selected_option")),
)
# 用户明确回应了上一轮提议时,直接确定意图,不再经过常规门控
intent = None
if offer is not None:
intent = resolve_offer_response(
decision_res.results["offer_response"],
accept_min_probability=self.config.intent_min_probability,
decline_min_probability=self.config.intent_qa_min_probability,
)
# 同意时把提议交给需求理解,作为本次要执行的修改
updated_state["accepted_offer"] = offer if intent == "modify_screen" else None
if intent is None:
# 用户刚点选了澄清选项时,选择本身已表达明确意图
history = context.clarification_history
# 按选中项概率、风险和澄清轮数确定最终意图,规则集中在 intent_policy
intent = resolve_intent(
decision,
action_min_probability=self.config.intent_min_probability,
qa_min_probability=self.config.intent_qa_min_probability,
clarified_rounds=len(history),
max_clarify_rounds=self.config.intent_max_clarify_rounds,
option_selected=bool(history and history[-1].get("selected_option")),
)
# 完整保留原始决策,便于查看模型选项及其置信度
updated_state["intent_decision"] = decision_res
+11 -6
View File
@@ -8,6 +8,10 @@ from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.planner import PLANNER_SYSTEM_PROMPT
from app.schemas.capabilities import (
RESOURCE_FALLBACK_ASSUMPTION,
RESOURCE_QUERY_SUPPORTED,
)
from app.schemas.screen_plan import ScreenPlan
# 规划输出未通过校验时,带着错误让模型重新规划的最大次数
@@ -69,12 +73,13 @@ class PlannerNode(BaseNode):
feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}"
messages = [*messages, HumanMessage(content=feedback)]
# 组件目录已在进入规划前完整获取,component 类资源需求不可能再有补充来源
# 保留会让动作阶段误判为“待获取资源”而失败,能力缺口应由 assumptions 表达
# TODO 接入数据源和 RAG 后,仅保留目录之外的资源需求
plan.resource_needs = [
need for need in plan.resource_needs if need.kind != "component"
]
# 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败
if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs:
self.logger.info(
f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}"
)
plan.resource_needs = []
plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION)
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
plan.constraints = list(
+66 -26
View File
@@ -1,5 +1,7 @@
"""基于当前大屏和澄清历史回答问题"""
import json
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.output_parsers import StrOutputParser
from langgraph.config import get_stream_writer
@@ -7,7 +9,8 @@ from langgraph.config import get_stream_writer
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.qa import QA_SYSTEM_PROMPT
from app.prompt.qa import OFFER_EXTRACT_PROMPT, QA_SYSTEM_PROMPT
from app.schemas.offer import ExtractedOffer, InformReply
class QANode(BaseNode):
@@ -17,32 +20,13 @@ class QANode(BaseNode):
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取普通对话模型,问答只需文本回答,不绑定结构化输出协议
llm_client = get_llm()
try:
# 图执行上下文中通过 custom 流逐段推送回答,独立调用节点时没有写入器
writer = get_stream_writer()
except RuntimeError:
writer = None
parser = StrOutputParser()
parts: list[str] = []
# 异步流式调用模型,结合用户问题、当前大屏和澄清记录生成回答
async for chunk in llm_client.astream(
[
# 系统消息提供稳定的问答规则和回答边界
SystemMessage(content=QA_SYSTEM_PROMPT),
# 将输入上下文序列化为 JSON 放入用户消息,只保留显式设置的字段
HumanMessage(
content=state["input_context"].model_dump_json(exclude_unset=True)
),
]
):
# 从每个片段提取文本,兼容文本内容块,工具调用等无文本片段会得到空串
delta = parser.invoke(chunk)
if not delta:
continue
parts.append(delta)
def emit(delta: str) -> None:
if writer is not None:
writer(
{
@@ -52,20 +36,76 @@ class QANode(BaseNode):
}
)
# 拼接完整回答,并去除首尾的空白
answer = "".join(parts).strip()
# 如果没有有效文本,抛出异常,避免将空回答标记为处理完成
if not answer:
raise ValueError("问答模型未返回文本回答")
content = state["input_context"].model_dump_json(exclude_unset=True)
offer = None
# 落地策略判定请求无法执行时,附上事实说明,由本节点如实告知用户
if notice := state.get("notice"):
content += f"\n\n系统说明:{notice}"
# 回复中的替代修改需要结构化保存,下一轮用户同意时直接执行,因此不流式输出
reply = await get_llm(InformReply).ainvoke(
[SystemMessage(content=QA_SYSTEM_PROMPT), HumanMessage(content=content)]
)
answer, offer = reply.answer.strip(), reply.offer
emit(answer)
else:
answer = await self._stream(content, emit)
# 如果没有有效文本,抛出异常,避免将空回答标记为处理完成
if not answer:
raise ValueError("问答模型未返回文本回答")
# 回答已流式输出,再提取其中等待用户确认的修改,下一轮同意时直接执行
offer = await self._extract_offer(state["input_context"].prompt, answer)
# 保存最终回答,供后续输出给用户
updated_state["answer"] = answer
# 保存等待用户确认的提议,没有提议时清空
updated_state["offer"] = offer
# 回答生成完成,问答分支在此结束
updated_state["status"] = "completed"
# 返回包含回答和完成状态的更新结果
return updated_state
async def _extract_offer(self, prompt: str, answer: str) -> str | None:
try:
result = await get_llm(ExtractedOffer).ainvoke(
[
SystemMessage(content=OFFER_EXTRACT_PROMPT),
HumanMessage(
content=json.dumps(
{"user": prompt, "assistant": answer}, ensure_ascii=False
)
),
]
)
except Exception as error:
# 回答已交付,提取失败只影响下一轮能否直接执行,不让本轮失败
self.logger.warning(f"提取回答中的提议失败: {error!r}")
return None
return result.offer
@staticmethod
async def _stream(content: str, emit) -> str:
# 获取普通对话模型,问答只需文本回答,不绑定结构化输出协议
llm_client = get_llm()
parser = StrOutputParser()
parts: list[str] = []
# 异步流式调用模型,结合用户问题、当前大屏和澄清记录生成回答
async for chunk in llm_client.astream(
[
# 系统消息提供稳定的问答规则和回答边界
SystemMessage(content=QA_SYSTEM_PROMPT),
# 输入上下文序列化为 JSON,只保留显式设置的字段
HumanMessage(content=content),
]
):
# 从每个片段提取文本,兼容文本内容块,工具调用等无文本片段会得到空串
delta = parser.invoke(chunk)
if delta:
parts.append(delta)
emit(delta)
# 拼接完整回答,并去除首尾的空白
return "".join(parts).strip()
if __name__ == "__main__":
import asyncio
+16 -23
View File
@@ -6,6 +6,7 @@ from langchain_core.messages import HumanMessage, SystemMessage
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.outcome_policy import decide_outcome
from app.processor.state import QueryState
from app.prompt.requirement import REQUIREMENT_SYSTEM_PROMPT, build_requirement_input
from app.schemas.requirement import Requirement
@@ -38,7 +39,11 @@ class RequirementNode(BaseNode):
# 系统消息提供稳定的需求提取规则和输出边界
SystemMessage(content=REQUIREMENT_SYSTEM_PROMPT),
# 用户消息承载本次需求、大屏、澄清记录和已确认的意图等具体资料
HumanMessage(content=build_requirement_input(context, intent)),
HumanMessage(
content=build_requirement_input(
context, intent, accepted_offer=state.get("accepted_offer")
)
),
]
)
@@ -56,29 +61,17 @@ class RequirementNode(BaseNode):
# 如果存在无效目标,立即抛出异常,并排序输出 ID 以便定位问题
raise ValueError(f"需求引用了不存在的节点: {sorted(unknown_ids)}")
# 纯新增需求缺少位置、尺寸等信息时不澄清,采用默认值先执行,用户之后可再次提出调整
# 提示词无法保证模型每次都遵守,因此在这里兜底;含待确认目标的需求不受影响
if (
requirement.needs_clarification
and requirement.changes
and all(
change.target_scope == "screen" and change.operation == "add"
for change in requirement.changes
)
):
requirement = requirement.model_copy(
update={
"needs_clarification": False,
"clarification_question": None,
"assumptions": [
*requirement.assumptions,
"未指定的位置、尺寸和数据来源采用默认值,数据使用物料示例数据",
],
}
)
# 模型只负责理解需求,本轮执行、澄清还是直接回复统一由落地策略根据真实组件决定
decision = decide_outcome(requirement, context.screen)
# 保存落地后的需求,唯一候选已改写为明确目标,供后续澄清或范围判断使用
updated_state["requirement"] = decision.requirement
updated_state["requirement_outcome"] = decision.outcome
updated_state["notice"] = decision.notice
if decision.clarification is not None:
# 选项来自真实组件,无需再由澄清节点调用模型
updated_state["clarification"] = decision.clarification
updated_state["status"] = "waiting_for_input"
# 保存通过结构及目标校验的需求,供后续澄清或范围判断使用
updated_state["requirement"] = requirement
# 返回更新后的状态,由图继续执行后续节点
return updated_state
+13
View File
@@ -9,7 +9,9 @@ from app.processor.base import BaseNode
from app.processor.nodes.action_node import action_block_reason
from app.processor.state import QueryState
from app.prompt.scope import SCOPE_SYSTEM_PROMPT
from app.schemas.capabilities import RESOURCE_QUERY_SUPPORTED
from app.schemas.change_scope import ChangeScope
from app.schemas.operations import requires_planning
class ScopeNode(BaseNode):
@@ -69,6 +71,17 @@ class ScopeNode(BaseNode):
if missing_ids := required_ids - set(scope.target_ids):
raise ValueError(f"范围遗漏了需求中的节点: {sorted(missing_ids)}")
# 新增、重排等操作只能通过规划完成,这是代码已知的能力边界,不依赖模型判断
if not scope.needs_planning and any(
requires_planning(change.target_scope, change.operation)
for change in requirement.changes
):
scope = scope.model_copy(update={"needs_planning": True})
# 资源查询接入前没有补充来源,资源需求不阻塞执行,改用物料默认配置和示例数据
if not RESOURCE_QUERY_SUPPORTED and scope.resource_needs:
self.logger.info(f"资源查询未接入,改用默认配置: {scope.resource_needs}")
scope = scope.model_copy(update={"resource_needs": []})
# 保存校验后的范围,下面的动作能力检查也需要读取该结果
updated_state["change_scope"] = scope
# 需要规划时先交给后续规划流程,不在此处判断能否直接生成动作
+134
View File
@@ -0,0 +1,134 @@
"""需求落地策略的单一来源:模型只负责理解需求,本轮是执行、澄清还是直接回复由这里决定
决策依据是当前大屏里真实存在的组件,而不是模型对"要不要澄清"的判断:
- 新增组件或目标可唯一确定:执行,缺少的信息采用默认值
- 目标有多个候选:澄清,选项直接由候选组件生成,不由模型编造
- 目标没有任何候选:直接回复,说明原因并可建议替代做法,不执行也不追问
- 没有可执行的变更:交给澄清节点询问
"""
from dataclasses import dataclass
from typing import Literal
from app.schemas.clarification import Clarification, ClarificationOption
from app.schemas.requirement import Requirement, RequirementChange
from app.schemas.screen import Screen, ScreenNode
Outcome = Literal["execute", "clarify", "inform"]
# 默认值补齐的说明,模型提出了非阻塞问题但仍执行时写入 assumptions
DEFAULT_ASSUMPTION = "未明确的位置、尺寸、样式和数据采用默认值,数据使用物料示例数据"
# 候选超过选项上限时不提供选项,只让用户描述
MAX_OPTIONS = 3
@dataclass(frozen=True)
class OutcomeDecision:
outcome: Outcome
requirement: Requirement
# 已由候选组件生成的澄清问题;为 None 且 outcome 为 clarify 时由澄清节点生成
clarification: Clarification | None = None
# 直接回复时交给回答节点的事实说明
notice: str | None = None
def decide_outcome(requirement: Requirement, screen: Screen) -> OutcomeDecision:
nodes = {node.id: node for node in screen.nodes}
unresolved = [c for c in requirement.changes if c.target_scope == "unresolved"]
for change in unresolved:
if not _candidates(change, nodes):
return OutcomeDecision(
"inform", requirement, notice=_missing_target_notice(change, screen)
)
ambiguous = [c for c in unresolved if len(_candidates(c, nodes)) > 1]
if ambiguous:
change = ambiguous[0]
return OutcomeDecision(
"clarify",
requirement,
clarification=_candidate_question(
change, [nodes[i] for i in _candidates(change, nodes)], requirement
),
)
if not requirement.changes:
# 没有可执行的变更时才需要模型组织问题
outcome: Outcome = "clarify" if requirement.needs_clarification else "execute"
return OutcomeDecision(outcome, requirement)
# 所有目标均已确定:唯一候选改写为明确目标,模型提出的非阻塞问题改用默认值
changes = [
_resolve(change, _candidates(change, nodes)[0])
if change.target_scope == "unresolved"
else change
for change in requirement.changes
]
assumptions = list(requirement.assumptions)
if requirement.needs_clarification and DEFAULT_ASSUMPTION not in assumptions:
assumptions.append(DEFAULT_ASSUMPTION)
resolved = Requirement.model_validate(
{
**requirement.model_dump(),
"changes": [change.model_dump() for change in changes],
"needs_clarification": False,
"clarification_question": None,
"assumptions": assumptions,
}
)
return OutcomeDecision("execute", resolved)
def _candidates(change: RequirementChange, nodes: dict[str, ScreenNode]) -> list[str]:
# 只保留真实存在的组件,模型虚构的 ID 视为没有候选
return [
node_id for node_id in dict.fromkeys(change.candidate_ids) if node_id in nodes
]
def _resolve(change: RequirementChange, node_id: str) -> RequirementChange:
return RequirementChange.model_validate(
{
**change.model_dump(),
"target_scope": "nodes",
"target_ids": [node_id],
"candidate_ids": [],
}
)
def _missing_target_notice(change: RequirementChange, screen: Screen) -> str:
target = change.target_description or "目标组件"
if not screen.nodes:
state = "当前画布上还没有任何组件"
else:
names = "、".join(_label(node) for node in screen.nodes[:5])
state = f"当前画布上的组件有:{names}"
return f"本次请求无法执行:没有找到“{target}”对应的组件,{state}"
def _candidate_question(
change: RequirementChange, candidates: list[ScreenNode], requirement: Requirement
) -> Clarification:
target = change.target_description or "组件"
question = requirement.clarification_question or f"你指的是哪个{target}?"
if len(candidates) > MAX_OPTIONS:
return Clarification(question=question)
# 选项 ID 使用组件 ID,用户点选后需求理解可直接定位目标
return Clarification(
question=question,
options=[
ClarificationOption(
id=node.id,
label=_label(node),
description=f"{node.type},位于 ({node.layout.x:g}, {node.layout.y:g})",
)
for node in candidates
],
)
def _label(node: ScreenNode) -> str:
return node.name or node.type
+6
View File
@@ -24,6 +24,12 @@ class QueryState(TypedDict):
intent_decision: NotRequired[DecisionResponse | None] # Jev 原始意图判断及分数
intent: NotRequired[Intent | None] # 经过置信度门控的最终意图
requirement: NotRequired[Requirement | None] # 结构化需求及信息完整性判断
requirement_outcome: NotRequired[
Literal["execute", "clarify", "inform"] | None
] # 落地策略决定的本轮去向,见 outcome_policy
notice: NotRequired[str | None] # 无法执行时交给回答节点的事实说明
offer: NotRequired[str | None] # 本轮助手提出、等待用户确认的修改指令
accepted_offer: NotRequired[str | None] # 用户本轮同意执行的上一轮提议
change_scope: NotRequired[ChangeScope | None] # 修改范围、规划和资源需求
screen_plan: NotRequired[
ScreenPlan | None