Fastwhisper / app /chat_plan.py
Mbonea's picture
Add ChatPlan paste/export loop with check-off and FSE annotations.
fb29daa
Raw
History Blame Contribute Delete
12.8 kB
"""Map ChatPlan JSON ↔ DayPlanPut / BlockFeedback for external AI chat loop."""
from __future__ import annotations
from typing import Any, Literal
from uuid import uuid4
from app.schedule_math import minutes_between, overlaps, validate_blocks
from app.schedule_store import (
BlockFeedback,
DayPlan,
DayPlanPut,
DayReview,
ScheduledBlock,
)
KIND_SET = {
"earn_ship",
"admin_spain",
"body_care",
"move_out",
"boundary",
"food_out",
"stabilize",
"explore",
"restore_fun",
"sleep_window",
"other",
}
INTENT_SET = {"duty", "explore", "restore_fun", "measure"}
PRIORITY_SET = {"P0", "P1", "P2"}
STATUS_SET = {"planned", "done", "partial", "skipped", "moved", "cancelled"}
DID_SET = {"done", "partial", "skipped"}
class ChatPlanError(Exception):
"""Validation failure with structured messages."""
def __init__(self, errors: list[str]) -> None:
self.errors = errors
Exception.__init__(self, "; ".join(errors))
def _as_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(x).strip() for x in value if str(x).strip()]
text = str(value).strip()
return [text] if text else []
def _coerce_kind(raw: Any, notes: str) -> tuple[str, str]:
kind = str(raw or "other").strip() or "other"
if kind in KIND_SET:
return kind, notes
prefix = f"[kind:{kind}]"
merged = f"{prefix} {notes}".strip() if notes else prefix
return "other", merged
def _status_from_review(status: str, review: dict[str, Any] | None) -> str:
if not review:
return status if status in STATUS_SET else "planned"
did = review.get("did")
if did in DID_SET and status in ("planned", "", None):
return str(did)
return status if status in STATUS_SET else "planned"
def is_legacy_day_plan_put(payload: dict[str, Any]) -> bool:
"""True when body looks like DayPlanPut (agent format), not ChatPlan."""
blocks = payload.get("blocks")
if not isinstance(blocks, list) or not blocks:
return False
first = blocks[0] if isinstance(blocks[0], dict) else {}
has_planned = "planned_min" in first
chatty = (
"intention" in payload
or "day_review" in payload
or "schema_version" in payload
or any(isinstance(b, dict) and "review" in b for b in blocks)
)
return has_planned and not chatty
def unwrap_import_payload(body: dict[str, Any]) -> tuple[dict[str, Any], Literal["replace", "merge"]]:
mode_raw = str(body.get("mode") or "replace").lower()
mode: Literal["replace", "merge"] = "merge" if mode_raw == "merge" else "replace"
if isinstance(body.get("plan"), dict) and "blocks" in body["plan"]:
return body["plan"], mode
if "blocks" in body:
chat = {k: v for k, v in body.items() if k != "mode"}
return chat, mode
raise ChatPlanError(["Expected ChatPlan with blocks (or {plan, mode})"])
def chat_plan_to_day_put(
chat: dict[str, Any],
day: str,
*,
max_blocks: int = 7,
previous_blocks: list[dict[str, Any]] | None = None,
mode: Literal["replace", "merge"] = "replace",
) -> tuple[DayPlanPut, list[BlockFeedback], list[str]]:
"""Map ChatPlan → DayPlanPut + optional feedback rows + warnings."""
errors: list[str] = []
warnings: list[str] = []
if chat.get("date") and str(chat["date"]) != day:
errors.append(f"date mismatch: JSON {chat['date']} vs URL {day}")
blocks_in = chat.get("blocks")
if not isinstance(blocks_in, list) or len(blocks_in) == 0:
errors.append("blocks must be a non-empty array")
raise ChatPlanError(errors)
if is_legacy_day_plan_put(chat):
put = DayPlanPut.model_validate({**chat, "source": chat.get("source") or "cursor"})
for block in put.blocks:
block.date = day
return put, [], warnings
mapped: list[ScheduledBlock] = []
feedbacks: list[BlockFeedback] = []
for raw in blocks_in:
if not isinstance(raw, dict):
errors.append("each block must be an object")
continue
start = str(raw.get("start") or "").strip()
end = str(raw.get("end") or "").strip()
title = str(raw.get("title") or "").strip() or "Untitled"
notes = str(raw.get("notes") or "")
kind, notes = _coerce_kind(raw.get("kind"), notes)
if str(raw.get("kind") or "") and str(raw.get("kind")) not in KIND_SET:
warnings.append(f"unknown kind coerced to other: {raw.get('kind')}")
intent = str(raw.get("intent") or "duty")
if intent not in INTENT_SET:
intent = "duty"
priority = str(raw.get("priority") or "P1")
if priority not in PRIORITY_SET:
priority = "P1"
review = raw.get("review") if isinstance(raw.get("review"), dict) else None
status = _status_from_review(str(raw.get("status") or "planned"), review)
try:
planned = int(raw.get("planned_min") or 0)
if planned <= 0:
planned = max(1, minutes_between(start, end))
except Exception: # noqa: BLE001
errors.append(f"invalid times for block {title}")
continue
block_id = str(raw.get("id") or "").strip() or str(uuid4())
locked = bool(raw.get("locked")) if raw.get("locked") is not None else priority == "P0"
block = ScheduledBlock(
id=block_id,
date=day,
start=start,
end=end,
title=title[:200],
kind=kind, # type: ignore[arg-type]
intent=intent, # type: ignore[arg-type]
priority=priority, # type: ignore[arg-type]
planned_min=planned,
status=status, # type: ignore[arg-type]
source="chat",
locked=locked,
notes=notes,
)
mapped.append(block)
if review and review.get("did") in DID_SET:
minutes = review.get("minutes")
intensity = review.get("intensity")
try:
intensity_i = int(intensity) if intensity is not None else None
except (TypeError, ValueError):
intensity_i = None
try:
actual = int(minutes) if minutes is not None else None
except (TypeError, ValueError):
actual = None
skip = review.get("skip_reason")
feedbacks.append(
BlockFeedback(
block_id=block_id,
date=day,
did=str(review["did"]), # type: ignore[arg-type]
actual_min=actual,
quality=review.get("quality"),
fun=review.get("fun"),
energy_after=review.get("energy_after"),
note=str(review.get("comment") or ""),
emotions=_as_list(review.get("emotions")),
fse_event=str(review.get("fse_event") or ""),
intensity=intensity_i,
skip_reason=skip if skip in {
"time", "fear", "fse", "locks", "boring", "urge", "other"
} else None,
)
)
if mode == "merge" and previous_blocks:
kept = list(previous_blocks)
for block in mapped:
conflict = False
for prev in kept:
if overlaps(
block.start,
block.end,
str(prev.get("start")),
str(prev.get("end")),
):
errors.append(
f"overlap merge conflict: {block.title} vs {prev.get('title')}"
)
conflict = True
break
if not conflict:
kept.append(block.model_dump())
# Rebuild mapped from kept
mapped = [ScheduledBlock.model_validate({**b, "date": day}) for b in kept]
val_errors, soft = validate_blocks(
[b.model_dump() for b in mapped],
max_blocks=max_blocks,
previous_p0=None,
allow_p0_move=True,
must_include_explore_or_restore=False,
capacity_hint=None,
)
errors.extend(val_errors)
warnings.extend(soft)
if errors:
raise ChatPlanError(errors)
review_raw = chat.get("day_review") if isinstance(chat.get("day_review"), dict) else {}
day_review = DayReview(
comment=str(review_raw.get("comment") or ""),
emotions=_as_list(review_raw.get("emotions")),
fse_events=str(review_raw.get("fse_events") or ""),
what_moved=str(review_raw.get("what_moved") or ""),
what_avoided=str(review_raw.get("what_avoided") or ""),
tomorrow_change=str(review_raw.get("tomorrow_change") or ""),
)
put = DayPlanPut(
blocks=mapped,
source="chat",
notes=str(chat.get("notes") or ""),
title=str(chat.get("title") or ""),
intention=str(chat.get("intention") or ""),
constraints=_as_list(chat.get("constraints")),
day_review=day_review,
force_p0_move=True,
)
return put, feedbacks, warnings
def plan_and_feedback_to_chat_plan(
plan: DayPlan | dict[str, Any],
feedbacks_by_block_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Build ChatPlan for export (includes summary)."""
if isinstance(plan, DayPlan):
pdata = plan.model_dump(mode="json")
else:
pdata = plan
blocks_out: list[dict[str, Any]] = []
blocks_done = 0
blocks_skipped = 0
p0_done = 0
p0_total = 0
planned_sum = 0
actual_sum = 0
for block in pdata.get("blocks") or []:
bid = str(block.get("id") or "")
fb = feedbacks_by_block_id.get(bid) or block.get("feedback") or {}
status = str(block.get("status") or "planned")
did = fb.get("did") if fb.get("did") in DID_SET else (
status if status in DID_SET else None
)
if status == "done" or did == "done":
blocks_done += 1
if status == "skipped" or did == "skipped":
blocks_skipped += 1
if block.get("priority") == "P0":
p0_total += 1
if status == "done" or did == "done":
p0_done += 1
planned_sum += int(block.get("planned_min") or 0)
if fb.get("actual_min") is not None:
actual_sum += int(fb.get("actual_min") or 0)
blocks_out.append(
{
"id": bid,
"start": block.get("start"),
"end": block.get("end"),
"title": block.get("title"),
"priority": block.get("priority"),
"kind": block.get("kind"),
"intent": block.get("intent"),
"notes": block.get("notes") or "",
"locked": bool(block.get("locked")),
"status": status if status in STATUS_SET else "planned",
"review": {
"did": did,
"minutes": fb.get("actual_min"),
"quality": fb.get("quality"),
"fun": fb.get("fun"),
"energy_after": fb.get("energy_after"),
"comment": fb.get("note") or "",
"emotions": list(fb.get("emotions") or []),
"fse_event": fb.get("fse_event") or "",
"intensity": fb.get("intensity"),
"skip_reason": fb.get("skip_reason"),
},
}
)
day_review = pdata.get("day_review") or {}
if hasattr(day_review, "model_dump"):
day_review = day_review.model_dump()
return {
"schema_version": 1,
"date": pdata.get("date"),
"title": pdata.get("title") or "",
"intention": pdata.get("intention") or "",
"constraints": list(pdata.get("constraints") or []),
"blocks": blocks_out,
"day_review": {
"comment": day_review.get("comment") or "",
"emotions": list(day_review.get("emotions") or []),
"fse_events": day_review.get("fse_events") or "",
"what_moved": day_review.get("what_moved") or "",
"what_avoided": day_review.get("what_avoided") or "",
"tomorrow_change": day_review.get("tomorrow_change") or "",
},
"summary": {
"blocks_total": len(blocks_out),
"blocks_done": blocks_done,
"blocks_skipped": blocks_skipped,
"p0_done": p0_done,
"p0_total": p0_total,
"planned_min_sum": planned_sum,
"actual_min_sum": actual_sum,
},
}