Compare commits

...
Author SHA1 Message Date
kcar 0ba72e4259 G1 add homework field to lessons API 2026-07-24 19:20:42 +01:00
kcarandClaude Opus 4.8 e48dd73fdf exam: capture sections + choice_groups + marks_confidence in extraction_meta (WS-2 R3)
api-ci-deploy / test-build-deploy (push) Has been cancelled
Persist the paper's question-layout signals (A-level Section A/B, EITHER/OR choice
groups, marks confidence) onto exam_templates.extraction_meta so the setup UI can
show them. Data was carried by the contract but dropped at persistence.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 13:13:45 +00:00
kcarandClaude Opus 4.8 547836e04b exam: persist exam_response_areas.meta on canvas replace-save (WS-2 item 4)
api-ci-deploy / test-build-deploy (push) Has been cancelled
The full-replace save dropped the rich region meta (figure name/description, OMR
geometry). Add meta to ResponseAreaPayload + the replace insert so a named context
figure survives a round-trip. Pairs with the app carrying name/description through.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:13:13 +00:00
kcarandClaude Opus 4.8 df128508a3 exam: persist command_word + preamble from analyse contract v2 (WS-2)
_map_service_contract_to_rows now carries the two v2 fields onto exam_questions
ghost rows (command_word on leaf parts, preamble jsonb). Requires supabase
migration 78; applied to dev .94.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 11:56:40 +00:00
kcar 81bf44c6cc Merge P3+P4 app wiring
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-03 02:09:17 +00:00
kcarandClaude Opus 4.8 544d858f62 P3+P4 app: persist audit gate (extraction_meta) + digital-text endpoint
_run_service_extract_merge now records extraction_meta {engine, slug, audit
(cover-total reconciliation), counts} on the template (migration 76) — a durable
trust signal the setup UI shows. New GET /templates/{id}/digital-text proxies the
service's /api/replica for the paper (resolved via extraction_meta.slug), returning
the digital-replica markdown. exam_extract.get_replica client added.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 02:09:17 +00:00
8 changed files with 95 additions and 9 deletions
@@ -0,0 +1,14 @@
-- G1: Homework as a first-class lesson/planner field.
-- Apply to DEV Supabase only before promoting app/api changes.
alter table if exists public.taught_lessons
add column if not exists homework text not null default '';
alter table if exists public.planned_lessons
add column if not exists homework text not null default '';
comment on column public.taught_lessons.homework is
'Teacher-authored homework for this taught lesson; surfaced in lesson/timetable views.';
comment on column public.planned_lessons.homework is
'Default homework carried by a lesson plan and copied to a taught lesson when delivered.';
@@ -53,6 +53,7 @@ class PlannedLessonNode(CCBaseNode):
subject: str
teacher_code: str
planning_status: str
homework: Optional[str] = None
topic_code: Optional[str] = None
topic_name: Optional[str] = None
lesson_code: Optional[str] = None
+13
View File
@@ -40,6 +40,19 @@ def _get(base: str, slug: str, timeout: int = 30) -> Dict[str, Any]:
return r.json()
def get_replica(slug: str, timeout: int = 30) -> Dict[str, Any]:
"""The digital-replica markdown for a paper (P4): {slug, title, n_questions, total_marks, markdown,
questions:[{label, marks, markdown}]}. Raises ExtractError if the paper has no replica yet."""
base = service_url()
if not base:
raise ExtractError("EXAM_EXTRACT_URL not configured")
r = requests.get(f"{base}/api/replica/{slug}", timeout=timeout)
if r.status_code == 404:
raise ExtractError(f"no digital replica for {slug}")
r.raise_for_status()
return r.json()
def extract_suggestions(slug: str, pdf_bytes: bytes, *, force: bool = False,
poll_timeout: int = 1500, poll_interval: int = 5) -> Dict[str, Any]:
"""POST the paper to the service and poll until the analyse contract is ready.
+18 -2
View File
@@ -83,6 +83,7 @@ class CreatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = "draft"
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -99,6 +100,7 @@ class UpdatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -249,6 +251,7 @@ async def create_plan(
"status": body.status or "draft",
"objectives": body.objectives or [],
"activities": body.activities or [],
"homework": body.homework or "",
"tags": body.tags or [],
}
for field in (
@@ -362,7 +365,7 @@ async def update_plan(
updates: Dict[str, Any] = {}
for field in (
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
"objectives", "activities", "status", "tags", "topic_code",
"objectives", "activities", "homework", "status", "tags", "topic_code",
"whiteboard_room_id", "course_id", "sequence_number",
):
val = getattr(body, field)
@@ -425,7 +428,7 @@ async def deliver_plan(
plan_res = (
sb.supabase.table("planned_lessons")
.select("id, whiteboard_room_id")
.select("id, whiteboard_room_id, homework")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
@@ -456,6 +459,19 @@ async def deliver_plan(
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
delivery = (res.data or [{}])[0]
# If the plan has default homework and was delivered into a taught lesson,
# copy it onto the lesson so homework appears in daily/timetable views.
plan_homework = plan_res.data.get("homework")
if body.taught_lesson_id and plan_homework is not None:
try:
sb.supabase.table("taught_lessons").update({
"homework": plan_homework,
"updated_at": datetime.utcnow().isoformat(),
}).eq("id", body.taught_lesson_id).eq("teacher_id", user_id).execute()
except Exception as e:
logger.warning(f"Could not copy homework to taught_lesson {body.taught_lesson_id}: {e}")
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
return delivery
@@ -4,7 +4,7 @@ Taught Lessons Router — materialization and lesson CRUD.
POST /materialize — slot template × academic_periods → taught_lessons rows
GET /lessons — teacher's lessons for a date range
GET /lessons/{id} — single lesson detail
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
PATCH /lessons/{id} — update lesson_plan, homework, notes, status (teacher-owned)
"""
import os
from collections import defaultdict
@@ -44,6 +44,7 @@ def _require_institute(user_id: str) -> str:
class UpdateLessonRequest(BaseModel):
lesson_plan: Optional[Dict[str, Any]] = None
homework: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
@@ -310,7 +311,7 @@ async def get_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"class_id,academic_period_id"
)
.eq("teacher_id", user_id)
@@ -418,13 +419,15 @@ async def update_lesson(
body: UpdateLessonRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Teacher updates their own lesson content: plan, notes, status."""
"""Teacher updates their own lesson content: plan, homework, notes, status."""
user_id = credentials.get("sub", "")
sb = _sb()
updates: Dict[str, Any] = {}
if body.lesson_plan is not None:
updates["lesson_plan"] = body.lesson_plan
if body.homework is not None:
updates["homework"] = body.homework
if body.notes is not None:
updates["notes"] = body.notes
if body.status is not None:
@@ -508,7 +511,7 @@ async def get_student_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"class_id,academic_period_id,teacher_id"
)
.in_("class_id", class_ids)
@@ -675,7 +675,7 @@ def _sync_taught_lessons_to_neo4j(
"""
lessons = (
sb.supabase.table("taught_lessons")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status,homework")
.eq("institute_id", institute_id)
.execute()
.data or []
@@ -728,6 +728,7 @@ def _sync_taught_lessons_to_neo4j(
SET tl.date = date($date), tl.period_code = $pcode,
tl.week_cycle = $wc, tl.day_of_week = $dow,
tl.status = $status,
tl.homework = $homework,
tl.node_storage_path = $path
""",
id=tl_id,
@@ -736,6 +737,7 @@ def _sync_taught_lessons_to_neo4j(
wc=lesson.get("week_cycle", ""),
dow=lesson.get("day_of_week", ""),
status=lesson.get("status", "planned"),
homework=lesson.get("homework") or "",
path=f"taught_lessons/{tl_id}",
)
count += 1
+3
View File
@@ -80,6 +80,9 @@ class ResponseAreaPayload(BaseModel):
] = None
# Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage).
context_type: Optional[str] = None
# Rich recognition payload (75-exam-marker-region-meta.sql): figure name/description, OMR geometry, unit…
# Carried on canvas save so a named context figure survives a round-trip.
meta: Optional[Dict[str, Any]] = None
source: Literal["manual", "ai"] = "manual"
confirmed: bool = True
confidence: Optional[float] = Field(default=None, ge=0, le=1)
+36 -2
View File
@@ -745,6 +745,9 @@ def _map_service_contract_to_rows(template_id: str, contract: Dict[str, Any],
"bounds": _frac_box_to_canvas(q.get("bounds"), q.get("page") or 1, pages),
"page": q.get("page"), "source": "ai", "confirmed": False,
"confidence": _safe_confidence(q.get("confidence")), "derivation": "extract-service",
# analyse contract v2 (migration 78): command verb + stem prose per part
"command_word": (q.get("command_word") or None) if not q.get("is_container") else None,
"preamble": q.get("preamble") or None,
})
for q in questions: # FK safety: de-parent a dangling parent_id
if q["parent_id"] and q["parent_id"] not in q_ids:
@@ -793,9 +796,21 @@ def _run_service_extract_merge(ctx: ExamContext, template_id: str, pdf_bytes: by
contract = exam_extract.extract_suggestions(slug, pdf_bytes)
rows = _map_service_contract_to_rows(template_id, contract, pdf_bytes)
_refresh_ai_rows(ctx, template_id, rows)
n_pages = (contract.get("meta") or {}).get("n_pages") or (contract.get("meta") or {}).get("pages")
meta = contract.get("meta") or {}
# P3: record provenance + the audit cover-reconciliation gate so the setup UI can flag under-reads,
# and store the slug so the digital-text view can resolve this paper's replica.
updates: Dict[str, Any] = {"extraction_meta": {
"engine": "extract-service", "slug": slug, "audit": meta.get("audit") or {},
"counts": {k: len(v) for k, v in rows.items()},
# question-layout signals for the setup UI: paper sections (e.g. A-level Section A/B) + EITHER/OR choices
"sections": meta.get("sections") or [],
"choice_groups": meta.get("choice_groups") or [],
"marks_confidence": meta.get("marks_confidence"),
}}
n_pages = meta.get("n_pages") or meta.get("pages")
if n_pages:
ctx.supabase.table("exam_templates").update({"page_count": n_pages}).eq("id", template_id).execute()
updates["page_count"] = n_pages
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
return rows
@@ -1034,6 +1049,24 @@ async def auto_map_status(
return body
@router.get("/templates/{template_id}/digital-text")
async def template_digital_text(
template_id: str,
ctx: ExamContext = Depends(get_exam_context),
) -> Dict[str, Any]:
"""P4: the digital-replica markdown for this template's paper (stem text, parts, marks, answer-space
placeholders, spec refs). Resolves the paper via the slug the extraction used."""
template = _fetch_template_or_404(ctx, template_id)
_require_source_visibility_or_404(ctx, template)
if not exam_extract.is_enabled():
raise HTTPException(status_code=503, detail="Extraction service not configured")
slug = (template.get("extraction_meta") or {}).get("slug") or _extract_slug(ctx, template)
try:
return exam_extract.get_replica(slug)
except exam_extract.ExtractError as exc:
raise HTTPException(status_code=404, detail=f"No digital text yet — run auto-map first ({exc})")
@router.put("/templates/{template_id}")
async def replace_template(
template_id: str,
@@ -1114,6 +1147,7 @@ async def replace_template(
"kind": ra.kind,
"response_form": ra.response_form,
"context_type": ra.context_type, # 73: optional Context differentiation
"meta": ra.meta, # 75: rich recognition payload (name/description/OMR/…)
"source": ra.source,
"confirmed": ra.confirmed,
"confidence": ra.confidence,