572 lines
22 KiB
Python
572 lines
22 KiB
Python
import html
|
||
import json
|
||
import re
|
||
from typing import Any, Dict, List
|
||
|
||
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
||
|
||
from .state import AgentState
|
||
from config import Config
|
||
from services.core.prompt_manager import get_prompt_manager
|
||
from services.core.template_matcher import get_template_matcher
|
||
from services.core.sql_prompt_manager import get_sql_prompt_manager
|
||
from tools.sr_api_tool import SrApiQueryTool
|
||
|
||
|
||
def _short(value, max_len: int = 500) -> str:
|
||
text = str(value)
|
||
return text if len(text) <= max_len else text[:max_len] + "..."
|
||
|
||
|
||
def _trace(state: AgentState, label: str, value: Any | None = None, *, clip: bool = True) -> None:
|
||
if not state.context.get("debug_node_trace"):
|
||
return
|
||
if value is None:
|
||
print(label)
|
||
else:
|
||
print(label, _short(value) if clip else value)
|
||
|
||
|
||
FOLLOW_UP_HINTS = (
|
||
"那",
|
||
"那么",
|
||
"然后",
|
||
"改成",
|
||
"改为",
|
||
"换成",
|
||
"只看",
|
||
"那如果",
|
||
"how about",
|
||
"what about",
|
||
"same",
|
||
"also",
|
||
)
|
||
|
||
TOPN_RE = re.compile(r"\btop\s*(\d+)\b", re.IGNORECASE)
|
||
TOPN_CN_RE = re.compile(r"前\s*(?:\d+|[一二三四五六七八九十百千万]+)")
|
||
NUMBER_RE = re.compile(r"\b\d{8,14}\b")
|
||
DATE_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}/\d{1,2}(?:/\d{2,4})?\b")
|
||
AGGREGATE_ENGLISH_RE = re.compile(
|
||
r"\b(?:count|summary|summarize|aggregate|sum)\b|\bgroup\s+by\b",
|
||
re.IGNORECASE,
|
||
)
|
||
HISTORY_ENGLISH_RE = re.compile(r"\b(?:history|historical|changelog)\b", re.IGNORECASE)
|
||
|
||
|
||
def _has_topn_hint(text: str) -> bool:
|
||
lowered = (text or "").lower()
|
||
if TOPN_RE.search(lowered):
|
||
return True
|
||
if TOPN_CN_RE.search(text or ""):
|
||
return True
|
||
return any(token in (text or "") for token in ["排名", "最高", "最大", "最小"])
|
||
|
||
|
||
def _last_human_message(state: AgentState) -> HumanMessage | None:
|
||
for message in reversed(state.messages):
|
||
if isinstance(message, HumanMessage):
|
||
return message
|
||
return None
|
||
|
||
|
||
def _looks_like_follow_up(text: str) -> bool:
|
||
lowered = (text or "").strip().lower()
|
||
return any(hint in lowered for hint in FOLLOW_UP_HINTS)
|
||
|
||
|
||
def _detect_query_mode(text: str) -> str:
|
||
lowered = (text or "").lower()
|
||
if not lowered:
|
||
return "detail"
|
||
if HISTORY_ENGLISH_RE.search(lowered) or any(token in lowered for token in ["历史", "变更记录", "历史变更", "change record", "change log", "changes"]):
|
||
return "history"
|
||
if AGGREGATE_ENGLISH_RE.search(lowered) or any(token in lowered for token in ["聚合", "统计", "汇总", "计数", "数量", "多少", "几个", "分组", "求和", "总计", "合计"]):
|
||
return "aggregate"
|
||
if _has_topn_hint(text):
|
||
return "topn"
|
||
return "detail"
|
||
|
||
|
||
def _extract_query_entities(text: str, prompt_data: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
||
lowered = (text or "").lower()
|
||
entity_numbers = NUMBER_RE.findall(text or "")
|
||
entity_dates = DATE_RE.findall(text or "")
|
||
|
||
top_n = None
|
||
match = TOPN_RE.search(lowered)
|
||
if match:
|
||
try:
|
||
top_n = int(match.group(1))
|
||
except Exception:
|
||
top_n = None
|
||
elif _has_topn_hint(text):
|
||
top_n = 10
|
||
|
||
sort_direction = "desc"
|
||
if any(token in lowered for token in ["从小到大", "升序", "ascending", "asc"]):
|
||
sort_direction = "asc"
|
||
elif any(token in lowered for token in ["从大到小", "降序", "descending", "desc"]):
|
||
sort_direction = "desc"
|
||
|
||
countries: List[str] = []
|
||
regions: List[str] = []
|
||
if prompt_data:
|
||
additional_fields = (((prompt_data.get("field_mapping_reference") or {}).get("additional_fields") or {}))
|
||
countries = list((((additional_fields.get("ship_to_country") or {}).get("values")) or []))
|
||
regions = list((((additional_fields.get("region") or {}).get("values")) or []))
|
||
|
||
words = re.findall(r"\b[A-Z]{2,10}\b", text or "")
|
||
matched_countries = [word for word in words if word in countries]
|
||
matched_regions = [word for word in words if word in regions]
|
||
|
||
return {
|
||
"numbers": entity_numbers,
|
||
"dates": entity_dates,
|
||
"top_n": top_n,
|
||
"sort_direction": sort_direction,
|
||
"country_codes": matched_countries,
|
||
"regions": matched_regions,
|
||
"mentions_eta_info": "eta信息" in lowered or "eta info" in lowered,
|
||
"mentions_history": any(token in lowered for token in ["history", "historical", "changelog", "历史", "变更记录", "历史变更"]),
|
||
}
|
||
|
||
|
||
def _get_default_table_name() -> str | None:
|
||
cfg = Config.get_section("ragflow")
|
||
table_name = str(cfg.get("default_table_name") or "").strip()
|
||
return table_name or None
|
||
|
||
|
||
def _looks_like_json(text: str) -> bool:
|
||
stripped = (text or "").strip()
|
||
return stripped.startswith("{") or stripped.startswith("[")
|
||
|
||
|
||
def _try_json_loads(value: Any) -> Any:
|
||
if isinstance(value, (dict, list)):
|
||
return value
|
||
if isinstance(value, str) and _looks_like_json(value):
|
||
try:
|
||
return json.loads(value)
|
||
except Exception:
|
||
return value
|
||
return value
|
||
|
||
|
||
def _parse_sr_api_result(raw_result: Any) -> Any:
|
||
parsed = _try_json_loads(raw_result)
|
||
if isinstance(parsed, dict) and "text" in parsed:
|
||
text_payload = _try_json_loads(parsed.get("text"))
|
||
parsed = {**parsed, "text": text_payload}
|
||
return parsed
|
||
|
||
|
||
def _extract_result_rows(value: Any) -> list[Any] | None:
|
||
if isinstance(value, list):
|
||
return value
|
||
|
||
if isinstance(value, dict):
|
||
for key in ("data", "rows", "records", "items", "list", "result", "values"):
|
||
rows = value.get(key)
|
||
if isinstance(rows, list):
|
||
return rows
|
||
|
||
nested = value.get("text")
|
||
if isinstance(nested, (dict, list)):
|
||
return _extract_result_rows(nested)
|
||
|
||
return None
|
||
|
||
|
||
def _is_empty_sr_api_result(raw_result: Any) -> bool:
|
||
parsed = _parse_sr_api_result(raw_result)
|
||
|
||
rows = _extract_result_rows(parsed)
|
||
if rows is not None:
|
||
return len(rows) == 0
|
||
|
||
if isinstance(parsed, dict):
|
||
total = parsed.get("total")
|
||
if isinstance(total, int):
|
||
return total == 0
|
||
|
||
text_payload = parsed.get("text")
|
||
if isinstance(text_payload, dict):
|
||
total = text_payload.get("total")
|
||
if isinstance(total, int):
|
||
return total == 0
|
||
|
||
return False
|
||
|
||
|
||
def _format_empty_result_response(query: str, llm_response: str) -> str:
|
||
"""将空结果的 LLM 回复格式化为纯文本格式,与 endpoints._build_rich_answer_html 保持一致"""
|
||
safe_query = html.escape((query or "").strip())
|
||
safe_response = html.escape((llm_response or "").strip())
|
||
|
||
return (
|
||
f"Question: {safe_query}\n"
|
||
f"{safe_response}\n"
|
||
f"Rows: 0"
|
||
)
|
||
|
||
|
||
def _default_normalizer_prompt() -> str:
|
||
return (
|
||
"You are a translation and normalization assistant. "
|
||
"Convert the user's input to a clear, grammatically correct English sentence suitable for SQL intent. "
|
||
"Preserve business identifiers, codes, country abbreviations, order numbers, and field aliases exactly when possible. "
|
||
"Return only the final English sentence without extra explanations."
|
||
)
|
||
|
||
|
||
def process_input(state: AgentState) -> AgentState:
|
||
"""处理用户输入"""
|
||
_trace(state, "[process_input][in] messages=", _short(state.messages))
|
||
last_message = _last_human_message(state)
|
||
if last_message:
|
||
state.original_input = str(last_message.content)
|
||
state.context["is_follow_up"] = _looks_like_follow_up(state.original_input)
|
||
state.set_current_step("processed")
|
||
_trace(state, "[process_input][out] current_step=", state.current_step)
|
||
return state
|
||
|
||
|
||
def normalize_input(state: AgentState, model) -> AgentState:
|
||
"""将用户输入规范化为保留业务标识的标准英文语句"""
|
||
last_message = _last_human_message(state)
|
||
if not last_message:
|
||
return state
|
||
|
||
_trace(state, "[normalize_input][in] user_input=", _short(last_message.content))
|
||
|
||
prompt_manager = get_prompt_manager()
|
||
normalizer_prompt = (
|
||
prompt_manager.get("user", "english_normalizer")
|
||
or prompt_manager.get("system", "english_normalizer")
|
||
or _default_normalizer_prompt()
|
||
)
|
||
system_prompt = SystemMessage(content=normalizer_prompt)
|
||
|
||
try:
|
||
response = model.invoke([system_prompt, HumanMessage(content=last_message.content)])
|
||
normalized = response.content if hasattr(response, "content") else str(response)
|
||
except Exception as exc:
|
||
normalized = str(last_message.content)
|
||
state.add_error(f"normalize_input_failed:{exc}")
|
||
|
||
state.original_input = str(last_message.content)
|
||
state.normalized_input = normalized.strip() or str(last_message.content)
|
||
state.sync_context()
|
||
state.set_current_step("normalized")
|
||
_trace(state, "[normalize_input][out] normalized=", _short(state.normalized_input))
|
||
return state
|
||
|
||
|
||
def classify_query_mode(state: AgentState) -> AgentState:
|
||
"""识别查询模式:detail / aggregate / topn / history。"""
|
||
text = "\n".join(filter(None, [state.original_input, state.normalized_input]))
|
||
state.query_mode = _detect_query_mode(text)
|
||
|
||
if not state.intent:
|
||
state.intent = "sql_query" if state.original_input else "general"
|
||
|
||
state.query_entities = _extract_query_entities(text)
|
||
state.sync_context()
|
||
state.set_current_step("query_mode_classified")
|
||
_trace(state, "[classify_query_mode][out] query_mode=", state.query_mode)
|
||
_trace(state, "[classify_query_mode][out] query_entities=", _short(state.query_entities))
|
||
return state
|
||
|
||
|
||
def match_table(state: AgentState) -> AgentState:
|
||
"""根据规范化输入检索候选表,并在追问场景下回退到上一轮表或配置默认表。"""
|
||
query = state.normalized_input or state.original_input
|
||
if not query:
|
||
return state
|
||
|
||
_trace(state, "[match_table][in] query=", _short(query))
|
||
matcher = get_template_matcher()
|
||
match_result = matcher.match(query)
|
||
table_name = (match_result or {}).get("table_name")
|
||
candidate_tables = list((match_result or {}).get("candidates") or [])
|
||
|
||
if not table_name and state.context.get("is_follow_up"):
|
||
last_context = state.context.get("last_context") or {}
|
||
fallback_table = last_context.get("table_name") or ((last_context.get("table_match") or {}).get("table_name"))
|
||
if fallback_table:
|
||
table_name = fallback_table
|
||
candidate_tables = candidate_tables or [{"table_name": fallback_table, "source": "last_context"}]
|
||
match_result = {
|
||
"table_name": fallback_table,
|
||
"candidates": candidate_tables,
|
||
"raw": {"source": "last_context"},
|
||
}
|
||
state.context["table_match_fallback"] = "last_context"
|
||
|
||
if not table_name:
|
||
default_table = _get_default_table_name()
|
||
if default_table:
|
||
table_name = default_table
|
||
candidate_tables = candidate_tables or [{"table_name": default_table, "source": "config_default"}]
|
||
match_result = {
|
||
"table_name": default_table,
|
||
"candidates": candidate_tables,
|
||
"raw": {"source": "config_default"},
|
||
}
|
||
state.context["table_match_fallback"] = "config_default"
|
||
state.context["default_table_name"] = default_table
|
||
|
||
state.table_match = dict(match_result or {})
|
||
state.candidate_tables = candidate_tables
|
||
state.table_name = table_name
|
||
state.sync_context()
|
||
state.set_current_step("table_matched")
|
||
_trace(state, "[match_table][out] table_name=", state.table_name)
|
||
return state
|
||
|
||
|
||
def load_sql_prompt(state: AgentState) -> AgentState:
|
||
"""加载目标表对应的 SQL prompt JSON。"""
|
||
if not state.table_name:
|
||
_trace(state, "[load_sql_prompt][skip] missing table_name")
|
||
return state
|
||
|
||
prompt_manager = get_sql_prompt_manager()
|
||
prompt_data, source = prompt_manager.get_prompt_with_source(state.table_name)
|
||
if not prompt_data:
|
||
state.add_error(f"sql_prompt_not_found:{state.table_name}")
|
||
_trace(state, "[load_sql_prompt][skip] prompt not found for table=", state.table_name)
|
||
return state
|
||
|
||
state.sql_prompt = prompt_data
|
||
state.sql_prompt_source = source
|
||
state.sync_context()
|
||
state.set_current_step("sql_prompt_loaded")
|
||
_trace(state, f"[load_sql_prompt][out] table_name={state.table_name} source={source}")
|
||
return state
|
||
|
||
|
||
def build_sql_plan(state: AgentState) -> AgentState:
|
||
"""构建结构化 SQL 计划,为最终 SQL 生成提供显式上下文。"""
|
||
prompt_data = state.sql_prompt or {}
|
||
business_rules = (prompt_data.get("business_logic_rules") or {})
|
||
data_model = (prompt_data.get("data_model_specification") or {})
|
||
meta = (prompt_data.get("meta") or {})
|
||
default_fields = ((data_model.get("mandatory_display_fields") or {}).get("default_fields")) or ""
|
||
|
||
text = "\n".join(filter(None, [state.original_input, state.normalized_input]))
|
||
extracted = _extract_query_entities(text, prompt_data)
|
||
if state.query_entities:
|
||
extracted = {**state.query_entities, **{k: v for k, v in extracted.items() if v not in (None, [], {}, "")}}
|
||
|
||
state.query_entities = extracted
|
||
state.sql_plan = {
|
||
"intent": state.intent or "sql_query",
|
||
"query_mode": state.query_mode or "detail",
|
||
"selected_table": state.table_name,
|
||
"candidate_tables": [item.get("table_name", item) for item in state.candidate_tables],
|
||
"data_source": meta.get("data_source"),
|
||
"domain": meta.get("domain"),
|
||
"default_select_fields": default_fields,
|
||
"default_filters": list(business_rules.get("default_filters") or []),
|
||
"aggregate_rules": dict(business_rules.get("aggregate_rules") or {}),
|
||
"top_n_rules": dict(business_rules.get("top_n_rules") or {}),
|
||
"query_entities": extracted,
|
||
"previous_context": {
|
||
key: (state.context.get("last_context") or {}).get(key)
|
||
for key in ("table_name", "query_mode", "final_sql", "sql_plan")
|
||
if (state.context.get("last_context") or {}).get(key) is not None
|
||
},
|
||
}
|
||
state.sync_context()
|
||
state.set_current_step("sql_plan_built")
|
||
_trace(state, "[build_sql_plan][out] sql_plan=", _short(state.sql_plan))
|
||
return state
|
||
|
||
|
||
def generate_sql(state: AgentState, model) -> AgentState:
|
||
"""根据表 prompt + 结构化计划生成 SQL。"""
|
||
if not state.table_name or not state.normalized_input:
|
||
_trace(state, "[generate_sql][skip] missing table_name or normalized_input")
|
||
return state
|
||
|
||
prompt_data = state.sql_prompt
|
||
if not prompt_data:
|
||
_trace(state, "[generate_sql][skip] missing sql_prompt")
|
||
return state
|
||
|
||
_trace(state, "[generate_sql][in] table_name=", state.table_name)
|
||
_trace(state, "[generate_sql][in] query_mode=", state.query_mode)
|
||
|
||
prompt_text = json.dumps(prompt_data, ensure_ascii=False, indent=2)
|
||
plan_text = json.dumps(state.sql_plan or {}, ensure_ascii=False, indent=2)
|
||
prompt_manager = get_prompt_manager()
|
||
system_template = prompt_manager.get("system", "sql_mysql_select_only")
|
||
system_content = system_template.format(table_prompt_json=prompt_text)
|
||
user_content = (
|
||
f"Original user question: {state.original_input}\n"
|
||
f"Normalized user question: {state.normalized_input}\n"
|
||
f"Detected query mode: {state.query_mode or 'detail'}\n"
|
||
f"SQL planning context JSON:\n{plan_text}\n"
|
||
"Generate the best SQL for the selected table and query mode. "
|
||
"If the query mode is topn and the plan contains top_n, LIMIT is allowed and required. "
|
||
"If update_date is used as a filter, do not add data_flag. "
|
||
"Return only the final SQL."
|
||
)
|
||
response = model.invoke([SystemMessage(content=system_content), HumanMessage(content=user_content)])
|
||
sql_text = response.content if hasattr(response, "content") else str(response)
|
||
state.final_sql = sql_text.strip()
|
||
state.sync_context()
|
||
state.set_current_step("sql_generated")
|
||
_trace(state, "[generate_sql][out] sql=", state.final_sql, clip=False)
|
||
return state
|
||
|
||
|
||
def execute_sql(state: AgentState) -> AgentState:
|
||
"""在需要时执行生成后的 SQL。skip_sr_api=True 时跳过执行。"""
|
||
if not state.final_sql:
|
||
_trace(state, "[execute_sql][skip] missing final_sql")
|
||
state.set_current_step("sql_execution_skipped")
|
||
return state
|
||
|
||
if state.skip_sr_api:
|
||
_trace(state, "[execute_sql][skip] skip_sr_api=true")
|
||
state.set_current_step("sql_execution_skipped")
|
||
return state
|
||
|
||
try:
|
||
tool = SrApiQueryTool()
|
||
state.sr_api_result = tool.run(json.dumps({"sql": state.final_sql}, ensure_ascii=False))
|
||
_trace(state, "[execute_sql][out] sr_api_result=", _short(state.sr_api_result))
|
||
except Exception as exc:
|
||
state.add_error(f"sql_execution_failed:{exc}")
|
||
_trace(state, "[execute_sql][error]", exc)
|
||
state.sync_context()
|
||
state.set_current_step("sql_executed")
|
||
return state
|
||
|
||
|
||
def check_empty_result(state: AgentState) -> AgentState:
|
||
"""检查 SQL 执行结果是否为空,设置 is_empty_result 标记。"""
|
||
sr_api_result = state.sr_api_result
|
||
|
||
if not sr_api_result:
|
||
state.context["is_empty_result"] = None
|
||
state.context["result_checked"] = False
|
||
_trace(state, "[check_empty_result][skip] no sr_api_result")
|
||
state.set_current_step("result_checked")
|
||
return state
|
||
|
||
is_empty = _is_empty_sr_api_result(sr_api_result)
|
||
state.context["is_empty_result"] = is_empty
|
||
state.context["result_checked"] = True
|
||
|
||
if is_empty:
|
||
_trace(state, "[check_empty_result][out] is_empty=True")
|
||
else:
|
||
result_rows = _extract_result_rows(sr_api_result)
|
||
row_count = len(result_rows) if result_rows else 0
|
||
state.context["result_row_count"] = row_count
|
||
_trace(state, f"[check_empty_result][out] is_empty=False, row_count={row_count}")
|
||
|
||
state.sync_context()
|
||
state.set_current_step("result_checked")
|
||
return state
|
||
|
||
|
||
def generate_response(state: AgentState, model) -> AgentState:
|
||
"""使用 SQL 执行结果、SQL 本身或模型回退生成最终回复。"""
|
||
_trace(state, "[generate_response][in] context_keys=", list((state.context or {}).keys()))
|
||
|
||
# 优先使用 context 中的 is_empty_result(由 check_empty_result 节点设置)
|
||
is_empty_result = state.context.get("is_empty_result")
|
||
sr_api_result = state.sr_api_result
|
||
|
||
# 如果有执行结果且标记为空
|
||
if sr_api_result and is_empty_result is True:
|
||
sql_plan_text = json.dumps(state.sql_plan or {}, ensure_ascii=False, indent=2)
|
||
|
||
fallback_system = SystemMessage(
|
||
content=(
|
||
"You are a friendly business query assistant. "
|
||
"The query executed successfully but returned no data. "
|
||
"Answer the user in a concise and helpful way. "
|
||
"IMPORTANT RULES:\n"
|
||
"1. DO NOT show any SQL statements, technical field names, or database terminology to the user\n"
|
||
"2. Use business language that non-technical users can understand\n"
|
||
"3. Clearly state that no matching data was found\n"
|
||
"4. Provide specific suggestions about which conditions might be too restrictive\n"
|
||
"5. Use the query context to suggest alternatives, but express them in plain language\n"
|
||
"6. For example, say 'try removing the country filter' instead of 'remove ship_to_country condition'\n"
|
||
"7. For example, say 'try searching all records instead of just the latest' instead of 'remove data_flag filter'"
|
||
)
|
||
)
|
||
fallback_user = HumanMessage(
|
||
content=(
|
||
f"Original user question: {state.original_input}\n"
|
||
f"Query mode: {state.query_mode or 'detail'}\n"
|
||
f"SQL plan context (for your reference only, DO NOT show to user):\n{sql_plan_text}\n"
|
||
"Please answer the user in plain business language without any SQL or technical terms."
|
||
)
|
||
)
|
||
|
||
try:
|
||
response = model.invoke([fallback_system, fallback_user])
|
||
llm_content = response.content if hasattr(response, "content") else str(response)
|
||
state.messages.append(response)
|
||
|
||
formatted_html = _format_empty_result_response(
|
||
state.original_input,
|
||
llm_content
|
||
)
|
||
state.context["formatted_answer"] = formatted_html
|
||
state.context["response_source"] = "model_empty_result_fallback"
|
||
_trace(state, "[generate_response][out] source=model_empty_result_fallback")
|
||
except Exception as exc:
|
||
state.add_error(f"empty_result_fallback_failed:{exc}")
|
||
fixed_content = "未查询到符合条件的数据,请尝试调整筛选条件后再查询。"
|
||
state.messages.append(AIMessage(content=fixed_content))
|
||
|
||
formatted_html = _format_empty_result_response(
|
||
state.original_input,
|
||
fixed_content
|
||
)
|
||
state.context["formatted_answer"] = formatted_html
|
||
state.context["response_source"] = "empty_result_fixed_fallback"
|
||
_trace(state, "[generate_response][out] source=empty_result_fixed_fallback")
|
||
|
||
state.sync_context()
|
||
state.set_current_step("response_generated")
|
||
return state
|
||
|
||
# 有执行结果且不为空
|
||
if sr_api_result:
|
||
state.context["is_empty_result"] = False
|
||
state.context["response_source"] = "sr_api_result"
|
||
state.messages.append(AIMessage(content=str(sr_api_result)))
|
||
_trace(state, "[generate_response][out] source=sr_api_result")
|
||
state.sync_context()
|
||
state.set_current_step("response_generated")
|
||
return state
|
||
|
||
# 没有执行结果,返回 SQL(skip_sr_api=True 的情况)
|
||
final_sql = state.final_sql
|
||
if final_sql:
|
||
state.context["response_source"] = "final_sql"
|
||
state.messages.append(AIMessage(content=final_sql))
|
||
_trace(state, "[generate_response][out] source=final_sql")
|
||
state.sync_context()
|
||
state.set_current_step("response_generated")
|
||
return state
|
||
|
||
# 兜底:使用模型生成回复
|
||
if state.messages:
|
||
response = model.invoke(state.messages)
|
||
state.messages.append(response)
|
||
state.context["response_source"] = "model_invoke"
|
||
_trace(state, "[generate_response][out] source=model_invoke")
|
||
state.sync_context()
|
||
state.set_current_step("response_generated")
|
||
return state
|