w1x-cli 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
w1x/engine.py ADDED
@@ -0,0 +1,565 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Движок генерации ответов модели, вызов инструментов и статистика."""
3
+ import re
4
+ import sys
5
+ import time
6
+ from datetime import datetime
7
+ from rich.console import Group
8
+ from rich.live import Live
9
+ from rich.panel import Panel
10
+ from ollama import chat
11
+
12
+ from .config import (
13
+ MAX_TOOL_ROUNDS,
14
+ MAX_RESPONSE_TOKENS,
15
+ TOOL_INSTRUCTIONS,
16
+ FORMAT_INSTRUCTIONS,
17
+ SPINNER,
18
+ get_keep_alive,
19
+ get_context_tokens,
20
+ get_max_history_chars,
21
+ )
22
+ from .terminal import (
23
+ console,
24
+ render_message,
25
+ run_with_spinner,
26
+ uses_modern_windows_terminal,
27
+ ChatMarkdown,
28
+ )
29
+ from .tools import (
30
+ calculate_datetime,
31
+ calculate_expression,
32
+ parse_math_request,
33
+ previous_math_expression,
34
+ split_datetime_request,
35
+ datetime_answer,
36
+ needs_clarification,
37
+ ask_antigravity,
38
+ TOOL_FUNCTIONS,
39
+ DIRECT_PASSTHROUGH_TOOLS,
40
+ format_direct_tool_result,
41
+ execute_tool_call,
42
+ )
43
+ from .memory import relevant_memory_block
44
+
45
+
46
+ def trim_messages_for_model(messages):
47
+ """Собрать историю для контекста модели с сохранением последних сообщений."""
48
+ system_messages = [item for item in messages if item.get("role") == "system"]
49
+ conversation = [
50
+ item for item in messages
51
+ if item.get("role") in {"user", "assistant"}
52
+ and isinstance(item.get("content"), str)
53
+ and item["content"].strip()
54
+ ]
55
+
56
+ total_chars = 0
57
+ selected = []
58
+ max_history = get_max_history_chars()
59
+ # Всегда гарантированно сохраняем хотя бы последние 6 сообщений диалога
60
+ for idx, item in enumerate(reversed(conversation)):
61
+ content_len = len(item["content"])
62
+ if idx >= 6 and (total_chars + content_len > max_history):
63
+ break
64
+ selected.append({"role": item["role"], "content": item["content"]})
65
+ total_chars += content_len
66
+
67
+ selected.reverse()
68
+ system_payload = (
69
+ system_messages[0]["content"] if system_messages else "Отвечай по-русски."
70
+ )
71
+ return [{"role": "system", "content": system_payload}, *selected]
72
+
73
+
74
+ def weak_tool_answer(text):
75
+ normalized = " ".join(text.lower().split())
76
+ if not normalized:
77
+ return True
78
+ return bool(
79
+ re.search(
80
+ r"^(?:(?:к\s+сожалению|извините)[,\s]+)?(?:я\s+)?"
81
+ r"(?:не\s+могу|не\s+удалось|не\s+получилось|"
82
+ r"не\s+нашел|не\s+наш[её]л|не\s+вижу|нет\s+информации)",
83
+ normalized,
84
+ )
85
+ or re.search(
86
+ r"(?:поиск|инструмент).*(?:не\s+дал|не\s+вернул|не\s+помог|ошибк)",
87
+ normalized,
88
+ )
89
+ )
90
+
91
+
92
+ def format_duration(nanoseconds):
93
+ if not nanoseconds:
94
+ return "—"
95
+ if nanoseconds < 1_000_000:
96
+ return "<1 мс"
97
+ seconds = nanoseconds / 1_000_000_000
98
+ return f"{seconds * 1000:.0f} мс" if seconds < 1 else f"{seconds:.2f} с"
99
+
100
+
101
+ def collect_stats(response):
102
+ if response is None:
103
+ return None
104
+ prompt_tokens = getattr(response, "prompt_eval_count", None) or 0
105
+ output_tokens = getattr(response, "eval_count", None) or 0
106
+ eval_duration = getattr(response, "eval_duration", None) or 0
107
+ speed = (
108
+ output_tokens / (eval_duration / 1_000_000_000)
109
+ if output_tokens and eval_duration
110
+ else 0
111
+ )
112
+ return {
113
+ "source": "model",
114
+ "prompt_tokens": prompt_tokens,
115
+ "output_tokens": output_tokens,
116
+ "total_tokens": prompt_tokens + output_tokens,
117
+ "total_duration": getattr(response, "total_duration", None),
118
+ "load_duration": getattr(response, "load_duration", None),
119
+ "prompt_duration": getattr(response, "prompt_eval_duration", None),
120
+ "eval_duration": eval_duration,
121
+ "speed": speed,
122
+ }
123
+
124
+
125
+ def direct_tool_stats(tool_name, started_ns):
126
+ duration_ns = time.perf_counter_ns() - started_ns
127
+ return {
128
+ "source": "direct_tool",
129
+ "tool_name": tool_name,
130
+ "prompt_tokens": 0,
131
+ "output_tokens": 0,
132
+ "total_tokens": 0,
133
+ "total_duration": duration_ns,
134
+ "load_duration": 0,
135
+ "prompt_duration": 0,
136
+ "eval_duration": duration_ns,
137
+ "speed": 0,
138
+ }
139
+
140
+
141
+ def merge_stats(aggregate, step_stats):
142
+ if step_stats is None:
143
+ return aggregate
144
+ if aggregate is None:
145
+ return dict(step_stats)
146
+
147
+ aggregate["prompt_tokens"] = max(
148
+ aggregate.get("prompt_tokens", 0),
149
+ step_stats.get("prompt_tokens", 0),
150
+ )
151
+ aggregate["output_tokens"] = (
152
+ aggregate.get("output_tokens", 0)
153
+ + step_stats.get("output_tokens", 0)
154
+ )
155
+ aggregate["total_tokens"] = (
156
+ aggregate.get("prompt_tokens", 0)
157
+ + aggregate.get("output_tokens", 0)
158
+ )
159
+ aggregate["eval_duration"] = (
160
+ aggregate.get("eval_duration", 0)
161
+ + (step_stats.get("eval_duration") or 0)
162
+ )
163
+ return aggregate
164
+
165
+
166
+ def finalize_stats(aggregate, started_ns):
167
+ if aggregate is None:
168
+ return None
169
+ total_duration = time.perf_counter_ns() - started_ns
170
+ eval_duration = aggregate.get("eval_duration") or 0
171
+ output_tokens = aggregate.get("output_tokens") or 0
172
+ speed = (
173
+ output_tokens / (eval_duration / 1_000_000_000)
174
+ if output_tokens and eval_duration
175
+ else 0
176
+ )
177
+ aggregate["total_duration"] = total_duration
178
+ aggregate["speed"] = speed
179
+ return aggregate
180
+
181
+
182
+ def empty_totals():
183
+ return {
184
+ "requests": 0,
185
+ "prompt_tokens": 0,
186
+ "output_tokens": 0,
187
+ "total_tokens": 0,
188
+ "total_duration": 0,
189
+ }
190
+
191
+
192
+ def add_to_totals(totals, stats):
193
+ if stats is None:
194
+ return
195
+ totals["requests"] += 1
196
+ totals["prompt_tokens"] += stats.get("prompt_tokens", 0)
197
+ totals["output_tokens"] += stats.get("output_tokens", 0)
198
+ totals["total_tokens"] += stats.get("total_tokens", 0)
199
+ totals["total_duration"] += stats.get("total_duration") or 0
200
+
201
+
202
+ def print_stats(stats, totals):
203
+ if stats is None:
204
+ return
205
+
206
+ if stats.get("source") == "direct_tool":
207
+ duration = format_duration(stats.get("total_duration"))
208
+ tool_label = stats.get("tool_name", "инструмент")
209
+ console.print(f"[dim]Ответ: {tool_label} • время: {duration}[/dim]")
210
+ return
211
+
212
+ duration = format_duration(stats.get("total_duration"))
213
+ eval_time = format_duration(stats.get("eval_duration"))
214
+ prompt_tokens = stats.get("prompt_tokens", 0)
215
+ output_tokens = stats.get("output_tokens", 0)
216
+ speed = stats.get("speed", 0)
217
+ console.print(
218
+ f"[dim]Токены: {prompt_tokens} ввод + {output_tokens} ответ • "
219
+ f"скорость: {speed:.1f} т/с • время: {duration} (генерация {eval_time})[/dim]"
220
+ )
221
+
222
+
223
+ def generate_answer(model, messages, think_mode):
224
+ request_started_ns = time.perf_counter_ns()
225
+ last_user_text = next(
226
+ (
227
+ item["content"]
228
+ for item in reversed(messages)
229
+ if item.get("role") == "user"
230
+ ),
231
+ "",
232
+ )
233
+
234
+ if needs_clarification(last_user_text):
235
+ answer = "Уточни, пожалуйста, что именно нужно сделать: повторить прошлый ответ, изменить его или решить новую задачу?"
236
+ console.print()
237
+ render_message("ИИ ›", role="assistant_label")
238
+ render_message(answer, role="assistant")
239
+ return answer, direct_tool_stats("clarification", request_started_ns), False
240
+
241
+ math_expression = parse_math_request(last_user_text)
242
+ if math_expression is None and re.search(
243
+ r"\bпроверь\w*\s+(?:это\s+)?калькулятор", last_user_text.casefold()
244
+ ):
245
+ math_expression = previous_math_expression(messages[:-1])
246
+ if math_expression is not None:
247
+ result = calculate_expression(math_expression)
248
+ answer = format_direct_tool_result("calculate_expression", result)
249
+ console.print()
250
+ render_message("ИИ ›", role="assistant_label")
251
+ console.print()
252
+ render_message(f"Считаю: {math_expression}", role="tool")
253
+ render_message("Ответ ›", role="assistant_label")
254
+ render_message(answer, role="assistant")
255
+ return answer, direct_tool_stats("calculate_expression", request_started_ns), False
256
+
257
+ datetime_arguments, remaining_request = split_datetime_request(last_user_text)
258
+ if datetime_arguments is not None and not remaining_request:
259
+ answer = datetime_answer(datetime_arguments)
260
+ console.print()
261
+ render_message("ИИ ›", role="assistant_label")
262
+ console.print()
263
+ render_message("Вычисляю точные дату и время", role="tool")
264
+ render_message("Ответ ›", role="assistant_label")
265
+ render_message(answer, role="assistant")
266
+ return (
267
+ answer,
268
+ direct_tool_stats("calculate_datetime", request_started_ns),
269
+ False,
270
+ )
271
+
272
+ working_messages = trim_messages_for_model(messages)
273
+ if datetime_arguments is not None and remaining_request:
274
+ for message in reversed(working_messages):
275
+ if message.get("role") == "user":
276
+ message["content"] = remaining_request
277
+ break
278
+ # Находим математические подвыражения в тексте для мгновенного точного авто-вычисления
279
+ precomputed_math = []
280
+ math_candidates = re.findall(r"[\d\s().,+\-*/%]{3,}", last_user_text.replace("×", "*").replace("÷", "/"))
281
+ for cand in math_candidates:
282
+ cand_clean = cand.strip(" ,.")
283
+ if re.search(r"[+\-*/%]", cand_clean) and re.search(r"\d", cand_clean) and len(cand_clean) >= 3:
284
+ val = calculate_expression(cand_clean)
285
+ if not val.startswith("Ошибка"):
286
+ precomputed_math.append(f"{cand_clean} = {val}")
287
+
288
+ math_pre_block = ""
289
+ if precomputed_math:
290
+ math_pre_block = (
291
+ "Точные математические вычисления системы:\n"
292
+ + "\n".join(f"- {item}" for item in precomputed_math)
293
+ + "\nИспользуй эти точные результаты в ответе без повторного вызова калькулятора."
294
+ )
295
+
296
+ now_dt = datetime.now()
297
+ date_info_block = (
298
+ f"Текущая реальная дата и время: {now_dt.strftime('%d.%m.%Y (%A)')}, текущий {now_dt.year} год. "
299
+ f"Для актуальных вопросов (события, игры, патчи, новости 'сейчас/сегодня') всегда учитывай "
300
+ f"{now_dt.year} год и составляй поисковые запросы с {now_dt.year} годом."
301
+ )
302
+
303
+ memory_block = relevant_memory_block(last_user_text)
304
+ working_messages[0]["content"] = (
305
+ f"{working_messages[0]['content']}\n\n"
306
+ f"Сейчас внутри W1x работает базовая модель Ollama: {model}. "
307
+ "Это не меняет твою личность W1x.\n\n"
308
+ f"{date_info_block}\n\n"
309
+ f"{TOOL_INSTRUCTIONS}\n\n{FORMAT_INSTRUCTIONS}"
310
+ + (f"\n\n{memory_block}" if memory_block else "")
311
+ + (f"\n\n{math_pre_block}" if math_pre_block else "")
312
+ )
313
+ aggregate_stats = None
314
+ tools_used = False
315
+ force_final_answer = False
316
+ followup_tool_functions = None
317
+ recovery_attempted = False
318
+ compact_tool_results = []
319
+ console.print()
320
+ render_message("ИИ ›", role="assistant_label")
321
+
322
+ for tool_round in range(MAX_TOOL_ROUNDS):
323
+ answer = ""
324
+ thinking = ""
325
+ tool_calls = []
326
+ last_chunk = None
327
+ interrupted = False
328
+ stream = None
329
+ chat_arguments = {
330
+ "model": model,
331
+ "messages": working_messages,
332
+ "stream": True,
333
+ "think": think_mode,
334
+ "keep_alive": get_keep_alive(),
335
+ "options": {
336
+ "temperature": 0.55 if force_final_answer else 0.72,
337
+ "top_p": 0.9,
338
+ "repeat_penalty": 1.1,
339
+ "num_ctx": get_context_tokens(),
340
+ "num_predict": MAX_RESPONSE_TOKENS,
341
+ },
342
+ }
343
+ if not force_final_answer:
344
+ chat_arguments["tools"] = TOOL_FUNCTIONS
345
+ elif followup_tool_functions:
346
+ chat_arguments["tools"] = followup_tool_functions
347
+ try:
348
+ stream = chat(**chat_arguments)
349
+ with Live(
350
+ console=console,
351
+ auto_refresh=False,
352
+ ) as live:
353
+ for chunk in stream:
354
+ last_chunk = chunk
355
+ chunk_thinking = getattr(chunk.message, "thinking", None) or ""
356
+ chunk_answer = getattr(chunk.message, "content", None) or ""
357
+ chunk_tools = getattr(chunk.message, "tool_calls", None) or []
358
+
359
+ if chunk_tools:
360
+ tool_calls.extend(chunk_tools)
361
+
362
+ if chunk_thinking:
363
+ thinking += chunk_thinking
364
+
365
+ if chunk_answer:
366
+ answer += chunk_answer
367
+
368
+ renderables = []
369
+ if thinking:
370
+ renderables.append(
371
+ Panel(
372
+ ChatMarkdown(thinking),
373
+ title="Размышление",
374
+ border_style="bright_black",
375
+ padding=(0, 1),
376
+ )
377
+ )
378
+ if answer:
379
+ renderables.append(ChatMarkdown(answer))
380
+
381
+ if len(renderables) == 1:
382
+ live.update(renderables[0], refresh=True)
383
+ elif len(renderables) > 1:
384
+ live.update(Group(*renderables), refresh=True)
385
+ except KeyboardInterrupt:
386
+ interrupted = True
387
+ except Exception as error:
388
+ error_msg = str(error)
389
+ if not force_final_answer and (
390
+ "XML syntax error" in error_msg
391
+ or "status code: -1" in error_msg
392
+ or "tool" in error_msg.lower()
393
+ ):
394
+ force_final_answer = True
395
+ continue
396
+ raise error
397
+ finally:
398
+ if stream is not None:
399
+ close = getattr(stream, "close", None)
400
+ if callable(close):
401
+ try:
402
+ close()
403
+ except Exception:
404
+ pass
405
+
406
+ should_recover = (
407
+ tools_used
408
+ and not tool_calls
409
+ and weak_tool_answer(answer)
410
+ and not recovery_attempted
411
+ )
412
+ should_escalate_after_recovery = (
413
+ tools_used
414
+ and not tool_calls
415
+ and weak_tool_answer(answer)
416
+ and recovery_attempted
417
+ )
418
+
419
+ aggregate_stats = merge_stats(aggregate_stats, collect_stats(last_chunk))
420
+ if interrupted:
421
+ return (
422
+ answer,
423
+ finalize_stats(aggregate_stats, request_started_ns),
424
+ True,
425
+ )
426
+
427
+ if thinking or answer or tool_calls:
428
+ working_messages.append(
429
+ {
430
+ "role": "assistant",
431
+ "thinking": thinking,
432
+ "content": answer,
433
+ "tool_calls": tool_calls,
434
+ }
435
+ )
436
+
437
+ if not tool_calls:
438
+ if should_escalate_after_recovery:
439
+ render_message(
440
+ "Две локальные попытки не дали нормального ответа — ESCALATE",
441
+ role="notice",
442
+ )
443
+ escalated = run_with_spinner(
444
+ "Antigravity думает...",
445
+ lambda: ask_antigravity(
446
+ last_user_text,
447
+ "\n\n".join(compact_tool_results)[-1200:],
448
+ ),
449
+ )
450
+ render_message("Antigravity ›", role="assistant_label")
451
+ render_message(escalated, role="assistant")
452
+ if aggregate_stats is not None:
453
+ aggregate_stats["tool_answers"] = (
454
+ aggregate_stats.get("tool_answers", 0) + 1
455
+ )
456
+ return (
457
+ escalated,
458
+ finalize_stats(aggregate_stats, request_started_ns),
459
+ False,
460
+ )
461
+ if should_recover:
462
+ recovery_attempted = True
463
+ working_messages.append(
464
+ {
465
+ "role": "user",
466
+ "content": (
467
+ "Твой предыдущий ответ пустой или содержит отказ, хотя "
468
+ "инструменты уже вернули полезные данные. Ответь по "
469
+ "найденным данным прямо сейчас. Если информации точно "
470
+ "нет, ответь одним словом: ESCALATE"
471
+ ),
472
+ }
473
+ )
474
+ continue
475
+ return (
476
+ answer,
477
+ finalize_stats(aggregate_stats, request_started_ns),
478
+ False,
479
+ )
480
+
481
+ round_tool_results = []
482
+ for call in tool_calls:
483
+ result = execute_tool_call(call)
484
+ round_tool_results.append((call.function.name, result))
485
+ tools_used = True
486
+ compact_tool_results.append(
487
+ f"{call.function.name}: {result[:2200]}"
488
+ )
489
+ working_messages.append(
490
+ {
491
+ "role": "tool",
492
+ "tool_name": call.function.name,
493
+ "content": result,
494
+ }
495
+ )
496
+ if round_tool_results and all(
497
+ name in DIRECT_PASSTHROUGH_TOOLS for name, _ in round_tool_results
498
+ ):
499
+ answer = "\n\n".join(
500
+ format_direct_tool_result(name, result)
501
+ for name, result in round_tool_results
502
+ )
503
+ console.print()
504
+ label = (
505
+ "Antigravity ›"
506
+ if any(name == "ask_antigravity" for name, _ in round_tool_results)
507
+ else "Ответ ›"
508
+ )
509
+ render_message(label, role="assistant_label")
510
+ render_message(answer, role="assistant")
511
+ if aggregate_stats is not None:
512
+ aggregate_stats["tool_answers"] = (
513
+ aggregate_stats.get("tool_answers", 0) + len(round_tool_results)
514
+ )
515
+ return (
516
+ answer,
517
+ finalize_stats(aggregate_stats, request_started_ns),
518
+ False,
519
+ )
520
+ used_names = {name for name, _ in round_tool_results}
521
+ python_results = [
522
+ result
523
+ for name, result in round_tool_results
524
+ if name == "run_python_code"
525
+ ]
526
+ python_check_unreliable = any(
527
+ "СТАТУС ПРОВЕРКИ: НЕОПРЕДЕЛЁН" in result
528
+ or not result.startswith("Код завершения: 0")
529
+ for result in python_results
530
+ )
531
+ if "search_web" in used_names or "run_python_code" in used_names or "memory" in used_names:
532
+ force_final_answer = True
533
+ followup_tool_functions = [calculate_datetime, calculate_expression] if "search_web" in used_names else None
534
+ next_instruction = (
535
+ "Теперь сформулируй итоговый, естественный и дружелюбный ответ для пользователя на основе полученных данных. "
536
+ "Если использовалась память — ответь человеческим языком (например: 'Твоего хомяка зовут Ракета!'), без служебных маркеров. "
537
+ "Если пользователь просил написать код — предоставь чистый код в блоке ```python ... ```. "
538
+ "Не вызывай другие инструменты."
539
+ )
540
+ elif tool_round == 0 and not python_check_unreliable:
541
+ force_final_answer = False
542
+ followup_tool_functions = None
543
+ next_instruction = (
544
+ "Проанализируй полученный результат. Если всё понятно — начни с прямого вывода и дай итоговый ответ."
545
+ )
546
+ else:
547
+ force_final_answer = True
548
+ followup_tool_functions = None
549
+ next_instruction = (
550
+ "Теперь ответь на исходный вопрос, используя результаты проверок выше. "
551
+ "Начни с прямого вывода. Не вызывай другие инструменты."
552
+ )
553
+ working_messages.append(
554
+ {
555
+ "role": "user",
556
+ "content": (
557
+ f"{next_instruction}\n"
558
+ f"Исходный вопрос: {last_user_text}"
559
+ ),
560
+ }
561
+ )
562
+
563
+ answer = "Остановился: модель превысила лимит последовательных вызовов инструментов."
564
+ render_message(answer, role="notice")
565
+ return answer, finalize_stats(aggregate_stats, request_started_ns), False