Repository navigation
Expand file tree
/
Copy pathanswer_node.py
More file actions
214 lines (183 loc) · 9.65 KB
/
Copy pathanswer_node.py
File metadata and controls
214 lines (183 loc) · 9.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
from __future__ import annotations
import json
from typing import Any, Optional
from src.core.logging_settings import logger
from src.services.agent.graph_state import GraphState, NODE_ANSWER
from src.services.agent.prompts import build_memory_context
from src.services.llm.llm_client import LLMClient
ANSWER_SYSTEM_PROMPT = """Ты — финальный ассистент по данным о ценах на цветной лом.
У тебя есть:
1. Исходный вопрос пользователя
2. SQL-запрос (что было выполнено)
3. Результат запроса (данные из базы)
Твоя задача — дать ЕСТЕСТВЕННЫЙ, ПОЛНЫЙ и ЧЕЛОВЕЧЕСКИЙ ответ на русском языке,
используя информацию из вопроса и данных. НЕ просто "средняя_цена: X", а полноценное
предложение, которое повторяет контекст вопроса и объясняет результат.
Требования:
- Повтори контекст из вопроса (что за лом, какой период, какой поставщик/источник).
- Чётко назови искомую метрику (средняя/минимальная/максимальная цена, сумма и т.д.).
- Приведи число с форматированием (разделители тысяч), укажи валюту/единицы (руб/тн), если уместно.
- Если данных нет — объясни, что по данному запросу цен в источнике не нашлось.
- Ответ — 1-3 предложения, без markdown, без перечисления полей JSON.
Пример:
Вопрос: "Средняя цена всех видов медного лома по Сплав-21 в декабре 2025?"
Результат: [{"средняя_цена": 759991.4}]
Ответ: "Средняя цена всех видов медного лома по данным компании Сплав-21 в декабре 2025 года составила 759 991.4 руб./тн."
"""
CONCISE_SYSTEM_PROMPT = """Ты — краткий ассистент по данным о ценах на цветной лом.
У тебя есть:
1. Исходный вопрос пользователя
2. SQL-запрос (что было выполнено)
3. Результат запроса (данные из базы)
Твоя задача — ответить МАКСИМАЛЬНО КРАТКО: ТОЛЬКО число (с указанием валюты/единиц измерения, если уместно) ИЛИ одно короткое слово.
Правила:
- Никаких предложений, пояснений, вводных фраз, markdown и лишней пунктуации.
- Если в данных есть конкретное числовое значение — верни только его, например: "759 991.4 руб./тн" или "12 500".
- Если данных нет — верни одно слово: "нет" или "не найдено".
- Не добавляй ничего сверх самого ответа.
Пример:
Вопрос: "Средняя цена всех видов медного лома по Сплав-21 в декабре 2025?"
Результат: [{"средняя_цена": 759991.4}]
Ответ: 759 991.4 руб./тн
"""
def _deterministic_answer(sql_result: list, is_concise: bool) -> str:
if is_concise:
return "не найдено"
if sql_result:
return (
"Получены данные, но не удалось сформировать ответ. "
"Пожалуйста, уточните вопрос."
)
return (
"Не удалось найти ответ на ваш вопрос. "
"Попробуйте переформулировать запрос."
)
def _result_has_numbers(sql_result: list) -> bool:
"""True, если в результате запроса есть хотя бы одно числовое значение.
Число в ответе критично для вопросов «какая цена/сумма/дельта» — если LLM
сгенерировал усечённый ответ без числа, его нельзя отдавать пользователю.
"""
for row in sql_result or []:
if not isinstance(row, dict):
continue
for v in row.values():
if isinstance(v, bool):
continue
if isinstance(v, (int, float)):
return True
return False
def _has_digit(text: str) -> bool:
"""True, если в тексте есть хотя бы одна цифра."""
return any(ch.isdigit() for ch in (text or ""))
async def answer_node(
state: GraphState,
llm: Optional[LLMClient] = None,
**kwargs: Any,
) -> GraphState:
llm = llm or LLMClient()
request_id = state.get("request_id", "?")[:8]
question = state.get("question", "")
answer = state.get("answer", "")
sql_result = state.get("sql_result", [])
sql_query = state.get("sql_query", "")
response_mode = state.get("response_mode", "detailed")
is_concise = response_mode == "concise"
logger.info(
"Answer Node [{}]: finalizing answer ({} chars, mode={})",
request_id,
len(answer),
response_mode,
)
# Детерминированный ответ — fallback на случай ошибки LLM.
if not answer:
deterministic = _deterministic_answer(sql_result, is_concise)
else:
deterministic = answer
if sql_result and question:
# Пытаемся сгенерировать ответ по вопросу + данным в выбранном режиме.
try:
system_prompt = (
CONCISE_SYSTEM_PROMPT if is_concise else ANSWER_SYSTEM_PROMPT
)
if is_concise:
instruction = (
"Дай ответ в формате «только число или слово» на основе этих данных."
)
temperature = 0.0
max_tokens = 40
else:
instruction = (
"Сформулируй естественный, полный ответ на вопрос пользователя "
"на основе этих данных."
)
temperature = 0.2
max_tokens = 512
memory_ctx = build_memory_context(state.get("conversation_history", []))
memory_section = (
f"\n\nИстория диалога (контекст предыдущих вопросов):\n{memory_ctx}"
if memory_ctx
else ""
)
user_message = f"""Вопрос пользователя: {question}{memory_section}
SQL-запрос:
{sql_query}
Результат ({len(sql_result)} строк):
{json.dumps(sql_result[:5], ensure_ascii=False, indent=2, default=str)}
{instruction}"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
generated = (await llm.chat(
messages=messages,
model=None,
temperature=temperature,
max_tokens=max_tokens,
)).strip()
min_len = 1 if is_concise else 10
if generated and len(generated) > min_len:
# Страховка от усечённых ответов LLM: если в данных есть числовое
# значение (цена/сумма/дельта), а сгенерированный ответ потерял
# все цифры ("Минимальная среднерыночная цена на цинковый...") —
# отдаём детерминированный ответ, где число гарантированно есть.
if not is_concise and _result_has_numbers(sql_result) and not _has_digit(generated):
logger.warning(
"Answer Node [{}]: generated answer lost the numeric value, "
"using deterministic fallback",
request_id,
)
answer = deterministic
else:
answer = generated
logger.info(
"Answer Node [{}]: natural answer generated ({} chars, mode={})",
request_id,
len(answer),
response_mode,
)
else:
answer = deterministic
except Exception as exc:
logger.warning(
"Answer Node [{}]: natural answer failed ({}), using deterministic",
request_id,
exc,
)
answer = deterministic
else:
answer = deterministic
state["answer"] = answer
state["confidence"] = state.get("confidence", 0.0)
state["trace"] = state.get("trace", {})
state["trace"][NODE_ANSWER] = {
"answer_length": len(answer),
"confidence": state.get("confidence", 0.0),
"response_mode": response_mode,
}
logger.info(
"Answer Node [{}]: done, confidence={:.2f}, mode={}",
request_id,
state.get("confidence", 0.0),
response_mode,
)
return state