"""Reschedule day plans via OpenRouter or deterministic rules backup. P0/locked blocks stay put; P2 shrinks under load; explore/restore inserted when thin. """ from __future__ import annotations import json import re from datetime import datetime, timezone from typing import Any from uuid import uuid4 import httpx from app.config import Settings from app.schedule_math import minutes_between, parse_hhmm, trigger_operators from app.schedule_store import DayPlanPut, ScheduledBlock, ScheduleStore from app.schedule_templates import ( KIND_LABELS, SEED_SLOTS, seed_template_ids, template_by_id, ) def _hhmm(minutes: int) -> str: minutes = max(0, min(24 * 60 - 1, minutes)) return f"{minutes // 60:02d}:{minutes % 60:02d}" def seed_starter_blocks( day: str, *, capacity_hint: float, max_blocks: int = 7, ) -> list[ScheduledBlock]: """Build a thin starter day from templates (P0 locked; capacity cuts P2).""" slot_map = {tid: (start, end) for tid, start, end in SEED_SLOTS} out: list[ScheduledBlock] = [] for tid in seed_template_ids(capacity_hint=capacity_hint): if len(out) >= max_blocks: break tmpl = template_by_id(tid) if not tmpl: continue start, end = slot_map.get(tid, ("10:00", "10:30")) planned = max(1, minutes_between(start, end)) out.append( ScheduledBlock( date=day, start=start, end=end, title=str(tmpl["title"]), kind=tmpl["kind"], intent=tmpl["intent"], priority=tmpl["priority"], planned_min=planned, status="planned", source="rules", locked=bool(tmpl.get("locked") or tmpl.get("priority") == "P0"), notes="seed", version_added=1, ) ) out.sort(key=lambda b: b.start) return out def rules_backup_reschedule( plan_blocks: list[dict[str, Any]], *, day: str, risk_score: float, capacity_hint: float, day_start: str = "06:00", day_end: str = "23:00", max_blocks: int = 7, ) -> list[ScheduledBlock]: """Deterministic reschedule: keep P0, drop/shrink P2, optional stabilize/fun.""" if not plan_blocks: return seed_starter_blocks(day, capacity_hint=capacity_hint, max_blocks=max_blocks) kept: list[dict[str, Any]] = [] for block in plan_blocks: if block.get("priority") == "P0" or block.get("locked"): item = dict(block) item["status"] = item.get("status") or "planned" item["source"] = "rules" kept.append(item) elif block.get("priority") == "P1": item = dict(block) item["source"] = "rules" kept.append(item) elif block.get("priority") == "P2" and capacity_hint >= 0.55: # Shrink long P2 blocks start = parse_hhmm(str(block["start"])) end = parse_hhmm(str(block["end"])) span = max(10, min(end - start, 25)) item = dict(block) item["end"] = _hhmm(start + span) item["planned_min"] = span item["source"] = "rules" kept.append(item) # Fill gaps with stabilize if risk high if risk_score >= 1.0 or capacity_hint < 0.55: occupied = [(parse_hhmm(b["start"]), parse_hhmm(b["end"])) for b in kept] cursor = parse_hhmm(day_start) + 60 end_limit = parse_hhmm(day_end) - 30 placed = False while cursor + 15 <= end_limit and not placed: clash = any(s < cursor + 15 and cursor < e for s, e in occupied) if not clash: kept.append( { "id": str(uuid4()), "date": day, "start": _hhmm(cursor), "end": _hhmm(cursor + 15), "title": "Stabilize: walk/move 10m", "kind": "stabilize", "intent": "measure", "priority": "P1", "planned_min": 15, "status": "planned", "source": "rules", "locked": False, "notes": "rules backup", "version_added": 1, } ) placed = True cursor += 30 has_explore = any( b.get("intent") in ("explore", "restore_fun") for b in kept ) if not has_explore and capacity_hint >= 0.4 and len(kept) < max_blocks: start_m = parse_hhmm(day_start) + 4 * 60 end_m = start_m + 25 occupied = [(parse_hhmm(b["start"]), parse_hhmm(b["end"])) for b in kept] while any(s < end_m and start_m < e for s, e in occupied) and end_m < parse_hhmm(day_end): start_m += 30 end_m = start_m + 25 if end_m <= parse_hhmm(day_end): kept.append( { "id": str(uuid4()), "date": day, "start": _hhmm(start_m), "end": _hhmm(end_m), "title": "Fun recharge / try something new", "kind": "restore_fun", "intent": "restore_fun", "priority": "P2", "planned_min": 25, "status": "planned", "source": "rules", "locked": False, "notes": "rules explore pad", "version_added": 1, } ) kept.sort(key=lambda b: b["start"]) # Deduplicate overlaps by preferring P0 then P1 final: list[dict[str, Any]] = [] for block in kept: if len(final) >= max_blocks: break clash = False for existing in final: if parse_hhmm(existing["start"]) < parse_hhmm(block["end"]) and parse_hhmm( block["start"] ) < parse_hhmm(existing["end"]): clash = True break if not clash: final.append(block) return [ScheduledBlock.model_validate(b) for b in final] class RescheduleService: """OpenRouter reschedule with rules fallback.""" def __init__(self, settings: Settings, store: ScheduleStore) -> None: self.settings = settings self.store = store def can_reschedule(self, *, force: bool = False) -> tuple[bool, str | None]: meta = self.store.get_meta() last = meta.get("last_reschedule_at") if force or not last: return True, None try: ts = datetime.fromisoformat(str(last).replace("Z", "+00:00")) except ValueError: return True, None gap = (datetime.now(timezone.utc) - ts).total_seconds() / 60.0 if gap < self.settings.reschedule_min_gap_min: return False, f"Reschedule cooldown ({int(self.settings.reschedule_min_gap_min - gap)}m left)" return True, None def call_openrouter(self, messages: list[dict[str, str]]) -> str | None: if not self.settings.openrouter_api_key: return None url = f"{self.settings.openrouter_base_url.rstrip('/')}/chat/completions" headers = { "Authorization": f"Bearer {self.settings.openrouter_api_key}", "Content-Type": "application/json", "X-Title": "Habit Journal", } body = { "model": self.settings.openrouter_model, "messages": messages, "temperature": 0.25, "max_tokens": 1200, } try: with httpx.Client(timeout=self.settings.coach_timeout_sec) as client: response = client.post(url, headers=headers, json=body) if response.status_code >= 400: return None content = ( response.json() .get("choices", [{}])[0] .get("message", {}) .get("content") ) return str(content) if content else None except Exception: # noqa: BLE001 return None def parse_blocks_json(self, raw: str, day: str) -> list[ScheduledBlock] | None: text = raw.strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?\s*", "", text) text = re.sub(r"\s*```$", "", text) try: payload = json.loads(text) except json.JSONDecodeError: match = re.search(r"\{[\s\S]*\}", text) if not match: return None try: payload = json.loads(match.group(0)) except json.JSONDecodeError: return None blocks_raw = payload.get("blocks") if not isinstance(blocks_raw, list): return None blocks: list[ScheduledBlock] = [] for item in blocks_raw: try: data = dict(item) data["date"] = day data.setdefault("id", str(uuid4())) data.setdefault("status", "planned") data.setdefault("source", "openrouter") if not data.get("planned_min"): data["planned_min"] = max( 1, minutes_between(str(data["start"]), str(data["end"])) ) if data.get("priority") == "P0": data["locked"] = True blocks.append(ScheduledBlock.model_validate(data)) except Exception: # noqa: BLE001 continue return blocks or None def reschedule( self, day: str, *, reason: str = "user", force: bool = False, risk_score: float = 0.0, capacity_hint: float = 0.7, priors_md: str = "", context_notes: str = "", ) -> dict[str, Any]: ok, msg = self.can_reschedule(force=force) if not ok: raise PermissionError(msg or "cooldown") plan = self.store.get_plan(day) locked = [b.model_dump() for b in plan.blocks if b.priority == "P0" or b.locked] source = "rules" blocks: list[ScheduledBlock] | None = None raw = None ops = trigger_operators( risk_1h_tags=[], triggers_yesterday=[], ) # Prefer operators passed via context_notes when present; else derive from risk. trigger_day = risk_score >= 1.0 or "triggers_y=" in context_notes if "raise_lambda_f" in context_notes or trigger_day: ops = { **ops, "trigger_day": trigger_day, "raise_lambda_f": trigger_day, "raise_eta": trigger_day, } system = ( "Checklist schedule coach. JSON only: " '{"version_notes":"","blocks":[{"id":"...","start":"HH:MM","end":"HH:MM",' '"title":"...","kind":"...","intent":"duty|explore|restore_fun|measure",' '"priority":"P0|P1|P2","planned_min":N,"locked":false,"notes":""}]}. ' "EV is server-owned — never invent p_done, d_hat, or %. " "P0/locked (sleep, food, visa/admin deadlines) stay at the same times — " "never silent-move. Escape (porn/court/rerun binge) is NOT explore/fun. " "On trigger_day: raise FSE weight and fun weight; only P0 overrides cuts. " "Explore/restore must yield data or skill contact, not escape. " "Do not schedule family/relationship optimization tasks. " "Include ≥1 explore/restore_fun if capacity allows; cut P2 when overload. " "Max 7 blocks. No pep talk, no guilt, no duty maximizer tone. " "Schedule does not replace Daily check-in or Log entries." ) user = json.dumps( { "date": day, "reason": reason, "capacity_hint": capacity_hint, "risk_score": risk_score, "operators": ops, "locked_p0": locked, "current_blocks": [b.model_dump(mode="json") for b in plan.blocks], "SERVER_PRIORS": priors_md, "notes": context_notes, "kind_labels": KIND_LABELS, "day_window": [ self.settings.schedule_day_start, self.settings.schedule_day_end, ], }, ensure_ascii=False, ) if self.settings.openrouter_api_key: raw = self.call_openrouter( [ {"role": "system", "content": system}, {"role": "user", "content": user}, ] ) if raw: blocks = self.parse_blocks_json(raw, day) if blocks: source = "openrouter" if blocks is None: blocks = rules_backup_reschedule( [b.model_dump() for b in plan.blocks], day=day, risk_score=risk_score, capacity_hint=capacity_hint, day_start=self.settings.schedule_day_start, day_end=self.settings.schedule_day_end, max_blocks=self.settings.schedule_max_blocks, ) source = "rules" # Preserve P0 times from previous plan by id/title prev_p0 = { b.id: b for b in plan.blocks if b.priority == "P0" or b.locked } fixed: list[ScheduledBlock] = [] for block in blocks: if block.id in prev_p0: old = prev_p0[block.id] fixed.append( block.model_copy( update={ "start": old.start, "end": old.end, "planned_min": old.planned_min, "locked": True, "priority": "P0" if old.priority == "P0" else block.priority, } ) ) else: fixed.append(block) saved = self.store.save_plan( day, DayPlanPut( blocks=fixed, source=source, capacity_hint=capacity_hint, notes=f"reschedule:{reason}", force_p0_move=False, ), bump_version=True, ) self.store.set_meta(last_reschedule_at=datetime.now(timezone.utc).isoformat()) return { "plan": self.store.plan_with_feedback(day), "source": source, "raw_model": raw, "version": saved.version, }