{"repo": "autho369-code/stellar-redesigned", "files": {"api/chat.js": {"fdd5c210725a9e5cd6aa0ce34e5621f61dbc89ec": {"t": 1786068493.4161994, "n": 17573, "h": "e8079b8f52a3138d", "c": "// Vercel serverless function: Claude-powered site concierge (\"Arthur\").\n// Requires ANTHROPIC_API_KEY in the Vercel project environment variables.\n// The site widget falls back to its built-in knowledge base when this\n// endpoint is unavailable, so the bot works either way.\n\n// Allows res.write() to reach the client incrementally when the widget\n// requests a streamed reply ({ stream: true } in the body).\nexport const config = { supportsResponseStreaming: true };\n\n// Identity (name + personality) is loaded LIVE from the shared ops database\n// (agent_public_profile view) so the website concierge is the same Arthur\n// configured at /ops/agent. The facts and safety rules below stay in code.\nconst CORE_PROMPT = `Stellar Property Management is a Chicago community association management firm (condominiums, HOAs, townhomes only; NO apartment rentals).\n\nFACTS (the only facts you may state):\n- Serving Chicago & the North Shore since 2007. 42 associations, 2,450+ residences, 96% client retention.\n- Office: 5107 N Western Ave, Suite 1S, Chicago, IL 60625. Phone: 773.728.0652 (24/7 live emergency line). Email: mirsad@stellarpropertygroup.com.\n- Services: condominium management, HOA management, townhome management, financial management (flat-fee, never percentage-based), maintenance coordination with 24/7 emergency response, board support, violation management.\n- Owner/resident portal (AppFolio): https://stellarpropertygrp.appfolio.com/connect/ — payments (eCheck or card), autopay, maintenance requests, documents.\n- Free payment option — bank Bill Pay: Payee \"Your Association Name\"; mail to 5107 N. Western Avenue, Suite 1S, Chicago, IL 60625; memo = association 4-number address (e.g. \"1740\") and unit number. Details: stellarpropertygroup.com/resources#bill-pay\n- Report a violation: stellarpropertygroup.com/resources?form=violation. Board nomination: /resources?form=nomination.\n- Coverage: 70+ Chicago neighborhoods (Gold Coast, Streeterville, River North, Lincoln Park, Lakeview, Loop, West Loop, South Loop, Edgewater, Hyde Park, and more) plus North Shore: Evanston, Skokie, Glenview, Wilmette, Winnetka, Highland Park, Northbrook, Glencoe, Kenilworth, Lake Forest, Deerfield.\n- Switching firms: managed 30–60 day transition (records, banking, vendors, owner communication) — the board only signs.\n- Pricing: customized flat monthly fee; NEVER quote a number — invite a proposal request at stellarpropertygroup.com/contact.\n\nRULES:\n- Answer ONLY about Stellar, its services, payments, portals, forms, and service areas. Politely decline anything else.\n- Never give legal, tax, or investment advice; suggest the association consult its attorney.\n- Never invent facts, prices, staff names, or client names.\n- Building emergencies (flood, fire, elevator entrapment): tell them to call 773.728.0652 immediately — and 911 first if life-threatening.\n- Be warm, concise (2-4 short sentences), and concrete. When relevant, end by offering the proposal form (/contact) or the portal link.\n- Plain text only — no markdown. Write links as bare URLs (e.g. stellarpropertygroup.com/contact), never [text](url).\n- If the visitor seems to be a board member interested in management services, encourage them to leave name/email/building via the \"Request a Proposal\" option.`;\n\n// Shared stellar-ops database (browser-safe publishable key; all reads below\n// run under the VISITOR'S OWN session token, so row-level security limits\n// them to the signed-in owner's records and their association's knowledge).\nconst SUPABASE_URL = 'https://qfjhmzvuaifxnvmwblux.supabase.co';\nconst SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_zLJMg0YOC9jHmg05IfE7-g_VIWA-v1G';\n\n// Staff authorized for Arthur's staff session (full, all-association access).\n// Must match AUTHORIZED_STAFF in src/lib/ownerAuth.ts.\nconst AUTHORIZED_STAFF = [\n 'mirsad@stellarpropertygroup.com',\n 'mustafa@stellarpropertygroup.com',\n 'meho@stellarpropertygroup.com',\n];\n\n// USD per million tokens — used only for the estimated_cost_usd ledger column.\nconst CHAT_PRICES = {\n 'claude-haiku-4-5-20251001': { in: 1.0, out: 5.0, cacheRead: 0.1, cacheWrite: 1.25 },\n 'deepseek-chat': { in: 0.27, out: 1.1, cacheRead: 0.07, cacheWrite: 0.27 },\n};\n\n/** Best-effort usage row into ai_usage_ledger (service role; never blocks the reply). */\nasync function logChatUsage(model, u) {\n const key = process.env.SUPABASE_SERVICE_ROLE_KEY;\n if (!key || !u || (!u.input && !u.output)) return;\n const p = CHAT_PRICES[model] || { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 };\n const cost =\n ((u.input || 0) * p.in +\n (u.output || 0) * p.out +\n (u.cacheRead || 0) * p.cacheRead +\n (u.cacheCreate || 0) * p.cacheWrite) /\n 1e6;\n try {\n await fetch(`${SUPABASE_URL}/rest/v1/ai_usage_ledger`, {\n method: 'POST',\n headers: {\n apikey: key,\n authorization: `Bearer ${key}`,\n 'content-type': 'application/json',\n prefer: 'return=minimal',\n },\n body: JSON.stringify({\n kind: 'chat',\n model,\n input_tokens: u.input || 0,\n output_tokens: u.output || 0,\n cache_read_input_tokens: u.cacheRead || 0,\n cache_creation_input_tokens: u.cacheCreate || 0,\n estimated_cost_usd: Number(cost.toFixed(6)),\n status: 'succeeded',\n }),\n });\n } catch (e) {\n console.error('chat usage log failed', e?.message);\n }\n}\n\nasync function restGet(path, userToken) {\n const r = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {\n headers: {\n apikey: SUPABASE_PUBLISHABLE_KEY,\n authorization: `Bearer ${userToken || SUPABASE_PUBLISHABLE_KEY}`,\n },\n });\n if (!r.ok) return null;\n return r.json();\n}\n\n/** Live identity from the ops database — same Arthur as /ops/agent. */\nasync function buildIdentity() {\n let name = 'Arthur';\n let persona = '';\n try {\n const rows = await restGet('agent_public_profile?select=agent_name,persona&limit=1', null);\n if (Array.isArray(rows) && rows[0]) {\n name = String(rows[0].agent_name || 'Arthur').slice(0, 60);\n persona = String(rows[0].persona || '').slice(0, 1500);\n }\n } catch { /* fall back to defaults */ }\n return (\n `You are ${name}, the online concierge for Stellar Property Management.` +\n (persona ? `\\nPERSONALITY (set by the office — embody it): ${persona}` : '')\n );\n}\n\n/** Email claim from a Supabase JWT (display/filtering only — RLS enforces access). */\nfunction jwtEmail(token) {\n try {\n const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));\n return String(payload.email || '').toLowerCase();\n } catch {\n return '';\n }\n}\n\n/** Owner profile + association-knowledge retrieval for signed-in visitors. */\nasync function buildOwnerContext(userToken, lastUserMessage) {\n let ownerNote = '';\n let knowledgeNote = '';\n let isStaff = false;\n\n // Match the owner row to the login email explicitly — staff sessions can\n // read every owner row and must not be presented as a random owner.\n const email = jwtEmail(userToken);\n const owners = email\n ? await restGet(\n `owners?select=name,unit:units!owners_unit_id_fkey(number,association:associations(name))&email=ilike.${encodeURIComponent(email)}&limit=1`,\n userToken\n )\n : null;\n const o = Array.isArray(owners) ? owners[0] : null;\n if (o?.name) {\n const unit = o.unit?.number ? `, unit ${o.unit.number}` : '';\n const assoc = o.unit?.association?.name ? `, ${o.unit.association.name}` : '';\n ownerNote = `\\n\\nSIGNED-IN OWNER (verified session): ${o.name}${unit}${assoc}. Greet them by first name and tailor answers to their community. Never reveal information about other owners or units.`;\n } else if (email && AUTHORIZED_STAFF.includes(email)) {\n // No owner row — Stellar staff (team_members) sign in with company-wide\n // RLS; recognize them from their own team_members record.\n const staff = await restGet(\n `team_members?select=name&email=ilike.${encodeURIComponent(email)}&active=eq.true&limit=1`,\n userToken\n );\n const s = Array.isArray(staff) ? staff[0] : null;\n if (s?.name) {\n isStaff = true;\n ownerNote = `\\n\\nSIGNED-IN STAFF (verified session): ${s.name}, Stellar Property Management staff. Greet them by first name. They may ask about any association — answer from the knowledge entries and always say which association each fact belongs to.`;\n }\n }\n\n // Full-text search the curated owner knowledge base with the visitor's\n // question; RLS returns only global entries + their association's entries.\n const words = String(lastUserMessage || '')\n .replace(/[^\\p{L}\\p{N}\\s]/gu, ' ')\n .split(/\\s+/)\n .filter((w) => w.length > 2)\n .slice(0, 10)\n .join(' ');\n if (words) {\n const rows = await restGet(\n `owner_knowledge?select=title,body,association:associations(name)&fts=wfts.${encodeURIComponent(words)}&limit=3`,\n userToken\n );\n if (Array.isArray(rows) && rows.length) {\n const entries = rows\n .map((k) => `• [${k.association?.name || 'ALL COMMUNITIES'}] ${k.title}: ${String(k.body).slice(0, 1200)}`)\n .join('\\n');\n knowledgeNote = isStaff\n ? `\\n\\nASSOCIATION KNOWLEDGE BASE (staff session — entries from any association may be used, but each applies ONLY to the association named in brackets; always name the association when citing a rule):\\n${entries}`\n : `\\n\\nASSOCIATION KNOWLEDGE BASE (each entry applies ONLY to the association named in brackets — NEVER answer using another association's rules; if no entry matches the owner's own community, say the office will confirm and point to the AppFolio document library or the board):\\n${entries}`;\n }\n }\n\n return ownerNote + knowledgeNote;\n}\n\n/**\n * Relay an upstream SSE body to the client as a plain-text token stream.\n * `extract` maps one parsed SSE JSON payload to its text delta (or '').\n * Returns the accumulated text so callers can detect an empty reply.\n */\nasync function relaySse(upstreamBody, res, extract) {\n const decoder = new TextDecoder();\n let buffer = '';\n let total = '';\n const reader = upstreamBody.getReader();\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data:')) continue;\n const payload = line.slice(5).trim();\n if (!payload || payload === '[DONE]') continue;\n try {\n const delta = extract(JSON.parse(payload));\n if (delta) {\n total += delta;\n res.write(delta);\n }\n } catch { /* skip malformed frames */ }\n }\n }\n return total;\n}\n\nexport default async function handler(req, res) {\n if (req.method !== 'POST') {\n res.status(405).json({ error: 'Method not allowed' });\n return;\n }\n\n // Provider auto-detect (same pattern as the stellar-ops agent):\n // DeepSeek (OpenAI-compatible, low cost) if configured, else Anthropic.\n const deepseekKey = process.env.DEEPSEEK_API_KEY;\n const anthropicKey = process.env.ANTHROPIC_API_KEY;\n if (!deepseekKey && !anthropicKey) {\n res.status(503).json({ error: 'AI not configured' });\n return;\n }\n\n try {\n const { messages, ownerContext, stream } = req.body || {};\n const wantStream = stream === true;\n if (!Array.isArray(messages) || messages.length === 0 || messages.length > 30) {\n res.status(400).json({ error: 'Bad request' });\n return;\n }\n\n // Owner personalization. Preferred path: the visitor's own session token\n // (Authorization header) — the server reads their profile + association\n // knowledge under THEIR RLS scope. Fallback: client-passed context.\n let ownerNote = '';\n const authHeader = req.headers.authorization || '';\n if (authHeader.startsWith('Bearer ') && authHeader.length > 20) {\n const lastUser = [...messages].reverse().find((m) => m && m.role === 'user');\n try {\n ownerNote = await buildOwnerContext(authHeader.slice(7), lastUser?.content);\n } catch (e) {\n console.error('owner context fetch failed', e);\n ownerNote = '';\n }\n }\n if (!ownerNote && ownerContext && typeof ownerContext === 'object') {\n const clean = (v) => (typeof v === 'string' ? v.replace(/[\\r\\n]+/g, ' ').slice(0, 120) : '');\n const name = clean(ownerContext.name);\n const unit = clean(ownerContext.unit);\n const assoc = clean(ownerContext.association);\n // Staff sessions are only honored via the verified token path above —\n // a client-claimed isStaff flag is ignored.\n if (name && ownerContext.isStaff !== true) {\n ownerNote = `\\n\\nSIGNED-IN OWNER (verified session): ${name}${unit ? `, unit ${unit}` : ''}${assoc ? `, ${assoc}` : ''}. Greet them by first name and tailor answers to their community. Still never reveal information about other owners or units.`;\n }\n }\n\n // Only pass role/content through; cap message sizes.\n const safeMessages = messages.slice(-12).map((m) => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: String(m.content || '').slice(0, 2000),\n }));\n\n const identity = await buildIdentity();\n const system = identity + '\\n\\n' + CORE_PROMPT + ownerNote;\n\n let text = '';\n\n if (deepseekKey) {\n // DeepSeek — OpenAI-compatible chat completions.\n const r = await fetch('https://api.deepseek.com/chat/completions', {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${deepseekKey}`,\n },\n body: JSON.stringify({\n model: 'deepseek-chat',\n max_tokens: 400,\n stream: wantStream,\n ...(wantStream ? { stream_options: { include_usage: true } } : {}),\n messages: [{ role: 'system', content: system }, ...safeMessages],\n }),\n });\n if (!r.ok) {\n const detail = await r.text();\n console.error('DeepSeek API error', r.status, detail.slice(0, 300));\n res.status(502).json({ error: 'Upstream error' });\n return;\n }\n if (wantStream && r.body) {\n res.writeHead(200, {\n 'content-type': 'text/plain; charset=utf-8',\n 'cache-control': 'no-cache',\n });\n const usage = {};\n const total = await relaySse(r.body, res, (d) => {\n if (d.usage) {\n usage.input = d.usage.prompt_tokens || 0;\n usage.output = d.usage.completion_tokens || 0;\n usage.cacheRead = d.usage.prompt_cache_hit_tokens || 0;\n }\n return d.choices?.[0]?.delta?.content || '';\n });\n if (!total.trim()) res.write('I’m sorry — could you rephrase that?');\n await logChatUsage('deepseek-chat', usage);\n res.end();\n return;\n }\n const data = await r.json();\n text = (data.choices?.[0]?.message?.content || '').trim();\n await logChatUsage('deepseek-chat', {\n input: data.usage?.prompt_tokens,\n output: data.usage?.completion_tokens,\n cacheRead: data.usage?.prompt_cache_hit_tokens,\n });\n } else {\n const r = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'x-api-key': anthropicKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: 'claude-haiku-4-5-20251001',\n max_tokens: 400,\n stream: wantStream,\n system,\n messages: safeMessages,\n }),\n });\n if (!r.ok) {\n const detail = await r.text();\n console.error('Anthropic API error', r.status, detail.slice(0, 300));\n res.status(502).json({ error: 'Upstream error' });\n return;\n }\n if (wantStream && r.body) {\n res.writeHead(200, {\n 'content-type': 'text/plain; charset=utf-8',\n 'cache-control': 'no-cache',\n });\n const usage = {};\n const total = await relaySse(r.body, res, (d) => {\n if (d.type === 'message_start' && d.message?.usage) {\n usage.input = d.message.usage.input_tokens || 0;\n usage.cacheRead = d.message.usage.cache_read_input_tokens || 0;\n usage.cacheCreate = d.message.usage.cache_creation_input_tokens || 0;\n }\n if (d.type === 'message_delta' && d.usage) {\n usage.output = d.usage.output_tokens || 0;\n }\n return d.type === 'content_block_delta' && d.delta?.type === 'text_delta'\n ? d.delta.text\n : '';\n });\n if (!total.trim()) res.write('I’m sorry — could you rephrase that?');\n await logChatUsage('claude-haiku-4-5-20251001', usage);\n res.end();\n return;\n }\n const data = await r.json();\n text = (data.content || [])\n .filter((b) => b.type === 'text')\n .map((b) => b.text)\n .join('\\n')\n .trim();\n await logChatUsage('claude-haiku-4-5-20251001', {\n input: data.usage?.input_tokens,\n output: data.usage?.output_tokens,\n cacheRead: data.usage?.cache_read_input_tokens,\n cacheCreate: data.usage?.cache_creation_input_tokens,\n });\n }\n\n res.status(200).json({ reply: text || 'I’m sorry — could you rephrase that?' });\n } catch (err) {\n console.error('chat handler error', err);\n if (res.headersSent) {\n res.end();\n } else {\n res.status(500).json({ error: 'Server error' });\n }\n }\n}\n"}}}}