Back to portfolio

March 2026

I Built a Chat Interface for a 1990s Student Database

10Tools
3msLocal Query
80%Queries Routed Locally
30Tests

Aeries SIS is a student information system used by over 1,000 school districts in California. It holds everything -- attendance, grades, contacts, enrollment history. It's also the kind of software where looking up a student's phone number requires navigating through four dropdown menus and a popup window that blocks the rest of the screen.

Our office staff look up students dozens of times a day. A parent calls about absences. A teacher needs a contact number. An admin needs to verify enrollment. Every time, it's the same painful UI dance. I built a chat interface where they can just type "look up Maria Garcia" and get the answer.

Two-Tier Routing: The Interesting Problem

The obvious approach is: send every query to Claude, let it figure out what API calls to make, return the answer. That works, but it's slow (9+ seconds per query) and it means every student lookup gets sent to a third-party AI service. Under FERPA, that's a third-party disclosure you have to document.

Most queries from office staff are simple: "look up student 12345", "attendance for Maria Garcia", "contacts for school 994." These don't need AI reasoning. They need a regex match and an API call.

So I built a two-tier router:

def route_query(query: str) -> RouteResult | None:
    """Try to handle locally without sending to Claude."""
    normalized = query.strip().lower()

    # Direct student ID lookup: "look up 12345", "student 12345"
    id_match = re.search(r"\b(\d{4,7})\b", normalized)
    if id_match and any(kw in normalized for kw in ["look", "student", "find"]):
        return RouteResult(tool="get_student", params={"student_id": int(id_match.group(1))})

    # Name search: "look up Maria Garcia"
    name_match = re.search(r"(?:look up|find|search)\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)", query)
    if name_match:
        return RouteResult(tool="search_students", params={"name": name_match.group(1)})

    # Bail to Claude for complex queries ("who has the most absences?")
    if any(kw in normalized for kw in ["who has", "compare", "failing", "most"]):
        return None  # AI path

    # ... 8 patterns total covering all 10 tools

When the router matches, the query never leaves the server. No API call to Anthropic, no token cost, no FERPA disclosure. The audit log marks it processing_mode: "local" instead of "ai-assisted".

The benchmark results surprised me:

Local path: 3ms average response time. Zero tokens. Zero third-party disclosure.
AI path: 9.3 seconds average. ~1,500 tokens per query. Logged as external processing.
Ratio: Local is ~2,741x faster. In practice, ~80% of office queries match the regex patterns.

The AI path still exists for complex questions ("which 3rd graders have more than 5 unexcused absences this month?") where Claude needs to reason about which API calls to chain together. But the common case -- the parent calling about their kid -- resolves instantly without touching an external service.

The PII Stripping Layer

Aeries API responses include everything. Social security numbers, medical flags, parent employment info, IEP status. None of that should surface in a chat interface for general office staff.

SAFE_STUDENT_FIELDS = [
    "StudentID", "FirstName", "LastName", "Grade",
    "SchoolCode", "Gender", "Birthdate",
    "HomePhone", "CellPhone",  # Contact info only
]

SAFE_CONTACT_FIELDS = [
    "ContactName", "Relationship",
    "HomePhone", "WorkPhone", "CellPhone",
]

def filter_student(raw: dict) -> dict:
    """Strip everything except allowlisted fields. Fail closed."""
    return {k: v for k, v in raw.items() if k in SAFE_STUDENT_FIELDS}

This is a fail-closed approach. If Aeries adds a new field to their API response tomorrow (they've done it before -- no changelog, no warning), it gets silently dropped. The only way data surfaces in the chat is if it's explicitly in the allowlist.

The Audit Log That Can't Be Deleted

Every query -- local or AI-routed -- writes a row to a SQLite audit database:

CREATE TABLE audit_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT NOT NULL,
    session_id TEXT NOT NULL,
    query TEXT NOT NULL,
    processing_mode TEXT NOT NULL,  -- "local" or "ai-assisted"
    tools_called TEXT,
    third_party_disclosure TEXT NOT NULL,  -- "none" or "anthropic"
    student_ids_accessed TEXT
);

The app only inserts. No UPDATE, no DELETE statements exist in the codebase. If a parent or auditor asks "who accessed my child's record and when," the answer is in the database. If they ask "was my child's data sent to an AI service," the third_party_disclosure column tells you exactly which queries hit Claude and which stayed local.

What It Looks Like

The frontend is vanilla HTML/CSS/JS. No React, no build step, no node_modules. A chat interface with typing indicators, markdown table rendering for results, and a badge under each response showing whether it was processed locally (green) or AI-assisted (orange).

Staff type natural language. The system returns formatted tables or conversational answers depending on the query. Quick-action buttons parsed from Claude's responses let staff drill into attendance or contacts without typing follow-ups.

Current State: Demo-Ready, Not Production

The app currently talks to a mock Aeries server running on localhost with 3 fake schools and 13 fake students. The mock data is coherent enough for demos (real-looking attendance patterns, grade distributions, contact trees). Switching to production is a one-line .env change -- replace the mock URL with a real Aeries base URL and add the district-issued API certificate.

What's ready:

What's not:

The same Aeries API certificate that blocks the SARB Tracker blocks this. Once district IT hands over credentials, both projects go live against real data the same day.