From 224938d94b9bf18a0afa45e822a34ac62903e2f1 Mon Sep 17 00:00:00 2001 From: Ghost69 Date: Tue, 21 Jul 2026 12:26:23 +0100 Subject: [PATCH] Use F.A.T.U brand mark in Concierge and add OpenAI/Anthropic/Gemini fallback. Concierge UI now shows the brand logo, and live polish tries providers in order with current flagship model defaults. Co-authored-by: Cursor --- .env.example | 25 ++- .../concierge/concierge-launcher.tsx | 8 +- .../components/concierge/concierge-panel.tsx | 17 +- apps/storefront/lib/concierge/orchestrator.ts | 46 +---- .../lib/concierge/providers/fallback.spec.ts | 42 ++++ .../lib/concierge/providers/fallback.ts | 187 ++++++++++++++++++ packages/configuration/src/index.spec.ts | 6 +- packages/configuration/src/index.ts | 37 +++- packages/contracts/src/index.ts | 2 +- 9 files changed, 307 insertions(+), 63 deletions(-) create mode 100644 apps/storefront/lib/concierge/providers/fallback.spec.ts create mode 100644 apps/storefront/lib/concierge/providers/fallback.ts diff --git a/.env.example b/.env.example index 0b5293b..5a2f5d2 100644 --- a/.env.example +++ b/.env.example @@ -52,14 +52,25 @@ FLUTTERWAVE_BASE_URL=https://api.flutterwave.com/v3 FLUTTERWAVE_SECRET_KEY= FLUTTERWAVE_WEBHOOK_HASH= # F.A.T.U Concierge (AI customer care). Keys are server-only — never NEXT_PUBLIC_*. -# Default provider is mock (deterministic grounded answers) so CI works without secrets. +# AI_PROVIDER=auto tries providers in AI_PROVIDER_ORDER until one succeeds. +# Default mock keeps CI green without secrets. For live polish use auto + at least one key. AI_ENABLED=true -AI_PROVIDER=mock -AI_MODEL=gpt-4.1-mini -AI_API_KEY= -AI_FALLBACK_MODEL= +AI_PROVIDER=auto +AI_PROVIDER_ORDER=openai,anthropic,gemini +AI_ASSISTANT_NAME=F.A.T.U Concierge AI_MAX_DAILY_REQUESTS=2000 +AI_CHAT_RETENTION_DAYS=90 AI_KNOWLEDGE_SYNC_SECRET= AI_SUPPORT_EMAIL= -AI_CHAT_RETENTION_DAYS=90 -AI_ASSISTANT_NAME=F.A.T.U Concierge +# OpenAI (flagship alias → gpt-5.6-sol). AI_API_KEY is accepted as an OPENAI_API_KEY alias. +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.6 +AI_API_KEY= +AI_MODEL=gpt-5.6 +AI_FALLBACK_MODEL= +# Anthropic (Sonnet 5 is the strong default; set claude-fable-5 for frontier) +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-sonnet-5 +# Google Gemini (3.5 Flash is the current GA flagship; override if you have Pro access) +GEMINI_API_KEY= +GEMINI_MODEL=gemini-3.5-flash diff --git a/apps/storefront/components/concierge/concierge-launcher.tsx b/apps/storefront/components/concierge/concierge-launcher.tsx index d46026e..660f2a8 100644 --- a/apps/storefront/components/concierge/concierge-launcher.tsx +++ b/apps/storefront/components/concierge/concierge-launcher.tsx @@ -1,8 +1,8 @@ 'use client'; import { cn } from '@tms/ui'; -import { MessageCircle } from 'lucide-react'; import { useConcierge } from './concierge-provider'; +import { BrandLogo } from '@/components/site/brand-logo'; export function ConciergeLauncher() { const { panel, open, assistantName } = useConcierge(); @@ -14,14 +14,14 @@ export function ConciergeLauncher() { onClick={open} aria-label={`Open ${assistantName}`} className={cn( - 'fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full border border-line bg-ink px-4 py-3 text-sm font-medium text-canvas shadow-lg outline-none', + 'fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-2.5 text-sm font-medium text-ink shadow-lg outline-none', 'transition-[transform,opacity] duration-200 hover:scale-[1.02] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]', 'motion-reduce:transition-none motion-reduce:hover:scale-100', 'min-h-11 min-w-11', )} > - - {assistantName} + + {assistantName} {panel === 'minimised' ? ( ) : null} diff --git a/apps/storefront/components/concierge/concierge-panel.tsx b/apps/storefront/components/concierge/concierge-panel.tsx index 8fc17ce..1a28be9 100644 --- a/apps/storefront/components/concierge/concierge-panel.tsx +++ b/apps/storefront/components/concierge/concierge-panel.tsx @@ -7,11 +7,11 @@ import { RotateCw, Send, ShieldCheck, - Sparkles, X, } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useId, useRef } from 'react'; +import { BrandLogo } from '@/components/site/brand-logo'; import { type ConciergeMessage, useConcierge } from './concierge-provider'; export function ConciergePanel() { @@ -71,16 +71,19 @@ export function ConciergePanel() { )} >
- - - +
-

{assistantName}

+

{assistantName}

Artwork, sizing, delivery, Design Studio, and orders — grounded in studio sources.

-
+
) : null} diff --git a/apps/storefront/lib/concierge/orchestrator.ts b/apps/storefront/lib/concierge/orchestrator.ts index 9a76b23..601ff07 100644 --- a/apps/storefront/lib/concierge/orchestrator.ts +++ b/apps/storefront/lib/concierge/orchestrator.ts @@ -7,6 +7,7 @@ import type { import { routeIntent } from './intent'; import { formatCitationLinks, retrieveKnowledge } from './knowledge/corpus'; import { buildSystemPrompt } from './prompts/system'; +import { hasAnyLiveProvider, polishWithFallback } from './providers/fallback'; import { redactSensitive, sanitizeModelOutput } from './security/redact'; import { getCartTool } from './tools/cart'; import { @@ -223,17 +224,20 @@ export async function runConciergeTurn( text = `${contextBits.join(' ')}\n\n${text}`; } - // Optional LLM polish — never invents tools; only rephrases when configured. - if (process.env.AI_PROVIDER === 'openai' && process.env.AI_API_KEY) { + // Optional LLM polish with OpenAI → Anthropic → Gemini fallback. + // Never invents tools/facts — only rephrases the grounded draft. + if (hasAnyLiveProvider()) { try { - const polished = await polishWithOpenAI({ + const polished = await polishWithFallback({ system: buildSystemPrompt(assistantName()), user: message, draft: text, }); if (polished) { - text = polished; - provider = 'openai'; + text = polished.text; + provider = polished.provider; + } else { + provider = 'fallback'; } } catch { provider = 'fallback'; @@ -252,35 +256,3 @@ export async function runConciergeTurn( guarded, }; } - -async function polishWithOpenAI(input: { - system: string; - user: string; - draft: string; -}): Promise { - const model = process.env.AI_MODEL || 'gpt-4.1-mini'; - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.AI_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model, - temperature: 0.3, - messages: [ - { role: 'system', content: input.system }, - { - role: 'user', - content: `Customer said: ${input.user}\n\nGrounded draft (do not add facts):\n${input.draft}\n\nRewrite briefly in Concierge voice. Do not add products, prices, policies, or promises absent from the draft.`, - }, - ], - }), - signal: AbortSignal.timeout(12_000), - }); - if (!response.ok) return null; - const json = (await response.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - return json.choices?.[0]?.message?.content?.trim() || null; -} diff --git a/apps/storefront/lib/concierge/providers/fallback.spec.ts b/apps/storefront/lib/concierge/providers/fallback.spec.ts new file mode 100644 index 0000000..2d2e5dc --- /dev/null +++ b/apps/storefront/lib/concierge/providers/fallback.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { hasAnyLiveProvider, polishWithFallback } from './fallback'; + +const KEYS = [ + 'AI_PROVIDER', + 'AI_PROVIDER_ORDER', + 'OPENAI_API_KEY', + 'AI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', +] as const; + +afterEach(() => { + for (const key of KEYS) delete process.env[key]; +}); + +describe('hasAnyLiveProvider', () => { + it('is false in mock mode', () => { + process.env.AI_PROVIDER = 'mock'; + process.env.OPENAI_API_KEY = 'sk-test'; + expect(hasAnyLiveProvider()).toBe(false); + }); + + it('is true in auto mode when any key is present', () => { + process.env.AI_PROVIDER = 'auto'; + process.env.ANTHROPIC_API_KEY = 'ant-test'; + expect(hasAnyLiveProvider()).toBe(true); + }); +}); + +describe('polishWithFallback', () => { + it('returns null when no keys are configured', async () => { + process.env.AI_PROVIDER = 'auto'; + const result = await polishWithFallback({ + system: 'sys', + user: 'hi', + draft: 'draft', + }); + expect(result).toBeNull(); + }); +}); diff --git a/apps/storefront/lib/concierge/providers/fallback.ts b/apps/storefront/lib/concierge/providers/fallback.ts new file mode 100644 index 0000000..ff8d07e --- /dev/null +++ b/apps/storefront/lib/concierge/providers/fallback.ts @@ -0,0 +1,187 @@ +/** + * Multi-provider LLM polish for the Concierge. + * Tries providers in AI_PROVIDER_ORDER (default: openai → anthropic → gemini). + * Grounded draft facts must not be invented — polish only rewrites tone. + */ + +export type AiProviderId = 'openai' | 'anthropic' | 'gemini' | 'mock'; + +export interface PolishInput { + system: string; + user: string; + draft: string; +} + +export interface PolishResult { + text: string; + provider: AiProviderId; + model: string; +} + +const DEFAULT_MODELS = { + openai: 'gpt-5.6', + anthropic: 'claude-sonnet-5', + gemini: 'gemini-3.5-flash', +} as const; + +function polishPrompt(input: PolishInput): string { + return `Customer said: ${input.user}\n\nGrounded draft (do not add facts):\n${input.draft}\n\nRewrite briefly in Concierge voice. Do not add products, prices, policies, or promises absent from the draft.`; +} + +function parseProviderOrder(): AiProviderId[] { + const raw = (process.env.AI_PROVIDER_ORDER || 'openai,anthropic,gemini') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + const allowed: AiProviderId[] = ['openai', 'anthropic', 'gemini']; + const ordered = raw.filter((id): id is AiProviderId => + (allowed as string[]).includes(id), + ); + return ordered.length > 0 ? ordered : [...allowed]; +} + +function openaiKey(): string | undefined { + return process.env.OPENAI_API_KEY || process.env.AI_API_KEY || undefined; +} + +function anthropicKey(): string | undefined { + return process.env.ANTHROPIC_API_KEY || undefined; +} + +function geminiKey(): string | undefined { + return process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || undefined; +} + +function hasKey(provider: AiProviderId): boolean { + if (provider === 'openai') return Boolean(openaiKey()); + if (provider === 'anthropic') return Boolean(anthropicKey()); + if (provider === 'gemini') return Boolean(geminiKey()); + return false; +} + +async function callOpenAI(input: PolishInput): Promise { + const key = openaiKey(); + if (!key) return null; + const model = process.env.OPENAI_MODEL || process.env.AI_MODEL || DEFAULT_MODELS.openai; + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + temperature: 0.3, + messages: [ + { role: 'system', content: input.system }, + { role: 'user', content: polishPrompt(input) }, + ], + }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) return null; + const json = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = json.choices?.[0]?.message?.content?.trim(); + return text ? { text, provider: 'openai', model } : null; +} + +async function callAnthropic(input: PolishInput): Promise { + const key = anthropicKey(); + if (!key) return null; + const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODELS.anthropic; + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + max_tokens: 1024, + temperature: 0.3, + system: input.system, + messages: [{ role: 'user', content: polishPrompt(input) }], + }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) return null; + const json = (await response.json()) as { + content?: Array<{ type?: string; text?: string }>; + }; + const text = json.content?.find((block) => block.type === 'text')?.text?.trim(); + return text ? { text, provider: 'anthropic', model } : null; +} + +async function callGemini(input: PolishInput): Promise { + const key = geminiKey(); + if (!key) return null; + const model = process.env.GEMINI_MODEL || DEFAULT_MODELS.gemini; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: input.system }] }, + contents: [{ role: 'user', parts: [{ text: polishPrompt(input) }] }], + generationConfig: { temperature: 0.3, maxOutputTokens: 1024 }, + }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) return null; + const json = (await response.json()) as { + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; + }; + const text = json.candidates?.[0]?.content?.parts?.map((p) => p.text ?? '').join('').trim(); + return text ? { text, provider: 'gemini', model } : null; +} + +const CALLERS: Record< + Exclude, + (input: PolishInput) => Promise +> = { + openai: callOpenAI, + anthropic: callAnthropic, + gemini: callGemini, +}; + +/** + * Whether any live LLM key is configured (so we can skip the polish attempt). + */ +export function hasAnyLiveProvider(): boolean { + const mode = (process.env.AI_PROVIDER || 'auto').toLowerCase(); + if (mode === 'mock') return false; + if (mode === 'openai' || mode === 'anthropic' || mode === 'gemini') { + return hasKey(mode); + } + // auto / cascade + return parseProviderOrder().some(hasKey); +} + +/** + * Polish grounded draft text via the first successful provider in the fallback chain. + */ +export async function polishWithFallback(input: PolishInput): Promise { + const mode = (process.env.AI_PROVIDER || 'auto').toLowerCase(); + if (mode === 'mock') return null; + + const chain: AiProviderId[] = + mode === 'openai' || mode === 'anthropic' || mode === 'gemini' + ? [mode, ...parseProviderOrder().filter((id) => id !== mode)] + : parseProviderOrder(); + + for (const provider of chain) { + if (provider === 'mock' || !hasKey(provider)) continue; + try { + const result = await CALLERS[provider](input); + if (result?.text) return result; + } catch { + // try next provider + } + } + return null; +} + +export const DEFAULT_AI_MODELS = DEFAULT_MODELS; diff --git a/packages/configuration/src/index.spec.ts b/packages/configuration/src/index.spec.ts index c1129bb..42f9296 100644 --- a/packages/configuration/src/index.spec.ts +++ b/packages/configuration/src/index.spec.ts @@ -35,7 +35,11 @@ describe('loadEnvironment', () => { ADMIN_MFA_ENCRYPTION_KEY: 'bG9jYWwtZGV2ZWxvcG1lbnQtbWZhLWtleS0xMjM0NTY', AI_ENABLED: true, AI_PROVIDER: 'mock', - AI_MODEL: 'gpt-4.1-mini', + AI_PROVIDER_ORDER: 'openai,anthropic,gemini', + AI_MODEL: 'gpt-5.6', + OPENAI_MODEL: 'gpt-5.6', + ANTHROPIC_MODEL: 'claude-sonnet-5', + GEMINI_MODEL: 'gemini-3.5-flash', AI_MAX_DAILY_REQUESTS: 2_000, AI_CHAT_RETENTION_DAYS: 90, AI_ASSISTANT_NAME: 'F.A.T.U Concierge', diff --git a/packages/configuration/src/index.ts b/packages/configuration/src/index.ts index b279d48..a216b4f 100644 --- a/packages/configuration/src/index.ts +++ b/packages/configuration/src/index.ts @@ -55,14 +55,22 @@ const EnvironmentSchema = z .regex(/^[A-Za-z0-9_-]{43}$/) .default(localMfaEncryptionKey), // F.A.T.U Concierge — provider keys stay server-side only (never NEXT_PUBLIC_*). + // AI_PROVIDER=auto tries OPENAI → ANTHROPIC → GEMINI (see AI_PROVIDER_ORDER). AI_ENABLED: z .enum(['true', 'false']) .default('true') .transform((value) => value === 'true'), - AI_PROVIDER: z.enum(['openai', 'mock']).default('mock'), - AI_MODEL: z.string().min(1).default('gpt-4.1-mini'), + AI_PROVIDER: z.enum(['auto', 'openai', 'anthropic', 'gemini', 'mock']).default('mock'), + AI_PROVIDER_ORDER: z.string().min(1).default('openai,anthropic,gemini'), + AI_MODEL: z.string().min(1).default('gpt-5.6'), AI_API_KEY: z.string().min(1).optional(), AI_FALLBACK_MODEL: z.string().min(1).optional(), + OPENAI_API_KEY: z.string().min(1).optional(), + OPENAI_MODEL: z.string().min(1).default('gpt-5.6'), + ANTHROPIC_API_KEY: z.string().min(1).optional(), + ANTHROPIC_MODEL: z.string().min(1).default('claude-sonnet-5'), + GEMINI_API_KEY: z.string().min(1).optional(), + GEMINI_MODEL: z.string().min(1).default('gemini-3.5-flash'), AI_MAX_DAILY_REQUESTS: z.coerce.number().int().min(1).max(1_000_000).default(2_000), AI_KNOWLEDGE_SYNC_SECRET: z.string().min(16).optional(), AI_SUPPORT_EMAIL: z.string().email().optional(), @@ -122,12 +130,29 @@ const EnvironmentSchema = z }); } } - // OpenAI-compatible provider needs a key; mock mode is the safe default for CI. - if (environment.AI_PROVIDER === 'openai' && !environment.AI_API_KEY) { + // A pinned provider needs its own key (auto mode uses whichever keys exist). + if (environment.AI_PROVIDER === 'openai') { + const key = environment.OPENAI_API_KEY ?? environment.AI_API_KEY; + if (!key) { + context.addIssue({ + code: 'custom', + path: ['OPENAI_API_KEY'], + message: 'OPENAI_API_KEY (or AI_API_KEY) is required when AI_PROVIDER is openai.', + }); + } + } + if (environment.AI_PROVIDER === 'anthropic' && !environment.ANTHROPIC_API_KEY) { + context.addIssue({ + code: 'custom', + path: ['ANTHROPIC_API_KEY'], + message: 'ANTHROPIC_API_KEY is required when AI_PROVIDER is anthropic.', + }); + } + if (environment.AI_PROVIDER === 'gemini' && !environment.GEMINI_API_KEY) { context.addIssue({ code: 'custom', - path: ['AI_API_KEY'], - message: 'AI_API_KEY is required when AI_PROVIDER is openai.', + path: ['GEMINI_API_KEY'], + message: 'GEMINI_API_KEY is required when AI_PROVIDER is gemini.', }); } }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c98ebfb..7d6bd29 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1157,7 +1157,7 @@ export interface ConciergeChatTurnResult { citations: ConciergeCitation[]; cards: ConciergeProductCard[]; ticketReference?: string; - provider: 'openai' | 'mock' | 'fallback'; + provider: 'openai' | 'anthropic' | 'gemini' | 'mock' | 'fallback'; guarded: boolean; }