added more pomidoro

This commit is contained in:
2026-06-09 11:54:32 +03:00
parent 244935e4ac
commit c8599b3d13
20 changed files with 817 additions and 91 deletions
+101
View File
@@ -0,0 +1,101 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
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.pomodoro.cycle import PHASE_LONG_BREAK, PHASE_SHORT_BREAK, PHASE_WORK, CycleManager
from app.pomodoro.service import PomodoroService
PHASE_LABELS = {
PHASE_WORK: "работа",
PHASE_SHORT_BREAK: "короткий перерыв",
PHASE_LONG_BREAK: "длинный перерыв",
}
class PomodoroCompletionHandler:
def __init__(self, db: Session):
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
cycle = self.cycle.get()
if phase == PHASE_WORK:
if cycle.completed_work_sessions + 1 >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if phase == PHASE_LONG_BREAK:
return None
return None
async def process(self, session: PomodoroSession) -> None:
if session.completion_notified:
return
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)
self.cycle.bump_notify_seq()
self.pomodoro.mark_notified(session)
self.pomodoro.advance_after_completion(session)
+89
View File
@@ -0,0 +1,89 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroCycle
PHASE_WORK = "work"
PHASE_SHORT_BREAK = "short_break"
PHASE_LONG_BREAK = "long_break"
class CycleManager:
def __init__(self, db: Session):
self.db = db
def get(self) -> PomodoroCycle:
cycle = self.db.scalar(select(PomodoroCycle).limit(1))
if not cycle:
cycle = PomodoroCycle()
self.db.add(cycle)
self.db.commit()
self.db.refresh(cycle)
return cycle
def to_dict(self, cycle: PomodoroCycle | None = None) -> dict:
c = cycle or self.get()
return {
"completed_work_sessions": c.completed_work_sessions,
"sessions_until_long_break": c.sessions_until_long_break,
"task_note": c.task_note,
"work_duration_min": c.work_duration_min,
"short_break_min": c.short_break_min,
"long_break_min": c.long_break_min,
"auto_advance": c.auto_advance,
"chat_notify_seq": c.chat_notify_seq,
}
def reset(self, clear_task: bool = False) -> dict:
cycle = self.get()
cycle.completed_work_sessions = 0
if clear_task:
cycle.task_note = ""
self.db.commit()
self.db.refresh(cycle)
return self.to_dict(cycle)
def bump_notify_seq(self) -> int:
cycle = self.get()
cycle.chat_notify_seq += 1
self.db.commit()
self.db.refresh(cycle)
return cycle.chat_notify_seq
def on_work_completed(self) -> str:
"""Returns next phase: short_break or long_break."""
cycle = self.get()
cycle.completed_work_sessions += 1
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
next_phase = PHASE_LONG_BREAK
else:
next_phase = PHASE_SHORT_BREAK
self.db.commit()
return next_phase
def on_long_break_completed(self) -> None:
cycle = self.get()
cycle.completed_work_sessions = 0
self.db.commit()
def duration_for_phase(self, phase: str, cycle: PomodoroCycle | None = None) -> int:
c = cycle or self.get()
if phase == PHASE_WORK:
return c.work_duration_min
if phase == PHASE_SHORT_BREAK:
return c.short_break_min
if phase == PHASE_LONG_BREAK:
return c.long_break_min
return c.work_duration_min
def next_phase_after(self, completed_phase: str) -> str | None:
if completed_phase == PHASE_WORK:
cycle = self.get()
if cycle.completed_work_sessions >= cycle.sessions_until_long_break:
return PHASE_LONG_BREAK
return PHASE_SHORT_BREAK
if completed_phase == PHASE_SHORT_BREAK:
return PHASE_WORK
if completed_phase == PHASE_LONG_BREAK:
return None
return None
+162 -27
View File
@@ -4,6 +4,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import PomodoroSession
from app.pomodoro.cycle import (
PHASE_LONG_BREAK,
PHASE_SHORT_BREAK,
PHASE_WORK,
CycleManager,
)
def _utcnow() -> datetime:
@@ -13,6 +19,7 @@ def _utcnow() -> datetime:
class PomodoroService:
def __init__(self, db: Session):
self.db = db
self.cycle = CycleManager(db)
def _get_active(self) -> PomodoroSession | None:
stmt = (
@@ -37,30 +44,95 @@ class PomodoroService:
total = session.duration_min * 60
return max(0, total - self._elapsed(session))
def _try_auto_complete(self, session: PomodoroSession) -> bool:
if session.status != "running":
return False
if self._remaining(session) > 0:
return False
self._finalize_session(session, auto=True)
return True
def _finalize_session(
self,
session: PomodoroSession,
*,
auto: bool,
result: str = "",
completed: bool | None = None,
cancelled: bool = False,
) -> None:
session.elapsed_seconds = self._elapsed(session)
session.started_at = None
session.finished_at = _utcnow()
session.completion_notified = False
session.result = result or None
if cancelled:
session.status = "cancelled"
session.completed = False
elif completed is not None:
session.status = "completed"
session.completed = completed
else:
session.status = "completed"
session.completed = True
self.db.commit()
self.db.refresh(session)
def _start_phase(
self,
phase: str,
*,
duration_min: int | None = None,
task_note: str | None = None,
) -> PomodoroSession:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
cycle = self.cycle.get()
if task_note is not None:
cycle.task_note = task_note
elif phase == PHASE_WORK and not cycle.task_note:
cycle.task_note = ""
duration = duration_min or self.cycle.duration_for_phase(phase, cycle)
note = task_note if task_note is not None else cycle.task_note
session = PomodoroSession(
status="running",
phase=phase,
duration_min=duration,
task_note=note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return session
def _to_status_dict(self, session: PomodoroSession | None) -> dict:
cycle_dict = self.cycle.to_dict()
if not session:
return {
"status": "idle",
"duration_min": 25,
"task_note": "",
"phase": PHASE_WORK,
"duration_min": cycle_dict["work_duration_min"],
"task_note": cycle_dict["task_note"],
"elapsed_seconds": 0,
"remaining_seconds": 0,
"session_id": None,
"cycle": cycle_dict,
}
elapsed = self._elapsed(session)
total = session.duration_min * 60
remaining = max(0, total - elapsed)
if session.status == "running" and remaining == 0:
session.status = "completed"
session.finished_at = _utcnow()
session.completed = True
self.db.commit()
self.db.refresh(session)
return {
"status": session.status,
"phase": session.phase,
"duration_min": session.duration_min,
"task_note": session.task_note,
"elapsed_seconds": elapsed,
@@ -68,27 +140,35 @@ class PomodoroService:
"session_id": session.id,
"started_at": session.started_at.isoformat() if session.started_at else None,
"finished_at": session.finished_at.isoformat() if session.finished_at else None,
"cycle": cycle_dict,
}
def get_status(self) -> dict:
return self._to_status_dict(self._get_active())
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
active = self._get_active()
if active:
raise ValueError("Таймер уже запущен. Сначала остановите текущую сессию.")
self._try_auto_complete(active)
active = self._get_active()
return self._to_status_dict(active)
session = PomodoroSession(
status="running",
def start_work(self, duration_min: int | None = None, task_note: str = "") -> dict:
session = self._start_phase(
PHASE_WORK,
duration_min=duration_min,
task_note=task_note,
started_at=_utcnow(),
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def start_short_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_SHORT_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start_long_break(self, duration_min: int | None = None) -> dict:
session = self._start_phase(PHASE_LONG_BREAK, duration_min=duration_min)
return self._to_status_dict(session)
def start(self, duration_min: int = 25, task_note: str = "") -> dict:
return self.start_work(duration_min=duration_min, task_note=task_note)
def pause(self) -> dict:
session = self._get_active()
if not session or session.status != "running":
@@ -119,15 +199,69 @@ class PomodoroService:
if not session:
raise ValueError("Нет активного таймера.")
session.elapsed_seconds = self._elapsed(session)
session.status = "completed" if completed else "cancelled"
session.result = result
session.completed = completed
session.finished_at = _utcnow()
session.started_at = None
if completed:
self._finalize_session(session, auto=False, result=result, completed=True)
else:
self._finalize_session(session, auto=False, result=result, cancelled=True)
session.completion_notified = True
self.db.commit()
return self._to_status_dict(None)
def reset_cycle(self, clear_task: bool = False) -> dict:
active = self._get_active()
if active:
self._finalize_session(active, auto=False, cancelled=True)
active.completion_notified = True
self.db.commit()
cycle = self.cycle.reset(clear_task=clear_task)
status = self._to_status_dict(None)
status["cycle"] = cycle
return status
def skip_phase(self) -> dict:
session = self._get_active()
if not session:
raise ValueError("Нет активного таймера.")
self._finalize_session(session, auto=True)
return self._to_status_dict(None)
def get_pending_completions(self) -> list[PomodoroSession]:
stmt = (
select(PomodoroSession)
.where(
PomodoroSession.status == "completed",
PomodoroSession.completed.is_(True),
PomodoroSession.completion_notified.is_(False),
)
.order_by(PomodoroSession.id.asc())
)
return list(self.db.scalars(stmt))
def mark_notified(self, session: PomodoroSession) -> None:
session.completion_notified = True
self.db.commit()
self.db.refresh(session)
return self._to_status_dict(session)
def advance_after_completion(self, session: PomodoroSession) -> dict | None:
"""Update cycle counters and auto-start next phase. Returns new status or None."""
phase = session.phase
cycle = self.cycle.get()
if phase == PHASE_WORK:
next_phase = self.cycle.on_work_completed()
elif phase == PHASE_SHORT_BREAK:
next_phase = PHASE_WORK
elif phase == PHASE_LONG_BREAK:
self.cycle.on_long_break_completed()
next_phase = None
else:
next_phase = None
if not cycle.auto_advance or next_phase is None:
return None
new_session = self._start_phase(next_phase)
return self._to_status_dict(new_session)
def history(self, limit: int = 20) -> list[dict]:
stmt = (
@@ -141,6 +275,7 @@ class PomodoroService:
{
"id": s.id,
"status": s.status,
"phase": s.phase,
"duration_min": s.duration_min,
"task_note": s.task_note,
"result": s.result,
+38
View File
@@ -0,0 +1,38 @@
import asyncio
import logging
from app.db.base import SessionLocal
from app.pomodoro.completion import PomodoroCompletionHandler
from app.pomodoro.service import PomodoroService
logger = logging.getLogger(__name__)
WATCH_INTERVAL_SEC = 2
async def pomodoro_watcher_loop() -> None:
while True:
try:
await asyncio.sleep(WATCH_INTERVAL_SEC)
await _tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Pomodoro watcher error")
async def _tick() -> None:
db = SessionLocal()
try:
service = PomodoroService(db)
service.get_status()
pending = service.get_pending_completions()
if not pending:
return
handler = PomodoroCompletionHandler(db)
for session in pending:
await handler.process(session)
finally:
db.close()