๐ 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.
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"]
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"
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.
@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.
@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.
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:
groups.append(names[i:])
break
elif remaining == 5:
groups.append(names[i:])
break
else:
groups.append(names[i:i+3])
i += 3
return groups
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",
]