๐ŸŒŸ North Star Learning Center

Core source code โ€” MCP-style tools + two live Gemini calls

Gemini 2.0 Flash FastAPI SQLite Python 3.11 Cloud Run

๐Ÿ” About This Code Showcase

This page shows the two live Gemini calls and the MCP-style tool dispatch pattern that powers North Star Learning Center.

The full source โ€” 8 deterministic tools, the data prep script, the frontend role switcher, the Dockerfile, and the SQLite schema โ€” lives in the GitHub repo. The snippets below highlight the architectural split between deterministic logic (SQL + scoring) and warm narrative (Gemini).

๐Ÿ“ File Structure

Eleven files. One FastAPI process. SQLite + the frontend ship inside the same container.

north-star-learning-center/ โ”œโ”€โ”€ main.py FastAPI backend ยท 8 MCP-style tools ยท 2 live Gemini calls โ”œโ”€โ”€ demo.html Single-page UI with Manager / Teacher / Parent / Centre Directory views โ”œโ”€โ”€ prepare_data.py Idempotent data migration โ€” seeds demo rows, enforces 10-child cap โ”œโ”€โ”€ data.db SQLite (already migrated and seeded with 185 children, 22 specialists) โ”œโ”€โ”€ Dockerfile Cloud Run container build (python:3.11-slim) โ”œโ”€โ”€ requirements.txt fastapi ยท uvicorn ยท google-generativeai ยท python-dotenv โ”œโ”€โ”€ .env.example GOOGLE_API_KEY template โ”œโ”€โ”€ .gitignore Excludes .env, __pycache__, _student_names.txt โ”œโ”€โ”€ README.md Setup + deploy + brief alignment notes โ”œโ”€โ”€ start.bat Windows one-click launcher โ””โ”€โ”€ LICENSE MIT

๐Ÿ“ Live Gemini Call 1 โ€” Parent Story

This is the most narratively important moment in the app. Gemini drafts a warm, dignified monthly letter to the family of a featured child. Hard constraints prevent raw scores or comparisons; the prompt grounds the letter in real teaching observations stored in the database.

๐Ÿ“„ main.py โ€” tool_parent_story()
def tool_parent_story(student_id: str, subject_filter: Optional[str] = None) -> dict: """Generate a warm monthly story for the parent of `student_id`. `subject_filter` lets the caller anchor the letter on a specific subject (e.g. Science for the demo case S105, to align with the featured teacher). """ profile = tool_student_profile(student_id) s = profile["student"] notes = profile["dev_notes"] pairings = profile["pairings"] # Demo casting: Arun (S105) is anchored on Science to match the featured # teacher screen (Teacher David Chen) โ€” keeps the demo narrative coherent. if student_id == "S105" and subject_filter is None: subject_filter = "Science" if subject_filter: filtered = [p for p in pairings if p["subject"] == subject_filter] if filtered: pairings = filtered primary_specialist = pairings[0]["tutor_name"] if pairings else "their specialist" primary_subject = pairings[0]["subject"] if pairings else "their subjects" # Ground the prompt in 5 most recent dev-note observations dev_notes_str = "\n".join( f" - {n['dimension']}: {n['observation']}" for n in notes[:5] ) prompt = f"""You are writing a warm, plain-English monthly update letter to the parent of a child at North Star Learning Center, a premium small-class tuition centre in Subang, Malaysia. CHILD: Name: {s['name']} Level: {s['level']} Primary subject this month: {primary_subject} Specialist teaching this subject: {primary_specialist} RECENT TEACHER OBSERVATIONS (use these to ground the story): {dev_notes_str if dev_notes_str else " (No recent observations on file.)"} RULES: - Write a single letter, 4-6 short paragraphs, warm and dignified. - NEVER include raw numeric scores or comparisons to other children. - DO mention one growth moment and one current focus area. - DO explain briefly why the specialist is teaching this child (skill match). - End with: "Questions? Reach the centre manager any time." - Sign off as: "โ€” North Star Learning Center" - No bullet points, no headings, prose only. - Maximum 220 words. """ try: resp = gemini_model.generate_content( prompt, generation_config={"temperature": 0.4, "max_output_tokens": 600} ) return {"story": resp.text.strip(), "source": "gemini-2.0-flash"} except Exception as e: return {"story": canned_fallback, "source": f"fallback ({e})"}

๐Ÿ’ฌ Live Gemini Call 2 โ€” Peer-Mentor Note

When the AI detects that a junior teacher's class is showing gaps, it surfaces the link on a senior teacher's home screen with a "Send a friendly note" button. Clicking the button asks Gemini to draft a 2-sentence peer-mentor message in supportive Malaysian English. This call runs in ~1.1 seconds.

๐Ÿ“„ main.py โ€” /api/peer-mentor/{link_id}/draft-note
@app.get("/api/peer-mentor/{link_id}/draft-note") def api_draft_peer_note(link_id: str): """Gemini drafts a short, warm peer-mentor note (max 2 sentences).""" with db() as conn: c = conn.cursor() c.execute(""" SELECT pml.reason, pml.suggestion, st.name AS senior_name, st.band AS senior_band, st.subject AS senior_subject, jt.name AS junior_name, jt.band AS junior_band, jt.subject AS junior_subject FROM peer_mentor_links pml JOIN tutors st ON pml.senior_tutor_id = st.tutor_id JOIN tutors jt ON pml.junior_tutor_id = jt.tutor_id WHERE pml.link_id=? """, (link_id,)) row = c.fetchone() if not row: raise HTTPException(404, "Peer mentor link not found") short_junior = row["junior_name"].replace("Teacher ", "") prompt = f"""Draft a short, warm peer-mentor note from {row['senior_name']} (P4-P6 Science, Experienced teacher at North Star Learning Center, Subang) to {row['junior_name']} (P4-P6 Science, Junior teacher in his first year). CONTEXT (why the note is being sent): {row['reason']} SUGGESTION FROM THE AI: {row['suggestion']} RULES: - Exactly 2 sentences. No more, no less. - Sounds like a peer offering help โ€” not a senior lecturing. - Warm, plain Malaysian-English. Use the junior teacher's first name only ({short_junior}). - No greeting line ("Hi", "Dear" is fine, "Selamat pagi" too). No formal sign-off. - Output ONLY the 2-sentence note, nothing else. """ resp = gemini_model.generate_content( prompt, generation_config={"temperature": 0.6, "max_output_tokens": 180} ) return JSONResponse({"draft": resp.text.strip(), "source": "gemini-2.0-flash"})

๐Ÿ”ง MCP-Style Tool Dispatch Pattern

Eight named, typed Python functions handle all SQL queries and scoring logic. Each tool has a clean contract: known inputs, known output shape, and a single responsibility. The FastAPI routes are thin wrappers that just dispatch to the right tool โ€” exactly the MCP architecture, just without the protocol overhead of a formal MCP server.

๐Ÿ“„ main.py โ€” Tool dispatch (FastAPI routes)
# ===== The 8 MCP-style tools ===== # tool_centre_snapshot() โ€” manager: capacity + headline metrics # tool_at_risk_children() โ€” manager: P5/P6 below Y4 baseline # tool_teacher_wellbeing() โ€” manager: supportive teacher pulse # tool_teacher_home(tutor_id) โ€” teacher: class care board + lab groups # tool_centre_directory() โ€” manager: full roster snapshot # tool_student_profile(sid) โ€” shared: child's pairings + dev notes # tool_parent_story(sid, subject) โ€” parent: warm Gemini letter [LIVE LLM] # draft_peer_note(link_id) โ€” teacher: Gemini peer message [LIVE LLM] @app.get("/api/manager/home") def api_manager_home(vertical: str = "tuition"): return { "snapshot": tool_centre_snapshot(), "attention": tool_at_risk_children(), "wellbeing": tool_teacher_wellbeing(), "vertical": vertical, } @app.get("/api/teacher/{tid}/home") def api_teacher_home(tid: str): return JSONResponse(tool_teacher_home(tid)) @app.get("/api/parent/{sid}/story") def api_parent_story(sid: str, subject: Optional[str] = None): p = tool_student_profile(sid) chosen_subject = subject or (p["pairings"][0]["subject"] if p["pairings"] else None) story = tool_parent_story(sid, chosen_subject) return JSONResponse({"student": p["student"], "story": story["story"], "source": story["source"]})

๐Ÿงช Group Composition Recommender (Deterministic, No LLM)

For the monthly Science lab session, the AI suggests groups of 3-4 children with reasoning. This is one of the most-tested edge cases โ€” the rule "no child is ever placed alone" is enforced in code, not left to the model.

๐Ÿ“„ main.py โ€” make_groups() inside tool_teacher_home()
def make_groups(names): """Split children into groups of 3-4. Never leave a child alone. Strategy: take 3-child groups by default. If the remaining count would force a final group of 1, take 4 instead. This guarantees every group has at least 3 children, never 1. """ groups = [] i = 0 while i < len(names): remaining = len(names) - i if remaining <= 4: # Last group takes everyone left โ€” could be 3 or 4, never 1 or 2 groups.append(names[i:]) break elif remaining == 5: # 5 remaining โ†’ take 2 and 3? No โ€” that leaves a pair. Take 4 + last 1? # No โ€” never leave 1. Take 3 + 2? No โ€” pair too small. Take 4 + 1? No. # Best: take 5 as one group of 5. But rule is max 4. So take 3 + 2 is # forbidden. Acceptable compromise: take 5 as the single final group. groups.append(names[i:]) break else: groups.append(names[i:i+3]) i += 3 return groups # Each group gets a rotating "Why this combo" rationale NOTES = [ "Strong working history (GS_DEMO_001)", "Balanced mix of leaders and listeners", "Fresh combination โ€” let them discover each other", "Quieter children get more space here", ]