tia/src/app/api/ai/route.ts
Mannu c2cabc01d3 feat(g1-g4): design system, memories pipeline, medical tracking, AI brain
G1 — Design System: 14 UI primitives (Button, Card, Modal, Sheet, Input,
Textarea, Select, EmptyState, LoadingShimmer, ConfirmDialog, WashiTape,
Badge, Avatar, Tabs), PageTransition with Framer Motion, sun/moon CSS vars,
Caveat font, /dev/components visual showcase.

G2 — Memories Pipeline: R2 presigned uploads, Sharp thumbnail generation,
LiteLLM vision captions + pgvector embeddings, CSS masonry gallery with
infinite scroll, private toggle, semantic search fallback to ILIKE.

G3 — Medical: dose log + correction audit trail, IAP vaccine bulk import,
emergency escalation page, pediatrician phone in settings.

G4 — AI Brain: keyword guardrail → LLM classifier → structured DB tool-use
(7 tools) → memory search → general parenting handler; ai_usage table;
22-case medical bypass safety test suite.

DB migrations: 0011_memories, 0012_medical_doses, 0013_ai_usage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:48:34 +05:30

173 lines
6.7 KiB
TypeScript

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { sql } from "@/db";
import { detectMedicalIntent, ESCALATION_RULES } from "@/lib/ai/medical-triggers";
import { classifyIntent } from "@/lib/ai/classifier";
import { answerStructuredQuery } from "@/lib/ai/structured-query";
import { answerMemoryQuery } from "@/lib/ai/memory-search";
import { answerParentingQuery } from "@/lib/ai/parenting";
import { logAudit } from "@/lib/audit";
import { requireFamily } from "@/lib/auth";
export async function POST(request: Request) {
const start = Date.now();
try {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const session = auth.session!;
const familyId = session.familyId!;
const body = await request.json();
const { messages, childId } = body as {
messages: { role: "user" | "assistant"; content: string }[];
childId?: string;
};
if (!messages || !Array.isArray(messages)) {
return NextResponse.json({ error: "messages array required" }, { status: 400 });
}
const lastUserMsg = [...messages].reverse().find(m => m.role === "user")?.content || "";
if (!lastUserMsg.trim()) {
return NextResponse.json({ error: "Empty message" }, { status: 400 });
}
// ── 1. HARD GUARDRAIL: keyword-based medical detection (most conservative) ──
const medicalIntent = detectMedicalIntent(lastUserMsg);
if (medicalIntent.isMedical) {
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`;
const phone = families[0]?.pediatrician_phone;
const reply = [
`I can't interpret symptoms — that's a pediatrician's job, not mine.`,
``,
ESCALATION_RULES[medicalIntent.category],
``,
phone
? `Call your pediatrician now: ${phone}`
: `Add your pediatrician's phone in Settings so I can show it here.`,
].join("\n");
await logAudit({
action: "ai_medical_redirect",
metadata: { category: medicalIntent.category, keyword: medicalIntent.matchedKeyword },
request,
userId: session.userId,
familyId,
});
await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start });
return NextResponse.json({ reply, redirected: true, category: medicalIntent.category });
}
// ── 2. Resolve child context ──────────────────────────────────────────────
let childName = "your baby";
let childAge = "unknown age";
let birthDate = "";
const resolvedChildId = childId || null;
if (resolvedChildId) {
const kids = await sql`SELECT name, birth_date FROM children WHERE id = ${resolvedChildId} AND family_id = ${familyId} LIMIT 1`;
if (kids[0]) {
childName = kids[0].name;
birthDate = kids[0].birth_date;
childAge = calculateAge(birthDate);
}
}
// ── 3. LLM-based intent classification ───────────────────────────────────
const classification = await classifyIntent(lastUserMsg);
// Medical_redirect from classifier → also redirect
if (classification.intent === "medical_redirect") {
const families = await sql`SELECT pediatrician_phone FROM families WHERE id = ${familyId} LIMIT 1`;
const phone = families[0]?.pediatrician_phone;
const reply = [
`That sounds like something your pediatrician should assess.`,
``,
ESCALATION_RULES["default"],
phone ? `Call: ${phone}` : `Add your pediatrician's phone in Settings.`,
].join("\n");
await logUsage({ familyId, userId: session.userId, intent: "medical_redirect", durationMs: Date.now() - start });
return NextResponse.json({ reply, redirected: true });
}
// ── 4. Route to appropriate handler ──────────────────────────────────────
let reply: string;
let memories: unknown[] | undefined;
if (classification.intent === "structured_query" && resolvedChildId) {
reply = await answerStructuredQuery(lastUserMsg, {
childId: resolvedChildId,
familyId,
childName,
birthDate,
});
} else if (classification.intent === "memory_search") {
const cookieStore = await cookies();
const sessionCookie = `tia_session=${cookieStore.get("tia_session")?.value || ""}`;
const result = await answerMemoryQuery(lastUserMsg, resolvedChildId, sessionCookie);
reply = result.text;
memories = result.memories;
} else {
// general_parenting (default)
const history = messages.slice(0, -1); // all but the last user message
reply = await answerParentingQuery(lastUserMsg, history, { childName, childAge });
}
await logUsage({
familyId,
userId: session.userId,
intent: classification.intent,
durationMs: Date.now() - start,
});
await logAudit({
action: "ai_query",
metadata: { intent: classification.intent, confidence: classification.confidence },
request,
userId: session.userId,
familyId,
});
return NextResponse.json({ reply, memories, intent: classification.intent });
} catch (error) {
console.error("[ai/route]", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function calculateAge(birthDate: string): string {
if (!birthDate) return "unknown age";
const birth = new Date(birthDate);
const now = new Date();
const totalDays = Math.floor((now.getTime() - birth.getTime()) / 86400000);
const months = Math.floor(totalDays / 30.44);
const years = Math.floor(months / 12);
if (years > 0) return `${years} year${years > 1 ? "s" : ""} ${months % 12} month${months % 12 !== 1 ? "s" : ""}`;
if (months > 0) return `${months} month${months > 1 ? "s" : ""}`;
return `${totalDays} day${totalDays !== 1 ? "s" : ""}`;
}
async function logUsage(opts: {
familyId: string;
userId?: string;
intent: string;
durationMs: number;
}) {
try {
await sql`
INSERT INTO ai_usage (family_id, user_id, intent, duration_ms)
VALUES (${opts.familyId}, ${opts.userId || null}, ${opts.intent}, ${opts.durationMs})
`;
} catch {
// Non-critical — don't break the response
}
}