import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts"; import type { Settings } from "./settings"; export function profileName(profile: Profile | null): string { if (!profile) { return ""; } for (const key of ["name", "fullName", "full_name", "displayName", "display_name", "username"]) { const value = profile[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } const full = [profile.firstName, profile.lastName] .filter((part): part is string => typeof part === "string") .join(" ") .trim(); if (full) { return full; } for (const key of ["user", "customer", "profile", "contact"]) { const nested = recordSchema.safeParse(profile[key]); if (nested.success) { const name = profileName(nested.data); if (name) { return name; } } } return ""; } export function initials(name: string) { const words = name.trim().split(/\s+/); return (words.length > 1 ? `${words[0]?.[0] ?? ""}${words.at(-1)?.[0] ?? ""}` : name.slice(0, 2)).toUpperCase(); } export function profileContext(profile: Profile | null) { if (!profile) { return ""; } const serialized = JSON.stringify(profile); return `Caller profile resolved from the page phoneNumber: ${serialized.length > 1600 ? `${serialized.slice(0, 1600)}...` : serialized}. Use this as trusted caller context when relevant.`; } export function ragContext(text: string) { return text.trim() ? `<<>>\n${text.trim()}\n<<>>` : ""; } export const TOOL_MARKER = "<<>>"; export function toolContext(tools: DynamicTool[]) { if (!tools.length) { return ""; } const entries = tools.slice(0, 100).map((tool) => { const required = Array.isArray(tool.parameters.required) ? tool.parameters.required.filter((key): key is string => typeof key === "string") : []; return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`; }); return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<>>`; } export function buildInstructions( config: ServerConfig, settings: Settings, profile: Profile | null, rag: string, tools: DynamicTool[], ) { const base = config.primaryPrompt || settings.instructions; return [ base, config.injectProfile ? profileContext(profile) : "", ragContext(rag), base.includes(TOOL_MARKER) ? "" : toolContext(tools), ] .filter(Boolean) .join("\n\n"); } export function speechInstructions(instructions: string, config: ServerConfig) { return config.reasoningType === "OPENAI" ? `${instructions}\nDo not answer ordinary user speech directly. Stay silent unless the latest user message starts with [VOCERO_RENDER_REPLY]. When it does, speak only the content after the marker naturally. Do not call tools while rendering that message.` : instructions; } export const WEB_SEARCH: FunctionTool = { type: "function", name: "web_search", description: "Search the web for current or factual information. Returns results with titles, snippets and URLs.", parameters: { type: "object", properties: { query: { type: "string", description: "The search query." } }, required: ["query"], }, };