Fastwhisper / app /schedule_reschedule.py
Mbonea's picture
Complete P1 schedule loop: seed day, live capacity, templates, API tests.
9af2b22
Raw
History Blame Contribute Delete
15 kB
"""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,
}