Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions geddes/k8s/01-secrets.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ stringData:
NEXT_PUBLIC_API_URL: "https://dt-nanohub.geddes.rcac.purdue.edu/api"
NEXT_PUBLIC_AUTH_ENABLED: "true"

# AI assistant (/assistant, all signed-in users). Calls are spread round-robin
# over every provider with a key, within each provider's requests/minute.
# GenAI Studio: genai.rcac.purdue.edu > Settings > Account > API Keys
# (20 req/min; public and L2 data).
# AnvilGPT: anvilgpt.rcac.purdue.edu, needs an active Anvil allocation
# (60 req/min; authorized for PUBLIC DATA ONLY). Chats carry private project
# data, so it is used only with ANVILGPT_ALLOW_PLATFORM_DATA="true", once
# it is confirmed that this data may go to a public-data-only service.
# GENAI_API_KEY: "replace_with_genai_studio_api_key"
# GENAI_MODEL: "gpt-oss:120b"
# GENAI_RPM: "20"
# ANVILGPT_API_KEY: "replace_with_anvilgpt_api_key"
# ANVILGPT_MODEL: "gpt-oss:120b"
# ANVILGPT_RPM: "60"
# ANVILGPT_ALLOW_PLATFORM_DATA: "false"
# Per-user caps (defaults shown):
# ASSISTANT_QUESTIONS_PER_5MIN: "10"
# ASSISTANT_QUESTIONS_PER_DAY: "100"

---
apiVersion: v1
kind: Secret
Expand Down
243 changes: 75 additions & 168 deletions web/app/api/assistant/route.ts
Original file line number Diff line number Diff line change
@@ -1,163 +1,39 @@
import { NextRequest, NextResponse } from "next/server";

import { resolveMcpSession, type PlatformSession } from "@/lib/mcp/dt-call";
import { runTool, type ToolResult } from "@/lib/mcp/tools";
import { runAssistant, type ChatMessage } from "@/lib/assistant/agent";
import { GenAiError, llmProviders } from "@/lib/assistant/genai";
import { takeQuestionSlot } from "@/lib/assistant/limits";
import { resolveMcpSession } from "@/lib/mcp/dt-call";

/**
* In-app AI assistant (Option A).
* In-app AI assistant, for every signed-in user.
*
* The user opens the Assistant panel in the web app and asks a question; the
* app talks to the MCP tool layer internally (no manual MCP client setup). This
* route runs in-process against the same tool registry the MCP server exposes,
* so every answer is permission-aware and audited.
*
* Reasoning backend:
* - A local deterministic planner picks the right read-only tool(s) from the
* user's message. No external LLM/API call is made in this phase.
* The user chats on /assistant; this route runs a tool-calling loop with a
* Purdue RCAC-hosted model (GenAI Studio, optionally AnvilGPT; see
* lib/assistant/genai.ts) over the read-only tool registry (lib/mcp/tools.ts).
* Every tool call goes through dtCall as the signed-in user, so answers only
* contain data that user can already see, and each call is audited.
* Per-user limits are in lib/assistant/limits.ts.
*/

export const runtime = "nodejs";

interface ChatMessage {
role: "user" | "assistant";
content: string;
}

interface ToolCallTrace {
name: string;
args: Record<string, unknown>;
ok: boolean;
count: number | null;
}

function countOf(data: unknown): number | null {
if (Array.isArray(data)) return data.length;
if (data && typeof data === "object") return 1;
return null;
}

// ── Local planner ────────────────────────────────────────────────────────────

interface NamedProject {
id: string;
name: string;
}

async function resolveProject(
session: PlatformSession,
query: string,
): Promise<NamedProject | null> {
const result = await runTool(session, "list_projects", {}, "assistant");
if (!result.ok || !Array.isArray(result.data)) return null;
const projects = result.data as NamedProject[];
const lower = query.toLowerCase();
return (
projects.find((p) => p.id && lower.includes(p.id.toLowerCase())) ??
projects.find((p) => p.name && lower.includes(p.name.toLowerCase())) ??
null
);
}

function summarize(name: string, result: ToolResult): string {
if (!result.ok) return `\u2022 ${name} failed: ${result.error}`;
const data = result.data;
if (Array.isArray(data)) {
if (data.length === 0) return `\u2022 ${name}: no matching records.`;
const preview = data
.slice(0, 8)
.map((item) => {
if (item && typeof item === "object") {
const o = item as Record<string, unknown>;
const label = o.name ?? o.lot_name ?? o.run_id ?? o.id ?? "record";
const id = o.id ?? o.run_id ?? "";
return id && id !== label ? `${label} (${id})` : String(label);
}
return String(item);
})
.join(", ");
const more = data.length > 8 ? ` … and ${data.length - 8} more` : "";
return `\u2022 ${name}: ${data.length} result(s): ${preview}${more}.`;
}
if (data && typeof data === "object") {
const o = data as Record<string, unknown>;
// Composite payload from summarize_project.
if (o.project && typeof o.project === "object") {
const project = o.project as Record<string, unknown>;
const members = Array.isArray(o.members) ? o.members.length : 0;
const runs = Array.isArray(o.runs) ? o.runs.length : 0;
const experiments = Array.isArray(o.experiments) ? o.experiments.length : 0;
return `\u2022 ${name}: ${project.name} (${project.id}) — PI ${project.pi ?? "?"}, access ${project.access ?? "?"}, status ${project.status ?? "?"}; ${members} member(s), ${experiments} unit experiment(s), ${runs} run(s).`;
}
const label = o.name ?? o.id ?? "object";
return `\u2022 ${name}: ${label}${o.id && o.id !== label ? ` (${o.id})` : ""}.`;
export const maxDuration = 300;

const MAX_MESSAGES = 40;
const MAX_MESSAGE_CHARS = 8_000;

function parseMessages(body: unknown): ChatMessage[] | null {
if (!body || typeof body !== "object") return null;
const raw = (body as { messages?: unknown }).messages;
if (!Array.isArray(raw) || raw.length === 0) return null;
const messages: ChatMessage[] = [];
for (const item of raw.slice(-MAX_MESSAGES)) {
if (!item || typeof item !== "object") return null;
const { role, content } = item as { role?: unknown; content?: unknown };
if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null;
messages.push({ role, content: content.slice(0, MAX_MESSAGE_CHARS) });
}
return `\u2022 ${name}: ${String(data)}`;
}

async function localPlan(
session: PlatformSession,
query: string,
): Promise<{ reply: string; toolCalls: ToolCallTrace[] }> {
const lower = query.toLowerCase();
const traces: ToolCallTrace[] = [];
const lines: string[] = [];

const record = (name: string, args: Record<string, unknown>, r: ToolResult) => {
traces.push({ name, args, ok: r.ok, count: r.ok ? countOf(r.data) : null });
lines.push(summarize(name, r));
};

const has = (...words: string[]) => words.some((w) => lower.includes(w));
const runIds = (query.match(/\b\d{1,9}\b/g) ?? []).map(Number);

if (has("summarize", "summary", "overview") && has("project")) {
const project = await resolveProject(session, query);
if (project) {
const r = await runTool(session, "summarize_project", { project_id: project.id }, "assistant");
record("summarize_project", { project_id: project.id }, r);
} else {
const r = await runTool(session, "list_projects", {}, "assistant");
record("list_projects", {}, r);
lines.push("(Could not match a specific project name; listing all visible projects.)");
}
} else if (has("compare") && has("run") && runIds.length >= 2) {
const r = await runTool(session, "compare_runs", { run_ids: runIds }, "assistant");
record("compare_runs", { run_ids: runIds }, r);
} else if (has("drift", "out of bound", "outlier", "anomaly")) {
const project = await resolveProject(session, query);
const args = project ? { project_id: project.id } : {};
const r = await runTool(session, "find_out_of_bounds_runs", args, "assistant");
record("find_out_of_bounds_runs", args, r);
} else if (has("unit experiment", "experiment type", "experiment-type")) {
const r = await runTool(session, "list_unit_experiments", {}, "assistant");
record("list_unit_experiments", {}, r);
} else if (has("process")) {
const r = await runTool(session, "list_processes", {}, "assistant");
record("list_processes", {}, r);
} else if (has("equipment", "etch", "etcher", "tool ", "instrument")) {
const r = await runTool(session, "list_equipment", {}, "assistant");
record("list_equipment", {}, r);
} else if (has("run")) {
const project = await resolveProject(session, query);
const args = project ? { project_id: project.id } : {};
const r = await runTool(session, "list_project_runs", args, "assistant");
record("list_project_runs", args, r);
} else if (has("project")) {
const r = await runTool(session, "list_projects", {}, "assistant");
record("list_projects", {}, r);
} else {
const [projects, equipment] = await Promise.all([
runTool(session, "list_projects", {}, "assistant"),
runTool(session, "list_equipment", {}, "assistant"),
]);
record("list_projects", {}, projects);
record("list_equipment", {}, equipment);
lines.unshift(
"I can query equipment, unit experiments, processes, projects and runs. Here is a quick overview:",
);
}

return { reply: lines.join("\n"), toolCalls: traces };
return messages;
}

export async function POST(request: NextRequest) {
Expand All @@ -166,37 +42,68 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}

let body: { messages?: ChatMessage[]; query?: string };
const providers = llmProviders();
if (providers.length === 0) {
return NextResponse.json(
{ error: "The assistant is not configured yet (no model provider; set GENAI_API_KEY)." },
{ status: 503 },
);
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const messages: ChatMessage[] =
body.messages && body.messages.length > 0
? body.messages
: body.query
? [{ role: "user", content: body.query }]
: [];

const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (!lastUser?.content?.trim()) {
const messages = parseMessages(body);
const last = messages?.at(-1);
if (!messages || last?.role !== "user" || !last.content.trim()) {
return NextResponse.json({ error: "No user message provided" }, { status: 400 });
}

try {
const result = await localPlan(session, lastUser.content);
const slot = takeQuestionSlot(session.id);
if (!slot.ok) {
return NextResponse.json(
{ error: slot.reason },
{ status: 429, headers: { "Retry-After": String(slot.retryAfterSeconds) } },
);
}

const started = Date.now();
try {
const result = await runAssistant(providers, session, messages);
console.log(
"[ASSISTANT]",
JSON.stringify({
timestamp: new Date().toISOString(),
userId: session.id,
providers: result.providers,
rounds: result.rounds,
tools: result.toolCalls.map((t) => t.name),
ms: Date.now() - started,
}),
);
return NextResponse.json({
reply: result.reply,
toolCalls: result.toolCalls,
mode: "local",
model: result.model,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Assistant failed" },
{ status: 500 },
const message =
err instanceof GenAiError ? err.message : "The assistant failed to answer. Please try again.";
console.error(
"[ASSISTANT]",
JSON.stringify({
timestamp: new Date().toISOString(),
userId: session.id,
error: err instanceof Error ? err.message : String(err),
ms: Date.now() - started,
}),
);
const status = err instanceof GenAiError ? (err.status === 429 ? 429 : 502) : 500;
return NextResponse.json({ error: message }, { status });
} finally {
slot.release();
}
}
4 changes: 2 additions & 2 deletions web/app/api/tools/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";

import { resolveMcpBearerSession } from "@/lib/mcp/dt-call";
import { TOOLS, runTool } from "@/lib/mcp/tools";
import { hasTool, runTool } from "@/lib/mcp/tools";

/**
* OpenAPI-compatible tool execution endpoint.
Expand Down Expand Up @@ -67,7 +67,7 @@ export async function POST(

const { name } = await params;

if (!TOOLS.has(name)) {
if (!hasTool(name, "mcp")) {
return NextResponse.json(
{ ok: false, error: `Unknown tool: ${name}` },
{ status: 404, headers: corsHeaders(request) },
Expand Down
Loading