"""Human schedule/plan routes: CRUD, feedback, priors, templates, reschedule. Session-authenticated; mirrors the app envelope pattern. """ from __future__ import annotations import json from datetime import date from typing import Any from fastapi import APIRouter, Depends, Request, status from fastapi.responses import Response from pydantic import BaseModel from app.chat_plan import ( ChatPlanError, chat_plan_to_day_put, plan_and_feedback_to_chat_plan, unwrap_import_payload, ) from app.deps import require_login from app.models import ApiEnvelope, err, ok from app.schedule_math import capacity_hint, trigger_operators from app.schedule_reschedule import seed_starter_blocks from app.schedule_risk import risk_from_stores from app.schedule_store import ( BlockCheckBody, BlockCreate, BlockFeedback, BlockPatch, DayPlanPut, PlanMetaPatch, ) router = APIRouter(tags=["plan"], dependencies=[Depends(require_login)]) class RescheduleBody(BaseModel): reason: str = "user" force: bool = False class FeedbackBody(BlockFeedback): block_id: str = "" date: str | None = None def _store(request: Request): return request.app.state.schedule_store def _risk_bundle(request: Request, target: date | None = None) -> dict[str, Any]: risk = risk_from_stores( request.app.state.entry_store, request.app.state.daily_store, target_day=target, ) cap = capacity_hint( risk_1h_score=risk["risk_1h_score"], triggers_yesterday=risk["triggers_yesterday"], yesterday_trigger=risk["yesterday_trigger"], last_hour_high=risk["last_hour_high"], ) ops = trigger_operators( risk_1h_tags=risk["risk_1h_tags"], triggers_yesterday=risk["triggers_yesterday"], ) return {**risk, "capacity_hint": cap, "operators": ops} def _enrich_plan(request: Request, day: date) -> dict[str, Any]: data = _store(request).plan_with_feedback(day.isoformat()) bundle = _risk_bundle(request, day) data["capacity_hint"] = bundle["capacity_hint"] data["operators"] = bundle["operators"] data["risk_1h"] = { "score": bundle["risk_1h_score"], "tags": bundle["risk_1h_tags"], "high": bundle["last_hour_high"], } data["triggers_yesterday"] = bundle["triggers_yesterday"] return data def _feedback_map(store: Any, day: str) -> dict[str, dict[str, Any]]: by_block: dict[str, dict[str, Any]] = {} for row in store.feedback_for_plan(day): bid = str(row.get("block_id") or "") if bid: by_block[bid] = row return by_block @router.get("/api/plan/{day}", response_model=ApiEnvelope) def get_plan(request: Request, day: date) -> dict[str, Any]: return ok(_enrich_plan(request, day)) @router.put("/api/plan/{day}", response_model=ApiEnvelope) def put_plan(request: Request, day: date, body: DayPlanPut) -> object: try: put = body.model_copy(update={"source": body.source or "user"}) _store(request).save_plan(day.isoformat(), put) return ok(_enrich_plan(request, day)) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.post("/api/plan/{day}/blocks", response_model=ApiEnvelope) def add_block(request: Request, day: date, body: BlockCreate) -> object: try: _store(request).add_block(day.isoformat(), body) return ok(_enrich_plan(request, day)) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.patch("/api/plan/{day}/blocks/{block_id}", response_model=ApiEnvelope) def patch_block( request: Request, day: date, block_id: str, body: BlockPatch, ) -> object: try: _store(request).patch_block(day.isoformat(), block_id, body) return ok(_enrich_plan(request, day)) except KeyError: return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.delete("/api/plan/{day}/blocks/{block_id}", response_model=ApiEnvelope) def delete_block(request: Request, day: date, block_id: str) -> object: try: _store(request).delete_block(day.isoformat(), block_id) return ok(_enrich_plan(request, day)) except KeyError: return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND) @router.post("/api/plan/{day}/blocks/{block_id}/feedback", response_model=ApiEnvelope) def post_feedback( request: Request, day: date, block_id: str, body: FeedbackBody, ) -> object: fb = body.model_copy(update={"block_id": block_id, "date": day.isoformat()}) try: stored, _plan = _store(request).submit_feedback(day.isoformat(), fb) return ok( { "feedback": stored.model_dump(mode="json"), "plan": _enrich_plan(request, day), "priors": list(_store(request).load_priors().values()), } ) except KeyError: return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND) @router.post("/api/plan/{day}/blocks/{block_id}/check", response_model=ApiEnvelope) def check_block( request: Request, day: date, block_id: str, body: BlockCheckBody, ) -> object: """Fast checkbox — status only, no full feedback required.""" try: _store(request).patch_block( day.isoformat(), block_id, BlockPatch(status=body.status), ) if body.status == "skipped" and body.skip_reason: try: _store(request).submit_feedback( day.isoformat(), BlockFeedback( block_id=block_id, date=day.isoformat(), did="skipped", skip_reason=body.skip_reason, ), ) except KeyError: pass return ok(_enrich_plan(request, day)) except KeyError: return err("not_found", "Block not found", status.HTTP_404_NOT_FOUND) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.patch("/api/plan/{day}/meta", response_model=ApiEnvelope) def patch_plan_meta(request: Request, day: date, body: PlanMetaPatch) -> object: try: _store(request).patch_meta(day.isoformat(), body) return ok(_enrich_plan(request, day)) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.post("/api/plan/{day}/import", response_model=ApiEnvelope) def import_chat_plan(request: Request, day: date, body: dict[str, Any]) -> object: store = _store(request) day_s = day.isoformat() try: chat, mode = unwrap_import_payload(body) current = store.get_plan(day_s) put, feedbacks, warnings = chat_plan_to_day_put( chat, day_s, max_blocks=request.app.state.settings.schedule_max_blocks, previous_blocks=[b.model_dump() for b in current.blocks], mode=mode, ) store.save_plan(day_s, put, bump_version=True) for fb in feedbacks: try: store.submit_feedback(day_s, fb) except KeyError: warnings.append(f"feedback skipped for missing block {fb.block_id}") data = _enrich_plan(request, day) data["import_warnings"] = warnings data["import_mode"] = mode return ok(data) except ChatPlanError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.get("/api/plan/{day}/export", response_model=ApiEnvelope) def export_chat_plan(request: Request, day: date) -> dict[str, Any]: store = _store(request) day_s = day.isoformat() plan = store.get_plan(day_s) chat = plan_and_feedback_to_chat_plan(plan, _feedback_map(store, day_s)) return ok(chat) @router.get("/api/plan/{day}/export.txt") def export_chat_plan_file(request: Request, day: date) -> Response: store = _store(request) day_s = day.isoformat() plan = store.get_plan(day_s) chat = plan_and_feedback_to_chat_plan(plan, _feedback_map(store, day_s)) body = json.dumps(chat, ensure_ascii=False, indent=2) + "\n" return Response( content=body, media_type="application/json", headers={ "Content-Disposition": f'attachment; filename="plan-{day_s}.json"' }, ) @router.post("/api/plan/{day}/seed", response_model=ApiEnvelope) def seed_plan(request: Request, day: date) -> object: """Fill an empty day from templates under live capacity (P0 locked).""" store = _store(request) existing = store.get_plan(day.isoformat()) if existing.blocks: return err( "validation_error", "Day already has blocks — clear or reschedule instead", status.HTTP_422_UNPROCESSABLE_ENTITY, ) bundle = _risk_bundle(request, day) blocks = seed_starter_blocks( day.isoformat(), capacity_hint=bundle["capacity_hint"], max_blocks=request.app.state.settings.schedule_max_blocks, ) try: store.save_plan( day.isoformat(), DayPlanPut( blocks=blocks, source="rules", capacity_hint=bundle["capacity_hint"], notes="seed", force_p0_move=True, ), ) data = _enrich_plan(request, day) data["seeded"] = True return ok(data) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY) @router.get("/api/schedule/priors", response_model=ApiEnvelope) def get_priors(request: Request) -> dict[str, Any]: kinds = _store(request).load_priors() return ok({"kinds": list(kinds.values()), "markdown": _store(request).priors_table()}) @router.get("/api/schedule/templates", response_model=ApiEnvelope) def get_templates(request: Request) -> dict[str, Any]: return ok({"items": _store(request).templates()}) @router.get("/api/plan/{day}/health", response_model=ApiEnvelope) def get_health(request: Request, day: date) -> dict[str, Any]: return ok(_store(request).health(day.isoformat())) @router.post("/api/plan/{day}/reschedule", response_model=ApiEnvelope) def reschedule(request: Request, day: date, body: RescheduleBody | None = None) -> object: payload = body or RescheduleBody() bundle = _risk_bundle(request, day) try: result = request.app.state.reschedule_service.reschedule( day.isoformat(), reason=payload.reason, force=payload.force, risk_score=bundle["risk_1h_score"], capacity_hint=bundle["capacity_hint"], priors_md=_store(request).priors_table(), context_notes=( f"risk_tags={bundle['risk_1h_tags']}; " f"triggers_y={bundle['triggers_yesterday']}; " f"operators={bundle['operators']}" ), ) result["operators"] = bundle["operators"] result["capacity_hint"] = bundle["capacity_hint"] if "plan" in result: plan = result["plan"] if isinstance(plan, dict): plan["capacity_hint"] = bundle["capacity_hint"] plan["operators"] = bundle["operators"] return ok(result) except PermissionError as exc: return err("rate_limited", str(exc), status.HTTP_429_TOO_MANY_REQUESTS) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)