fixed injection watcher
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user