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,
)
round_notices: list[str] = []
for tool_call in tool_calls:
fn = tool_call["function"]
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
@@ -251,7 +252,7 @@ class ChatService:
notice = format_tool_notice(fn["name"], result)
if 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:
yield self._sse(
@@ -259,6 +260,9 @@ class ChatService:
{"name": fn["name"], "result": json.loads(result)},
)
for notice in round_notices:
yield self._sse("notice", {"content": notice})
continue
final_content = "".join(content_parts).strip()
+2 -18
View File
@@ -1,21 +1,5 @@
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import ChatSession, Message
from app.chat.notice_inbox import post_notice_to_latest_chat
def post_chat_notice(content: str) -> None:
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()
finally:
db.close()
post_notice_to_latest_chat(content)
+22 -1
View File
@@ -4,6 +4,8 @@ import random
from datetime import datetime
from zoneinfo import ZoneInfo
import httpx
from app.config import get_settings
from app.db.base import SessionLocal
from app.homelab.comfyui import ComfyUIClient
@@ -76,6 +78,15 @@ async def _tick_netdata() -> None:
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:
settings = get_settings()
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
@@ -114,8 +125,18 @@ async def _tick_rofl() -> None:
return
client = ComfyUIClient()
if not await _comfyui_reachable(client.base_url):
return
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"):
logger.warning("Rofl image failed: %s", result.get("error"))
return
+27 -4
View File
@@ -44,6 +44,21 @@ class LLMClient:
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
def attach_reasoning_to_message(
message: dict[str, Any],
@@ -54,8 +69,9 @@ class LLMClient:
if reasoning:
message["reasoning"] = reasoning
message["reasoning_content"] = reasoning
if reasoning_details:
message["reasoning_details"] = reasoning_details
normalized = LLMClient._normalize_reasoning_details(reasoning_details)
if normalized:
message["reasoning_details"] = normalized
return message
async def stream_chat(
@@ -126,14 +142,21 @@ class LLMClient:
if choice.finish_reason:
reasoning = "".join(reasoning_parts)
if reasoning or reasoning_details:
normalized_details = self._normalize_reasoning_details(reasoning_details)
if reasoning or normalized_details:
yield {
"type": "reasoning",
"reasoning": reasoning,
"reasoning_details": reasoning_details or None,
"reasoning_details": normalized_details,
}
if tool_calls:
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}
except Exception as 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 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.db.models import ChatSession, Message, PomodoroSession
from app.llm.client import LLMClient
from app.db.models import PomodoroSession
from app.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
@@ -20,48 +22,6 @@ class PomodoroCompletionHandler:
self.db = db
self.pomodoro = PomodoroService(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:
phase = session.phase
@@ -83,19 +43,10 @@ class PomodoroCompletionHandler:
next_phase = self._resolve_next_phase(session)
notice = format_phase_completed_notice(session, next_phase)
chat_id = self._latest_chat_session_id()
if not chat_id:
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)
# Только notice — role=assistant ломает tool/reasoning цепочки OpenRouter.
post_notice_to_latest_chat(notice)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
logger.info("Pomodoro phase completed notice posted (phase=%s)", session.phase)