fixed injection watcher

This commit is contained in:
2026-06-11 07:18:19 +03:00
parent 827f9016cd
commit 06e09cd728
7 changed files with 93 additions and 84 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Инжект системных оповещений в чат без role=assistant (не ломает LLM-историю)."""
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
def post_notice_to_latest_chat(content: str) -> int | None:
"""Сохраняет notice в последний активный чат. Возвращает session_id."""
db = SessionLocal()
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Уведомления")
db.add(session)
db.commit()
db.refresh(session)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
return session.id
finally:
db.close()
+5 -1
View File
@@ -234,6 +234,7 @@ class ChatService:
reasoning_json=reasoning_json, reasoning_json=reasoning_json,
) )
round_notices: list[str] = []
for tool_call in tool_calls: for tool_call in tool_calls:
fn = tool_call["function"] fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", "")) args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
@@ -251,7 +252,7 @@ class ChatService:
notice = format_tool_notice(fn["name"], result) notice = format_tool_notice(fn["name"], result)
if notice: if notice:
self._save_message(session_id, "notice", notice) self._save_message(session_id, "notice", notice)
yield self._sse("notice", {"content": notice}) round_notices.append(notice)
if fn["name"] in POMODORO_TOOL_NAMES: if fn["name"] in POMODORO_TOOL_NAMES:
yield self._sse( yield self._sse(
@@ -259,6 +260,9 @@ class ChatService:
{"name": fn["name"], "result": json.loads(result)}, {"name": fn["name"], "result": json.loads(result)},
) )
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue continue
final_content = "".join(content_parts).strip() final_content = "".join(content_parts).strip()
+2 -18
View File
@@ -1,21 +1,5 @@
from sqlalchemy import select from app.chat.notice_inbox import post_notice_to_latest_chat
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
def post_chat_notice(content: str) -> None: def post_chat_notice(content: str) -> None:
db = SessionLocal() post_notice_to_latest_chat(content)
try:
session = db.scalar(
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
)
if not session:
session = ChatSession(title="Уведомления")
db.add(session)
db.commit()
db.refresh(session)
db.add(Message(session_id=session.id, role="notice", content=content))
db.commit()
finally:
db.close()
+22 -1
View File
@@ -4,6 +4,8 @@ import random
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import httpx
from app.config import get_settings from app.config import get_settings
from app.db.base import SessionLocal from app.db.base import SessionLocal
from app.homelab.comfyui import ComfyUIClient from app.homelab.comfyui import ComfyUIClient
@@ -76,6 +78,15 @@ async def _tick_netdata() -> None:
db.close() db.close()
async def _comfyui_reachable(base_url: str) -> bool:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3.0, connect=2.0)) as client:
response = await client.get(f"{base_url.rstrip('/')}/system_stats")
return response.status_code < 500
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError):
return False
async def _tick_rofl() -> None: async def _tick_rofl() -> None:
settings = get_settings() settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled: if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
@@ -114,8 +125,18 @@ async def _tick_rofl() -> None:
return return
client = ComfyUIClient() client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
return
prompt = client.random_rofl_prompt() prompt = client.random_rofl_prompt()
result = await client.generate_image(prompt) try:
result = await asyncio.wait_for(
client.generate_image(prompt),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning("Rofl image skipped (ComfyUI): %s", exc)
return
if not result.get("ok"): if not result.get("ok"):
logger.warning("Rofl image failed: %s", result.get("error")) logger.warning("Rofl image failed: %s", result.get("error"))
return return
+27 -4
View File
@@ -44,6 +44,21 @@ class LLMClient:
return "".join(parts), details return "".join(parts), details
@staticmethod
def _normalize_reasoning_details(details: Any) -> list[Any] | None:
if not details:
return None
items = details if isinstance(details, list) else [details]
normalized: list[Any] = []
for item in items:
if hasattr(item, "model_dump"):
normalized.append(item.model_dump())
elif isinstance(item, dict):
normalized.append(item)
else:
normalized.append(item)
return normalized or None
@staticmethod @staticmethod
def attach_reasoning_to_message( def attach_reasoning_to_message(
message: dict[str, Any], message: dict[str, Any],
@@ -54,8 +69,9 @@ class LLMClient:
if reasoning: if reasoning:
message["reasoning"] = reasoning message["reasoning"] = reasoning
message["reasoning_content"] = reasoning message["reasoning_content"] = reasoning
if reasoning_details: normalized = LLMClient._normalize_reasoning_details(reasoning_details)
message["reasoning_details"] = reasoning_details if normalized:
message["reasoning_details"] = normalized
return message return message
async def stream_chat( async def stream_chat(
@@ -126,14 +142,21 @@ class LLMClient:
if choice.finish_reason: if choice.finish_reason:
reasoning = "".join(reasoning_parts) reasoning = "".join(reasoning_parts)
if reasoning or reasoning_details: normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield { yield {
"type": "reasoning", "type": "reasoning",
"reasoning": reasoning, "reasoning": reasoning,
"reasoning_details": reasoning_details or None, "reasoning_details": normalized_details,
} }
if tool_calls: if tool_calls:
yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())} yield {"type": "tool_calls", "tool_calls": list(tool_calls.values())}
logger.debug(
"LLM stream done: finish_reason=%s tool_calls=%d reasoning_len=%d",
choice.finish_reason,
len(tool_calls),
len(reasoning),
)
yield {"type": "done", "finish_reason": choice.finish_reason} yield {"type": "done", "finish_reason": choice.finish_reason}
except Exception as exc: except Exception as exc:
logger.exception("LLM stream read failed: %s", exc) logger.exception("LLM stream read failed: %s", exc)
+9 -58
View File
@@ -1,13 +1,15 @@
from sqlalchemy import select import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.character.service import CharacterService from app.chat.notice_inbox import post_notice_to_latest_chat
from app.chat.notices import format_phase_completed_notice from app.chat.notices import format_phase_completed_notice
from app.db.models import ChatSession, Message, PomodoroSession from app.db.models import PomodoroSession
from app.llm.client import LLMClient
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = { PHASE_LABELS = {
PHASE_WORK: "работа", PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв", PHASE_SHORT_BREAK: "короткий перерыв",
@@ -20,48 +22,6 @@ class PomodoroCompletionHandler:
self.db = db self.db = db
self.pomodoro = PomodoroService(db) self.pomodoro = PomodoroService(db)
self.cycle = CycleManager(db) self.cycle = CycleManager(db)
self.llm = LLMClient()
self.character = CharacterService()
def _latest_chat_session_id(self) -> int | None:
stmt = select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
session = self.db.scalar(stmt)
return session.id if session else None
def _save_chat_message(self, session_id: int, role: str, content: str) -> None:
self.db.add(Message(session_id=session_id, role=role, content=content))
chat = self.db.get(ChatSession, session_id)
if chat:
chat.updated_at = chat.updated_at # trigger onupdate
self.db.commit()
async def _generate_llm_comment(
self,
session: PomodoroSession,
next_phase: str | None,
) -> str:
cycle = self.cycle.to_dict()
phase_label = PHASE_LABELS.get(session.phase, session.phase)
next_label = PHASE_LABELS.get(next_phase, "пауза") if next_phase else "отдых, цикл сброшен"
work_done = cycle["completed_work_sessions"]
if session.phase == PHASE_WORK:
work_done += 1
system = self.character.get_system_prompt()
user_prompt = f"""Фаза помидоро «{phase_label}» только что завершилась.
Задача: {session.task_note or 'без описания'}
Прогресс цикла: {work_done}/{cycle['sessions_until_long_break']} работ.
Следующая фаза: {next_label}.
Напиши пользователю короткое сообщение (2-4 предложения) на русском: поздравь, поддержи или предложи отдохнуть. Без markdown."""
result = await self.llm.complete(
[
{"role": "system", "content": system},
{"role": "user", "content": user_prompt},
]
)
return (result.get("content") or "").strip() or "Фаза завершена! Отличная работа."
def _resolve_next_phase(self, session: PomodoroSession) -> str | None: def _resolve_next_phase(self, session: PomodoroSession) -> str | None:
phase = session.phase phase = session.phase
@@ -83,19 +43,10 @@ class PomodoroCompletionHandler:
next_phase = self._resolve_next_phase(session) next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase) notice = format_phase_completed_notice(session, next_phase)
chat_id = self._latest_chat_session_id() # Только notice — role=assistant ломает tool/reasoning цепочки OpenRouter.
if not chat_id: post_notice_to_latest_chat(notice)
chat = ChatSession(title="Помидоро")
self.db.add(chat)
self.db.commit()
self.db.refresh(chat)
chat_id = chat.id
self._save_chat_message(chat_id, "notice", notice)
comment = await self._generate_llm_comment(session, next_phase)
self._save_chat_message(chat_id, "assistant", comment)
self.cycle.bump_notify_seq() self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session) self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session) self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed notice posted (phase=%s)", session.phase)
+3 -2
View File
@@ -114,11 +114,12 @@ export default function Chat() {
if (seq > lastNotifySeq) { if (seq > lastNotifySeq) {
setLastNotifySeq(seq); setLastNotifySeq(seq);
refreshPomodoro().catch(console.error); refreshPomodoro().catch(console.error);
if (activeId) { // Не перезагружать историю во время стрима — ломает UI и сбивает ответ.
if (activeId && !loading) {
loadMessages(activeId).catch(console.error); loadMessages(activeId).catch(console.error);
} }
} }
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]); }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]);
const handleNewChat = async () => { const handleNewChat = async () => {
const session = await api.createSession(); const session = await api.createSession();