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