Spaces:
Sleeping
Sleeping
| """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 | |
| def get_plan(request: Request, day: date) -> dict[str, Any]: | |
| return ok(_enrich_plan(request, day)) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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"' | |
| }, | |
| ) | |
| 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) | |
| def get_priors(request: Request) -> dict[str, Any]: | |
| kinds = _store(request).load_priors() | |
| return ok({"kinds": list(kinds.values()), "markdown": _store(request).priors_table()}) | |
| def get_templates(request: Request) -> dict[str, Any]: | |
| return ok({"items": _store(request).templates()}) | |
| def get_health(request: Request, day: date) -> dict[str, Any]: | |
| return ok(_store(request).health(day.isoformat())) | |
| 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) | |