feat(phase-c): lesson plans library backend — CRUD, delivery linking, AI suggest
Adds lesson_plans_router.py with 10 endpoints under /lessons/plans:
GET/POST /plans, GET/PATCH/DELETE /plans/{id}, POST /plans/{id}/deliver,
GET /plans/{id}/deliveries, POST/DELETE /plans/{id}/collaborators,
POST /plans/{id}/suggest (Ollama-backed per-field AI suggestions).
objectives and activities stored as JSONB arrays with Bloom taxonomy support.
Registers router in run/routers.py. Adds seed_test_environment.py for
platform-admin triggered reset + seed of demo users and Neo4j.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
import os
|
||||
import uuid
|
||||
import aiohttp
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from modules.logger_tool import initialise_logger
|
||||
from modules.auth.supabase_bearer import SupabaseBearer
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
|
||||
router = APIRouter()
|
||||
|
||||
_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
_OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
||||
_LLM_TIMEOUT = 60
|
||||
|
||||
VALID_STATUS = {"draft", "ready", "archived"}
|
||||
VALID_FIELDS = {"objectives", "activity_description", "title"}
|
||||
BLOOM_LEVELS = {"remember", "understand", "apply", "analyse", "evaluate", "create"}
|
||||
|
||||
|
||||
# ─── Supabase helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sb() -> SupabaseServiceRoleClient:
|
||||
return SupabaseServiceRoleClient()
|
||||
|
||||
|
||||
def _resolve_institute_id(user_id: str) -> Optional[str]:
|
||||
try:
|
||||
sb = _sb()
|
||||
p = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute()
|
||||
return str((p.data or {}).get("school_id") or "") or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_institute(user_id: str) -> str:
|
||||
iid = _resolve_institute_id(user_id)
|
||||
if not iid:
|
||||
raise HTTPException(status_code=400, detail="User is not linked to a school")
|
||||
return iid
|
||||
|
||||
|
||||
def _is_creator(plan: Dict[str, Any], user_id: str) -> bool:
|
||||
return str(plan.get("created_by", "")) == str(user_id)
|
||||
|
||||
|
||||
def _can_edit_plan(sb: SupabaseServiceRoleClient, plan_id: str, user_id: str) -> bool:
|
||||
try:
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("created_by")
|
||||
.eq("id", plan_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
return False
|
||||
if str(plan_res.data.get("created_by", "")) == str(user_id):
|
||||
return True
|
||||
collab = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.select("can_edit")
|
||||
.eq("planned_lesson_id", plan_id)
|
||||
.eq("profile_id", user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
return bool((collab.data or {}).get("can_edit", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ─── Request models ───────────────────────────────────────────────────────────
|
||||
|
||||
class CreatePlanRequest(BaseModel):
|
||||
title: str
|
||||
class_id: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
year_group: Optional[str] = None
|
||||
estimated_duration_minutes: Optional[int] = None
|
||||
objectives: Optional[List[Dict[str, Any]]] = None
|
||||
activities: Optional[List[Dict[str, Any]]] = None
|
||||
status: Optional[str] = "draft"
|
||||
tags: Optional[List[str]] = None
|
||||
topic_code: Optional[str] = None
|
||||
whiteboard_room_id: Optional[str] = None
|
||||
course_id: Optional[str] = None
|
||||
sequence_number: Optional[int] = None
|
||||
|
||||
|
||||
class UpdatePlanRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
class_id: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
year_group: Optional[str] = None
|
||||
estimated_duration_minutes: Optional[int] = None
|
||||
objectives: Optional[List[Dict[str, Any]]] = None
|
||||
activities: Optional[List[Dict[str, Any]]] = None
|
||||
status: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
topic_code: Optional[str] = None
|
||||
whiteboard_room_id: Optional[str] = None
|
||||
course_id: Optional[str] = None
|
||||
sequence_number: Optional[int] = None
|
||||
|
||||
|
||||
class DeliverPlanRequest(BaseModel):
|
||||
taught_lesson_id: Optional[str] = None
|
||||
class_id: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddCollaboratorRequest(BaseModel):
|
||||
profile_id: str
|
||||
can_edit: bool = True
|
||||
|
||||
|
||||
class SuggestRequest(BaseModel):
|
||||
field: str
|
||||
context: Optional[str] = None
|
||||
activity_section: Optional[str] = None
|
||||
objective_texts: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/plans")
|
||||
async def list_plans(
|
||||
class_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
q = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("*")
|
||||
.eq("institute_id", institute_id)
|
||||
)
|
||||
if class_id:
|
||||
q = q.eq("class_id", class_id)
|
||||
if status:
|
||||
q = q.eq("status", status)
|
||||
if subject:
|
||||
q = q.eq("subject", subject)
|
||||
|
||||
owned = q.eq("created_by", user_id).execute().data or []
|
||||
|
||||
collab_rows = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.select("planned_lesson_id")
|
||||
.eq("profile_id", user_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
collab_plan_ids = [r["planned_lesson_id"] for r in collab_rows]
|
||||
|
||||
collab_plans: List[Dict] = []
|
||||
if collab_plan_ids:
|
||||
q2 = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("*")
|
||||
.eq("institute_id", institute_id)
|
||||
.in_("id", collab_plan_ids)
|
||||
)
|
||||
if class_id:
|
||||
q2 = q2.eq("class_id", class_id)
|
||||
if status:
|
||||
q2 = q2.eq("status", status)
|
||||
if subject:
|
||||
q2 = q2.eq("subject", subject)
|
||||
collab_plans = q2.execute().data or []
|
||||
|
||||
seen_ids: set = set()
|
||||
all_plans: List[Dict] = []
|
||||
for p in owned + collab_plans:
|
||||
if p["id"] not in seen_ids:
|
||||
seen_ids.add(p["id"])
|
||||
all_plans.append(p)
|
||||
|
||||
if not all_plans:
|
||||
return {"plans": []}
|
||||
|
||||
plan_ids = [p["id"] for p in all_plans]
|
||||
|
||||
collab_counts: Dict[str, int] = {}
|
||||
delivery_counts: Dict[str, int] = {}
|
||||
|
||||
try:
|
||||
cc = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.select("planned_lesson_id")
|
||||
.in_("planned_lesson_id", plan_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
for r in cc:
|
||||
collab_counts[r["planned_lesson_id"]] = collab_counts.get(r["planned_lesson_id"], 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
dc = (
|
||||
sb.supabase.table("lesson_deliveries")
|
||||
.select("planned_lesson_id")
|
||||
.in_("planned_lesson_id", plan_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
for r in dc:
|
||||
delivery_counts[r["planned_lesson_id"]] = delivery_counts.get(r["planned_lesson_id"], 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
enriched = [
|
||||
{
|
||||
**p,
|
||||
"collaborator_count": collab_counts.get(p["id"], 0),
|
||||
"delivery_count": delivery_counts.get(p["id"], 0),
|
||||
"is_owner": str(p.get("created_by", "")) == str(user_id),
|
||||
}
|
||||
for p in all_plans
|
||||
]
|
||||
return {"plans": enriched}
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
async def create_plan(
|
||||
body: CreatePlanRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
if body.status and body.status not in VALID_STATUS:
|
||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"created_by": user_id,
|
||||
"institute_id": institute_id,
|
||||
"title": body.title,
|
||||
"status": body.status or "draft",
|
||||
"objectives": body.objectives or [],
|
||||
"activities": body.activities or [],
|
||||
"tags": body.tags or [],
|
||||
}
|
||||
for field in (
|
||||
"class_id", "subject", "year_group", "estimated_duration_minutes",
|
||||
"topic_code", "whiteboard_room_id", "course_id", "sequence_number",
|
||||
):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
row[field] = val
|
||||
|
||||
res = sb.supabase.table("planned_lessons").insert(row).execute()
|
||||
plan = (res.data or [{}])[0]
|
||||
logger.info(f"Lesson plan created: {plan.get('id')} '{body.title}' by {user_id}")
|
||||
return plan
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}")
|
||||
async def get_plan(
|
||||
plan_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("*")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Lesson plan not found")
|
||||
|
||||
plan = plan_res.data
|
||||
if not _is_creator(plan, user_id):
|
||||
collab_check = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.select("can_edit")
|
||||
.eq("planned_lesson_id", plan_id)
|
||||
.eq("profile_id", user_id)
|
||||
.execute()
|
||||
.data
|
||||
)
|
||||
if collab_check is None:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
collab_rows = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.select("profile_id, can_edit, added_at")
|
||||
.eq("planned_lesson_id", plan_id)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
profile_ids = [r["profile_id"] for r in collab_rows]
|
||||
profile_map: Dict[str, Dict] = {}
|
||||
if profile_ids:
|
||||
try:
|
||||
profiles = (
|
||||
sb.supabase.table("profiles")
|
||||
.select("id, full_name, email")
|
||||
.in_("id", profile_ids)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
profile_map = {p["id"]: p for p in profiles}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
collaborators = [
|
||||
{
|
||||
**r,
|
||||
"full_name": profile_map.get(r["profile_id"], {}).get("full_name"),
|
||||
"email": profile_map.get(r["profile_id"], {}).get("email"),
|
||||
}
|
||||
for r in collab_rows
|
||||
]
|
||||
|
||||
deliveries = (
|
||||
sb.supabase.table("lesson_deliveries")
|
||||
.select("*")
|
||||
.eq("planned_lesson_id", plan_id)
|
||||
.order("started_at", desc=True)
|
||||
.limit(10)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
|
||||
return {**plan, "collaborators": collaborators, "recent_deliveries": deliveries}
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}")
|
||||
async def update_plan(
|
||||
plan_id: str,
|
||||
body: UpdatePlanRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
if not _can_edit_plan(sb, plan_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied — not creator or editor collaborator")
|
||||
|
||||
if body.status and body.status not in VALID_STATUS:
|
||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUS}")
|
||||
|
||||
updates: Dict[str, Any] = {}
|
||||
for field in (
|
||||
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
|
||||
"objectives", "activities", "status", "tags", "topic_code",
|
||||
"whiteboard_room_id", "course_id", "sequence_number",
|
||||
):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
updates[field] = val
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Nothing to update")
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().isoformat()
|
||||
res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.update(updates)
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
)
|
||||
if not res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
return res.data[0]
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def delete_plan(
|
||||
plan_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("created_by")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
if not _is_creator(plan_res.data, user_id):
|
||||
raise HTTPException(status_code=403, detail="Only the creator can delete a plan")
|
||||
|
||||
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).execute()
|
||||
sb.supabase.table("planned_lessons").delete().eq("id", plan_id).execute()
|
||||
logger.info(f"Lesson plan deleted: {plan_id} by {user_id}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/deliver")
|
||||
async def deliver_plan(
|
||||
plan_id: str,
|
||||
body: DeliverPlanRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("id, whiteboard_room_id")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
if not _can_edit_plan(sb, plan_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"planned_lesson_id": plan_id,
|
||||
"delivered_by": user_id,
|
||||
"institute_id": institute_id,
|
||||
"started_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
if body.taught_lesson_id:
|
||||
row["taught_lesson_id"] = body.taught_lesson_id
|
||||
if body.class_id:
|
||||
row["class_id"] = body.class_id
|
||||
if body.notes:
|
||||
row["notes"] = body.notes
|
||||
|
||||
whiteboard_room_id = plan_res.data.get("whiteboard_room_id")
|
||||
if whiteboard_room_id:
|
||||
row["whiteboard_room_id"] = whiteboard_room_id
|
||||
|
||||
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
|
||||
delivery = (res.data or [{}])[0]
|
||||
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
|
||||
return delivery
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}/deliveries")
|
||||
async def list_deliveries(
|
||||
plan_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("created_by")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
deliveries = (
|
||||
sb.supabase.table("lesson_deliveries")
|
||||
.select("*")
|
||||
.eq("planned_lesson_id", plan_id)
|
||||
.order("started_at", desc=True)
|
||||
.execute()
|
||||
.data or []
|
||||
)
|
||||
return {"deliveries": deliveries}
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/collaborators")
|
||||
async def add_collaborator(
|
||||
plan_id: str,
|
||||
body: AddCollaboratorRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("created_by")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
if not _is_creator(plan_res.data, user_id):
|
||||
raise HTTPException(status_code=403, detail="Only the creator can add collaborators")
|
||||
|
||||
res = (
|
||||
sb.supabase.table("lesson_collaborators")
|
||||
.upsert(
|
||||
{
|
||||
"planned_lesson_id": plan_id,
|
||||
"profile_id": body.profile_id,
|
||||
"can_edit": body.can_edit,
|
||||
"added_at": datetime.utcnow().isoformat(),
|
||||
},
|
||||
on_conflict="planned_lesson_id,profile_id",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return {"status": "ok", "row": (res.data or [{}])[0]}
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}/collaborators/{profile_id}")
|
||||
async def remove_collaborator(
|
||||
plan_id: str,
|
||||
profile_id: str,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("created_by")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
if not _is_creator(plan_res.data, user_id):
|
||||
raise HTTPException(status_code=403, detail="Only the creator can remove collaborators")
|
||||
|
||||
sb.supabase.table("lesson_collaborators").delete().eq("planned_lesson_id", plan_id).eq("profile_id", profile_id).execute()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/suggest")
|
||||
async def suggest_field(
|
||||
plan_id: str,
|
||||
body: SuggestRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
user_id = credentials.get("sub", "")
|
||||
institute_id = _require_institute(user_id)
|
||||
sb = _sb()
|
||||
|
||||
if body.field not in VALID_FIELDS:
|
||||
raise HTTPException(status_code=400, detail=f"field must be one of {VALID_FIELDS}")
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("*")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
if not plan_res.data:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
if not _can_edit_plan(sb, plan_id, user_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
plan = plan_res.data
|
||||
subject = plan.get("subject") or "a UK secondary school subject"
|
||||
year_group = plan.get("year_group") or "unspecified year group"
|
||||
title = plan.get("title", "")
|
||||
context = body.context or ""
|
||||
|
||||
context_part = ("Teacher notes: " + context + "\n") if context else ""
|
||||
if body.field == "objectives":
|
||||
existing = ""
|
||||
if body.objective_texts:
|
||||
existing = "\n".join("- " + t for t in body.objective_texts)
|
||||
objectives_part = ("Existing objectives:\n" + existing + "\n") if existing else ""
|
||||
prompt = (
|
||||
"You are an expert UK secondary school teacher.\n"
|
||||
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
|
||||
+ objectives_part + context_part
|
||||
+ "Suggest ONE clear, measurable learning objective for this lesson using Bloom's taxonomy. "
|
||||
"Write only the objective text, no bullet point or prefix."
|
||||
)
|
||||
elif body.field == "activity_description":
|
||||
section = body.activity_section or "Main"
|
||||
prompt = (
|
||||
"You are an expert UK secondary school teacher.\n"
|
||||
"Lesson: '" + title + "', Subject: " + subject + ", Year: " + year_group + ".\n"
|
||||
"Activity section: " + section + ".\n"
|
||||
+ context_part
|
||||
+ "Write a concise, practical description (2-4 sentences) for a " + section + " activity "
|
||||
"suitable for UK secondary pupils. Include what the teacher does and what pupils do."
|
||||
)
|
||||
else: # title
|
||||
prompt = (
|
||||
"You are an expert UK secondary school teacher.\n"
|
||||
"Subject: " + subject + ", Year: " + year_group + ".\n"
|
||||
+ context_part
|
||||
+ "Suggest ONE engaging, clear lesson title for a UK secondary school lesson. "
|
||||
"Write only the title, no explanation."
|
||||
)
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"model": _OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{_OLLAMA_URL}/api/generate",
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=_LLM_TIMEOUT),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body_text = await resp.text()
|
||||
logger.error(f"Ollama suggest error ({resp.status}): {body_text}")
|
||||
raise HTTPException(status_code=502, detail=f"LLM request failed: {resp.status}")
|
||||
data = await resp.json()
|
||||
suggestion = (data.get("response") or "").strip()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"suggest_field LLM call failed for plan {plan_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="LLM service unavailable")
|
||||
|
||||
return {"suggestion": suggestion}
|
||||
Reference in New Issue
Block a user