answerwise-demo / app.py
openfree's picture
Answerwise demo: multi-tenant clinic AI consult + RAG + safety guardrail + lead CRM (JGOS Darwin-398B)
18af949 verified
Raw
History Blame Contribute Delete
12.5 kB
# -*- 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")