File size: 12,803 Bytes
fb29daa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""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,
        },
    }