Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/annachen-coding_lash-booking-system.json
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
12 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<string, string> = {\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<Response> => {\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"}}}}