{"repo": "annachen-coding/lash-booking-system", "files": {"vue-Lashes-project/supabase/functions/service-recommend/index.ts": {"52684d47f828b6e9c2389bc8af8cf1f9f2bae240": {"t": 1786067813.2034554, "n": 11366, "h": "5ec8216f41a79e5d", "c": "/**\n * Edge Function:根据顾客自然语言描述,调用 **DeepSeek**(OpenAI 兼容 Chat Completions)推荐一项店内服务。\n * 使用 `response_format: { type: \"json_object\" }`;服务端仍按 JSON Schema 规则做严格校验。\n *\n * 部署(在项目根目录):\n * supabase secrets set DEEPSEEK_API_KEY=sk-...\n * supabase functions deploy service-recommend\n *\n * Secrets / 环境变量:\n * DEEPSEEK_API_KEY(必填)\n * DEEPSEEK_MODEL(可选,默认 deepseek-chat)\n * DEEPSEEK_API_BASE_URL(可选,默认 https://api.deepseek.com,勿尾斜杠)\n *\n * 与前端 `src/data/services.ts` 中的 name 保持一致;修改菜单时请同步更新下方列表与 JSON Schema enum。\n */\n\ntype Body = {\n prompt?: string\n}\n\n/** 返回给前端的稳定契约(与 src/utils/serviceAiRecommend.ts 对齐) */\ntype OkResponse = {\n schemaVersion: '1.0'\n serviceName: string\n category: 'nails' | 'lashes'\n confidence: 'high' | 'medium' | 'low'\n responseLanguage: 'zh' | 'en' | 'mixed'\n summary: string\n supportingPoints: string[]\n /** 纯文本拼接,便于搜索/复制 */\n why: string\n}\n\nconst corsHeaders: Record = {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Headers':\n 'authorization, x-client-info, apikey, content-type',\n}\n\n/** 必须与 src/data/services.ts 的 `name` 完全一致 */\nconst ALLOWED_SERVICES = [\n 'Classic Manicure',\n 'Gel Manicure',\n 'Nail Extension',\n 'Classic Lash Extensions',\n 'Hybrid Lash Extensions',\n 'Lash Lift',\n] as const\n\nconst SERVICE_CATEGORY: Record<(typeof ALLOWED_SERVICES)[number], 'nails' | 'lashes'> = {\n 'Classic Manicure': 'nails',\n 'Gel Manicure': 'nails',\n 'Nail Extension': 'nails',\n 'Classic Lash Extensions': 'lashes',\n 'Hybrid Lash Extensions': 'lashes',\n 'Lash Lift': 'lashes',\n}\n\nconst SERVICE_CONTEXT = `Catalog (serviceName must be exactly one of these strings):\n- Classic Manicure (nails): clean shaping, cuticles, classic polish; everyday neat look.\n- Gel Manicure (nails): chip-resistant glossy gel, longer-lasting than regular polish.\n- Nail Extension (nails): added length and sculpted shape for a bolder nail line.\n- Classic Lash Extensions (lashes): one-by-one extensions, soft and natural for daily wear.\n- Hybrid Lash Extensions (lashes): mix of classic + volume fans; fuller but still balanced.\n- Lash Lift (lashes): lifts and curls natural lashes; no lash extensions attached.`\n\n/** 目标 JSON 形状(写入 system 提示 + 服务端校验);DeepSeek 使用 json_object 而非 API 内 json_schema */\nconst RECOMMENDATION_JSON_SCHEMA = {\n type: 'object',\n additionalProperties: false,\n properties: {\n schema_version: {\n type: 'string',\n enum: ['1.0'],\n },\n serviceName: {\n type: 'string',\n enum: [...ALLOWED_SERVICES],\n },\n confidence: {\n type: 'string',\n enum: ['high', 'medium', 'low'],\n },\n responseLanguage: {\n type: 'string',\n enum: ['zh', 'en', 'mixed'],\n },\n summary: {\n type: 'string',\n minLength: 40,\n maxLength: 600,\n },\n supportingPoints: {\n type: 'array',\n minItems: 2,\n maxItems: 4,\n items: {\n type: 'string',\n minLength: 10,\n maxLength: 220,\n },\n },\n },\n required: [\n 'schema_version',\n 'serviceName',\n 'confidence',\n 'responseLanguage',\n 'summary',\n 'supportingPoints',\n ],\n} as const\n\nfunction coerceServiceName(raw: string): (typeof ALLOWED_SERVICES)[number] {\n const t = raw.trim()\n const exact = ALLOWED_SERVICES.find((s) => s === t)\n if (exact) return exact\n const ci = ALLOWED_SERVICES.find((s) => s.toLowerCase() === t.toLowerCase())\n if (ci) return ci\n const loose = ALLOWED_SERVICES.find(\n (s) =>\n t.toLowerCase().includes(s.toLowerCase()) ||\n s.toLowerCase().includes(t.toLowerCase())\n )\n return loose ?? 'Classic Lash Extensions'\n}\n\ntype ModelPayload = {\n schema_version?: string\n serviceName?: string\n confidence?: string\n responseLanguage?: string\n summary?: string\n supportingPoints?: unknown\n}\n\nfunction isConfidence(v: string): v is OkResponse['confidence'] {\n return v === 'high' || v === 'medium' || v === 'low'\n}\n\nfunction isResponseLanguage(v: string): v is OkResponse['responseLanguage'] {\n return v === 'zh' || v === 'en' || v === 'mixed'\n}\n\nfunction normalizePayload(raw: ModelPayload): OkResponse | null {\n if (raw.schema_version !== '1.0') return null\n const serviceName = coerceServiceName(typeof raw.serviceName === 'string' ? raw.serviceName : '')\n const conf = typeof raw.confidence === 'string' ? raw.confidence : ''\n const lang = typeof raw.responseLanguage === 'string' ? raw.responseLanguage : ''\n const summary = typeof raw.summary === 'string' ? raw.summary.trim() : ''\n if (!isConfidence(conf) || !isResponseLanguage(lang) || summary.length < 40) return null\n\n if (!Array.isArray(raw.supportingPoints)) return null\n const points = raw.supportingPoints.filter(\n (p): p is string => typeof p === 'string' && p.trim().length >= 10\n )\n if (points.length < 2 || points.length > 4) return null\n\n const category = SERVICE_CATEGORY[serviceName]\n const why =\n `${summary}\\n\\n` +\n points\n .map((p, i) => `${i + 1}. ${p.trim()}`)\n .join('\\n')\n\n return {\n schemaVersion: '1.0',\n serviceName,\n category,\n confidence: conf,\n responseLanguage: lang,\n summary,\n supportingPoints: points.map((p) => p.trim()),\n why,\n }\n}\n\ntype ChatCompletionResponse = {\n choices?: Array<{\n message?: { content?: string | null; refusal?: string | null }\n finish_reason?: string\n }>\n error?: { message?: string; type?: string }\n}\n\nfunction stripMarkdownCodeFence(raw: string): string {\n let t = raw.trim()\n if (t.startsWith('```')) {\n t = t.replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```$/i, '')\n }\n return t.trim()\n}\n\nfunction buildDeepSeekSystemPrompt(): string {\n const schemaJson = JSON.stringify(RECOMMENDATION_JSON_SCHEMA, null, 2)\n return `You are a beauty salon assistant. Recommend exactly ONE service from the catalog.\n\n${SERVICE_CONTEXT}\n\nYou MUST reply with a single JSON object only (no markdown fences, no extra text). The word json is required by the API.\n\nThe JSON object MUST:\n- Use exactly these property names: schema_version, serviceName, confidence, responseLanguage, summary, supportingPoints\n- Match this JSON Schema (types, enums, lengths, array size):\n${schemaJson}\n\nRules:\n- schema_version must be \"1.0\"\n- serviceName must be copied exactly from the allowed catalog names\n- summary: 2–4 sentences in the customer's primary language (Chinese if they wrote mostly Chinese; English otherwise); length at least 40 characters\n- supportingPoints: 2–4 distinct short strings (each at least 10 characters); add new information (fit, lifestyle, maintenance)—do not repeat the summary\n- confidence: high when the request clearly maps to one service; low when ambiguous\n- responseLanguage: zh, en, or mixed according to the customer's message`\n}\n\nDeno.serve(async (req: Request): Promise => {\n if (req.method === 'OPTIONS') {\n return new Response('ok', { headers: corsHeaders })\n }\n\n if (req.method !== 'POST') {\n return new Response(JSON.stringify({ error: 'Method not allowed' }), {\n status: 405,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n let body: Body = {}\n try {\n const text = await req.text()\n if (!text.trim()) {\n return new Response(JSON.stringify({ error: 'Empty body' }), {\n status: 400,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n body = JSON.parse(text) as Body\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e)\n return new Response(JSON.stringify({ error: `Invalid JSON: ${msg}` }), {\n status: 400,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''\n if (!prompt) {\n return new Response(JSON.stringify({ error: 'Missing prompt' }), {\n status: 400,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n const apiKey = Deno.env.get('DEEPSEEK_API_KEY')?.trim()\n if (!apiKey) {\n return new Response(JSON.stringify({ error: 'DEEPSEEK_API_KEY is not configured' }), {\n status: 500,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n const model = Deno.env.get('DEEPSEEK_MODEL')?.trim() || 'deepseek-chat'\n const baseUrl = (Deno.env.get('DEEPSEEK_API_BASE_URL')?.trim() || 'https://api.deepseek.com').replace(\n /\\/$/,\n ''\n )\n const url = `${baseUrl}/chat/completions`\n\n const systemPrompt = buildDeepSeekSystemPrompt()\n\n try {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n model,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: `Customer request:\\n${prompt}` },\n ],\n response_format: { type: 'json_object' },\n temperature: 0.35,\n max_tokens: 1200,\n }),\n })\n\n const raw = await res.text()\n if (!res.ok) {\n console.error('[service-recommend] DeepSeek HTTP', res.status, raw)\n return new Response(\n JSON.stringify({ error: `DeepSeek error (${res.status})` }),\n {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n }\n )\n }\n\n let completion: ChatCompletionResponse\n try {\n completion = JSON.parse(raw) as ChatCompletionResponse\n } catch {\n return new Response(JSON.stringify({ error: 'Invalid DeepSeek response' }), {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n if (completion.error?.message) {\n return new Response(\n JSON.stringify({ error: completion.error.message }),\n {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n }\n )\n }\n\n const rawContent = completion.choices?.[0]?.message?.content\n const content =\n typeof rawContent === 'string' && rawContent.trim()\n ? stripMarkdownCodeFence(rawContent)\n : ''\n\n if (!content) {\n console.error('[service-recommend] empty content', completion.choices?.[0]?.finish_reason)\n return new Response(JSON.stringify({ error: 'Empty model output' }), {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n let parsed: ModelPayload\n try {\n parsed = JSON.parse(content) as ModelPayload\n } catch {\n return new Response(JSON.stringify({ error: 'Model output was not valid JSON' }), {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n const out = normalizePayload(parsed)\n if (!out) {\n return new Response(JSON.stringify({ error: 'Model JSON failed server validation' }), {\n status: 502,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n\n return new Response(JSON.stringify(out), {\n status: 200,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e)\n console.error('[service-recommend]', msg)\n return new Response(JSON.stringify({ error: msg }), {\n status: 500,\n headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n })\n }\n})\n"}}}}