From 481b93e84a2194a4001147dcd8174171f7fccd45 Mon Sep 17 00:00:00 2001 From: grigo Date: Thu, 11 Jun 2026 08:11:51 +0300 Subject: [PATCH] fixed injection watcher --- backend/app/chat/service.py | 54 ++++++++++++++++++-- backend/app/integrations/wger.py | 86 ++++++++++++++++++++------------ frontend/src/App.css | 5 +- frontend/src/pages/Chat.css | 8 +++ frontend/src/pages/Chat.tsx | 15 +++++- 5 files changed, 131 insertions(+), 37 deletions(-) diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 2950001..85d8da1 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import time from collections.abc import AsyncIterator from typing import Any @@ -179,14 +180,21 @@ class ChatService: self._save_message(session_id, "user", user_text) yield self._sse("status", {"phase": "preparing"}) + t0 = time.monotonic() messages = await asyncio.to_thread(_build_messages_for_session, session_id) + prepare_sec = time.monotonic() - t0 if not messages: yield self._sse("error", {"message": "Session not found"}) return yield self._sse("status", {"phase": "generating"}) streamed_reply_parts: list[str] = [] + all_tool_notices: list[str] = [] + tools_executed = 0 + tool_round = 0 for _ in range(MAX_TOOL_ROUNDS): + tool_round += 1 + t_round = time.monotonic() content_parts: list[str] = [] tool_calls: list[dict[str, Any]] = [] reasoning = "" @@ -201,11 +209,29 @@ class ChatService: if event.get("reasoning_details"): reasoning_details = event["reasoning_details"] elif event["type"] == "error": + logger.warning( + "chat session=%s llm_error round=%d prepare=%.2fs: %s", + session_id, + tool_round, + prepare_sec, + event.get("content"), + ) yield self._sse("error", {"message": event.get("content", "LLM error")}) return elif event["type"] == "tool_calls": tool_calls = event["tool_calls"] + logger.info( + "chat session=%s round=%d prepare=%.2fs llm=%.2fs " + "content_len=%d tool_calls=%d", + session_id, + tool_round, + prepare_sec, + time.monotonic() - t_round, + len("".join(content_parts)), + len(tool_calls), + ) + if tool_calls: round_text = "".join(content_parts) if round_text.strip(): @@ -241,6 +267,7 @@ class ChatService: result = await execute_tool( self.db, fn["name"], args, session_id=session_id ) + tools_executed += 1 tool_message = { "role": "tool", "tool_call_id": tool_call["id"], @@ -253,6 +280,7 @@ class ChatService: if notice: self._save_message(session_id, "notice", notice) round_notices.append(notice) + all_tool_notices.append(notice) if fn["name"] in POMODORO_TOOL_NAMES: yield self._sse( @@ -270,14 +298,27 @@ class ChatService: final_content = "".join(streamed_reply_parts).strip() if not final_content and reasoning: final_content = reasoning.strip() + if not final_content and all_tool_notices: + # Notices уже ушли в SSE event: notice; здесь только финальный текст в БД. + final_content = "\n\n".join(all_tool_notices) + if not final_content and tools_executed: + retry = await self.llm.complete(messages, tools=None, temperature=0.4) + final_content = (retry.get("content") or "").strip() + if final_content: + yield self._sse("token", {"content": final_content}) if not final_content: + logger.warning( + "chat session=%s empty_reply tools=%d rounds=%d", + session_id, + tools_executed, + tool_round, + ) yield self._sse( "error", { "message": ( - "Модель не вернула текст. Для deepseek-v4-pro: " - "OPENROUTER_TOOLS_ENABLED=true и OPENROUTER_REASONING_EFFORT=none. " - "Для памяти: MEMORY_EXTRACT_MODEL=deepseek/deepseek-chat." + "Модель не вернула текст после выполнения команд. " + "Проверь OPENROUTER_MODEL и OPENROUTER_REASONING_EFFORT=none." ), }, ) @@ -285,6 +326,13 @@ class ChatService: self._save_message(session_id, "assistant", final_content) + logger.info( + "chat session=%s done tools=%d reply_len=%d total=%.2fs", + session_id, + tools_executed, + len(final_content), + time.monotonic() - t0, + ) yield self._sse("done", {}) if get_settings().memory_auto_extract: asyncio.create_task( diff --git a/backend/app/integrations/wger.py b/backend/app/integrations/wger.py index b96e494..d083a57 100644 --- a/backend/app/integrations/wger.py +++ b/backend/app/integrations/wger.py @@ -4,45 +4,69 @@ import httpx from app.config import get_settings +# wger language ids (https://wger.de/api/v2/language/) +_LANG_RU = 5 +_LANG_EN = 2 + class WgerClient: def __init__(self) -> None: settings = get_settings() self.base_url = settings.wger_base_url.rstrip("/") + @staticmethod + def _pick_name(item: dict[str, Any]) -> str: + translations = item.get("translations") or [] + for lang_id in (_LANG_RU, _LANG_EN): + for tr in translations: + if tr.get("language") == lang_id and tr.get("name"): + return str(tr["name"]) + for tr in translations: + if tr.get("name"): + return str(tr["name"]) + return f"#{item.get('id')}" + + def _fetch_exerciseinfo( + self, + client: httpx.Client, + *, + query: str, + languagecode: str, + limit: int, + ) -> list[dict[str, Any]]: + response = client.get( + f"{self.base_url}/exerciseinfo/", + params={ + "name__search": query, + "languagecode": languagecode, + "limit": limit, + }, + ) + response.raise_for_status() + return response.json().get("results") or [] + def search_exercises(self, query: str, limit: int = 8) -> list[dict[str, Any]]: + query = query.strip() + if not query: + return [] + with httpx.Client(timeout=20.0) as client: - response = client.get( - f"{self.base_url}/exercise/search/", - params={"term": query, "language": "ru"}, + results = self._fetch_exerciseinfo( + client, query=query, languagecode="ru", limit=limit ) - response.raise_for_status() - data = response.json() - sug = data.get("suggestions", data) if isinstance(data, dict) else [] - if isinstance(sug, dict): - results = sug.get("results", []) - elif isinstance(sug, list): - results = sug - else: - results = [] + if not results: + results = self._fetch_exerciseinfo( + client, query=query, languagecode="en", limit=limit + ) - out: list[dict[str, Any]] = [] - for item in results[:limit]: - if isinstance(item, dict): - name = item.get("value") or item.get("name") or str(item) - out.append({"name": name, "data": item}) - elif isinstance(item, str): - out.append({"name": item}) - if out: - return out - - response2 = client.get( - f"{self.base_url}/exerciseinfo/", - params={"language": 2, "limit": limit}, + out: list[dict[str, Any]] = [] + for item in results[:limit]: + category = item.get("category") or {} + out.append( + { + "id": item.get("id"), + "name": self._pick_name(item), + "category": category.get("name") if isinstance(category, dict) else category, + } ) - response2.raise_for_status() - for item in (response2.json().get("results") or [])[:limit]: - name = item.get("name") or f"#{item.get('id')}" - if query.lower() in name.lower(): - out.append({"id": item.get("id"), "name": name, "category": item.get("category")}) - return out[:limit] + return out diff --git a/frontend/src/App.css b/frontend/src/App.css index 044cd00..b1560be 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -41,7 +41,10 @@ .app-main { flex: 1; min-height: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } @media (max-width: 768px) { diff --git a/frontend/src/pages/Chat.css b/frontend/src/pages/Chat.css index 36bd0bc..98e5757 100644 --- a/frontend/src/pages/Chat.css +++ b/frontend/src/pages/Chat.css @@ -179,6 +179,14 @@ font-size: 0.92rem; } +.message-error { + align-self: center; + max-width: 92%; + background: #2a1a1a; + border: 1px solid #5a2a2a; + color: #f0b0b0; +} + .message-role { font-size: 0.75rem; color: #8b95a5; diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index f2f53a3..2b8547b 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -42,6 +42,7 @@ export default function Chat() { "thinking", ); const [liveNotices, setLiveNotices] = useState([]); + const [chatError, setChatError] = useState(null); const messagesRef = useRef(null); const inputRef = useRef(null); const scrollRafRef = useRef(null); @@ -93,7 +94,7 @@ export default function Chat() { cancelAnimationFrame(scrollRafRef.current); } }; - }, [messages, streaming, liveNotices, loading, scrollToBottom]); + }, [messages, streaming, liveNotices, loading, chatError, scrollToBottom]); const dismissKeyboard = useCallback(() => { inputRef.current?.blur(); @@ -151,6 +152,7 @@ export default function Chat() { setStreaming(""); setPendingPhase("thinking"); setLiveNotices([]); + setChatError(null); const tempUser: ChatMessage = { id: Date.now(), @@ -187,6 +189,7 @@ export default function Chat() { if (chunk.event === "done") { setStreaming(""); setLiveNotices([]); + setChatError(null); setLoading(false); if (assistantText.trim()) { setMessages((prev) => [ @@ -208,8 +211,9 @@ export default function Chat() { } } catch (err) { console.error(err); + const message = err instanceof Error ? err.message : "Ошибка чата"; + setChatError(message); setStreaming(""); - setLiveNotices([]); } finally { setLoading(false); } @@ -310,6 +314,13 @@ export default function Chat() { )} + + {chatError && ( +
+
ошибка
+
{chatError}
+
+ )}