added RAG, Multiuser, TG bot
This commit is contained in:
@@ -1,91 +1,92 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
|
||||
from app.chat.notices import format_phase_completed_notice
|
||||
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.service import PomodoroService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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()
|
||||
|
||||
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},
|
||||
],
|
||||
temperature=0.8,
|
||||
visible_reply=True,
|
||||
)
|
||||
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)
|
||||
post_notice_to_latest_chat(notice)
|
||||
|
||||
try:
|
||||
comment = await self._generate_llm_comment(session, next_phase)
|
||||
if comment:
|
||||
post_character_comment_to_latest_chat(comment)
|
||||
except Exception:
|
||||
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
|
||||
|
||||
self.cycle.bump_notify_seq()
|
||||
self.pomodoro.mark_notified(session)
|
||||
self.pomodoro.advance_after_completion(session)
|
||||
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.character.service import CharacterService
|
||||
from app.chat.notice_inbox import post_character_comment_to_latest_chat, post_notice_to_latest_chat
|
||||
from app.chat.notices import format_phase_completed_notice
|
||||
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.service import PomodoroService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHASE_LABELS = {
|
||||
PHASE_WORK: "работа",
|
||||
PHASE_SHORT_BREAK: "короткий перерыв",
|
||||
PHASE_LONG_BREAK: "длинный перерыв",
|
||||
}
|
||||
|
||||
|
||||
class PomodoroCompletionHandler:
|
||||
def __init__(self, db: Session, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
self.pomodoro = PomodoroService(db, user_id)
|
||||
self.cycle = CycleManager(db, user_id)
|
||||
self.llm = LLMClient()
|
||||
self.character = CharacterService(db, user_id)
|
||||
|
||||
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},
|
||||
],
|
||||
temperature=0.8,
|
||||
visible_reply=True,
|
||||
)
|
||||
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)
|
||||
post_notice_to_latest_chat(notice, self.user_id)
|
||||
|
||||
try:
|
||||
comment = await self._generate_llm_comment(session, next_phase)
|
||||
if comment:
|
||||
post_character_comment_to_latest_chat(comment, self.user_id)
|
||||
except Exception:
|
||||
logger.exception("Pomodoro LLM comment failed (phase=%s)", session.phase)
|
||||
|
||||
self.cycle.bump_notify_seq()
|
||||
self.pomodoro.mark_notified(session)
|
||||
self.pomodoro.advance_after_completion(session)
|
||||
logger.info("Pomodoro phase completed (phase=%s, next=%s)", session.phase, next_phase)
|
||||
|
||||
@@ -1,89 +1,90 @@
|
||||
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
|
||||
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, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
|
||||
def get(self) -> PomodoroCycle:
|
||||
cycle = self.db.scalar(select(PomodoroCycle).where(PomodoroCycle.user_id == self.user_id).limit(1))
|
||||
if not cycle:
|
||||
cycle = PomodoroCycle(user_id=self.user_id)
|
||||
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
|
||||
|
||||
+296
-287
@@ -1,287 +1,296 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PomodoroService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.cycle = CycleManager(db)
|
||||
|
||||
def _get_active(self) -> PomodoroSession | None:
|
||||
stmt = (
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(("running", "paused")))
|
||||
.order_by(PomodoroSession.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def _elapsed(self, session: PomodoroSession) -> int:
|
||||
elapsed = session.elapsed_seconds
|
||||
if session.status == "running" and session.started_at:
|
||||
started = session.started_at
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
delta = _utcnow() - started
|
||||
elapsed += int(delta.total_seconds())
|
||||
return elapsed
|
||||
|
||||
def _remaining(self, session: PomodoroSession) -> int:
|
||||
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",
|
||||
"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)
|
||||
|
||||
return {
|
||||
"status": session.status,
|
||||
"phase": session.phase,
|
||||
"duration_min": session.duration_min,
|
||||
"task_note": session.task_note,
|
||||
"elapsed_seconds": elapsed,
|
||||
"remaining_seconds": remaining,
|
||||
"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:
|
||||
active = self._get_active()
|
||||
if active:
|
||||
self._try_auto_complete(active)
|
||||
active = self._get_active()
|
||||
return self._to_status_dict(active)
|
||||
|
||||
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,
|
||||
)
|
||||
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":
|
||||
raise ValueError("Нет активного запущенного таймера.")
|
||||
|
||||
session.elapsed_seconds = self._elapsed(session)
|
||||
session.status = "paused"
|
||||
session.paused_at = _utcnow()
|
||||
session.started_at = None
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def resume(self) -> dict:
|
||||
session = self._get_active()
|
||||
if not session or session.status != "paused":
|
||||
raise ValueError("Нет таймера на паузе.")
|
||||
|
||||
session.status = "running"
|
||||
session.started_at = _utcnow()
|
||||
session.paused_at = None
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def stop(self, result: str = "", completed: bool = False) -> dict:
|
||||
session = self._get_active()
|
||||
if not session:
|
||||
raise ValueError("Нет активного таймера.")
|
||||
|
||||
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()
|
||||
|
||||
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 = (
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(("completed", "cancelled")))
|
||||
.order_by(PomodoroSession.finished_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
sessions = self.db.scalars(stmt).all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"status": s.status,
|
||||
"phase": s.phase,
|
||||
"duration_min": s.duration_min,
|
||||
"task_note": s.task_note,
|
||||
"result": s.result,
|
||||
"completed": s.completed,
|
||||
"elapsed_seconds": s.elapsed_seconds,
|
||||
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PomodoroService:
|
||||
def __init__(self, db: Session, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
self.cycle = CycleManager(db, user_id)
|
||||
|
||||
def _get_active(self) -> PomodoroSession | None:
|
||||
stmt = (
|
||||
select(PomodoroSession)
|
||||
.where(
|
||||
PomodoroSession.user_id == self.user_id,
|
||||
PomodoroSession.status.in_(("running", "paused")),
|
||||
)
|
||||
.order_by(PomodoroSession.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def _elapsed(self, session: PomodoroSession) -> int:
|
||||
elapsed = session.elapsed_seconds
|
||||
if session.status == "running" and session.started_at:
|
||||
started = session.started_at
|
||||
if started.tzinfo is None:
|
||||
started = started.replace(tzinfo=timezone.utc)
|
||||
delta = _utcnow() - started
|
||||
elapsed += int(delta.total_seconds())
|
||||
return elapsed
|
||||
|
||||
def _remaining(self, session: PomodoroSession) -> int:
|
||||
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(
|
||||
user_id=self.user_id,
|
||||
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",
|
||||
"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)
|
||||
|
||||
return {
|
||||
"status": session.status,
|
||||
"phase": session.phase,
|
||||
"duration_min": session.duration_min,
|
||||
"task_note": session.task_note,
|
||||
"elapsed_seconds": elapsed,
|
||||
"remaining_seconds": remaining,
|
||||
"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:
|
||||
active = self._get_active()
|
||||
if active:
|
||||
self._try_auto_complete(active)
|
||||
active = self._get_active()
|
||||
return self._to_status_dict(active)
|
||||
|
||||
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,
|
||||
)
|
||||
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":
|
||||
raise ValueError("Нет активного запущенного таймера.")
|
||||
|
||||
session.elapsed_seconds = self._elapsed(session)
|
||||
session.status = "paused"
|
||||
session.paused_at = _utcnow()
|
||||
session.started_at = None
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def resume(self) -> dict:
|
||||
session = self._get_active()
|
||||
if not session or session.status != "paused":
|
||||
raise ValueError("Нет таймера на паузе.")
|
||||
|
||||
session.status = "running"
|
||||
session.started_at = _utcnow()
|
||||
session.paused_at = None
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return self._to_status_dict(session)
|
||||
|
||||
def stop(self, result: str = "", completed: bool = False) -> dict:
|
||||
session = self._get_active()
|
||||
if not session:
|
||||
raise ValueError("Нет активного таймера.")
|
||||
|
||||
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.user_id == self.user_id,
|
||||
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()
|
||||
|
||||
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 = (
|
||||
select(PomodoroSession)
|
||||
.where(
|
||||
PomodoroSession.user_id == self.user_id,
|
||||
PomodoroSession.status.in_(("completed", "cancelled")),
|
||||
)
|
||||
.order_by(PomodoroSession.finished_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
sessions = self.db.scalars(stmt).all()
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"status": s.status,
|
||||
"phase": s.phase,
|
||||
"duration_min": s.duration_min,
|
||||
"task_note": s.task_note,
|
||||
"result": s.result,
|
||||
"completed": s.completed,
|
||||
"elapsed_seconds": s.elapsed_seconds,
|
||||
"finished_at": s.finished_at.isoformat() if s.finished_at else None,
|
||||
}
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
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()
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import User
|
||||
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:
|
||||
users = db.scalars(select(User).where(User.is_active.is_(True))).all()
|
||||
for user in users:
|
||||
service = PomodoroService(db, user.id)
|
||||
service.get_status()
|
||||
pending = service.get_pending_completions()
|
||||
if not pending:
|
||||
continue
|
||||
handler = PomodoroCompletionHandler(db, user.id)
|
||||
for session in pending:
|
||||
await handler.process(session)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user