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>
173 lines
6.7 KiB
TypeScript
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
|
|
}
|
|
}
|