| |
| |
|
|
| const BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000' |
|
|
| |
| |
|
|
| const EMOTION_KEYWORDS = { |
| Happy: { |
| pos: ['great','amazing','excellent','perfect','love','happy','wonderful','fantastic','impressed', |
| 'thank','thanks','pleased','delighted','satisfied','joy','awesome','brilliant','good','best', |
| 'glad','excited','thrilled','appreciate','helpful','solved','working','fast','quick'], |
| neg: [], |
| }, |
| Angry: { |
| pos: ['ridiculous','unacceptable','outrageous','furious','angry','frustrated','terrible','horrible', |
| 'awful','disgusting','useless','incompetent','worst','hate','rubbish','appalling','disgusted', |
| 'rage','livid','infuriated','fed up','sick of','waste','disaster','pathetic','shameful'], |
| neg: ['not angry','calm down'], |
| }, |
| Fear: { |
| pos: ['worried','scared','afraid','anxious','nervous','concern','unsure','unsafe','insecure', |
| 'panic','terrified','frightened','dread','suspicious','paranoid','uneasy','not sure', |
| 'not recognise','unauthorized','breach','compromised','hacked','stolen','fraud'], |
| neg: [], |
| }, |
| Sad: { |
| pos: ['sad','disappointed','sorry','unfortunate','regret','miss','lost','heartbroken','depressed', |
| 'unhappy','let down','devastated','gutted','upset','crying','tears','grief','miserable', |
| 'failing','failed','broken','down','hopeless'], |
| neg: [], |
| }, |
| Surprised: { |
| pos: ['surprised','unexpected','wow','unbelievable','shocked','astonished','amazed','incredible', |
| 'never thought','did not expect','suddenly','out of nowhere','just noticed','wait what'], |
| neg: [], |
| }, |
| Disgust: { |
| pos: ['disgusting','gross','revolting','repulsive','vile','nasty','yuck','eww','filthy','repelled'], |
| neg: [], |
| }, |
| } |
|
|
| const POSITIVE_WORDS = new Set([ |
| 'great','amazing','excellent','perfect','love','wonderful','fantastic','impressed','thank','thanks', |
| 'pleased','delighted','satisfied','joy','awesome','brilliant','good','best','glad','excited', |
| 'thrilled','appreciate','helpful','solved','working','fast','quick','smooth','easy','clear', |
| ]) |
|
|
| const NEGATIVE_WORDS = new Set([ |
| 'not','no','never','nothing','nobody','nowhere','none','cannot','cant',"can't",'wont',"won't", |
| 'dont',"don't",'didnt',"didn't",'isnt',"isn't",'wasnt',"wasn't",'bad','worst','terrible', |
| 'horrible','awful','useless','unacceptable','ridiculous','disgusting','hate','angry','frustrated', |
| 'broken','failed','failing','wrong','incorrect','error','problem','issue','complaint', |
| 'waiting','waited','slow','late','delay','delayed','missing','missed','lost','stolen', |
| 'worried','scared','anxious','afraid','suspicious','unauthorized','fraud','breach', |
| ]) |
|
|
| function scoreText(text) { |
| const lower = text.toLowerCase() |
| const words = lower.split(/\s+/) |
|
|
| |
| const scores = {} |
| for (const [emotion, { pos, neg }] of Object.entries(EMOTION_KEYWORDS)) { |
| let score = 0 |
| for (const kw of pos) { |
| if (lower.includes(kw)) score += kw.split(' ').length > 1 ? 2 : 1 |
| } |
| for (const kw of neg) { |
| if (lower.includes(kw)) score -= 2 |
| } |
| scores[emotion] = Math.max(0, score) |
| } |
|
|
| |
| scores.Neutral = 0.5 |
|
|
| |
| let posCount = 0, negCount = 0 |
| for (const w of words) { |
| if (POSITIVE_WORDS.has(w)) posCount++ |
| if (NEGATIVE_WORDS.has(w)) negCount++ |
| } |
| const sentiment = posCount + negCount === 0 |
| ? 0.5 |
| : Math.min(1, Math.max(0, 0.5 + (posCount - negCount) / (posCount + negCount + 2))) |
|
|
| |
| const total = Object.values(scores).reduce((a, b) => a + b, 0) |
| if (total <= 0.5) { |
| scores.Neutral = 3 |
| } |
|
|
| |
| const sum = Object.values(scores).reduce((a, b) => a + b, 0) |
| const probs = {} |
| for (const [k, v] of Object.entries(scores)) probs[k] = v / sum |
|
|
| |
| const topEmotion = Object.entries(probs).sort((a, b) => b[1] - a[1])[0][0] |
| const confidence = probs[topEmotion] |
|
|
| |
| const VALENCE = { Happy: 0.85, Angry: -0.80, Fear: -0.45, Sad: -0.65, Surprised: 0.15, Disgust: -0.70, Neutral: 0.02 } |
| const AROUSAL = { Happy: 0.55, Angry: 0.90, Fear: 0.70, Sad: 0.30, Surprised: 0.75, Disgust: 0.60, Neutral: 0.22 } |
| const RISK_MAP = { Happy: 'low', Angry: 'high', Fear: 'medium', Sad: 'medium', Surprised: 'low', Disgust: 'medium', Neutral: 'low' } |
|
|
| const RECS = { |
| low: 'Stable session. Maintain current engagement tone.', |
| medium: 'β Monitor closely. Apply calm reassurance protocol. Follow-up within 24h.', |
| high: 'π¨ Immediate escalation required. Senior agent + compensation. Churn risk elevated.', |
| } |
|
|
| |
| const keywords = [] |
| for (const [emotion, { pos }] of Object.entries(EMOTION_KEYWORDS)) { |
| for (const kw of pos) { |
| if (lower.includes(kw)) { |
| const sent = emotion === 'Happy' ? 'pos' : (emotion === 'Neutral' ? 'neu' : 'neg') |
| keywords.push([kw, sent]) |
| } |
| } |
| } |
|
|
| const risk = RISK_MAP[topEmotion] |
| const valence = VALENCE[topEmotion] + (sentiment - 0.5) * 0.3 |
|
|
| return { |
| text_emotion: topEmotion, |
| text_probs: probs, |
| sentiment_score: sentiment, |
| valence: Math.max(-1, Math.min(1, valence)), |
| arousal: AROUSAL[topEmotion], |
| risk_level: risk, |
| recommendation: RECS[risk], |
| keywords, |
| confidence, |
| } |
| } |
|
|
| |
| function simulateFacialEmotion(textEmotion, textProbs) { |
| |
| const noise = () => (Math.random() - 0.5) * 0.08 |
| const facial_probs = {} |
| for (const [k, v] of Object.entries(textProbs)) { |
| facial_probs[k] = Math.max(0, v + noise()) |
| } |
| |
| const sum = Object.values(facial_probs).reduce((a, b) => a + b, 0) |
| for (const k of Object.keys(facial_probs)) facial_probs[k] /= sum |
|
|
| const facialTop = Object.entries(facial_probs).sort((a, b) => b[1] - a[1])[0][0] |
| return { facial_emotion: facialTop, facial_probs } |
| } |
|
|
| export function analyzeLocally(text) { |
| const t = scoreText(text) |
| const { facial_emotion, facial_probs } = simulateFacialEmotion(t.text_emotion, t.text_probs) |
|
|
| |
| const fused_probs = {} |
| for (const k of Object.keys(t.text_probs)) { |
| fused_probs[k] = facial_probs[k] * 0.42 + t.text_probs[k] * 0.58 |
| } |
| const fusedTop = Object.entries(fused_probs).sort((a, b) => b[1] - a[1])[0][0] |
| const fusedConf = fused_probs[fusedTop] |
|
|
| return { |
| facial_emotion, |
| facial_probs, |
| text_emotion: t.text_emotion, |
| text_probs: t.text_probs, |
| sentiment_score: t.sentiment_score, |
| fused_emotion: fusedTop, |
| fused_probs, |
| confidence: fusedConf, |
| valence: t.valence, |
| arousal: t.arousal, |
| risk_level: t.risk_level, |
| modal_weights: { visual: 0.42, text: 0.58 }, |
| recommendation: t.recommendation, |
| keywords: t.keywords, |
| inference_ms: Math.round(50 + Math.random() * 40), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function analyzeMultimodal(imageFile, text) { |
| const form = new FormData() |
| form.append('image', imageFile) |
| form.append('text', text) |
|
|
| const res = await fetch(`${BASE}/analyze`, { method: 'POST', body: form }) |
| if (!res.ok) { |
| const err = await res.json().catch(() => ({ detail: 'Unknown error' })) |
| throw new Error(err.detail || `HTTP ${res.status}`) |
| } |
| return res.json() |
| } |
|
|
| |
| |
| |
| |
| |
| export async function analyzeText(text) { |
| const form = new FormData() |
| form.append('text', text) |
|
|
| const res = await fetch(`${BASE}/analyze/text`, { method: 'POST', body: form }) |
| if (!res.ok) throw new Error(`HTTP ${res.status}`) |
| return res.json() |
| } |
|
|
| |
| |
| |
| export async function healthCheck() { |
| const res = await fetch(`${BASE}/health`) |
| return res.json() |
| } |
|
|
| |
| export const DEMO_SCENARIOS = [ |
| { |
| id: 0, |
| name: 'Happy Customer', |
| emoji: 'π', |
| text: "This is exactly what I needed. The response was fast and the solution worked perfectly. Very impressed!", |
| result: { |
| facial_emotion: 'Happy', |
| text_emotion: 'Happy', |
| fused_emotion: 'Happy', |
| confidence: 0.942, |
| valence: 0.87, |
| arousal: 0.38, |
| sentiment_score: 0.87, |
| risk_level: 'low', |
| modal_weights: { visual: 0.56, text: 0.44 }, |
| fused_probs: { Happy: 0.82, Surprised: 0.08, Neutral: 0.06, Sad: 0.02, Angry: 0.01, Fear: 0.01, Disgust: 0.00 }, |
| facial_probs: { Happy: 0.79, Surprised: 0.09, Neutral: 0.07, Sad: 0.03, Angry: 0.01, Fear: 0.01, Disgust: 0.00 }, |
| text_probs: { Happy: 0.85, Surprised: 0.07, Neutral: 0.05, Sad: 0.01, Angry: 0.01, Fear: 0.01, Disgust: 0.00 }, |
| keywords: [['exactly','pos'],['needed','neu'],['fast','pos'],['perfectly','pos'],['impressed','pos']], |
| recommendation: 'Maintain current engagement. Ideal moment for upsell or NPS survey.', |
| inference_ms: 174, |
| } |
| }, |
| { |
| id: 1, |
| name: 'Frustrated User', |
| emoji: 'π€', |
| text: "This is ridiculous. I've been waiting for 45 minutes and nobody has resolved my issue. Completely unacceptable.", |
| result: { |
| facial_emotion: 'Angry', |
| text_emotion: 'Angry', |
| fused_emotion: 'Angry', |
| confidence: 0.887, |
| valence: -0.79, |
| arousal: 0.91, |
| sentiment_score: 0.06, |
| risk_level: 'high', |
| modal_weights: { visual: 0.62, text: 0.38 }, |
| fused_probs: { Angry: 0.71, Sad: 0.14, Disgust: 0.09, Neutral: 0.04, Happy: 0.01, Fear: 0.01, Surprised: 0.00 }, |
| facial_probs: { Angry: 0.68, Sad: 0.16, Disgust: 0.10, Neutral: 0.04, Happy: 0.01, Fear: 0.01, Surprised: 0.00 }, |
| text_probs: { Angry: 0.74, Sad: 0.12, Disgust: 0.08, Neutral: 0.04, Happy: 0.01, Fear: 0.01, Surprised: 0.00 }, |
| keywords: [['ridiculous','neg'],['waiting','neg'],['45 minutes','neg'],['nobody','neg'],['unacceptable','neg']], |
| recommendation: 'Immediate escalation. Offer compensation. Churn probability: 73%.', |
| inference_ms: 189, |
| } |
| }, |
| { |
| id: 2, |
| name: 'Anxious Caller', |
| emoji: 'π°', |
| text: "I'm not sure if my account is secure. I saw some transactions I didn't recognise and I'm worried about what happened.", |
| result: { |
| facial_emotion: 'Fear', |
| text_emotion: 'Fear', |
| fused_emotion: 'Fear', |
| confidence: 0.813, |
| valence: -0.42, |
| arousal: 0.67, |
| sentiment_score: 0.28, |
| risk_level: 'medium', |
| modal_weights: { visual: 0.51, text: 0.49 }, |
| fused_probs: { Fear: 0.58, Sad: 0.22, Neutral: 0.11, Surprised: 0.06, Happy: 0.02, Angry: 0.01, Disgust: 0.00 }, |
| facial_probs: { Fear: 0.55, Sad: 0.25, Neutral: 0.12, Surprised: 0.05, Happy: 0.02, Angry: 0.01, Disgust: 0.00 }, |
| text_probs: { Fear: 0.61, Sad: 0.19, Neutral: 0.10, Surprised: 0.07, Happy: 0.02, Angry: 0.01, Disgust: 0.00 }, |
| keywords: [["not sure",'neg'],['secure','neu'],['transactions','neu'],["didn't recognise",'neg'],['worried','neg']], |
| recommendation: 'Calm reassurance protocol. Verify account immediately. Follow-up call within 24h.', |
| inference_ms: 162, |
| } |
| }, |
| { |
| id: 3, |
| name: 'Neutral Session', |
| emoji: 'π', |
| text: "I would like to update my shipping address for order number 4829. The new address is 14 Oak Street.", |
| result: { |
| facial_emotion: 'Neutral', |
| text_emotion: 'Neutral', |
| fused_emotion: 'Neutral', |
| confidence: 0.789, |
| valence: 0.03, |
| arousal: 0.21, |
| sentiment_score: 0.51, |
| risk_level: 'low', |
| modal_weights: { visual: 0.44, text: 0.56 }, |
| fused_probs: { Neutral: 0.74, Happy: 0.12, Sad: 0.07, Surprised: 0.04, Angry: 0.02, Fear: 0.01, Disgust: 0.00 }, |
| facial_probs: { Neutral: 0.71, Happy: 0.14, Sad: 0.08, Surprised: 0.04, Angry: 0.02, Fear: 0.01, Disgust: 0.00 }, |
| text_probs: { Neutral: 0.77, Happy: 0.10, Sad: 0.06, Surprised: 0.04, Angry: 0.02, Fear: 0.01, Disgust: 0.00 }, |
| keywords: [['update','neu'],['shipping address','neu'],['order','neu'],['14 Oak Street','neu']], |
| recommendation: 'Transactional resolution. No emotional intervention needed. Focus on speed and accuracy.', |
| inference_ms: 155, |
| } |
| }, |
| ] |
|
|
| export const EMOTION_COLORS = { |
| Happy: '#30d988', |
| Angry: '#ff4d6a', |
| Fear: '#f5a623', |
| Sad: '#9b74f7', |
| Neutral: '#5e82aa', |
| Surprised:'#0ec9a8', |
| Disgust: '#ff6b77', |
| } |
|
|