Fastwhisper / app /routers /plan.py
Mbonea's picture
Add ChatPlan paste/export loop with check-off and FSE annotations.
fb29daa
Raw
History Blame Contribute Delete
12 kB
"""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)