File size: 12,487 Bytes
18af949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# -*- coding: utf-8 -*-
"""
Answerwise โ€” Foreign-Patient Clinic/Beauty AI Consultation & Lead-Conversion SaaS (DEMO)
๋ฐฑ์—”๋“œ: FastAPI. LLM: Darwin-398B-AX (JGOS-398B @ ornith.1street.ai/v1, no-think).
๋ฐ๋ชจ 5๋Œ€ ํ”Œ๋กœ์šฐ: โ‘ ๋ณ‘์›๋ณ„ ์ฑ—๋ด‡ โ‘ก๋‹ค๊ตญ์–ด RAG์ƒ๋‹ด โ‘ขํ…Œ๋„ŒํŠธ ๊ฒฉ๋ฆฌ โ‘ฃAI ์•ˆ์ „๊ฐ€๋“œ โ‘ค๋ฆฌ๋“œ+์–ด๋“œ๋ฏผ.
"""
import os, re, json, time, datetime, uuid
import requests
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware

LLM_URL   = os.environ.get("LLM_BACKEND_URL", "https://ornith.1street.ai/v1/chat/completions")
LLM_MODEL = os.environ.get("LLM_MODEL", "Darwin-398B-AX")

app = FastAPI(title="Answerwise Demo")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

# โ”€โ”€ ๋ฐ๋ชจ ํ…Œ๋„ŒํŠธ(์ž…์  ๋ณ‘์›) + ๋ณ‘์›๋ณ„ RAG ์ง€์‹(์ฒญํฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CLINICS = {
  "seoul_grace": {
    "name": "Seoul Grace Plastic Surgery", "category": "Plastic Surgery",
    "location": "Sinsa-dong, Gangnam, Seoul", "emoji": "๐Ÿฅ", "color": "#4338CA",
    "langs": ["en","zh","ja","th"],
    "promo": "July: 15% off rhinoplasty consultation package",
    "knowledge": [
      "Seoul Grace Plastic Surgery specializes in rhinoplasty (nose), facial contouring, and double-eyelid surgery.",
      "Rhinoplasty consultation package is 1,200,000 KRW; final surgery price is quoted after an in-person consultation.",
      "Double-eyelid surgery starts from 900,000 KRW. Facial contouring is quoted individually.",
      "Opening hours: Mon-Fri 10:00-19:00, Sat 10:00-15:00. Closed Sundays and public holidays.",
      "Lead surgeon: Dr. Ji-hoon Park, board-certified plastic surgeon, 15 years of experience.",
      "July promotion: 15% off the rhinoplasty consultation package for foreign patients.",
      "The clinic provides English, Chinese, Japanese and Thai interpretation support on request.",
    ],
  },
  "gangnam_glow": {
    "name": "Gangnam Glow Dermatology", "category": "Dermatology",
    "location": "Apgujeong-ro, Gangnam, Seoul", "emoji": "โœจ", "color": "#06B6D4",
    "langs": ["en","zh","ja","th"],
    "promo": "Skin-lifting laser bundle event this month",
    "knowledge": [
      "Gangnam Glow Dermatology focuses on skin lifting, laser toning, botox and filler.",
      "Skin-lifting (Ulthera 300 shots) is 1,500,000 KRW. Laser toning single session is 150,000 KRW.",
      "Botox (forehead or glabella) starts from 99,000 KRW. Filler starts from 350,000 KRW per 1cc.",
      "Opening hours: Mon-Sat 11:00-20:00. Closed Sundays.",
      "Lead dermatologist: Dr. Soo-min Lee, board-certified dermatologist.",
      "This month event: skin-lifting laser bundle discount for first-time foreign visitors.",
    ],
  },
  "bright_dental": {
    "name": "Apgujeong Bright Dental", "category": "Dental",
    "location": "Apgujeong, Gangnam, Seoul", "emoji": "๐Ÿฆท", "color": "#10B981",
    "langs": ["en","zh","ja"],
    "promo": "Whitening + scaling combo for travelers",
    "knowledge": [
      "Apgujeong Bright Dental offers teeth whitening, implants and laminate veneers.",
      "In-office whitening is 350,000 KRW. Single implant is quoted from 1,300,000 KRW.",
      "Opening hours: Mon-Fri 09:30-18:30, Sat 09:30-13:00.",
      "Traveler combo: whitening plus scaling in a single visit for foreign visitors.",
    ],
  },
}

# โ”€โ”€ ์œ„ํ—˜๋„ ์‚ฌ์ „(๋‹ค๊ตญ์–ด) โ€” ์‘๊ธ‰/์œ„ํ—˜ ํ‘œํ˜„ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
RISK_R3 = ["bleeding","heavy bleeding","fever","high fever","difficulty breathing","can't breathe",
           "infection","pus","fainting","unconscious","severe pain","์ถœํ˜ˆ","๊ณ ์—ด","ํ˜ธํก๊ณค๋ž€","๊ฐ์—ผ","์‹ค์‹ ",
           "ๅ‡บ่ก€","็™บ็†ฑ","ๅ‘ผๅธๅ›ฐ้›ฃ","ๆ„ŸๆŸ“","ใ‚ใพใ„","ๅ‡บ่ก€ไบ†","ๅ‘็ƒง","ๅ‘ผๅธๅ›ฐ้šพ","ๆ„ŸๆŸ“","เน€เธฅเธทเธญเธ”เธญเธญเธ","เธกเธตเน„เธ‚เน‰"]
RISK_R2 = ["side effect","swelling","won't stop","allergic","medication","re-surgery","revision",
           "๋ถ€์ž‘์šฉ","๋ถ“๊ธฐ","์•ฝ","์žฌ์ˆ˜์ˆ ","ๅ‰ฏไฝœ็”จ","่…ซใ‚Œ","่–ฌ","ๅ‰ฏไฝœ็”จ","่‚ฟ","่ฟ‡ๆ•","เธœเธฅเธ‚เน‰เธฒเธ‡เน€เธ„เธตเธขเธ‡"]

# โ”€โ”€ ๊ฐ€๋“œ๋ ˆ์ผ: ์˜๋ฃŒํŒ๋‹จ/์‚ฌ์ง„ํŒ๋…/์˜ˆ์•ฝํ™•์ •/๋ณด์žฅ ์š”์ฒญ ๊ฐ์ง€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def wants_diagnosis(m):
    m=m.lower()
    return any(k in m for k in ["is it safe for me","can i get","am i a good candidate","diagnose","what's wrong with me",
        "์ œ ์ƒํƒœ","์ง„๋‹จ","๋‚˜ ์ˆ˜์ˆ  ๊ฐ€๋Šฅ","็งใฏๆ‰‹่ก“ใงใใพใ™","ๆˆ‘่ƒฝๅš","่ฏŠๆ–ญ","เธงเธดเธ™เธดเธˆเธ‰เธฑเธข"])
def wants_photo_read(m):
    m=m.lower()
    return any(k in m for k in ["look at my photo","check my photo","read my photo","analyze my picture","see my picture",
        "์‚ฌ์ง„ ๋ด","์‚ฌ์ง„ ํŒ๋…","ๅ†™็œŸใ‚’่ฆ‹ใฆ","็œ‹ๆˆ‘็š„็…ง็‰‡","เธ”เธนเธฃเธนเธ›เธ‚เธญเธ‡เธ‰เธฑเธ™"])
def wants_confirm(m):
    m=m.lower()
    return any(k in m for k in ["confirm my appointment","book me","confirm the booking","guarantee","์˜ˆ์•ฝ ํ™•์ •","ํ™•์ •ํ•ด์ค˜",
        "ไบˆ็ด„ใ‚’็ขบๅฎš","็กฎ่ฎค้ข„็บฆ","ไฟ่ฏ"])

def risk_severity(m):
    ml=m.lower()
    if any(k.lower() in ml for k in RISK_R3): return 3
    if any(k.lower() in ml for k in RISK_R2): return 2
    return 0

# โ”€โ”€ ํ…Œ๋„ŒํŠธ ์Šค์ฝ”ํ”„ RAG (๊ฒฝ๋Ÿ‰ ํ‚ค์›Œ๋“œ ๊ฒ€์ƒ‰) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def retrieve(clinic, query, k=3):
    q=set(re.findall(r"[a-zA-Z๊ฐ€-ํžฃ]{2,}", query.lower()))
    scored=[]
    for i,ch in enumerate(clinic["knowledge"]):
        words=set(re.findall(r"[a-zA-Z]{2,}", ch.lower()))
        score=len(q & words)
        if score>0: scored.append((score,i,ch))
    scored.sort(reverse=True)
    return [(i,ch) for _,i,ch in scored[:k]]

LANG_NAME={"en":"English","zh":"Chinese","ja":"Japanese","th":"Thai","auto":"the user's language"}

def build_system(clinic, lang, chunks):
    ctx="\n".join(f"- {c}" for _,c in chunks) if chunks else "(no matching clinic information found)"
    return (
      f"You are the AI consultation assistant for '{clinic['name']}' ({clinic['category']}), located in {clinic['location']}.\n"
      f"Answer ONLY in {LANG_NAME.get(lang,'the user language')}. Be concise, warm and helpful.\n"
      "STRICT RULES:\n"
      "1. Answer ONLY using the CLINIC INFO below. If the info is not present, say you don't have that detail and offer to have the clinic confirm. NEVER invent prices, results or medical facts.\n"
      "2. NEVER diagnose the user's personal condition, judge surgery suitability, or read/interpret photos.\n"
      "3. NEVER confirm an appointment, guarantee results, promise no side-effects, or promise discounts. Appointments are confirmed only by the clinic staff.\n"
      "4. For appointment requests, collect the user's preferred date and contact, and say the clinic will confirm.\n"
      "5. Do not answer questions unrelated to this clinic; briefly redirect.\n\n"
      f"CLINIC INFO:\n{ctx}\n\nPROMOTION: {clinic.get('promo','')}"
    )

def call_llm(system, user, lang):
    try:
        r=requests.post(LLM_URL, json={
            "model":LLM_MODEL,
            "messages":[{"role":"system","content":system},{"role":"user","content":user}],
            "max_tokens":320,"temperature":0.3,"stream":False,
            "chat_template_kwargs":{"enable_thinking":False},
        }, timeout=90)
        j=r.json()
        return j["choices"][0]["message"]["content"].strip()
    except Exception as e:
        return "(LLM error: %s)"%e

# โ”€โ”€ ๋ฆฌ๋“œ ์ €์žฅ(์ธ๋ฉ”๋ชจ๋ฆฌ, ํ…Œ๋„ŒํŠธ๋ณ„) + ๊ฐ์‚ฌ ๋กœ๊ทธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
LEADS={}    # tenant_id -> list of leads
def grade(lead):
    s=0
    if lead.get("email") or lead.get("contact"): s=max(s,3)
    if lead.get("name"): s=max(s,3)
    if lead.get("preferred_date"): s=max(s,2)
    if lead.get("photo"): s=max(s,4)
    if (lead.get("name") and (lead.get("email") or lead.get("contact")) and lead.get("preferred_date")): s=5
    if lead.get("interest") and s<1: s=1
    return "L%d"%s

def upsert_lead(tenant, session_id, **fields):
    LEADS.setdefault(tenant,[])
    cur=next((l for l in LEADS[tenant] if l["session_id"]==session_id), None)
    if not cur:
        cur={"lead_id":uuid.uuid4().hex[:8],"session_id":session_id,"tenant":tenant,
             "created":datetime.datetime.utcnow().strftime("%H:%M UTC"),"status":"new","messages":0}
        LEADS[tenant].append(cur)
    cur.update({k:v for k,v in fields.items() if v})
    cur["grade"]=grade(cur)
    return cur

# โ”€โ”€ API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/api/clinics")
def clinics():
    return {"clinics":[{"id":k,"name":v["name"],"category":v["category"],"location":v["location"],
             "emoji":v["emoji"],"color":v["color"],"langs":v["langs"],"promo":v["promo"]} for k,v in CLINICS.items()]}

@app.post("/api/chat")
async def chat(req: Request):
    b=await req.json()
    tenant=b.get("tenant"); msg=(b.get("message") or "").strip(); lang=b.get("lang","en")
    session=b.get("session") or uuid.uuid4().hex[:10]
    if tenant not in CLINICS: return JSONResponse({"error":"unknown clinic"},status_code=404)
    clinic=CLINICS[tenant]
    upsert_lead(tenant,session,interest=msg[:60])
    LEADS[tenant][-1] if False else None
    # ๋ฉ”์‹œ์ง€ ์ˆ˜ ์ฆ๊ฐ€
    cur=next(l for l in LEADS[tenant] if l["session_id"]==session); cur["messages"]+=1

    # โ‘  ์‘๊ธ‰/์œ„ํ—˜๋„
    sev=risk_severity(msg)
    if sev>=3:
        cur["status"]="urgent_escalation"; cur["risk"]="R3"
        ans=("โš ๏ธ This may be a medical emergency. Please contact your nearest emergency service or the clinic's "
             "emergency line immediately. I cannot provide medical judgment. The clinic has been alerted.")
        return {"answer":ans,"tenant":tenant,"session":session,"sources":[],"risk":"R3","guardrail":"emergency","grade":cur["grade"]}
    # โ‘ก ๊ฐ€๋“œ๋ ˆ์ผ(์‚ฌ์ง„ํŒ๋…/๊ฐœ์ธ์ง„๋‹จ/์˜ˆ์•ฝํ™•์ •)
    gr=None
    if wants_photo_read(msg): gr="no_photo_read"
    elif wants_diagnosis(msg): gr="no_diagnosis"
    elif wants_confirm(msg): gr="no_confirm"
    if gr:
        cur["status"]="needs_clinic_review"
        tmpl={
          "no_photo_read":"I can receive your photo for the clinic to review, but I cannot analyze or diagnose photos myself. Our staff will check it. Could you share your name and preferred visit date?",
          "no_diagnosis":"I can't assess your personal medical condition โ€” only the clinic's doctor can do that. I can share general clinic information and pass your inquiry to the staff. What procedure are you interested in?",
          "no_confirm":"I can't confirm appointments or guarantee prices/results โ€” the clinic staff make the final confirmation. I can note your preferred date and hand it to them. When would you like to visit?",
        }
        return {"answer":tmpl[gr],"tenant":tenant,"session":session,"sources":[],"risk":"R"+str(sev),"guardrail":gr,"grade":cur["grade"]}
    # โ‘ข ํ…Œ๋„ŒํŠธ ์Šค์ฝ”ํ”„ RAG โ†’ โ‘ฃ LLM(no-think)
    chunks=retrieve(clinic,msg)
    system=build_system(clinic,lang,chunks)
    ans=call_llm(system,msg,lang)
    if sev==2: cur["status"]="needs_clinic_review"; cur["risk"]="R2"
    return {"answer":ans,"tenant":tenant,"session":session,
            "sources":[{"idx":i,"text":c} for i,c in chunks],
            "risk":"R"+str(sev),"guardrail":None,"grade":cur["grade"]}

@app.post("/api/lead")
async def lead(req: Request):
    b=await req.json(); tenant=b.get("tenant"); session=b.get("session")
    if tenant not in CLINICS: return JSONResponse({"error":"unknown clinic"},status_code=404)
    cur=upsert_lead(tenant,session,name=b.get("name"),email=b.get("email"),contact=b.get("contact"),
                    preferred_date=b.get("preferred_date"),interest=b.get("interest"),photo=b.get("photo"))
    return {"ok":True,"grade":cur["grade"],"lead":cur}

@app.get("/api/admin/leads")
def admin_leads(tenant: str):
    return {"tenant":tenant,"clinic":CLINICS.get(tenant,{}).get("name"),"leads":list(reversed(LEADS.get(tenant,[])))}

app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root(): return FileResponse("static/index.html")