Spaces:
Sleeping
Sleeping
| """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, | |
| } | |