Spaces:
Sleeping
Sleeping
| """Persist day plans, block feedback, and learned schedule priors. | |
| JSON files under DATA_ROOT/schedule/; feedback is append-only JSONL. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from datetime import date, datetime, timezone | |
| from typing import Any, Literal | |
| from uuid import uuid4 | |
| from pydantic import BaseModel, ConfigDict, Field, field_validator | |
| from app.fsutil import append_jsonl, atomic_write_text, file_lock, read_json, read_jsonl | |
| from app.paths import Paths | |
| from app.schedule_math import ( | |
| PRIOR_DEFAULT_MIN, | |
| is_strong_feedback, | |
| minutes_between, | |
| plan_health, | |
| priors_markdown, | |
| recompute_priors, | |
| validate_blocks, | |
| ) | |
| from app.schedule_templates import KIND_LABELS, list_templates | |
| TaskKind = Literal[ | |
| "earn_ship", | |
| "admin_spain", | |
| "body_care", | |
| "move_out", | |
| "boundary", | |
| "food_out", | |
| "stabilize", | |
| "explore", | |
| "restore_fun", | |
| "sleep_window", | |
| "other", | |
| ] | |
| Priority = Literal["P0", "P1", "P2"] | |
| Intent = Literal["duty", "explore", "restore_fun", "measure"] | |
| BlockStatus = Literal["planned", "done", "partial", "skipped", "moved", "cancelled"] | |
| Did = Literal["done", "partial", "skipped"] | |
| WouldRepeat = Literal["yes", "no", "maybe"] | |
| SkipReason = Literal["time", "fear", "fse", "locks", "boring", "urge", "other"] | |
| PlanSource = Literal["cursor", "openrouter", "user", "rules", "chat"] | |
| def utc_now() -> datetime: | |
| return datetime.now(timezone.utc) | |
| class DayReview(BaseModel): | |
| """End-of-day review for chat export / import.""" | |
| model_config = ConfigDict(extra="ignore") | |
| comment: str = "" | |
| emotions: list[str] = Field(default_factory=list) | |
| fse_events: str = "" | |
| what_moved: str = "" | |
| what_avoided: str = "" | |
| tomorrow_change: str = "" | |
| class ScheduledBlock(BaseModel): | |
| """One timed block on a day plan.""" | |
| model_config = ConfigDict(extra="ignore") | |
| id: str = Field(default_factory=lambda: str(uuid4())) | |
| date: str | |
| start: str | |
| end: str | |
| title: str = Field(min_length=1, max_length=200) | |
| kind: TaskKind = "other" | |
| intent: Intent = "duty" | |
| priority: Priority = "P2" | |
| planned_min: int = Field(ge=1, le=24 * 60) | |
| status: BlockStatus = "planned" | |
| source: PlanSource | str = "user" | |
| locked: bool = False | |
| notes: str = "" | |
| version_added: int = 1 | |
| def _hhmm(cls, value: str) -> str: | |
| parts = value.split(":") | |
| if len(parts) != 2: | |
| raise ValueError("time must be HH:MM") | |
| h, m = int(parts[0]), int(parts[1]) | |
| if not (0 <= h <= 23 and 0 <= m <= 59): | |
| raise ValueError("invalid time") | |
| return f"{h:02d}:{m:02d}" | |
| class BlockFeedback(BaseModel): | |
| """Thick feedback for one block.""" | |
| model_config = ConfigDict(extra="ignore") | |
| block_id: str | |
| date: str | |
| did: Did | |
| actual_min: int | None = Field(default=None, ge=0, le=24 * 60) | |
| quality: int | None = Field(default=None, ge=1, le=5) | |
| fun: int | None = Field(default=None, ge=1, le=5) | |
| energy_after: int | None = Field(default=None, ge=-2, le=2) | |
| money_amount: float | None = None | |
| money_currency: str = "TZS" | |
| would_repeat: WouldRepeat | None = None | |
| skip_reason: SkipReason | None = None | |
| note: str = "" | |
| emotions: list[str] = Field(default_factory=list) | |
| fse_event: str = "" | |
| intensity: int | None = Field(default=None, ge=1, le=10) | |
| strong: bool = False | |
| ts: datetime = Field(default_factory=utc_now) | |
| def with_strong_flag(self) -> "BlockFeedback": | |
| payload = self.model_dump() | |
| strong = is_strong_feedback(payload) | |
| return self.model_copy(update={"strong": strong}) | |
| class DayPlan(BaseModel): | |
| """Versioned day schedule.""" | |
| model_config = ConfigDict(extra="ignore") | |
| date: str | |
| version: int = 1 | |
| source: PlanSource | str = "user" | |
| blocks: list[ScheduledBlock] = Field(default_factory=list) | |
| capacity_hint: float = Field(default=1.0, ge=0.0, le=1.0) | |
| notes: str = "" | |
| title: str = "" | |
| intention: str = "" | |
| constraints: list[str] = Field(default_factory=list) | |
| day_review: DayReview = Field(default_factory=DayReview) | |
| warnings: list[str] = Field(default_factory=list) | |
| updated_at: datetime = Field(default_factory=utc_now) | |
| parent_version: int | None = None | |
| class BlockCreate(BaseModel): | |
| """Payload for adding one block.""" | |
| model_config = ConfigDict(extra="forbid") | |
| start: str | |
| end: str | |
| title: str = Field(min_length=1, max_length=200) | |
| kind: TaskKind = "other" | |
| intent: Intent = "duty" | |
| priority: Priority = "P2" | |
| planned_min: int | None = None | |
| locked: bool | None = None | |
| notes: str = "" | |
| class BlockPatch(BaseModel): | |
| """Partial block update.""" | |
| model_config = ConfigDict(extra="forbid") | |
| start: str | None = None | |
| end: str | None = None | |
| title: str | None = Field(default=None, min_length=1, max_length=200) | |
| kind: TaskKind | None = None | |
| intent: Intent | None = None | |
| priority: Priority | None = None | |
| planned_min: int | None = Field(default=None, ge=1, le=24 * 60) | |
| status: BlockStatus | None = None | |
| locked: bool | None = None | |
| notes: str | None = None | |
| class DayPlanPut(BaseModel): | |
| """Replace/create a day plan.""" | |
| model_config = ConfigDict(extra="ignore") | |
| blocks: list[ScheduledBlock] | |
| source: PlanSource | str = "user" | |
| capacity_hint: float | None = Field(default=None, ge=0.0, le=1.0) | |
| notes: str = "" | |
| title: str | None = None | |
| intention: str | None = None | |
| constraints: list[str] | None = None | |
| day_review: DayReview | None = None | |
| force_p0_move: bool = False | |
| class PlanMetaPatch(BaseModel): | |
| """Day-level chat meta without touching blocks.""" | |
| model_config = ConfigDict(extra="forbid") | |
| title: str | None = None | |
| intention: str | None = None | |
| constraints: list[str] | None = None | |
| day_review: DayReview | None = None | |
| class BlockCheckBody(BaseModel): | |
| """Fast checkbox status without full feedback.""" | |
| model_config = ConfigDict(extra="forbid") | |
| status: Literal["done", "partial", "skipped", "planned"] | |
| skip_reason: SkipReason | None = None | |
| class ScheduleStore: | |
| """Read and mutate schedule plans and priors.""" | |
| def __init__(self, paths: Paths, *, shrink_k: float = 3.0, max_blocks: int = 7) -> None: | |
| self.paths = paths | |
| self.shrink_k = shrink_k | |
| self.max_blocks = max_blocks | |
| self.paths.schedule_dir.mkdir(parents=True, exist_ok=True) | |
| def empty_plan(self, day: str) -> DayPlan: | |
| return DayPlan(date=day, blocks=[], source="user") | |
| def get_plan(self, day: str) -> DayPlan: | |
| path = self.paths.plan_path(day) | |
| raw = read_json(path) | |
| if raw is None: | |
| return self.empty_plan(day) | |
| return DayPlan.model_validate(raw) | |
| def save_plan( | |
| self, | |
| day: str, | |
| put: DayPlanPut, | |
| *, | |
| bump_version: bool = True, | |
| ) -> DayPlan: | |
| current = self.get_plan(day) | |
| blocks = [] | |
| for block in put.blocks: | |
| data = block.model_dump() | |
| data["date"] = day | |
| if not data.get("planned_min"): | |
| data["planned_min"] = max(1, minutes_between(data["start"], data["end"])) | |
| if data.get("priority") == "P0" and put.source in ( | |
| "openrouter", | |
| "rules", | |
| "cursor", | |
| "chat", | |
| ): | |
| data["locked"] = True if data.get("locked") is None else data["locked"] | |
| blocks.append(ScheduledBlock.model_validate(data)) | |
| errors, warnings = validate_blocks( | |
| [b.model_dump() for b in blocks], | |
| max_blocks=self.max_blocks, | |
| previous_p0=[b.model_dump() for b in current.blocks], | |
| allow_p0_move=put.force_p0_move or put.source == "user", | |
| must_include_explore_or_restore=True, | |
| capacity_hint=put.capacity_hint | |
| if put.capacity_hint is not None | |
| else current.capacity_hint, | |
| hard_explore=False, | |
| ) | |
| if errors: | |
| raise ValueError("; ".join(errors)) | |
| version = current.version + 1 if bump_version and current.blocks else max(1, current.version) | |
| if not current.blocks and not bump_version: | |
| version = 1 | |
| day_review = ( | |
| put.day_review | |
| if put.day_review is not None | |
| else current.day_review | |
| ) | |
| plan = DayPlan( | |
| date=day, | |
| version=version, | |
| source=put.source, | |
| blocks=blocks, | |
| capacity_hint=put.capacity_hint if put.capacity_hint is not None else current.capacity_hint, | |
| notes=put.notes if put.notes is not None else current.notes, | |
| title=put.title if put.title is not None else current.title, | |
| intention=put.intention if put.intention is not None else current.intention, | |
| constraints=( | |
| put.constraints if put.constraints is not None else current.constraints | |
| ), | |
| day_review=day_review, | |
| warnings=warnings, | |
| updated_at=utc_now(), | |
| parent_version=current.version if current.blocks else None, | |
| ) | |
| self._write_plan(plan) | |
| return plan | |
| def patch_meta(self, day: str, patch: PlanMetaPatch) -> DayPlan: | |
| plan = self.get_plan(day) | |
| data = plan.model_dump() | |
| if patch.title is not None: | |
| data["title"] = patch.title | |
| if patch.intention is not None: | |
| data["intention"] = patch.intention | |
| if patch.constraints is not None: | |
| data["constraints"] = patch.constraints | |
| if patch.day_review is not None: | |
| data["day_review"] = patch.day_review.model_dump() | |
| data["updated_at"] = utc_now().isoformat() | |
| updated = DayPlan.model_validate(data) | |
| self._write_plan(updated) | |
| return updated | |
| def put_preserving_meta( | |
| self, | |
| day: str, | |
| blocks: list[ScheduledBlock], | |
| *, | |
| source: str = "user", | |
| force_p0_move: bool = True, | |
| bump_version: bool = False, | |
| notes: str | None = None, | |
| ) -> DayPlan: | |
| plan = self.get_plan(day) | |
| return self.save_plan( | |
| day, | |
| DayPlanPut( | |
| blocks=blocks, | |
| source=source, | |
| capacity_hint=plan.capacity_hint, | |
| notes=notes if notes is not None else plan.notes, | |
| title=plan.title, | |
| intention=plan.intention, | |
| constraints=plan.constraints, | |
| day_review=plan.day_review, | |
| force_p0_move=force_p0_move, | |
| ), | |
| bump_version=bump_version, | |
| ) | |
| def _write_plan(self, plan: DayPlan) -> None: | |
| path = self.paths.plan_path(plan.date) | |
| payload = json.dumps(plan.model_dump(mode="json"), ensure_ascii=False, indent=2) | |
| with file_lock(path): | |
| atomic_write_text(path, payload + "\n") | |
| def add_block(self, day: str, body: BlockCreate) -> DayPlan: | |
| plan = self.get_plan(day) | |
| planned = body.planned_min or max(1, minutes_between(body.start, body.end)) | |
| locked = body.locked if body.locked is not None else body.priority == "P0" | |
| block = ScheduledBlock( | |
| date=day, | |
| start=body.start, | |
| end=body.end, | |
| title=body.title, | |
| kind=body.kind, | |
| intent=body.intent, | |
| priority=body.priority, | |
| planned_min=planned, | |
| locked=locked, | |
| notes=body.notes, | |
| source="user", | |
| version_added=plan.version, | |
| ) | |
| blocks = list(plan.blocks) + [block] | |
| return self.put_preserving_meta(day, blocks, bump_version=False) | |
| def patch_block(self, day: str, block_id: str, patch: BlockPatch) -> DayPlan: | |
| plan = self.get_plan(day) | |
| found = False | |
| blocks: list[ScheduledBlock] = [] | |
| for block in plan.blocks: | |
| if block.id != block_id: | |
| blocks.append(block) | |
| continue | |
| found = True | |
| data = block.model_dump() | |
| data.update(patch.model_dump(exclude_unset=True)) | |
| if patch.start is not None or patch.end is not None: | |
| data["planned_min"] = patch.planned_min or max( | |
| 1, minutes_between(data["start"], data["end"]) | |
| ) | |
| blocks.append(ScheduledBlock.model_validate(data)) | |
| if not found: | |
| raise KeyError(block_id) | |
| return self.put_preserving_meta(day, blocks, bump_version=False) | |
| def delete_block(self, day: str, block_id: str) -> DayPlan: | |
| plan = self.get_plan(day) | |
| blocks = [b for b in plan.blocks if b.id != block_id] | |
| if len(blocks) == len(plan.blocks): | |
| raise KeyError(block_id) | |
| return self.put_preserving_meta(day, blocks, bump_version=False) | |
| def list_feedback(self, day: str | None = None) -> list[dict[str, Any]]: | |
| rows = read_jsonl(self.paths.schedule_feedback) | |
| if day is None: | |
| return rows | |
| return [r for r in rows if r.get("date") == day] | |
| def feedback_for_plan(self, day: str) -> list[dict[str, Any]]: | |
| return self.list_feedback(day) | |
| def submit_feedback(self, day: str, fb: BlockFeedback) -> tuple[BlockFeedback, DayPlan]: | |
| plan = self.get_plan(day) | |
| block = next((b for b in plan.blocks if b.id == fb.block_id), None) | |
| if block is None: | |
| raise KeyError(fb.block_id) | |
| stored = fb.model_copy(update={"date": day}).with_strong_flag() | |
| append_jsonl(self.paths.schedule_feedback, stored.model_dump(mode="json")) | |
| status_map = {"done": "done", "partial": "partial", "skipped": "skipped"} | |
| patched = self.patch_block( | |
| day, | |
| fb.block_id, | |
| BlockPatch(status=status_map[fb.did]), # type: ignore[arg-type] | |
| ) | |
| self.recompute_and_save_priors() | |
| return stored, patched | |
| def load_priors(self) -> dict[str, dict[str, Any]]: | |
| raw = read_json(self.paths.schedule_priors) | |
| if raw and isinstance(raw.get("kinds"), dict): | |
| return raw["kinds"] | |
| # Seed defaults | |
| kinds = { | |
| k: { | |
| "kind": k, | |
| "n": 0, | |
| "mean_actual": None, | |
| "d_hat": float(v), | |
| "mean_quality": None, | |
| "mean_fun": None, | |
| "mean_energy": None, | |
| "p_done": 0.5, | |
| "mean_slip": None, | |
| "repeat_score": 0.0, | |
| } | |
| for k, v in PRIOR_DEFAULT_MIN.items() | |
| } | |
| return kinds | |
| def recompute_and_save_priors(self) -> dict[str, dict[str, Any]]: | |
| feedback = self.list_feedback() | |
| blocks_by_id: dict[str, dict[str, Any]] = {} | |
| # Load blocks from feedback dates | |
| dates = {str(f.get("date")) for f in feedback if f.get("date")} | |
| for day in dates: | |
| for block in self.get_plan(day).blocks: | |
| blocks_by_id[block.id] = block.model_dump() | |
| kinds = recompute_priors(feedback, blocks_by_id, shrink_k=self.shrink_k) | |
| payload = { | |
| "updated_at": utc_now().isoformat(), | |
| "kinds": kinds, | |
| "kind_labels": KIND_LABELS, | |
| } | |
| text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" | |
| with file_lock(self.paths.schedule_priors): | |
| atomic_write_text(self.paths.schedule_priors, text) | |
| return kinds | |
| def get_meta(self) -> dict[str, Any]: | |
| return read_json(self.paths.schedule_meta) or {} | |
| def set_meta(self, **kwargs: Any) -> dict[str, Any]: | |
| meta = self.get_meta() | |
| meta.update(kwargs) | |
| text = json.dumps(meta, ensure_ascii=False, indent=2) + "\n" | |
| with file_lock(self.paths.schedule_meta): | |
| atomic_write_text(self.paths.schedule_meta, text) | |
| return meta | |
| def health(self, day: str) -> dict[str, Any]: | |
| plan = self.get_plan(day) | |
| return plan_health( | |
| [b.model_dump() for b in plan.blocks], | |
| self.feedback_for_plan(day), | |
| ) | |
| def plan_with_feedback(self, day: str) -> dict[str, Any]: | |
| plan = self.get_plan(day) | |
| feedback = self.feedback_for_plan(day) | |
| by_block = {str(f.get("block_id")): f for f in feedback} | |
| blocks = [] | |
| for block in plan.blocks: | |
| item = block.model_dump(mode="json") | |
| item["feedback"] = by_block.get(block.id) | |
| item["label"] = KIND_LABELS.get(block.kind, block.kind) | |
| blocks.append(item) | |
| data = plan.model_dump(mode="json") | |
| data["blocks"] = blocks | |
| data["health"] = self.health(day) | |
| return data | |
| def templates(self) -> list[dict[str, Any]]: | |
| return list_templates() | |
| def priors_table(self) -> str: | |
| return priors_markdown(self.load_priors()) | |