"""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, }, }