3v324v23 commited on
Commit
c622774
·
1 Parent(s): 9b63f7d

Migrate chatbot to Socratic sentiment tutor with Gemini Live Voice Session capability

Browse files
backend/benchmark_b.py CHANGED
@@ -74,10 +74,10 @@ for idx, (cat, q) in enumerate(samples, 1):
74
  results.append({
75
  "category": cat,
76
  "query": q,
77
- "latency_b": res_data["latency_b"],
78
- "tokens_b": res_data["tokens_b"]
79
  })
80
- print(f" Done: B ({res_data['latency_b']}s, {res_data['tokens_b']}t)")
81
  # Add a small delay between requests
82
  time.sleep(1.5)
83
  except Exception as e:
 
74
  results.append({
75
  "category": cat,
76
  "query": q,
77
+ "latency_b": res_data["latency"],
78
+ "tokens_b": res_data["tokens"]
79
  })
80
+ print(f" Done: B ({res_data['latency']}s, {res_data['tokens']}t)")
81
  # Add a small delay between requests
82
  time.sleep(1.5)
83
  except Exception as e:
backend/main.py CHANGED
@@ -1,25 +1,23 @@
1
- from fastapi import FastAPI, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.staticfiles import StaticFiles
4
  import time
5
  import os
6
- import threading
7
- import csv
8
  import re
 
 
9
  from datetime import datetime
10
- from typing import List, Optional, Dict, Any, TypedDict
11
- from pydantic import BaseModel, Field
12
  from dotenv import load_dotenv
13
 
14
- # LangChain / LangGraph imports
15
  from langchain_google_genai import ChatGoogleGenerativeAI
16
  from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
17
- from langchain_core.prompts import ChatPromptTemplate
18
- from langgraph.graph import StateGraph, END
19
 
20
  load_dotenv()
21
 
22
- app = FastAPI(title="Educational Sentiment Chatbot API")
23
 
24
  # Enable CORS for frontend integration
25
  app.add_middleware(
@@ -30,88 +28,6 @@ app.add_middleware(
30
  allow_headers=["*"],
31
  )
32
 
33
- # HuggingFace model state variables (DistilRoBERTa)
34
- classifier = None
35
- model_status = "loading"
36
- model_error = None
37
-
38
- # HuggingFace NER state variables
39
- ner_classifier = None
40
- ner_status = "loading"
41
- ner_error = None
42
-
43
- def load_distilroberta():
44
- global classifier, model_status, model_error
45
- try:
46
- print("Loading j-hartmann/emotion-english-distilroberta-base model...")
47
- # Import transformers inside the loader function to make startup instantaneous
48
- from transformers import pipeline
49
- classifier = pipeline(
50
- "text-classification",
51
- model="j-hartmann/emotion-english-distilroberta-base",
52
- top_k=None
53
- )
54
- model_status = "ready"
55
- print("DistilRoBERTa model loaded successfully!")
56
- except Exception as e:
57
- model_error = str(e)
58
- model_status = "failed"
59
- print(f"Error loading DistilRoBERTa model: {model_error}")
60
-
61
- def load_ner_model():
62
- global ner_classifier, ner_status, ner_error
63
- try:
64
- print("Loading NER model (dslim/distilbert-NER)...")
65
- from transformers import pipeline
66
- ner_classifier = pipeline(
67
- "ner",
68
- model="dslim/distilbert-NER",
69
- aggregation_strategy="simple"
70
- )
71
- ner_status = "ready"
72
- print("NER model loaded successfully!")
73
- except Exception as e:
74
- ner_error = str(e)
75
- ner_status = "failed"
76
- print(f"Error loading NER model: {ner_error}")
77
-
78
- def scrub_pii(text: str) -> str:
79
- if not text:
80
- return text
81
-
82
- # 1. Regex PII scrubbing
83
- # Email addresses
84
- text = re.sub(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', '[EMAIL]', text)
85
- # Phone numbers (safe regex for standard forms like 555-555-5555, +1-555-555-5555, (555) 555-5555)
86
- text = re.sub(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE]', text)
87
- # IP Addresses
88
- text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP_ADDRESS]', text)
89
- # SSNs
90
- text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', text)
91
-
92
- # 2. NER PII scrubbing (Person, Location, Organization)
93
- global ner_classifier
94
- if ner_classifier is not None:
95
- try:
96
- entities = ner_classifier(text)
97
- # Sort from right to left (reverse index order) to avoid shift offset issues
98
- entities = sorted(entities, key=lambda x: x["start"], reverse=True)
99
- for ent in entities:
100
- ent_type = ent["entity_group"]
101
- if ent_type in ["PER", "LOC", "ORG"]:
102
- start = ent["start"]
103
- end = ent["end"]
104
- text = text[:start] + f"[{ent_type}]" + text[end:]
105
- except Exception as e:
106
- print(f"NER PII scrub error: {e}")
107
-
108
- return text
109
-
110
- @app.on_event("startup")
111
- def startup_event():
112
- threading.Thread(target=load_distilroberta, daemon=True).start()
113
- threading.Thread(target=load_ner_model, daemon=True).start()
114
-
115
  # Pydantic Schemas
116
  class ChatMessage(BaseModel):
117
  role: str # "user" or "assistant"
@@ -120,69 +36,15 @@ class ChatMessage(BaseModel):
120
  class ChatRequest(BaseModel):
121
  message: str
122
  gemini_api_key: Optional[str] = None
123
- system_prompt: Optional[str] = None
124
- history_a: Optional[List[ChatMessage]] = None
125
- history_b: Optional[List[ChatMessage]] = None
126
- history_c: Optional[List[ChatMessage]] = None
127
- history_d: Optional[List[ChatMessage]] = None
128
- selected_option: Optional[str] = "all" # "all", "A", "B", "C", "D"
129
-
130
- class EmotionScore(BaseModel):
131
- label: str
132
- score: float
133
-
134
- class SentimentDetailsA(BaseModel):
135
- detected_sentiment: str
136
- explanation: str
137
-
138
- class SentimentDetailsB(BaseModel):
139
- mapped_sentiment: str
140
- raw_emotions: List[EmotionScore]
141
 
142
  class ChatResponse(BaseModel):
143
- sentiment_a: Optional[SentimentDetailsA] = None
144
- response_a: Optional[str] = None
145
- latency_a: Optional[float] = None
146
- prompt_context_a: Optional[str] = None
147
-
148
- sentiment_b: Optional[SentimentDetailsB] = None
149
- response_b: Optional[str] = None
150
- latency_b: Optional[float] = None
151
- prompt_context_b: Optional[str] = None
152
-
153
- response_c: Optional[str] = None
154
- latency_c: Optional[float] = None
155
- prompt_context_c: Optional[str] = None
156
-
157
- sentiment_d: Optional[SentimentDetailsB] = None
158
- response_d: Optional[str] = None
159
- latency_d: Optional[float] = None
160
- prompt_context_d: Optional[str] = None
161
-
162
- tokens_a: Optional[int] = None
163
- tokens_b: Optional[int] = None
164
- tokens_c: Optional[int] = None
165
- tokens_d: Optional[int] = None
166
-
167
- # State definition for LangGraph
168
- class AgentState(TypedDict):
169
- message: str
170
- system_prompt: str
171
  sentiment: str
172
- explanation: str
173
  response: str
174
- input_tokens: int
175
- output_tokens: int
176
- history: List[ChatMessage]
177
-
178
- # Pydantic model for LangChain Structured Output
179
- class SentimentAnalysis(BaseModel):
180
- detected_sentiment: str = Field(description="Must be strictly one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'.")
181
- explanation: str = Field(description="An extremely concise, single-sentence explanation of why this sentiment was chosen to minimize tokens.")
182
-
183
- class SentimentAndResponseB(BaseModel):
184
- detected_sentiment: str = Field(description="Must be strictly one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'.")
185
- response: str = Field(description="Your Socratic tutor response. Adjust tone based on the detected sentiment. Keep it under 2 brief paragraphs.")
186
 
187
  # Token estimation helper (using standard ~4 characters per token multiplier for English)
188
  def estimate_tokens(text: str) -> int:
@@ -195,116 +57,7 @@ def calculate_cost(input_tokens: int, output_tokens: int) -> float:
195
  output_cost = (output_tokens / 1_000_000.0) * 0.30
196
  return input_cost + output_cost
197
 
198
- # Markdown Logging helper
199
- MD_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log.md")
200
- MD_FILE_B = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log_b.md")
201
-
202
- def log_to_md(question, sentiment_a, sentiment_b, sentiment_d, latency_a, latency_b, latency_c, latency_d, cost_a, cost_b, cost_c, cost_d, tokens_in_a, tokens_out_a, tokens_in_b, tokens_out_b, tokens_in_c, tokens_out_c, tokens_in_d, tokens_out_d, answer_a, answer_b, answer_c, answer_d, selected_option="all"):
203
- target_file = MD_FILE_B if selected_option == "b" else MD_FILE
204
- file_exists = os.path.exists(target_file)
205
- try:
206
- with open(target_file, mode="a", encoding="utf-8") as f:
207
- if not file_exists:
208
- if selected_option == "b":
209
- f.write("# Sentiment Analysis Option B Log\n\n")
210
- f.write("This file tracks Option B (Gemini Single-Pass) user queries, detected sentiments, latencies, estimated costs, and responses.\n\n")
211
- else:
212
- f.write("# Sentiment Analysis & Response Comparison Log\n\n")
213
- f.write("This file tracks and compares user queries, detected sentiments, latencies, estimated costs, and responses across all options.\n\n")
214
-
215
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
216
-
217
- f.write(f"## [{timestamp}] Query: \"{question}\"\n\n")
218
- if selected_option == "b":
219
- total_tokens_b = tokens_in_b + tokens_out_b
220
- f.write("<table>\n")
221
- f.write(" <thead>\n")
222
- f.write(" <tr>\n")
223
- f.write(" <th align=\"left\">Metric</th>\n")
224
- f.write(" <th align=\"left\">Option B (Gemini Single-Pass)</th>\n")
225
- f.write(" </tr>\n")
226
- f.write(" </thead>\n")
227
- f.write(" <tbody>\n")
228
- f.write(" <tr>\n")
229
- f.write(f" <td><strong>Detected Sentiment</strong></td>\n")
230
- f.write(f" <td><code>{sentiment_b}</code></td>\n")
231
- f.write(" </tr>\n")
232
- f.write(" <tr>\n")
233
- f.write(f" <td><strong>Latency</strong></td>\n")
234
- f.write(f" <td>{round(latency_b, 3)}s</td>\n")
235
- f.write(" </tr>\n")
236
- f.write(" <tr>\n")
237
- f.write(f" <td><strong>Estimated Cost</strong></td>\n")
238
- f.write(f" <td><code>${cost_b:.7f}</code></td>\n")
239
- f.write(" </tr>\n")
240
- f.write(" <tr>\n")
241
- f.write(f" <td><strong>Tokens Used</strong></td>\n")
242
- f.write(f" <td>{total_tokens_b} ({tokens_in_b} in / {tokens_out_b} out)</td>\n")
243
- f.write(" </tr>\n")
244
- f.write(" </tbody>\n")
245
- f.write("</table>\n\n")
246
- f.write("### Option B Response\n")
247
- f.write(f"{answer_b}\n\n")
248
- f.write("---\n\n")
249
- else:
250
- total_tokens_a = tokens_in_a + tokens_out_a
251
- total_tokens_b = tokens_in_b + tokens_out_b
252
- total_tokens_c = tokens_in_c + tokens_out_c
253
- total_tokens_d = tokens_in_d + tokens_out_d
254
- f.write("<table>\n")
255
- f.write(" <thead>\n")
256
- f.write(" <tr>\n")
257
- f.write(" <th align=\"left\">Metric</th>\n")
258
- f.write(" <th align=\"left\">Option A (Gemini 3.1 Flash Lite Double-Pass)</th>\n")
259
- f.write(" <th align=\"left\">Option B (Gemini Single-Pass)</th>\n")
260
- f.write(" <th align=\"left\">Option C (DistilRoBERTa Distribution + Gemini)</th>\n")
261
- f.write(" <th align=\"left\">Option D (DistilRoBERTa Classifier + Gemini)</th>\n")
262
- f.write(" </tr>\n")
263
- f.write(" </thead>\n")
264
- f.write(" <tbody>\n")
265
- f.write(" <tr>\n")
266
- f.write(f" <td><strong>Detected Sentiment</strong></td>\n")
267
- f.write(f" <td><code>{sentiment_a}</code></td>\n")
268
- f.write(f" <td><code>{sentiment_b}</code></td>\n")
269
- f.write(f" <td><code>Distribution Context</code></td>\n")
270
- f.write(f" <td><code>{sentiment_d}</code></td>\n")
271
- f.write(" </tr>\n")
272
- f.write(" <tr>\n")
273
- f.write(f" <td><strong>Latency</strong></td>\n")
274
- f.write(f" <td>{round(latency_a, 3)}s</td>\n")
275
- f.write(f" <td>{round(latency_b, 3)}s</td>\n")
276
- f.write(f" <td>{round(latency_c, 3)}s</td>\n")
277
- f.write(f" <td>{round(latency_d, 3)}s</td>\n")
278
- f.write(" </tr>\n")
279
- f.write(" <tr>\n")
280
- f.write(f" <td><strong>Estimated Cost</strong></td>\n")
281
- f.write(f" <td><code>${cost_a:.7f}</code></td>\n")
282
- f.write(f" <td><code>${cost_b:.7f}</code></td>\n")
283
- f.write(f" <td><code>${cost_c:.7f}</code></td>\n")
284
- f.write(f" <td><code>${cost_d:.7f}</code></td>\n")
285
- f.write(" </tr>\n")
286
- f.write(" <tr>\n")
287
- f.write(f" <td><strong>Tokens Used</strong></td>\n")
288
- f.write(f" <td>{total_tokens_a} ({tokens_in_a} in / {tokens_out_a} out)</td>\n")
289
- f.write(f" <td>{total_tokens_b} ({tokens_in_b} in / {tokens_out_b} out)</td>\n")
290
- f.write(f" <td>{total_tokens_c} ({tokens_in_c} in / {tokens_out_c} out)</td>\n")
291
- f.write(f" <td>{total_tokens_d} ({tokens_in_d} in / {tokens_out_d} out)</td>\n")
292
- f.write(" </tr>\n")
293
- f.write(" </tbody>\n")
294
- f.write("</table>\n\n")
295
- f.write("### Option A Response\n")
296
- f.write(f"{answer_a}\n\n")
297
- f.write("### Option B Response\n")
298
- f.write(f"{answer_b}\n\n")
299
- f.write("### Option C Response\n")
300
- f.write(f"{answer_c}\n\n")
301
- f.write("### Option D Response\n")
302
- f.write(f"{answer_d}\n\n")
303
- f.write("---\n\n")
304
- except Exception as e:
305
- print(f"Error writing to MD log: {e}")
306
-
307
- # Helper to extract text from LangChain message content (which may be a list of dicts for Gemini models)
308
  def get_text_content(content: Any) -> str:
309
  if isinstance(content, str):
310
  return content
@@ -318,208 +71,111 @@ def get_text_content(content: Any) -> str:
318
  return "".join(text_parts)
319
  return str(content)
320
 
321
- # Emotion Mapping for DistilRoBERTa
322
- def map_distilroberta_emotions(raw_emotions: List[Any]) -> str:
323
- emo_dict = {}
324
- for item in raw_emotions:
325
- if isinstance(item, dict):
326
- label = item.get("label", "").lower()
327
- score = float(item.get("score", 0.0))
328
- else:
329
- label = getattr(item, "label", "").lower()
330
- score = float(getattr(item, "score", 0.0))
331
- emo_dict[label] = score
332
-
333
- # Define target sentiments based on combinations of raw emotions
334
- # Confusion: high surprise and fear
335
- confusion_score = emo_dict.get("surprise", 0.0) * 1.2 + emo_dict.get("fear", 0.0) * 0.8
336
-
337
- # Frustration: high anger and disgust
338
- frustration_score = emo_dict.get("anger", 0.0) * 1.2 + emo_dict.get("disgust", 0.0) * 0.8
339
-
340
- # Boredom: high neutral, and if sadness is minor combined with high neutral
341
- boredom_score = emo_dict.get("neutral", 0.0) * 1.3 + emo_dict.get("sadness", 0.0) * 0.2
342
-
343
- # Confidence: driven by joy
344
- confidence_score = emo_dict.get("joy", 0.0) * 1.2
345
-
346
- # Sadness: driven by sadness
347
- sadness_score = emo_dict.get("sadness", 0.0) * 1.2
348
 
349
- scores = {
350
- "confusion": confusion_score,
351
- "frustration": frustration_score,
352
- "boredom": boredom_score,
353
- "confidence": confidence_score,
354
- "sadness": sadness_score
355
- }
356
-
357
- return max(scores, key=scores.get)
358
 
359
- # LangGraph Flow A Executor
360
- def run_flow_a_langgraph(message: str, system_prompt: Optional[str], api_key: str, history: Optional[List[ChatMessage]] = None):
361
- llm = ChatGoogleGenerativeAI(
362
- model="gemini-3.1-flash-lite",
363
- google_api_key=api_key,
364
- temperature=0.0,
365
- max_tokens=300
366
- )
367
- structured_llm = llm.with_structured_output(SentimentAnalysis)
368
-
369
- def detect_sentiment_node(state: AgentState) -> dict:
370
- prompt = ChatPromptTemplate.from_messages([
371
- ("system", "Analyze the user's educational query. Determine their emotional state. Classify it strictly as one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'. Keep the explanation extremely short and concise (under 10 words)."),
372
- ("human", "{message}")
373
- ])
374
- chain = prompt | structured_llm
375
- res = chain.invoke({"message": state["message"]})
376
-
377
- # Estimate input & output tokens
378
- # Estimate input & output tokens
379
- input_prompt = f"Analyze the user's educational query. Determine their emotional state. Classify it strictly as one of: 'confusion', 'frustration', 'boredom', 'confidence', 'sadness', or 'neutral'. {state['message']}"
380
- est_input = estimate_tokens(input_prompt)
381
- est_output = 40 # Sentiment response is very short
382
-
383
- return {
384
- "sentiment": res.detected_sentiment.lower(),
385
- "explanation": res.explanation,
386
- "input_tokens": est_input,
387
- "output_tokens": est_output
388
- }
389
-
390
- def generate_response_node(state: AgentState) -> dict:
391
- custom_system = state.get("system_prompt") or (
392
- "You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
393
- "NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
394
- "Adjust your behavior and tone based on the user's sentiment. Keep responses brief (max 5 sentences)."
395
- )
396
-
397
- sentiment = state["sentiment"]
398
- tone_instruction = (
399
- "IMPORTANT: You are a Socratic tutor. NEVER directly state the answer, definition, or solution. "
400
- "Instead, nudge the user and guide them to find the answer themselves through questions. "
401
- "Be extremely concise and direct (strictly limit your response to max 5 sentences).\n"
402
- )
403
- if sentiment == "confusion":
404
- tone_instruction += "The user is confused. Give them a stronger, clearer hint to guide them, and ask a direct question to help them take the next step towards the answer without telling it to them."
405
- elif sentiment == "sadness":
406
- tone_instruction += "The user is sad. Give them brief, warm, empathetic encouragement and practical tips to overcome it (like taking a micro-break or focusing on progress), and ask a gentle guiding question to continue."
407
- elif sentiment == "frustration":
408
- tone_instruction += "The user is frustrated. Empathetically acknowledge their frustration, give them a helpful hint or alternative perspective, and ask a guiding question to help them work through it."
409
- elif sentiment == "boredom":
410
- tone_instruction += "The user is bored. Suggest a completely different way to learn this concept (e.g., through a hands-on project, analogy, or challenge) to spark interest, and ask a guiding question to get them started."
411
- elif sentiment == "confidence":
412
- tone_instruction += "The user is confident. Celebrate their success briefly, and offer a quick challenge or question to test their understanding."
413
- else:
414
- tone_instruction += "Ask a guiding question to nudge them towards the answer."
415
 
416
- prompt_context = f"{tone_instruction}\n\nUser Query: {state['message']}"
417
-
418
- messages = [SystemMessage(content=custom_system)]
419
-
420
- # Prepend history if exists
421
- if state.get("history"):
422
- for msg in state["history"]:
423
- if msg.role == "user":
424
- messages.append(HumanMessage(content=msg.content))
425
- else:
426
- messages.append(AIMessage(content=msg.content))
427
-
428
- messages.append(HumanMessage(content=prompt_context))
429
-
430
- res = llm.invoke(messages)
431
- response_text = get_text_content(res.content)
432
-
433
- est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
434
- est_output = estimate_tokens(response_text)
435
-
436
- return {
437
- "response": response_text,
438
- "input_tokens": state.get("input_tokens", 0) + est_input,
439
- "output_tokens": state.get("output_tokens", 0) + est_output
440
- }
441
-
442
- builder = StateGraph(AgentState)
443
- builder.add_node("detect_sentiment", detect_sentiment_node)
444
- builder.add_node("generate_response", generate_response_node)
445
- builder.set_entry_point("detect_sentiment")
446
- builder.add_edge("detect_sentiment", "generate_response")
447
- builder.add_edge("generate_response", END)
448
-
449
- graph = builder.compile()
450
-
451
- initial_state = {
452
- "message": message,
453
- "system_prompt": system_prompt or "",
454
- "sentiment": "",
455
- "explanation": "",
456
- "response": "",
457
- "input_tokens": 0,
458
- "output_tokens": 0,
459
- "history": history or []
460
- }
461
-
462
- return graph.invoke(initial_state)
463
 
464
  # Option B response helper doing both sentiment detection and response generation in one pass
465
- def run_flow_b(message: str, system_prompt: Optional[str], api_key: str, history: Optional[List[ChatMessage]] = None):
466
  import json
467
- from datetime import datetime
468
 
469
- # OPTIMIZATION 1: Enforce structural JSON natively. Drops formatting fluff from prompt.
470
  llm = ChatGoogleGenerativeAI(
471
  model="gemini-3.1-flash-lite",
472
  google_api_key=api_key,
473
- temperature=0.0,
474
  max_tokens=350,
475
  generation_config={"response_mime_type": "application/json"}
476
  )
477
 
478
- # Cleaned up core system prompt
479
- custom_system = system_prompt or "Socratic tutor. Never give direct answers. Guide using leading questions."
 
 
 
480
 
481
- # OPTIMIZATION 2: Condensed to minimize prompt tokens while retaining response style constraints.
482
  tone_instruction = (
483
- "JSON: {\"state\": \"confusion|frustration|boredom|confidence\", \"reply\": \"string\"}\n"
484
- "Rules: Socratic reply (max 5 sentences, no direct solutions). Acknowledge sentiment (confusion/frustration/boredom/confidence) with natural, varied phrasing. NEVER repeat the same acknowledgment templates (e.g., 'I understand', 'It's normal', 'Understandable').\n"
485
- "- confusion: Acknowledge confusion + hint + guiding question.\n"
486
- "- frustration: Validate frustration + alternative view + guiding question.\n"
487
- "- boredom: Acknowledge boredom + analogy/challenge + guiding question.\n"
488
- "- confidence: Praise + challenge question."
 
 
 
 
 
 
489
  )
490
 
491
- messages = [SystemMessage(content=f"{custom_system}\n{tone_instruction}")]
492
 
 
493
  if history:
494
- for msg in history:
 
 
 
 
 
495
  if msg.role == "user":
496
- messages.append(HumanMessage(content=msg.content))
497
  else:
498
- messages.append(AIMessage(content=msg.content))
499
 
500
  messages.append(HumanMessage(content=message))
501
 
502
  res = llm.invoke(messages)
503
  raw_response = get_text_content(res.content)
504
-
505
- # OPTIMIZATION 3: With response_mime_type active, markdown fences (```json) are bypassed entirely.
506
  cleaned_json = raw_response.strip()
507
 
508
  try:
509
  parsed = json.loads(cleaned_json)
510
- state_val = parsed.get("state", "confusion")
511
- reply_val = parsed.get("reply", "")
512
-
513
- supabase_payload = {
514
- "state": state_val,
515
- "reply": reply_val,
516
- "query": message,
517
- "timestamp": datetime.now().isoformat()
518
- }
519
- print(f"[Supabase Prototype] Directly writing payload to tracking table: {json.dumps(supabase_payload)}")
520
  except Exception as e:
521
  print(f"Failed to parse LLM JSON response: {e}. Raw response: {raw_response}")
522
- state_val = "confusion"
523
  reply_val = "Let's take a look at this concept step by step. What do you think is the first part?"
524
 
525
  prompt_context = f"{custom_system}\n{tone_instruction}\nUser Query: {message}"
@@ -528,115 +184,11 @@ def run_flow_b(message: str, system_prompt: Optional[str], api_key: str, history
528
 
529
  return state_val, reply_val, prompt_context, est_in, est_out
530
 
531
-
532
- # Option C response helper using raw DistilRoBERTa emotion scores directly as LLM prompt context
533
- def run_flow_c(message: str, system_prompt: Optional[str], api_key: str, raw_emotions: List[EmotionScore], history: Optional[List[ChatMessage]] = None):
534
- llm = ChatGoogleGenerativeAI(
535
- model="gemini-3.1-flash-lite",
536
- google_api_key=api_key,
537
- temperature=0.0,
538
- max_tokens=300
539
- )
540
- custom_system = system_prompt or (
541
- "You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
542
- "NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
543
- "Adjust your behavior and tone based on the user's emotional state. Keep responses brief (max 5 sentences)."
544
- )
545
-
546
- # Format raw emotions nicely for the model's context
547
- emotion_context_str = ", ".join([f"{item.label}: {item.score:.3f}" for item in raw_emotions])
548
-
549
- tone_instruction = (
550
- "IMPORTANT: Socratic tutor. NEVER state answer/definition/solution. Nudge/guide using questions. "
551
- "Max 5 sentences.\n"
552
- f"User emotions: {emotion_context_str}.\n"
553
- "Synthesize: If confusion (surprise/fear), give a stronger hint. If frustration (anger/disgust), be empathetic. "
554
- "If boredom (neutral), suggest alternative hands-on/analogy path. If sadness, offer quick warm tips. If confidence (joy), challenge them."
555
- )
556
-
557
- prompt_context = f"{tone_instruction}\n\nUser Query: {message}"
558
-
559
- messages = [SystemMessage(content=custom_system)]
560
-
561
- # Prepend history if exists
562
- if history:
563
- for msg in history:
564
- if msg.role == "user":
565
- messages.append(HumanMessage(content=msg.content))
566
- else:
567
- messages.append(AIMessage(content=msg.content))
568
-
569
- messages.append(HumanMessage(content=prompt_context))
570
-
571
- res = llm.invoke(messages)
572
- response_text = get_text_content(res.content)
573
-
574
- # Estimate tokens
575
- est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
576
- est_output = estimate_tokens(response_text)
577
-
578
- return response_text, prompt_context, est_input, est_output
579
-
580
- # Option D response helper using DistilRoBERTa mapped sentiment
581
- def run_flow_d(message: str, system_prompt: Optional[str], api_key: str, mapped_sentiment: str, history: Optional[List[ChatMessage]] = None):
582
- llm = ChatGoogleGenerativeAI(
583
- model="gemini-3.1-flash-lite",
584
- google_api_key=api_key,
585
- temperature=0.0,
586
- max_tokens=300
587
- )
588
- custom_system = system_prompt or (
589
- "You are a concise, Socratic educational tutor. Your focus is strictly to teach. "
590
- "NEVER give the user the direct answer or solution. Instead, guide them, nudge them, and ask leading questions to help them figure it out. "
591
- "Adjust your behavior and tone based on the user's sentiment. Keep responses brief (max 5 sentences)."
592
- )
593
-
594
- tone_instruction = (
595
- "IMPORTANT: You are a Socratic tutor. NEVER directly state the answer, definition, or solution. "
596
- "Instead, nudge the user and guide them to find the answer themselves through questions. "
597
- "Be extremely concise and direct (strictly limit your response to max 5 sentences).\n"
598
- )
599
- if mapped_sentiment == "confusion":
600
- tone_instruction += "The user is confused. Give them a stronger, clearer hint to guide them, and ask a direct question to help them take the next step towards the answer without telling it to them."
601
- elif mapped_sentiment == "sadness":
602
- tone_instruction += "The user is sad. Give them brief, warm, empathetic encouragement and practical tips to overcome it (like taking a micro-break or focusing on progress), and ask a gentle guiding question to continue."
603
- elif mapped_sentiment == "frustration":
604
- tone_instruction += "The user is frustrated. Empathetically acknowledge their frustration, give them a helpful hint or alternative perspective, and ask a guiding question to help them work through it."
605
- elif mapped_sentiment == "boredom":
606
- tone_instruction += "The user is bored. Suggest a completely different way to learn this concept (e.g., through a hands-on project, analogy, or challenge) to spark interest, and ask a guiding question to get them started."
607
- elif mapped_sentiment == "confidence":
608
- tone_instruction += "The user is confident. Celebrate their success briefly, and offer a quick challenge or question to test their understanding."
609
- else:
610
- tone_instruction += "Ask a guiding question to nudge them towards the answer."
611
-
612
- prompt_context = f"{tone_instruction}\n\nUser Query: {message}"
613
-
614
- messages = [SystemMessage(content=custom_system)]
615
- if history:
616
- for msg in history:
617
- if msg.role == "user":
618
- messages.append(HumanMessage(content=msg.content))
619
- else:
620
- messages.append(AIMessage(content=msg.content))
621
- messages.append(HumanMessage(content=prompt_context))
622
-
623
- res = llm.invoke(messages)
624
- response_text = get_text_content(res.content)
625
-
626
- est_input = estimate_tokens(custom_system) + estimate_tokens(prompt_context)
627
- est_output = estimate_tokens(response_text)
628
-
629
- return response_text, prompt_context, est_input, est_output
630
-
631
-
632
  # API Routes
633
  @app.get("/api/status")
634
  def get_status():
635
  return {
636
- "roberta_status": model_status,
637
- "roberta_error": model_error,
638
- "ner_status": ner_status,
639
- "ner_error": ner_error,
640
  "gemini_api_key_configured": bool(os.environ.get("GEMINI_API_KEY"))
641
  }
642
 
@@ -647,273 +199,149 @@ def chat_endpoint(request: ChatRequest):
647
  if not api_key:
648
  raise HTTPException(
649
  status_code=400,
650
- detail="Gemini API Key is missing. Please provide it in the Settings panel."
651
  )
652
 
653
- # Scrub PII from user query
654
- request.message = scrub_pii(request.message)
655
 
656
- # Initialize all option return variables
657
- sentiment_details_a = None
658
- response_a = None
659
- latency_a = None
660
- prompt_context_a = None
661
- tokens_a = None
662
-
663
- sentiment_details_b = None
664
- response_b = None
665
- latency_b = None
666
- prompt_context_b = None
667
- tokens_b = None
668
-
669
- response_c = None
670
- latency_c = None
671
- prompt_context_c = None
672
- tokens_c = None
673
-
674
- sentiment_details_d = None
675
- response_d = None
676
- latency_d = None
677
- prompt_context_d = None
678
- tokens_d = None
679
-
680
- # Track metrics for logging
681
- detected_sentiment_a = "N/A"
682
- mapped_sentiment_b = "N/A"
683
- mapped_sentiment_d = "N/A"
684
- cost_a = 0.0
685
- cost_b = 0.0
686
- cost_c = 0.0
687
- cost_d = 0.0
688
- est_in_b = 0
689
- est_out_b = 0
690
- est_in_c = 0
691
- est_out_c = 0
692
- est_in_d = 0
693
- est_out_d = 0
694
-
695
- selected = request.selected_option.lower() if request.selected_option else "all"
696
- run_a = (selected == "all" or selected == "a")
697
- run_b = (selected == "all" or selected == "b")
698
- run_c = (selected == "all" or selected == "c")
699
- run_d = (selected == "all" or selected == "d")
700
-
701
- # ------------------
702
- # FLOW A: LangGraph + LangChain Sentiment & Response
703
- # ------------------
704
- if run_a:
705
- start_a = time.time()
706
- try:
707
- final_state_a = run_flow_a_langgraph(
708
- message=request.message,
709
- system_prompt=request.system_prompt,
710
- api_key=api_key,
711
- history=request.history_a
712
- )
713
-
714
- detected_sentiment_a = final_state_a["sentiment"]
715
- explanation_a = final_state_a["explanation"]
716
- response_a = final_state_a["response"]
717
-
718
- prompt_context_a = f"Detected Sentiment (LangGraph): {detected_sentiment_a}\nExplanation: {explanation_a}"
719
- tokens_a = final_state_a.get("input_tokens", 0) + final_state_a.get("output_tokens", 0)
720
- cost_a = calculate_cost(final_state_a["input_tokens"], final_state_a["output_tokens"])
721
-
722
- sentiment_details_a = SentimentDetailsA(
723
- detected_sentiment=detected_sentiment_a,
724
- explanation=explanation_a
725
- )
726
- except Exception as e:
727
- print(f"Error in Flow A (LangGraph): {e}")
728
- detected_sentiment_a = "neutral"
729
- explanation_a = f"Error: {str(e)}"
730
- response_a = "An error occurred during Flow A generation."
731
- prompt_context_a = "N/A"
732
- cost_a = 0.0
733
- sentiment_details_a = SentimentDetailsA(
734
- detected_sentiment="neutral",
735
- explanation=explanation_a
736
- )
737
- latency_a = time.time() - start_a
738
-
739
- # ------------------
740
- # FLOW B: Gemini Single-Pass (Sentiment & Response in one call)
741
- # ------------------
742
- if run_b:
743
- start_b = time.time()
744
- try:
745
- mapped_sentiment_b, response_b, prompt_context_b, est_in_b, est_out_b = run_flow_b(
746
- message=request.message,
747
- system_prompt=request.system_prompt,
748
- api_key=api_key,
749
- history=request.history_b
750
- )
751
- cost_b = calculate_cost(est_in_b, est_out_b)
752
- tokens_b = est_in_b + est_out_b
753
- sentiment_details_b = SentimentDetailsB(
754
- mapped_sentiment=mapped_sentiment_b,
755
- raw_emotions=[]
756
- )
757
- except Exception as e:
758
- print(f"Flow B single-pass error: {e}")
759
- mapped_sentiment_b = "neutral"
760
- response_b = "An error occurred during Flow B generation."
761
- prompt_context_b = "N/A"
762
- cost_b = 0.0
763
- est_in_b = 0
764
- est_out_b = 0
765
- sentiment_details_b = SentimentDetailsB(
766
- mapped_sentiment="neutral",
767
- raw_emotions=[]
768
- )
769
- latency_b = time.time() - start_b
770
-
771
- # ------------------
772
- # FLOW C & D: DistilRoBERTa Classifier Setup
773
- # ------------------
774
- raw_emotions = []
775
- classifier_ran = False
776
 
777
- if run_c or run_d:
778
- try:
779
- if model_status == "loading":
780
- raise HTTPException(
781
- status_code=503,
782
- detail="DistilRoBERTa model is still downloading/loading. Please wait a few seconds and try again."
783
- )
784
- elif model_status == "failed" or classifier is None:
785
- raise HTTPException(
786
- status_code=500,
787
- detail=f"DistilRoBERTa model is unavailable. Load error: {model_error}"
788
- )
789
-
790
- # Run local classifier once
791
- classifier_results = classifier(request.message)[0]
792
- raw_emotions = [
793
- EmotionScore(label=item["label"], score=float(item["score"]))
794
- for item in classifier_results
795
- ]
796
- mapped_sentiment_d = map_distilroberta_emotions(classifier_results)
797
- classifier_ran = True
798
- except HTTPException as he:
799
- raise he
800
- except Exception as e:
801
- print(f"DistilRoBERTa classification error: {e}")
802
-
803
- # ------------------
804
- # FLOW C: Local DistilRoBERTa Raw Scores + Gemini Reply
805
- # ------------------
806
- if run_c:
807
- start_c = time.time()
808
- try:
809
- if not classifier_ran:
810
- raise Exception("Classifier did not run successfully.")
811
- response_c, prompt_context_c, est_in_c, est_out_c = run_flow_c(
812
- message=request.message,
813
- system_prompt=request.system_prompt,
814
- api_key=api_key,
815
- raw_emotions=raw_emotions,
816
- history=request.history_c
817
- )
818
- cost_c = calculate_cost(est_in_c, est_out_c)
819
- tokens_c = est_in_c + est_out_c
820
- except Exception as e:
821
- print(f"Flow C error: {e}")
822
- response_c = "An error occurred during Flow C generation."
823
- prompt_context_c = "N/A"
824
- cost_c = 0.0
825
- est_in_c = 0
826
- est_out_c = 0
827
- latency_c = time.time() - start_c
828
-
829
- # ------------------
830
- # FLOW D: Local DistilRoBERTa Classifier + Gemini Reply (Old Option B)
831
- # ------------------
832
- if run_d:
833
- start_d = time.time()
834
- try:
835
- if not classifier_ran:
836
- raise Exception("Classifier did not run successfully.")
837
- response_d, prompt_context_d, est_in_d, est_out_d = run_flow_d(
838
- message=request.message,
839
- system_prompt=request.system_prompt,
840
- api_key=api_key,
841
- mapped_sentiment=mapped_sentiment_d,
842
- history=request.history_d
843
- )
844
- cost_d = calculate_cost(est_in_d, est_out_d)
845
- tokens_d = est_in_d + est_out_d
846
- sentiment_details_d = SentimentDetailsB(
847
- mapped_sentiment=mapped_sentiment_d,
848
- raw_emotions=raw_emotions
849
- )
850
- except Exception as e:
851
- print(f"Flow D error: {e}")
852
- response_d = "An error occurred during Flow D generation."
853
- prompt_context_d = "N/A"
854
- cost_d = 0.0
855
- est_in_d = 0
856
- est_out_d = 0
857
- sentiment_details_d = SentimentDetailsB(
858
- mapped_sentiment="neutral",
859
- raw_emotions=[]
860
- )
861
- latency_d = time.time() - start_d
862
-
863
- # Log to Markdown file asynchronously or directly (only log values if ran)
864
- log_to_md(
865
- question=request.message,
866
- sentiment_a=detected_sentiment_a,
867
- sentiment_b=mapped_sentiment_b,
868
- sentiment_d=mapped_sentiment_d,
869
- latency_a=latency_a or 0.0,
870
- latency_b=latency_b or 0.0,
871
- latency_c=latency_c or 0.0,
872
- latency_d=latency_d or 0.0,
873
- cost_a=cost_a,
874
- cost_b=cost_b,
875
- cost_c=cost_c,
876
- cost_d=cost_d,
877
- tokens_in_a=final_state_a.get("input_tokens", 0) if (run_a and "final_state_a" in locals()) else 0,
878
- tokens_out_a=final_state_a.get("output_tokens", 0) if (run_a and "final_state_a" in locals()) else 0,
879
- tokens_in_b=est_in_b,
880
- tokens_out_b=est_out_b,
881
- tokens_in_c=est_in_c,
882
- tokens_out_c=est_out_c,
883
- tokens_in_d=est_in_d,
884
- tokens_out_d=est_out_d,
885
- answer_a=response_a or "Skipped",
886
- answer_b=response_b or "Skipped",
887
- answer_c=response_c or "Skipped",
888
- answer_d=response_d or "Skipped",
889
- selected_option=selected
890
- )
891
-
892
- return ChatResponse(
893
- sentiment_a=sentiment_details_a,
894
- response_a=response_a,
895
- latency_a=round(latency_a, 3) if latency_a is not None else None,
896
- prompt_context_a=prompt_context_a,
897
-
898
- sentiment_b=sentiment_details_b,
899
- response_b=response_b,
900
- latency_b=round(latency_b, 3) if latency_b is not None else None,
901
- prompt_context_b=prompt_context_b,
902
 
903
- response_c=response_c,
904
- latency_c=round(latency_c, 3) if latency_c is not None else None,
905
- prompt_context_c=prompt_context_c,
 
 
 
 
 
 
 
906
 
907
- sentiment_d=sentiment_details_d,
908
- response_d=response_d,
909
- latency_d=round(latency_d, 3) if latency_d is not None else None,
910
- prompt_context_d=prompt_context_d,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
911
 
912
- tokens_a=tokens_a,
913
- tokens_b=tokens_b,
914
- tokens_c=tokens_c,
915
- tokens_d=tokens_d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
 
918
  # Mount frontend static files in production if dist folder is built
919
  frontend_dist_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "dist")
 
1
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.staticfiles import StaticFiles
4
  import time
5
  import os
 
 
6
  import re
7
+ import asyncio
8
+ import base64
9
  from datetime import datetime
10
+ from typing import List, Optional, Any
11
+ from pydantic import BaseModel
12
  from dotenv import load_dotenv
13
 
14
+ # LangChain / Google GenAI imports
15
  from langchain_google_genai import ChatGoogleGenerativeAI
16
  from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
 
 
17
 
18
  load_dotenv()
19
 
20
+ app = FastAPI(title="Socratic Sentiment Chatbot API")
21
 
22
  # Enable CORS for frontend integration
23
  app.add_middleware(
 
28
  allow_headers=["*"],
29
  )
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  # Pydantic Schemas
32
  class ChatMessage(BaseModel):
33
  role: str # "user" or "assistant"
 
36
  class ChatRequest(BaseModel):
37
  message: str
38
  gemini_api_key: Optional[str] = None
39
+ history: Optional[List[ChatMessage]] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  class ChatResponse(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  sentiment: str
 
43
  response: str
44
+ latency: float
45
+ prompt_context: str
46
+ tokens: int
47
+ cost: float
 
 
 
 
 
 
 
 
48
 
49
  # Token estimation helper (using standard ~4 characters per token multiplier for English)
50
  def estimate_tokens(text: str) -> int:
 
57
  output_cost = (output_tokens / 1_000_000.0) * 0.30
58
  return input_cost + output_cost
59
 
60
+ # Helper to extract text from LangChain message content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def get_text_content(content: Any) -> str:
62
  if isinstance(content, str):
63
  return content
 
71
  return "".join(text_parts)
72
  return str(content)
73
 
74
+ # Regex PII scrubbing helper
75
+ def scrub_pii(text: str) -> str:
76
+ if not text:
77
+ return text
78
+ # Email addresses
79
+ text = re.sub(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', '[EMAIL]', text)
80
+ # Phone numbers
81
+ text = re.sub(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE]', text)
82
+ # IP Addresses
83
+ text = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP_ADDRESS]', text)
84
+ # SSNs
85
+ text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', text)
86
+ return text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ # Markdown Logging helper
89
+ MD_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sentiment_log.md")
 
 
 
 
 
 
 
90
 
91
+ def log_to_md(question: str, sentiment: str, latency: float, cost: float, tokens_in: int, tokens_out: int, reply: str):
92
+ file_exists = os.path.exists(MD_FILE)
93
+ try:
94
+ with open(MD_FILE, mode="a", encoding="utf-8") as f:
95
+ if not file_exists:
96
+ f.write("# Socratic Chatbot Sentiment & Response Log\n\n")
97
+ f.write("This file tracks detected user sentiments, response latencies, costs, and Socratic replies.\n\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
100
+ f.write(f"## [{timestamp}] Query: \"{question}\"\n\n")
101
+ f.write("<table>\n")
102
+ f.write(" <thead>\n")
103
+ f.write(" <tr><th align=\"left\">Metric</th><th align=\"left\">Value</th></tr>\n")
104
+ f.write(" </thead>\n")
105
+ f.write(" <tbody>\n")
106
+ f.write(f" <tr><td><strong>Detected Sentiment</strong></td><td><code>{sentiment}</code></td></tr>\n")
107
+ f.write(f" <tr><td><strong>Latency</strong></td><td>{round(latency, 3)}s</td></tr>\n")
108
+ f.write(f" <tr><td><strong>Estimated Cost</strong></td><td><code>${cost:.7f}</code></td></tr>\n")
109
+ f.write(f" <tr><td><strong>Tokens</strong></td><td>{tokens_in + tokens_out} ({tokens_in} in / {tokens_out} out)</td></tr>\n")
110
+ f.write(" </tbody>\n")
111
+ f.write("</table>\n\n")
112
+ f.write(f"### Socratic Tutor Reply\n{reply}\n\n")
113
+ f.write("---\n\n")
114
+ except Exception as e:
115
+ print(f"Error writing to MD log: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  # Option B response helper doing both sentiment detection and response generation in one pass
118
+ def run_flow_b(message: str, api_key: str, history: Optional[List[ChatMessage]] = None):
119
  import json
 
120
 
121
+ # Enforce structural JSON natively.
122
  llm = ChatGoogleGenerativeAI(
123
  model="gemini-3.1-flash-lite",
124
  google_api_key=api_key,
125
+ temperature=0.4,
126
  max_tokens=350,
127
  generation_config={"response_mime_type": "application/json"}
128
  )
129
 
130
+ custom_system = (
131
+ "Socratic tutor: guide with clear, substantial hints (no tiny nudges) to solve faster. "
132
+ "Confidence is not mastery—continue Socratic hints unless they are close. "
133
+ "Only when close to the solution, give the final answer & ask: 'Do you want to learn something else?'"
134
+ )
135
 
 
136
  tone_instruction = (
137
+ "JSON format: {\"s\": \"confusion|frustration|confused_but_engaged|confused_and_frustrated|starting_to_get_bored|confident_and_engaged|neutral\", \"r\": \"string\"}\n"
138
+ "Rules:\n"
139
+ "- No robotic templates (e.g. 'I understand', 'it can be frustrating').\n"
140
+ "- NEVER use the phrase 'if you' anywhere in your response (e.g. do not say 'if you think', 'if you were', etc.). Instead, frame instructions or scenarios directly (e.g., say 'think about', 'imagine', 'when looking at', or 'sometimes').\n"
141
+ "- Only ask one question at a time to avoid overwhelming the user.\n"
142
+ "- Close to answer: give final answer directly, ask: 'Do you want to learn something else?'\n"
143
+ "- Not close (including confident_and_engaged): Socratic guidance (substantial hint + question).\n"
144
+ "Replies by state:\n"
145
+ " * confusion: hint + question.\n"
146
+ " * frustration: simplify step + question.\n"
147
+ " * starting_to_get_bored: puzzle/analogy + question.\n"
148
+ " * confident_and_engaged / neutral: guide with hint + question."
149
  )
150
 
151
+ messages = [SystemMessage(content=f"{custom_system}\n\n{tone_instruction}")]
152
 
153
+ # Minimize tokens: slice history to last 4 messages and truncate to 60 characters
154
  if history:
155
+ compact_history = history[-4:]
156
+ for msg in compact_history:
157
+ content = msg.content
158
+ if len(content) > 60:
159
+ content = content[:60] + "..."
160
+
161
  if msg.role == "user":
162
+ messages.append(HumanMessage(content=content))
163
  else:
164
+ messages.append(AIMessage(content=content))
165
 
166
  messages.append(HumanMessage(content=message))
167
 
168
  res = llm.invoke(messages)
169
  raw_response = get_text_content(res.content)
 
 
170
  cleaned_json = raw_response.strip()
171
 
172
  try:
173
  parsed = json.loads(cleaned_json)
174
+ state_val = parsed.get("s", "neutral")
175
+ reply_val = parsed.get("r", "")
 
 
 
 
 
 
 
 
176
  except Exception as e:
177
  print(f"Failed to parse LLM JSON response: {e}. Raw response: {raw_response}")
178
+ state_val = "neutral"
179
  reply_val = "Let's take a look at this concept step by step. What do you think is the first part?"
180
 
181
  prompt_context = f"{custom_system}\n{tone_instruction}\nUser Query: {message}"
 
184
 
185
  return state_val, reply_val, prompt_context, est_in, est_out
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  # API Routes
188
  @app.get("/api/status")
189
  def get_status():
190
  return {
191
+ "status": "ready",
 
 
 
192
  "gemini_api_key_configured": bool(os.environ.get("GEMINI_API_KEY"))
193
  }
194
 
 
199
  if not api_key:
200
  raise HTTPException(
201
  status_code=400,
202
+ detail="Gemini API Key is missing. Please provide it in the Settings panel or environment."
203
  )
204
 
205
+ start_time = time.time()
 
206
 
207
+ # Scrub PII
208
+ scrubbed_message = scrub_pii(request.message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
+ try:
211
+ sentiment, reply, prompt_context, est_in, est_out = run_flow_b(
212
+ message=scrubbed_message,
213
+ api_key=api_key,
214
+ history=request.history
215
+ )
216
+ latency = time.time() - start_time
217
+ cost = calculate_cost(est_in, est_out)
218
+ tokens = est_in + est_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
+ # Log to Markdown
221
+ log_to_md(
222
+ question=request.message,
223
+ sentiment=sentiment,
224
+ latency=latency,
225
+ cost=cost,
226
+ tokens_in=est_in,
227
+ tokens_out=est_out,
228
+ reply=reply
229
+ )
230
 
231
+ return ChatResponse(
232
+ sentiment=sentiment,
233
+ response=reply,
234
+ latency=round(latency, 3),
235
+ prompt_context=prompt_context,
236
+ tokens=tokens,
237
+ cost=cost
238
+ )
239
+ except Exception as e:
240
+ print(f"Chat endpoint error: {e}")
241
+ raise HTTPException(
242
+ status_code=500,
243
+ detail=f"An error occurred: {str(e)}"
244
+ )
245
+
246
+ # WebSocket Endpoint for Socratic voice dialogue via Gemini Multimodal Live API
247
+ @app.websocket("/api/live-ws")
248
+ async def websocket_live_endpoint(websocket: WebSocket):
249
+ await websocket.accept()
250
+
251
+ # Retrieve Gemini API Key from query params or environment
252
+ api_key = websocket.query_params.get("api_key") or os.environ.get("GEMINI_API_KEY")
253
+ if not api_key:
254
+ await websocket.close(code=4000, reason="GEMINI_API_KEY is missing.")
255
+ return
256
 
257
+ try:
258
+ from google import genai
259
+ from google.genai import types
260
+ except ImportError:
261
+ await websocket.close(code=4001, reason="google-genai SDK not installed.")
262
+ return
263
+
264
+ client = genai.Client(api_key=api_key)
265
+
266
+ # Configure Socratic Tutor instruction for Gemini Live API
267
+ config = types.LiveConnectConfig(
268
+ response_modalities=["AUDIO"], # Audio modality
269
+ system_instruction=types.Content(
270
+ parts=[types.Part.from_text(
271
+ text="Socratic tutor: guide with clear, substantial hints (no tiny nudges) to solve faster. "
272
+ "Confidence is not mastery—continue Socratic hints unless they are close. "
273
+ "Only when close to the solution, give the final answer & ask: 'Do you want to learn something else?' "
274
+ "NEVER use the phrase 'if you' anywhere in your response (e.g. do not say 'if you think', 'if you were', etc.). Instead, frame instructions or scenarios directly (e.g., say 'think about', 'imagine', 'when looking at', or 'sometimes'). "
275
+ "Only ask one question at a time to avoid overwhelming the user. "
276
+ "Keep replies extremely concise (maximum 3 brief sentences) and conversational."
277
+ )]
278
+ )
279
  )
280
+
281
+ try:
282
+ # Establish async WebSocket connection to Gemini Live using the Gemini 3.1 Flash Live model
283
+ async with client.aio.live.connect(model="gemini-3.1-flash-live-preview", config=config) as session:
284
+
285
+ async def receive_from_client():
286
+ try:
287
+ while True:
288
+ # Receive JSON from browser client
289
+ message = await websocket.receive_json()
290
+ msg_type = message.get("type")
291
+
292
+ if msg_type == "audio":
293
+ # Decode base64 PCM audio chunk sent from frontend
294
+ audio_bytes = base64.b64decode(message["data"])
295
+ # Stream real-time audio (using 'audio' instead of deprecated 'media')
296
+ await session.send_realtime_input(
297
+ audio=types.Blob(data=audio_bytes, mime_type="audio/pcm;rate=16000")
298
+ )
299
+ elif msg_type == "text":
300
+ # Send real-time text input
301
+ await session.send_realtime_input(text=message["data"])
302
+ except WebSocketDisconnect:
303
+ pass
304
+ except Exception as e:
305
+ print(f"[WebSocket Proxy Client -> Gemini] Error: {e}")
306
+
307
+ async def send_to_client():
308
+ try:
309
+ async for response in session.receive():
310
+ server_content = response.server_content
311
+ if server_content is not None:
312
+ model_turn = server_content.model_turn
313
+ if model_turn is not None:
314
+ for part in model_turn.parts:
315
+ if part.inline_data is not None:
316
+ # Stream PCM audio output back to client as Base64
317
+ audio_b64 = base64.b64encode(part.inline_data.data).decode('utf-8')
318
+ await websocket.send_json({
319
+ "type": "audio",
320
+ "data": audio_b64
321
+ })
322
+ elif part.text is not None:
323
+ # Stream text transcription back to client
324
+ await websocket.send_json({
325
+ "type": "text",
326
+ "data": part.text
327
+ })
328
+
329
+ # Handle turn completion (model finished speaking)
330
+ if server_content.turn_complete:
331
+ await websocket.send_json({"type": "turn_complete"})
332
+ except Exception as e:
333
+ print(f"[WebSocket Proxy Gemini -> Client] Error: {e}")
334
+
335
+ # Run both tasks concurrently
336
+ await asyncio.gather(receive_from_client(), send_to_client())
337
+
338
+ except Exception as e:
339
+ print(f"WebSocket Gemini Live connection failed: {e}")
340
+ finally:
341
+ try:
342
+ await websocket.close()
343
+ except Exception:
344
+ pass
345
 
346
  # Mount frontend static files in production if dist folder is built
347
  frontend_dist_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend", "dist")
frontend/src/App.css CHANGED
@@ -345,7 +345,7 @@ body {
345
  border-radius: 16px;
346
  display: flex;
347
  flex-direction: column;
348
- min-height: 500px;
349
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
350
  overflow: hidden;
351
  transition: var(--transition);
@@ -412,28 +412,34 @@ body {
412
  letter-spacing: 0.5px;
413
  }
414
 
415
- .sentiment-badge.happy {
416
- background-color: var(--color-happy-bg);
417
- border: 1px solid var(--color-happy);
418
- color: var(--color-happy);
419
  }
420
 
421
- .sentiment-badge.sad {
422
- background-color: var(--color-sad-bg);
423
- border: 1px solid var(--color-sad);
424
- color: var(--color-sad);
425
  }
426
 
427
- .sentiment-badge.frustrated {
428
- background-color: var(--color-frustrated-bg);
429
- border: 1px solid var(--color-frustrated);
430
- color: var(--color-frustrated);
 
 
 
 
 
 
431
  }
432
 
433
  .sentiment-badge.neutral {
434
- background-color: var(--color-neutral-bg);
435
- border: 1px solid var(--color-neutral);
436
- color: var(--color-neutral);
437
  }
438
 
439
  /* Chat Body Response */
@@ -678,3 +684,206 @@ body {
678
  line-height: 1.5;
679
  color: var(--text-muted);
680
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  border-radius: 16px;
346
  display: flex;
347
  flex-direction: column;
348
+ min-height: auto;
349
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
350
  overflow: hidden;
351
  transition: var(--transition);
 
412
  letter-spacing: 0.5px;
413
  }
414
 
415
+ .sentiment-badge.confused_but_engaged, .sentiment-badge.confusion {
416
+ background-color: rgba(245, 158, 11, 0.1);
417
+ border: 1px solid #f59e0b;
418
+ color: #f59e0b;
419
  }
420
 
421
+ .sentiment-badge.confused_and_frustrated, .sentiment-badge.frustration {
422
+ background-color: rgba(244, 63, 94, 0.1);
423
+ border: 1px solid #f43f5e;
424
+ color: #f43f5e;
425
  }
426
 
427
+ .sentiment-badge.starting_to_get_bored {
428
+ background-color: rgba(168, 85, 247, 0.1);
429
+ border: 1px solid #a855f7;
430
+ color: #a855f7;
431
+ }
432
+
433
+ .sentiment-badge.confident_and_engaged {
434
+ background-color: rgba(16, 185, 129, 0.1);
435
+ border: 1px solid #10b981;
436
+ color: #10b981;
437
  }
438
 
439
  .sentiment-badge.neutral {
440
+ background-color: rgba(156, 163, 175, 0.1);
441
+ border: 1px solid #9ca3af;
442
+ color: #9ca3af;
443
  }
444
 
445
  /* Chat Body Response */
 
684
  line-height: 1.5;
685
  color: var(--text-muted);
686
  }
687
+
688
+ /* Voice Session Modal */
689
+ .voice-modal-backdrop {
690
+ position: fixed;
691
+ top: 0;
692
+ left: 0;
693
+ width: 100vw;
694
+ height: 100vh;
695
+ background: rgba(11, 12, 16, 0.85);
696
+ backdrop-filter: blur(20px);
697
+ display: flex;
698
+ justify-content: center;
699
+ align-items: center;
700
+ z-index: 2000;
701
+ animation: fadeIn 0.3s ease;
702
+ }
703
+
704
+ .voice-modal-content {
705
+ background: rgba(22, 24, 33, 0.95);
706
+ border: 1px solid rgba(168, 85, 247, 0.25);
707
+ box-shadow: 0 20px 80px rgba(168, 85, 247, 0.15);
708
+ border-radius: 24px;
709
+ width: 90%;
710
+ max-width: 460px;
711
+ padding: 2.5rem;
712
+ display: flex;
713
+ flex-direction: column;
714
+ align-items: center;
715
+ position: relative;
716
+ animation: scaleUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
717
+ }
718
+
719
+ .voice-modal-close {
720
+ position: absolute;
721
+ top: 1.5rem;
722
+ right: 1.5rem;
723
+ background: none;
724
+ border: none;
725
+ color: var(--text-secondary);
726
+ cursor: pointer;
727
+ padding: 0.5rem;
728
+ border-radius: 50%;
729
+ transition: var(--transition);
730
+ }
731
+
732
+ .voice-modal-close:hover {
733
+ background: rgba(255, 255, 255, 0.05);
734
+ color: var(--text-primary);
735
+ }
736
+
737
+ .voice-modal-header {
738
+ text-align: center;
739
+ margin-bottom: 2rem;
740
+ }
741
+
742
+ .voice-modal-header h2 {
743
+ font-size: 1.6rem;
744
+ font-weight: 700;
745
+ background: linear-gradient(135deg, var(--text-primary), var(--secondary));
746
+ -webkit-background-clip: text;
747
+ -webkit-text-fill-color: transparent;
748
+ margin-bottom: 0.3rem;
749
+ }
750
+
751
+ .voice-modal-header p {
752
+ font-size: 0.85rem;
753
+ color: var(--text-muted);
754
+ }
755
+
756
+ .voice-visualizer-container {
757
+ height: 200px;
758
+ display: flex;
759
+ justify-content: center;
760
+ align-items: center;
761
+ position: relative;
762
+ width: 100%;
763
+ margin-bottom: 1.5rem;
764
+ }
765
+
766
+ .voice-status-indicator {
767
+ display: flex;
768
+ flex-direction: column;
769
+ align-items: center;
770
+ gap: 1.5rem;
771
+ font-size: 0.95rem;
772
+ font-weight: 600;
773
+ color: var(--text-secondary);
774
+ }
775
+
776
+ .pulse-circle {
777
+ width: 80px;
778
+ height: 80px;
779
+ border-radius: 50%;
780
+ background: var(--text-muted);
781
+ transition: var(--transition);
782
+ }
783
+
784
+ .pulse-circle.active {
785
+ background: linear-gradient(135deg, var(--color-happy), #34d399);
786
+ box-shadow: 0 0 30px rgba(16, 185, 129, 0.4);
787
+ animation: float 3s ease-in-out infinite;
788
+ }
789
+
790
+ .pulse-circle.speaking-pulse {
791
+ background: linear-gradient(135deg, var(--secondary), #a855f7);
792
+ box-shadow: 0 0 35px rgba(168, 85, 247, 0.5);
793
+ }
794
+
795
+ .pulse-ring {
796
+ position: absolute;
797
+ width: 80px;
798
+ height: 80px;
799
+ border-radius: 50%;
800
+ border: 2px solid var(--color-happy);
801
+ opacity: 0;
802
+ pointer-events: none;
803
+ }
804
+
805
+ .pulse-ring.speaking-ring {
806
+ border-color: var(--secondary);
807
+ }
808
+
809
+ .pulse-ring.ring-1 {
810
+ animation: ripple 2s cubic-bezier(0.1, 0.8, 0.3, 1) infinite;
811
+ }
812
+
813
+ .pulse-ring.ring-2 {
814
+ animation: ripple 2s cubic-bezier(0.1, 0.8, 0.3, 1) 0.6s infinite;
815
+ }
816
+
817
+ .voice-transcript-box {
818
+ background: rgba(255, 255, 255, 0.02);
819
+ border: 1px solid var(--border-color);
820
+ border-radius: 16px;
821
+ padding: 1rem 1.25rem;
822
+ width: 100%;
823
+ max-height: 100px;
824
+ overflow-y: auto;
825
+ margin-bottom: 2rem;
826
+ text-align: center;
827
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
828
+ }
829
+
830
+ .voice-transcript-box p {
831
+ margin: 0;
832
+ font-size: 0.9rem;
833
+ line-height: 1.5;
834
+ font-style: italic;
835
+ color: var(--text-primary);
836
+ }
837
+
838
+ .voice-modal-controls {
839
+ display: flex;
840
+ justify-content: center;
841
+ width: 100%;
842
+ }
843
+
844
+ .voice-control-btn {
845
+ background: rgba(255, 255, 255, 0.03);
846
+ border: 1px solid var(--border-color);
847
+ color: var(--text-primary);
848
+ display: flex;
849
+ flex-direction: column;
850
+ align-items: center;
851
+ gap: 0.5rem;
852
+ padding: 0.8rem 1.8rem;
853
+ border-radius: 16px;
854
+ cursor: pointer;
855
+ transition: var(--transition);
856
+ font-size: 0.75rem;
857
+ font-weight: 700;
858
+ text-transform: uppercase;
859
+ }
860
+
861
+ .voice-control-btn:hover {
862
+ background: rgba(255, 255, 255, 0.07);
863
+ border-color: rgba(255, 255, 255, 0.15);
864
+ }
865
+
866
+ .voice-control-btn.muted {
867
+ border-color: rgba(244, 63, 94, 0.3);
868
+ background: rgba(244, 63, 94, 0.08);
869
+ color: var(--color-frustrated);
870
+ }
871
+
872
+ .voice-control-btn.muted:hover {
873
+ background: rgba(244, 63, 94, 0.15);
874
+ }
875
+
876
+ @keyframes ripple {
877
+ 0% { transform: scale(1); opacity: 0.8; }
878
+ 100% { transform: scale(2.2); opacity: 0; }
879
+ }
880
+
881
+ @keyframes scaleUp {
882
+ from { transform: scale(0.9); opacity: 0; }
883
+ to { transform: scale(1); opacity: 1; }
884
+ }
885
+
886
+ @keyframes float {
887
+ 0%, 100% { transform: translateY(0); }
888
+ 50% { transform: translateY(-6px); }
889
+ }
frontend/src/App.jsx CHANGED
@@ -1,7 +1,8 @@
1
  import React, { useState, useEffect } from 'react';
2
- import { Send, Settings as SettingsIcon, GraduationCap, AlertCircle, HelpCircle } from 'lucide-react';
3
  import Settings from './components/Settings';
4
  import ChatWindow from './components/ChatWindow';
 
5
  import './App.css';
6
 
7
  const API_BASE = window.location.origin === 'http://localhost:5173' ? 'http://localhost:8000' : '';
@@ -61,21 +62,15 @@ const samples = {
61
 
62
  export default function App() {
63
  const [apiKey, setApiKeyState] = useState(() => localStorage.getItem('gemini_api_key') || '');
64
- const [systemPrompt, setSystemPromptState] = useState(() => localStorage.getItem('system_prompt') || '');
65
  const [selectedSampleCategory, setSelectedSampleCategory] = useState('confusion');
66
  const [message, setMessage] = useState('');
67
- const [result, setResult] = useState(null);
68
  const [loading, setLoading] = useState(false);
69
  const [error, setError] = useState(null);
70
- const [historyA, setHistoryA] = useState([]);
71
- const [historyB, setHistoryB] = useState([]);
72
- const [historyC, setHistoryC] = useState([]);
73
- const [historyD, setHistoryD] = useState([]);
74
- const [selectedOption, setSelectedOption] = useState('all');
75
  const [showSettings, setShowSettings] = useState(false);
 
76
  const [backendStatus, setBackendStatus] = useState({
77
- roberta_status: 'loading',
78
- roberta_error: null,
79
  gemini_api_key_configured: false
80
  });
81
 
@@ -84,10 +79,6 @@ export default function App() {
84
  localStorage.setItem('gemini_api_key', val);
85
  };
86
 
87
- const setSystemPrompt = (val) => {
88
- setSystemPromptState(val);
89
- localStorage.setItem('system_prompt', val);
90
- };
91
 
92
  const checkBackendStatus = async () => {
93
  try {
@@ -95,31 +86,21 @@ export default function App() {
95
  if (res.ok) {
96
  const data = await res.json();
97
  setBackendStatus(data);
98
- return data.roberta_status;
99
  }
100
  } catch (err) {
101
  console.error("Failed to fetch backend status:", err);
102
  setBackendStatus({
103
- roberta_status: 'failed',
104
- roberta_error: 'Backend is offline or unreachable',
105
  gemini_api_key_configured: false
106
  });
107
  }
108
  return 'failed';
109
  };
110
 
111
- // Poll status on startup if model is loading
112
  useEffect(() => {
113
  checkBackendStatus();
114
-
115
- const interval = setInterval(async () => {
116
- const status = await checkBackendStatus();
117
- if (status === 'ready' || status === 'failed') {
118
- clearInterval(interval);
119
- }
120
- }, 4000);
121
-
122
- return () => clearInterval(interval);
123
  }, []);
124
 
125
  const handleSubmit = async (e, followUpText = null) => {
@@ -131,48 +112,31 @@ export default function App() {
131
  setLoading(true);
132
  setError(null);
133
 
134
- // Reset histories and results on a brand new query
135
- let currentHistoryA = historyA;
136
- let currentHistoryB = historyB;
137
- let currentHistoryC = historyC;
138
- let currentHistoryD = historyD;
139
-
140
  if (followUpText === null) {
141
- setResult(null);
142
- currentHistoryA = [];
143
- currentHistoryB = [];
144
- currentHistoryC = [];
145
- currentHistoryD = [];
146
- setHistoryA([]);
147
- setHistoryB([]);
148
- setHistoryC([]);
149
- setHistoryD([]);
150
  }
151
 
152
- // Prepend user message locally to create optimistic user bubble
153
- const nextHistoryA = [...currentHistoryA, { role: 'user', content: activeMessage }];
154
- const nextHistoryB = [...currentHistoryB, { role: 'user', content: activeMessage }];
155
- const nextHistoryC = [...currentHistoryC, { role: 'user', content: activeMessage }];
156
- const nextHistoryD = [...currentHistoryD, { role: 'user', content: activeMessage }];
157
-
158
- setHistoryA(nextHistoryA);
159
- setHistoryB(nextHistoryB);
160
- setHistoryC(nextHistoryC);
161
- setHistoryD(nextHistoryD);
162
 
163
  try {
 
 
 
 
 
 
164
  const res = await fetch(`${API_BASE}/api/chat`, {
165
  method: 'POST',
166
  headers: { 'Content-Type': 'application/json' },
167
  body: JSON.stringify({
168
  message: activeMessage,
169
  gemini_api_key: apiKey || null,
170
- system_prompt: systemPrompt || null,
171
- history_a: currentHistoryA,
172
- history_b: currentHistoryB,
173
- history_c: currentHistoryC,
174
- history_d: currentHistoryD,
175
- selected_option: selectedOption
176
  })
177
  });
178
 
@@ -182,13 +146,20 @@ export default function App() {
182
  }
183
 
184
  const data = await res.json();
185
- setResult(data);
186
 
187
- // Append backend assistant responses to respective histories
188
- setHistoryA([...nextHistoryA, { role: 'assistant', content: data.response_a }]);
189
- setHistoryB([...nextHistoryB, { role: 'assistant', content: data.response_b }]);
190
- setHistoryC([...nextHistoryC, { role: 'assistant', content: data.response_c }]);
191
- setHistoryD([...nextHistoryD, { role: 'assistant', content: data.response_d }]);
 
 
 
 
 
 
 
 
192
 
193
  if (followUpText === null) {
194
  setMessage('');
@@ -205,7 +176,10 @@ export default function App() {
205
  setMessage(promptText);
206
  };
207
 
208
- const isRobertaReady = backendStatus.roberta_status === 'ready';
 
 
 
209
 
210
  return (
211
  <div className="app-container">
@@ -214,25 +188,24 @@ export default function App() {
214
  <div className="header-title-section">
215
  <h1 style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
216
  <GraduationCap size={36} color="var(--primary)" />
217
- Sentiment Detection
218
  </h1>
219
- <p>Sentiment Analysis Comparison (Gemini vs. DistilRoBERTa)</p>
220
  </div>
221
 
222
  <div className="header-status">
223
- <div className="status-badge" style={{ padding: '0.4rem 0.8rem' }}>
224
- <span className={`status-dot ${isRobertaReady ? 'ready' : 'loading'}`}></span>
225
- <span style={{ fontSize: '0.85rem' }}>
226
- DistilRoBERTa: {isRobertaReady ? 'Loaded' : 'Initializing...'}
227
- </span>
228
- </div>
229
 
230
  <button
231
  className="settings-toggle-btn"
232
  onClick={() => setShowSettings(!showSettings)}
233
  >
234
  <SettingsIcon size={18} />
235
- Config Settings
236
  </button>
237
  </div>
238
  </header>
@@ -242,50 +215,15 @@ export default function App() {
242
  <Settings
243
  apiKey={apiKey}
244
  setApiKey={setApiKey}
245
- systemPrompt={systemPrompt}
246
- setSystemPrompt={setSystemPrompt}
247
  backendStatus={backendStatus}
248
  checkBackendStatus={checkBackendStatus}
249
  />
250
  )}
251
 
252
  {/* Main Grid */}
253
- <main className="main-grid">
254
  {/* Chat input box */}
255
  <section className="query-card">
256
- {/* Pipeline Selector */}
257
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.8rem' }}>
258
- <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>Active Pipeline:</span>
259
- <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
260
- {[
261
- { id: 'all', label: 'All (Compare)' },
262
- { id: 'A', label: 'Option A (LangGraph 2-Pass)' },
263
- { id: 'B', label: 'Option B (Gemini Single-Pass)' },
264
- { id: 'C', label: 'Option C (DistilRoBERTa Dist.)' },
265
- { id: 'D', label: 'Option D (DistilRoBERTa Class.)' }
266
- ].map((opt) => (
267
- <button
268
- key={opt.id}
269
- type="button"
270
- onClick={() => setSelectedOption(opt.id)}
271
- style={{
272
- background: selectedOption === opt.id ? 'var(--primary)' : 'rgba(255,255,255,0.03)',
273
- border: '1px solid ' + (selectedOption === opt.id ? 'var(--primary)' : 'var(--border-color)'),
274
- color: selectedOption === opt.id ? '#fff' : 'var(--text-secondary)',
275
- padding: '0.4rem 0.8rem',
276
- borderRadius: '8px',
277
- fontSize: '0.85rem',
278
- fontWeight: 600,
279
- cursor: 'pointer',
280
- transition: 'var(--transition)'
281
- }}
282
- >
283
- {opt.label}
284
- </button>
285
- ))}
286
- </div>
287
- </div>
288
-
289
  <form onSubmit={handleSubmit} className="query-input-wrapper">
290
  <textarea
291
  value={message}
@@ -299,20 +237,35 @@ export default function App() {
299
  }
300
  }}
301
  />
302
- <button
303
- type="submit"
304
- className="send-button"
305
- disabled={loading || !message.trim()}
306
- >
307
- <Send size={18} />
308
- Analyze & Chat
309
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  </form>
311
 
312
  {/* Quick prompts */}
313
  <div className="quick-prompts-section" style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', marginTop: '0.5rem' }}>
314
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.6rem' }}>
315
- <span className="quick-prompt-label" style={{ marginRight: 'auto' }}>8th Grade Sample Prompts:</span>
316
  <div style={{ display: 'flex', gap: '0.4rem' }}>
317
  {Object.keys(samples).map((cat) => (
318
  <button
@@ -353,21 +306,23 @@ export default function App() {
353
  </div>
354
  </section>
355
 
356
- {/* Comparison Chat Output */}
357
- <section>
358
  <ChatWindow
359
- result={result}
360
  loading={loading}
361
  error={error}
362
- historyA={historyA}
363
- historyB={historyB}
364
- historyC={historyC}
365
- historyD={historyD}
366
- selectedOption={selectedOption}
367
  onSubmitFollowUp={(text) => handleSubmit(null, text)}
368
  />
369
  </section>
370
  </main>
 
 
 
 
 
 
 
371
  </div>
372
  );
373
  }
 
1
  import React, { useState, useEffect } from 'react';
2
+ import { Send, Settings as SettingsIcon, GraduationCap, Mic } from 'lucide-react';
3
  import Settings from './components/Settings';
4
  import ChatWindow from './components/ChatWindow';
5
+ import VoiceSessionModal from './components/VoiceSessionModal';
6
  import './App.css';
7
 
8
  const API_BASE = window.location.origin === 'http://localhost:5173' ? 'http://localhost:8000' : '';
 
62
 
63
  export default function App() {
64
  const [apiKey, setApiKeyState] = useState(() => localStorage.getItem('gemini_api_key') || '');
 
65
  const [selectedSampleCategory, setSelectedSampleCategory] = useState('confusion');
66
  const [message, setMessage] = useState('');
 
67
  const [loading, setLoading] = useState(false);
68
  const [error, setError] = useState(null);
69
+ const [history, setHistory] = useState([]);
 
 
 
 
70
  const [showSettings, setShowSettings] = useState(false);
71
+ const [showVoiceModal, setShowVoiceModal] = useState(false);
72
  const [backendStatus, setBackendStatus] = useState({
73
+ status: 'loading',
 
74
  gemini_api_key_configured: false
75
  });
76
 
 
79
  localStorage.setItem('gemini_api_key', val);
80
  };
81
 
 
 
 
 
82
 
83
  const checkBackendStatus = async () => {
84
  try {
 
86
  if (res.ok) {
87
  const data = await res.json();
88
  setBackendStatus(data);
89
+ return data.status;
90
  }
91
  } catch (err) {
92
  console.error("Failed to fetch backend status:", err);
93
  setBackendStatus({
94
+ status: 'failed',
 
95
  gemini_api_key_configured: false
96
  });
97
  }
98
  return 'failed';
99
  };
100
 
101
+ // Poll status on startup
102
  useEffect(() => {
103
  checkBackendStatus();
 
 
 
 
 
 
 
 
 
104
  }, []);
105
 
106
  const handleSubmit = async (e, followUpText = null) => {
 
112
  setLoading(true);
113
  setError(null);
114
 
115
+ // If starting a brand new topic (not a follow-up), clear history
116
+ let currentHistory = history;
 
 
 
 
117
  if (followUpText === null) {
118
+ currentHistory = [];
119
+ setHistory([]);
 
 
 
 
 
 
 
120
  }
121
 
122
+ // Append optimistic user bubble
123
+ const nextHistory = [...currentHistory, { role: 'user', content: activeMessage }];
124
+ setHistory(nextHistory);
 
 
 
 
 
 
 
125
 
126
  try {
127
+ // Minimize context token usage: send only role and content to the API
128
+ const optimizedHistoryPayload = currentHistory.map(msg => ({
129
+ role: msg.role,
130
+ content: msg.content
131
+ }));
132
+
133
  const res = await fetch(`${API_BASE}/api/chat`, {
134
  method: 'POST',
135
  headers: { 'Content-Type': 'application/json' },
136
  body: JSON.stringify({
137
  message: activeMessage,
138
  gemini_api_key: apiKey || null,
139
+ history: optimizedHistoryPayload
 
 
 
 
 
140
  })
141
  });
142
 
 
146
  }
147
 
148
  const data = await res.json();
 
149
 
150
+ // Append assistant bubble along with sentiment analysis details & metrics
151
+ setHistory([
152
+ ...nextHistory,
153
+ {
154
+ role: 'assistant',
155
+ content: data.response,
156
+ sentiment: data.sentiment,
157
+ latency: data.latency,
158
+ tokens: data.tokens,
159
+ cost: data.cost,
160
+ prompt_context: data.prompt_context
161
+ }
162
+ ]);
163
 
164
  if (followUpText === null) {
165
  setMessage('');
 
176
  setMessage(promptText);
177
  };
178
 
179
+ const clearChat = () => {
180
+ setHistory([]);
181
+ setError(null);
182
+ };
183
 
184
  return (
185
  <div className="app-container">
 
188
  <div className="header-title-section">
189
  <h1 style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
190
  <GraduationCap size={36} color="var(--primary)" />
191
+ Socratic Sentiment Tutor
192
  </h1>
193
+ <p>Context-aware sentiment detection & guidance chatbot (Option B)</p>
194
  </div>
195
 
196
  <div className="header-status">
197
+ {history.length > 0 && (
198
+ <button className="settings-toggle-btn" onClick={clearChat} style={{ border: '1px solid rgba(244, 63, 94, 0.2)', color: 'var(--color-frustrated)' }}>
199
+ Clear Conversation
200
+ </button>
201
+ )}
 
202
 
203
  <button
204
  className="settings-toggle-btn"
205
  onClick={() => setShowSettings(!showSettings)}
206
  >
207
  <SettingsIcon size={18} />
208
+ Configure Tutor
209
  </button>
210
  </div>
211
  </header>
 
215
  <Settings
216
  apiKey={apiKey}
217
  setApiKey={setApiKey}
 
 
218
  backendStatus={backendStatus}
219
  checkBackendStatus={checkBackendStatus}
220
  />
221
  )}
222
 
223
  {/* Main Grid */}
224
+ <main className="main-grid" style={{ gridTemplateColumns: '1fr' }}>
225
  {/* Chat input box */}
226
  <section className="query-card">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  <form onSubmit={handleSubmit} className="query-input-wrapper">
228
  <textarea
229
  value={message}
 
237
  }
238
  }}
239
  />
240
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
241
+ <button
242
+ type="submit"
243
+ className="send-button"
244
+ disabled={loading || !message.trim()}
245
+ >
246
+ <Send size={18} />
247
+ Start Conversation
248
+ </button>
249
+ <button
250
+ type="button"
251
+ className="send-button voice-btn"
252
+ onClick={() => setShowVoiceModal(true)}
253
+ title="Start voice session"
254
+ style={{
255
+ background: 'linear-gradient(135deg, var(--secondary), #7c3aed)',
256
+ padding: '0.6rem 1.1rem'
257
+ }}
258
+ >
259
+ <Mic size={18} />
260
+ Voice Session
261
+ </button>
262
+ </div>
263
  </form>
264
 
265
  {/* Quick prompts */}
266
  <div className="quick-prompts-section" style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', marginTop: '0.5rem' }}>
267
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.6rem' }}>
268
+ <span className="quick-prompt-label" style={{ marginRight: 'auto' }}>Sample Initial Inquiries:</span>
269
  <div style={{ display: 'flex', gap: '0.4rem' }}>
270
  {Object.keys(samples).map((cat) => (
271
  <button
 
306
  </div>
307
  </section>
308
 
309
+ {/* Conversation Chat Output */}
310
+ <section style={{ width: '100%' }}>
311
  <ChatWindow
312
+ history={history}
313
  loading={loading}
314
  error={error}
 
 
 
 
 
315
  onSubmitFollowUp={(text) => handleSubmit(null, text)}
316
  />
317
  </section>
318
  </main>
319
+
320
+ {/* Voice Session Overlay Modal */}
321
+ <VoiceSessionModal
322
+ isOpen={showVoiceModal}
323
+ onClose={() => setShowVoiceModal(false)}
324
+ apiKey={apiKey}
325
+ />
326
  </div>
327
  );
328
  }
frontend/src/components/ChatWindow.jsx CHANGED
@@ -1,6 +1,5 @@
1
  import React, { useState } from 'react';
2
- import { Sparkles, Cpu, Clock, Terminal, ChevronDown, ChevronUp, AlertCircle, Brain } from 'lucide-react';
3
- import EmotionChart from './EmotionChart';
4
 
5
  // Render LaTeX and text mixed together
6
  function RenderLatex({ text }) {
@@ -32,7 +31,6 @@ function RenderLatex({ text }) {
32
  {inlineParts.map((ip, ipIdx) => {
33
  if (ip.startsWith('$') && ip.endsWith('$')) {
34
  const formula = ip.slice(1, -1);
35
- // Make sure it's not a lonely single dollar sign or empty
36
  if (formula.trim()) {
37
  try {
38
  if (window.katex) {
@@ -46,7 +44,6 @@ function RenderLatex({ text }) {
46
  return <span key={ipIdx}>{ip}</span>;
47
  }
48
 
49
- // Non-math text, run standard inline formatting
50
  return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: formatInline(ip) }} />;
51
  })}
52
  </React.Fragment>
@@ -56,53 +53,17 @@ function RenderLatex({ text }) {
56
  );
57
  }
58
 
59
- // Renders the scrollable thread of chat bubbles for a column
60
- function ChatThread({ history, title }) {
61
- const assistantMessages = history.filter(msg => msg.role === 'assistant');
62
- return (
63
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', flex: 1, overflowY: 'auto', maxHeight: '420px', paddingRight: '0.25rem' }}>
64
- {assistantMessages.map((msg, idx) => (
65
- <div
66
- key={idx}
67
- style={{
68
- alignSelf: 'flex-start',
69
- background: 'rgba(255, 255, 255, 0.02)',
70
- border: '1px solid var(--border-color)',
71
- borderRadius: '12px',
72
- padding: '0.75rem 1rem',
73
- maxWidth: '100%',
74
- }}
75
- >
76
- <span style={{
77
- fontSize: '0.7rem',
78
- fontWeight: 700,
79
- textTransform: 'uppercase',
80
- color: 'var(--text-secondary)',
81
- display: 'block',
82
- marginBottom: '0.3rem'
83
- }}>
84
- {title}
85
- </span>
86
- <SafeMarkdown content={msg.content} />
87
- </div>
88
- ))}
89
- </div>
90
- );
91
- }
92
-
93
  // A simple local Markdown parser that converts basic markdown elements to safe HTML
94
  function SafeMarkdown({ content }) {
95
  if (!content) return null;
96
 
97
- // Split by code blocks first
98
  const parts = content.split(/(```[\s\S]*?```)/g);
99
 
100
  return (
101
  <div className="chat-response-content">
102
  {parts.map((part, index) => {
103
  if (part.startsWith('```') && part.endsWith('```')) {
104
- // It's a code block
105
- const code = part.slice(3, -3).replace(/^\w+\n/, ''); // remove language identifier if present
106
  return (
107
  <pre key={index}>
108
  <code>{code}</code>
@@ -110,7 +71,6 @@ function SafeMarkdown({ content }) {
110
  );
111
  }
112
 
113
- // It's normal text, format paragraph breaks, bold, inline code, and lists
114
  const formatted = part
115
  .split('\n\n')
116
  .map((para, paraIdx) => {
@@ -122,7 +82,6 @@ function SafeMarkdown({ content }) {
122
  return (
123
  <ul key={paraIdx} style={{ marginBottom: '1rem', paddingLeft: '1.5rem' }}>
124
  {items.map((item, itemIdx) => {
125
- // strip initial bullet from first item if split didn't catch it
126
  let cleanItem = item;
127
  if (itemIdx === 0) {
128
  cleanItem = item.replace(/^\s*[-*]\s+/, '');
@@ -138,7 +97,6 @@ function SafeMarkdown({ content }) {
138
  );
139
  }
140
 
141
- // Normal paragraph
142
  return (
143
  <p key={paraIdx}>
144
  <RenderLatex text={para} />
@@ -155,137 +113,49 @@ function SafeMarkdown({ content }) {
155
  // Format bold (**), italics (*), and inline code (`)
156
  function formatInline(text) {
157
  return text
158
- // Bold
159
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
160
- // Italics
161
  .replace(/\*([^*]+)\*/g, '<em>$1</em>')
162
- // Inline code
163
  .replace(/`([^`]+)`/g, '<code>$1</code>')
164
- // Replace newlines within a paragraph with breaks
165
  .replace(/\n/g, '<br />');
166
  }
167
 
168
  export default function ChatWindow({
169
- result,
170
  loading,
171
  error,
172
- historyA = [],
173
- historyB = [],
174
- historyC = [],
175
- historyD = [],
176
- selectedOption = 'all',
177
  onSubmitFollowUp
178
  }) {
179
- const [showPromptA, setShowPromptA] = useState(false);
180
- const [showPromptB, setShowPromptB] = useState(false);
181
- const [showPromptC, setShowPromptC] = useState(false);
182
- const [showPromptD, setShowPromptD] = useState(false);
183
- const [showDistC, setShowDistC] = useState(false);
184
- const [showDistD, setShowDistD] = useState(false);
185
 
186
- // Render loading skeleton
187
- if (loading) {
188
- const showA = selectedOption === 'all' || selectedOption === 'A';
189
- const showB = selectedOption === 'all' || selectedOption === 'B';
190
- const showC = selectedOption === 'all' || selectedOption === 'C';
191
- const showD = selectedOption === 'all' || selectedOption === 'D';
192
 
 
 
193
  return (
194
- <div
195
- className="comparison-grid"
196
- style={{
197
- gridTemplateColumns: selectedOption !== 'all' ? '1fr' : undefined,
198
- maxWidth: selectedOption !== 'all' ? '700px' : undefined,
199
- margin: selectedOption !== 'all' ? '0 auto' : undefined
200
- }}
201
- >
202
- {/* Skeleton Column A */}
203
- {showA && (
204
- <div className="column-card">
205
- <div className="column-header">
206
- <div className="column-title-wrapper">
207
- <h2><Sparkles size={18} color="var(--primary)" /> Output A</h2>
208
- <p>Analyzing Sentiment...</p>
209
- </div>
210
- <div className="latency-badge">--s</div>
211
- </div>
212
- <div className="column-body">
213
- <div className="skeleton-box" />
214
- <div className="skeleton-wrapper">
215
- <div className="skeleton-line long" />
216
- <div className="skeleton-line medium" />
217
- <div className="skeleton-line long" />
218
- <div className="skeleton-line short" />
219
- </div>
220
- </div>
221
- </div>
222
- )}
223
-
224
- {/* Skeleton Column B */}
225
- {showB && (
226
- <div className="column-card">
227
- <div className="column-header">
228
- <div className="column-title-wrapper">
229
- <h2><Cpu size={18} color="var(--secondary)" /> Output B</h2>
230
- <p>Analyzing Sentiment...</p>
231
- </div>
232
- <div className="latency-badge">--s</div>
233
- </div>
234
- <div className="column-body">
235
- <div className="skeleton-box" />
236
- <div className="skeleton-wrapper">
237
- <div className="skeleton-line long" />
238
- <div className="skeleton-line medium" />
239
- <div className="skeleton-line long" />
240
- <div className="skeleton-line short" />
241
- </div>
242
- </div>
243
- </div>
244
- )}
245
-
246
- {/* Skeleton Column C */}
247
- {showC && (
248
- <div className="column-card">
249
- <div className="column-header">
250
- <div className="column-title-wrapper">
251
- <h2><Brain size={18} color="var(--color-happy)" /> Output C</h2>
252
- <p>Analyzing Sentiment...</p>
253
- </div>
254
- <div className="latency-badge">--s</div>
255
- </div>
256
- <div className="column-body">
257
- <div className="skeleton-box" />
258
- <div className="skeleton-wrapper">
259
- <div className="skeleton-line long" />
260
- <div className="skeleton-line medium" />
261
- <div className="skeleton-line long" />
262
- <div className="skeleton-line short" />
263
- </div>
264
  </div>
 
265
  </div>
266
- )}
267
-
268
- {/* Skeleton Column D */}
269
- {showD && (
270
- <div className="column-card">
271
- <div className="column-header">
272
- <div className="column-title-wrapper">
273
- <h2><Brain size={18} color="var(--secondary)" /> Output D</h2>
274
- <p>Analyzing Sentiment...</p>
275
- </div>
276
- <div className="latency-badge">--s</div>
277
- </div>
278
- <div className="column-body">
279
- <div className="skeleton-box" />
280
- <div className="skeleton-wrapper">
281
- <div className="skeleton-line long" />
282
- <div className="skeleton-line medium" />
283
- <div className="skeleton-line long" />
284
- <div className="skeleton-line short" />
285
- </div>
286
  </div>
287
  </div>
288
- )}
289
  </div>
290
  );
291
  }
@@ -301,7 +171,8 @@ export default function ChatWindow({
301
  display: 'flex',
302
  alignItems: 'flex-start',
303
  gap: '1rem',
304
- color: 'var(--color-frustrated)'
 
305
  }}>
306
  <AlertCircle size={24} style={{ flexShrink: 0 }} />
307
  <div>
@@ -313,328 +184,227 @@ export default function ChatWindow({
313
  }
314
 
315
  // Render empty state
316
- if (!result) {
317
  return (
318
  <div className="empty-state">
319
- <Terminal size={48} className="empty-state-icon" />
320
- <h3>Awaiting your query</h3>
321
- <p>Type an educational question above. The chatbot will process the query in two separate pipelines, show the classified sentiment, and respond using custom emotional contexts.</p>
322
  </div>
323
  );
324
  }
325
 
326
  return (
327
- <>
328
- {/* Student Question Card */}
329
- <div
330
- style={{
331
- background: 'rgba(99, 102, 241, 0.04)',
332
- border: '1px solid var(--border-color)',
333
- borderRadius: '16px',
334
- padding: '1.25rem 1.5rem',
335
- marginBottom: '1.5rem',
336
- display: 'flex',
337
- flexDirection: 'column',
338
- gap: '0.4rem',
339
- boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.15)',
340
- position: 'relative',
341
- overflow: 'hidden'
342
- }}
343
- >
344
- <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--primary), var(--secondary))' }} />
345
- <span style={{ fontSize: '0.75rem', fontWeight: 800, textTransform: 'uppercase', color: 'var(--primary)', letterSpacing: '0.5px' }}>
346
- Student Question
347
- </span>
348
- <p style={{ fontSize: '1.05rem', fontWeight: 500, lineHeight: 1.5, margin: 0, color: 'var(--text-primary)' }}>
349
- {historyA.length > 0 ? historyA[historyA.length - 2]?.content || historyA[0]?.content : '...'}
350
- </p>
351
- </div>
352
-
353
- <div
354
- className="comparison-grid"
355
- style={{
356
- gridTemplateColumns: selectedOption !== 'all' ? '1fr' : undefined,
357
- maxWidth: selectedOption !== 'all' ? '700px' : undefined,
358
- margin: selectedOption !== 'all' ? '0 auto' : undefined
359
- }}
360
- >
361
- {/* Column A: Gemini Sentiment */}
362
- {result.response_a && (
363
- <div className="column-card">
364
- <div className="column-header">
365
- <div className="column-title-wrapper">
366
- <h2><Sparkles size={18} color="var(--primary)" /> Output A</h2>
367
- <p>Gemini 2.5 Flash Lite Sentiment</p>
368
- </div>
369
- <div className="column-meta">
370
- <span className={`sentiment-badge ${result.sentiment_a.detected_sentiment}`}>
371
- {result.sentiment_a.detected_sentiment}
372
- </span>
373
- <div className="latency-badge" title="Response Latency & Tokens">
374
- <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
375
- {result.sentiment_a.latency_a || result.latency_a}s
376
- {result.tokens_a !== undefined && result.tokens_a !== null && (
377
- <>
378
- <span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
379
- <span>{result.tokens_a} tokens</span>
380
- </>
381
- )}
382
- </div>
383
- </div>
384
- </div>
385
-
386
- <div className="column-body">
387
- <div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
388
- <h3>Gemini Sentiment Analysis</h3>
389
- <p>{result.sentiment_a.explanation}</p>
390
- </div>
391
-
392
- <ChatThread history={historyA} title="Tutor A" />
393
- </div>
394
-
395
- {/* Prompt Context A Inspector */}
396
- <div className="inspector-section">
397
- <div className="inspector-header" onClick={() => setShowPromptA(!showPromptA)}>
398
- <span>
399
- <Terminal size={12} />
400
- Prompt Context Context (Flow A)
401
- </span>
402
- {showPromptA ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
403
- </div>
404
- {showPromptA && (
405
- <div className="inspector-body">
406
- {result.prompt_context_a}
407
  </div>
408
- )}
409
- </div>
410
- </div>
411
- )}
412
 
413
- {/* Column B: Gemini Single-Pass Sentiment */}
414
- {result.response_b && (
415
- <div className="column-card">
416
- <div className="column-header">
417
- <div className="column-title-wrapper">
418
- <h2><Cpu size={18} color="var(--secondary)" /> Output B</h2>
419
- <p>Gemini Single-Pass</p>
420
- </div>
421
- <div className="column-meta">
422
- <span className={`sentiment-badge ${result.sentiment_b.mapped_sentiment}`}>
423
- {result.sentiment_b.mapped_sentiment}
424
- </span>
425
- <div className="latency-badge" title="Response Latency & Tokens">
426
- <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
427
- {result.sentiment_b.latency_b || result.latency_b}s
428
- {result.tokens_b !== undefined && result.tokens_b !== null && (
429
- <>
430
- <span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
431
- <span>{result.tokens_b} tokens</span>
432
- </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  )}
434
  </div>
435
- </div>
436
- </div>
437
-
438
- <div className="column-body">
439
- <div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
440
- <h3>Gemini Single-Pass Analysis</h3>
441
- <p>Detected Sentiment and Socratic response generated in one pass: <strong>{result.sentiment_b.mapped_sentiment}</strong></p>
442
- </div>
443
-
444
- <ChatThread history={historyB} title="Tutor B" />
445
- </div>
446
-
447
- {/* Prompt Context B Inspector */}
448
- <div className="inspector-section">
449
- <div className="inspector-header" onClick={() => setShowPromptB(!showPromptB)}>
450
- <span>
451
- <Terminal size={12} />
452
- Prompt Context Context (Flow B)
453
- </span>
454
- {showPromptB ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
455
- </div>
456
- {showPromptB && (
457
- <div className="inspector-body">
458
- {result.prompt_context_b}
459
- </div>
460
- )}
461
- </div>
462
- </div>
463
- )}
464
 
465
- {/* Column C: DistilRoBERTa Raw Scores -> Gemini */}
466
- {result.response_c && (
467
- <div className="column-card">
468
- <div className="column-header">
469
- <div className="column-title-wrapper">
470
- <h2><Brain size={18} color="var(--color-happy)" /> Output C</h2>
471
- <p>RoBERTa Raw Scores + Gemini</p>
472
- </div>
473
- <div className="column-meta">
474
- <span className="sentiment-badge happy">
475
- Distribution
476
- </span>
477
- <div className="latency-badge" title="Response Latency & Tokens">
478
- <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
479
- {result.latency_c}s
480
- {result.tokens_c !== undefined && result.tokens_c !== null && (
481
- <>
482
- <span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
483
- <span>{result.tokens_c} tokens</span>
484
- </>
485
- )}
486
  </div>
487
- </div>
488
- </div>
489
 
490
- <div className="column-body">
491
- <div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
492
- <div
493
- onClick={() => setShowDistC(!showDistC)}
494
- style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
495
- >
496
- <h3 style={{ margin: 0 }}>DistilRoBERTa Full Distribution</h3>
497
- {showDistC ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
498
- </div>
499
- {showDistC && (
500
- <div style={{ marginTop: '0.5rem' }}>
501
- <p>All probability scores passed directly to Gemini for adaptive synthesis:</p>
502
- {result.sentiment_d && result.sentiment_d.raw_emotions && (
503
- <EmotionChart rawEmotions={result.sentiment_d.raw_emotions} modelName="DistilRoBERTa" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  )}
505
  </div>
506
  )}
507
  </div>
508
-
509
- <ChatThread history={historyC} title="Tutor C" />
510
- </div>
511
-
512
- {/* Prompt Context C Inspector */}
513
- <div className="inspector-section">
514
- <div className="inspector-header" onClick={() => setShowPromptC(!showPromptC)}>
515
- <span>
516
- <Terminal size={12} />
517
- Prompt Context Context (Flow C)
518
- </span>
519
- {showPromptC ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
520
- </div>
521
- {showPromptC && (
522
- <div className="inspector-body">
523
- {result.prompt_context_c}
524
- </div>
525
- )}
526
- </div>
527
- </div>
528
- )}
529
-
530
- {/* Column D: DistilRoBERTa Mapped -> Gemini */}
531
- {result.response_d && (
532
- <div className="column-card">
533
- <div className="column-header">
534
- <div className="column-title-wrapper">
535
- <h2><Brain size={18} color="var(--secondary)" /> Output D</h2>
536
- <p>DistilRoBERTa Classifier + Gemini</p>
537
- </div>
538
- <div className="column-meta">
539
- <span className={`sentiment-badge ${result.sentiment_d.mapped_sentiment}`}>
540
- {result.sentiment_d.mapped_sentiment}
541
- </span>
542
- <div className="latency-badge" title="Response Latency & Tokens">
543
- <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
544
- {result.sentiment_d.latency_d || result.latency_d}s
545
- {result.tokens_d !== undefined && result.tokens_d !== null && (
546
- <>
547
- <span style={{ margin: '0 6px', opacity: 0.4 }}>|</span>
548
- <span>{result.tokens_d} tokens</span>
549
- </>
550
- )}
551
  </div>
552
  </div>
553
- </div>
554
-
555
- <div className="column-body">
556
- <div className="sentiment-analysis-box" style={{ marginBottom: '0.5rem' }}>
557
- <div
558
- onClick={() => setShowDistD(!showDistD)}
559
- style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
560
- >
561
- <h3 style={{ margin: 0 }}>DistilRoBERTa Mapped Category</h3>
562
- {showDistD ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
563
  </div>
564
- {showDistD && (
565
- <div style={{ marginTop: '0.5rem' }}>
566
- <p>Mapped top-scoring emotions into high-level state: <strong>{result.sentiment_d.mapped_sentiment}</strong></p>
567
- <EmotionChart rawEmotions={result.sentiment_d.raw_emotions} modelName="DistilRoBERTa" />
568
- </div>
569
- )}
570
- </div>
571
-
572
- <ChatThread history={historyD} title="Tutor D" />
573
- </div>
574
-
575
- {/* Prompt Context D Inspector */}
576
- <div className="inspector-section">
577
- <div className="inspector-header" onClick={() => setShowPromptD(!showPromptD)}>
578
- <span>
579
- <Terminal size={12} />
580
- Prompt Context Context (Flow D)
581
- </span>
582
- {showPromptD ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
583
  </div>
584
- {showPromptD && (
585
- <div className="inspector-body">
586
- {result.prompt_context_d}
587
- </div>
588
- )}
589
  </div>
590
- </div>
591
- )}
592
 
593
- {/* Follow-up central conversation area */}
594
- <div className="query-card" style={{ gridColumn: '1 / -1', marginTop: '1rem', background: 'rgba(99, 102, 241, 0.05)', borderColor: 'var(--primary-glow)' }}>
595
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', marginBottom: '0.8rem' }}>
596
- <h3 style={{ fontSize: '1.05rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
597
- <Brain size={18} color="var(--primary)" />
598
- Continue the Conversation on this Topic
599
- </h3>
600
- <p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
601
- Reply to the tutors' questions. Active tutors will formulate their next Socratic nudges using their independent histories.
602
- </p>
603
- </div>
604
- <form
605
- onSubmit={(e) => {
606
- e.preventDefault();
607
- const inputEl = e.target.elements.followUpText;
608
- const val = inputEl.value.trim();
609
- if (val && !loading) {
610
- onSubmitFollowUp(val);
611
- inputEl.value = '';
612
- }
613
  }}
614
- className="query-input-wrapper"
615
  >
616
- <textarea
617
- name="followUpText"
618
- placeholder="Type your response to the Socratic tutor..."
619
- className="query-textarea"
620
- disabled={loading}
621
- onKeyDown={(e) => {
622
- if (e.key === 'Enter' && !e.shiftKey) {
623
- e.preventDefault();
624
- e.target.form.requestSubmit();
 
 
 
 
 
 
 
 
625
  }
626
  }}
627
- />
628
- <button
629
- type="submit"
630
- className="send-button"
631
- disabled={loading}
632
  >
633
- Send Reply
634
- </button>
635
- </form>
636
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  </div>
638
- </>
639
  );
640
  }
 
1
  import React, { useState } from 'react';
2
+ import { Sparkles, Clock, Terminal, ChevronDown, ChevronUp, AlertCircle, Brain, Cpu } from 'lucide-react';
 
3
 
4
  // Render LaTeX and text mixed together
5
  function RenderLatex({ text }) {
 
31
  {inlineParts.map((ip, ipIdx) => {
32
  if (ip.startsWith('$') && ip.endsWith('$')) {
33
  const formula = ip.slice(1, -1);
 
34
  if (formula.trim()) {
35
  try {
36
  if (window.katex) {
 
44
  return <span key={ipIdx}>{ip}</span>;
45
  }
46
 
 
47
  return <span key={ipIdx} dangerouslySetInnerHTML={{ __html: formatInline(ip) }} />;
48
  })}
49
  </React.Fragment>
 
53
  );
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  // A simple local Markdown parser that converts basic markdown elements to safe HTML
57
  function SafeMarkdown({ content }) {
58
  if (!content) return null;
59
 
 
60
  const parts = content.split(/(```[\s\S]*?```)/g);
61
 
62
  return (
63
  <div className="chat-response-content">
64
  {parts.map((part, index) => {
65
  if (part.startsWith('```') && part.endsWith('```')) {
66
+ const code = part.slice(3, -3).replace(/^\w+\n/, '');
 
67
  return (
68
  <pre key={index}>
69
  <code>{code}</code>
 
71
  );
72
  }
73
 
 
74
  const formatted = part
75
  .split('\n\n')
76
  .map((para, paraIdx) => {
 
82
  return (
83
  <ul key={paraIdx} style={{ marginBottom: '1rem', paddingLeft: '1.5rem' }}>
84
  {items.map((item, itemIdx) => {
 
85
  let cleanItem = item;
86
  if (itemIdx === 0) {
87
  cleanItem = item.replace(/^\s*[-*]\s+/, '');
 
97
  );
98
  }
99
 
 
100
  return (
101
  <p key={paraIdx}>
102
  <RenderLatex text={para} />
 
113
  // Format bold (**), italics (*), and inline code (`)
114
  function formatInline(text) {
115
  return text
 
116
  .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
 
117
  .replace(/\*([^*]+)\*/g, '<em>$1</em>')
 
118
  .replace(/`([^`]+)`/g, '<code>$1</code>')
 
119
  .replace(/\n/g, '<br />');
120
  }
121
 
122
  export default function ChatWindow({
123
+ history = [],
124
  loading,
125
  error,
 
 
 
 
 
126
  onSubmitFollowUp
127
  }) {
128
+ const [openInspectors, setOpenInspectors] = useState({});
 
 
 
 
 
129
 
130
+ const toggleInspector = (idx) => {
131
+ setOpenInspectors(prev => ({
132
+ ...prev,
133
+ [idx]: !prev[idx]
134
+ }));
135
+ };
136
 
137
+ // Render loading skeleton
138
+ if (loading && history.length === 0) {
139
  return (
140
+ <div className="single-column-chat">
141
+ <div className="column-card">
142
+ <div className="column-header">
143
+ <div className="column-title-wrapper">
144
+ <h2><Cpu size={18} color="var(--primary)" /> Socratic Tutor</h2>
145
+ <p>Analyzing Sentiment...</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </div>
147
+ <div className="latency-badge">--s</div>
148
  </div>
149
+ <div className="column-body">
150
+ <div className="skeleton-box" />
151
+ <div className="skeleton-wrapper">
152
+ <div className="skeleton-line long" />
153
+ <div className="skeleton-line medium" />
154
+ <div className="skeleton-line long" />
155
+ <div className="skeleton-line short" />
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  </div>
157
  </div>
158
+ </div>
159
  </div>
160
  );
161
  }
 
171
  display: 'flex',
172
  alignItems: 'flex-start',
173
  gap: '1rem',
174
+ color: 'var(--color-frustrated)',
175
+ marginBottom: '1rem'
176
  }}>
177
  <AlertCircle size={24} style={{ flexShrink: 0 }} />
178
  <div>
 
184
  }
185
 
186
  // Render empty state
187
+ if (history.length === 0) {
188
  return (
189
  <div className="empty-state">
190
+ <Brain size={48} className="empty-state-icon" style={{ color: 'var(--primary)' }} />
191
+ <h3>Welcome to Socratic Sentiment Tutor</h3>
192
+ <p>Ask any question about math, science, or programming. The Socratic tutor will detect your mood and guide you towards the answers without giving them away directly.</p>
193
  </div>
194
  );
195
  }
196
 
197
  return (
198
+ <div className="single-column-chat" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
199
+
200
+ {/* Scrollable Conversation Thread */}
201
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem', width: '100%' }}>
202
+ {history.map((msg, idx) => {
203
+ const isUser = msg.role === 'user';
204
+
205
+ if (isUser) {
206
+ return (
207
+ <div
208
+ key={idx}
209
+ style={{
210
+ alignSelf: 'flex-end',
211
+ background: 'linear-gradient(135deg, var(--primary-dark), var(--primary))',
212
+ border: '1px solid rgba(255, 255, 255, 0.1)',
213
+ borderRadius: '16px 16px 4px 16px',
214
+ padding: '0.9rem 1.25rem',
215
+ maxWidth: '85%',
216
+ boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)',
217
+ }}
218
+ >
219
+ <span style={{
220
+ fontSize: '0.65rem',
221
+ fontWeight: 800,
222
+ textTransform: 'uppercase',
223
+ color: 'rgba(255, 255, 255, 0.7)',
224
+ display: 'block',
225
+ marginBottom: '0.25rem',
226
+ letterSpacing: '0.5px'
227
+ }}>
228
+ You
229
+ </span>
230
+ <p style={{ margin: 0, fontSize: '0.975rem', lineHeight: 1.5, color: '#fff' }}>
231
+ {msg.content}
232
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  </div>
234
+ );
235
+ }
 
 
236
 
237
+ // Assistant / Tutor Bubble
238
+ return (
239
+ <div
240
+ key={idx}
241
+ className="column-card"
242
+ style={{
243
+ alignSelf: 'flex-start',
244
+ width: '100%',
245
+ maxWidth: '90%',
246
+ margin: 0,
247
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12)',
248
+ }}
249
+ >
250
+ {/* Card Header with sentiment state & metrics */}
251
+ <div className="column-header" style={{ padding: '0.8rem 1.25rem', borderBottom: '1px solid var(--border-color)' }}>
252
+ <div className="column-title-wrapper">
253
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}>
254
+ <Sparkles size={15} color="var(--secondary)" />
255
+ Socratic Tutor
256
+ </h3>
257
+ </div>
258
+
259
+ {msg.sentiment && (
260
+ <div className="column-meta" style={{ gap: '0.6rem' }}>
261
+ <div className="latency-badge" title="Response Latency & Tokens">
262
+ <Clock size={12} style={{ display: 'inline', marginRight: '4px', verticalAlign: 'middle' }} />
263
+ {msg.latency}s
264
+ {msg.tokens !== undefined && (
265
+ <>
266
+ <span style={{ margin: '0 4px', opacity: 0.3 }}>|</span>
267
+ <span>{msg.tokens}t</span>
268
+ </>
269
+ )}
270
+ {msg.cost !== undefined && (
271
+ <>
272
+ <span style={{ margin: '0 4px', opacity: 0.3 }}>|</span>
273
+ <span>${msg.cost.toFixed(5)}</span>
274
+ </>
275
+ )}
276
+ </div>
277
+ </div>
278
  )}
279
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
+ {/* Chat Bubble Body */}
282
+ <div className="column-body" style={{ padding: '1.25rem', gap: '1rem' }}>
283
+ <SafeMarkdown content={msg.content} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  </div>
 
 
285
 
286
+ {/* Prompt Context Inspector Toggle */}
287
+ {msg.prompt_context && (
288
+ <div className="inspector-section" style={{ borderTop: '1px solid var(--border-color)', borderRadius: '0 0 12px 12px' }}>
289
+ <div
290
+ className="inspector-header"
291
+ onClick={() => toggleInspector(idx)}
292
+ style={{ padding: '0.6rem 1.25rem', fontSize: '0.8rem', background: 'rgba(255,255,255,0.01)' }}
293
+ >
294
+ <span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
295
+ <Terminal size={12} />
296
+ View Socratic Context Inspector
297
+ </span>
298
+ {openInspectors[idx] ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
299
+ </div>
300
+ {openInspectors[idx] && (
301
+ <div className="inspector-body" style={{ fontSize: '0.8rem', whiteSpace: 'pre-wrap', borderTop: '1px solid var(--border-color)', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
302
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
303
+ <strong>Detected Student Sentiment:</strong>
304
+ <span className={`sentiment-badge ${msg.sentiment}`} style={{ fontSize: '0.75rem', padding: '0.2rem 0.5rem', borderRadius: '4px' }}>
305
+ {msg.sentiment.replace(/_/g, ' ')}
306
+ </span>
307
+ </div>
308
+ <div>
309
+ <strong>Prompt Context:</strong>
310
+ <div style={{ marginTop: '0.25rem', opacity: 0.8 }}>
311
+ {msg.prompt_context}
312
+ </div>
313
+ </div>
314
+ </div>
315
  )}
316
  </div>
317
  )}
318
  </div>
319
+ );
320
+ })}
321
+
322
+ {/* Loading Bubble when generating */}
323
+ {loading && (
324
+ <div
325
+ className="column-card"
326
+ style={{
327
+ alignSelf: 'flex-start',
328
+ width: '100%',
329
+ maxWidth: '90%',
330
+ margin: 0,
331
+ opacity: 0.7
332
+ }}
333
+ >
334
+ <div className="column-header" style={{ padding: '0.8rem 1.25rem' }}>
335
+ <div className="column-title-wrapper">
336
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.95rem', fontWeight: 700, margin: 0 }}>
337
+ <Cpu size={15} color="var(--primary)" />
338
+ Socratic Tutor thinking...
339
+ </h3>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  </div>
341
  </div>
342
+ <div className="column-body" style={{ padding: '1.25rem' }}>
343
+ <div className="skeleton-wrapper">
344
+ <div className="skeleton-line long" />
345
+ <div className="skeleton-line medium" />
346
+ <div className="skeleton-line short" />
 
 
 
 
 
347
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  </div>
 
 
 
 
 
349
  </div>
350
+ )}
351
+ </div>
352
 
353
+ {/* Follow-up / Reply Area */}
354
+ {history.length > 0 && !loading && (
355
+ <div
356
+ className="query-card"
357
+ style={{
358
+ marginTop: '1.5rem',
359
+ background: 'rgba(99, 102, 241, 0.03)',
360
+ borderColor: 'var(--primary-glow)',
361
+ boxShadow: 'none',
362
+ padding: '1.25rem'
 
 
 
 
 
 
 
 
 
 
363
  }}
 
364
  >
365
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.8rem' }}>
366
+ <h3 style={{ fontSize: '0.95rem', fontWeight: 800, display: 'flex', alignItems: 'center', gap: '0.4rem', margin: 0 }}>
367
+ <Brain size={16} color="var(--primary)" />
368
+ Socratic Dialogue
369
+ </h3>
370
+ <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
371
+ Reply to the Socratic tutor's guide question to continue exploring the concept.
372
+ </p>
373
+ </div>
374
+ <form
375
+ onSubmit={(e) => {
376
+ e.preventDefault();
377
+ const inputEl = e.target.elements.followUpText;
378
+ const val = inputEl.value.trim();
379
+ if (val) {
380
+ onSubmitFollowUp(val);
381
+ inputEl.value = '';
382
  }
383
  }}
384
+ className="query-input-wrapper"
 
 
 
 
385
  >
386
+ <textarea
387
+ name="followUpText"
388
+ placeholder="Provide your Socratic response..."
389
+ className="query-textarea"
390
+ style={{ minHeight: '60px' }}
391
+ onKeyDown={(e) => {
392
+ if (e.key === 'Enter' && !e.shiftKey) {
393
+ e.preventDefault();
394
+ e.target.form.requestSubmit();
395
+ }
396
+ }}
397
+ />
398
+ <button
399
+ type="submit"
400
+ className="send-button"
401
+ style={{ padding: '0.6rem 1.25rem' }}
402
+ >
403
+ Reply
404
+ </button>
405
+ </form>
406
+ </div>
407
+ )}
408
  </div>
 
409
  );
410
  }
frontend/src/components/EmotionChart.jsx DELETED
@@ -1,97 +0,0 @@
1
- import React, { useState } from 'react';
2
- import { ChevronDown, ChevronUp, BarChart2 } from 'lucide-react';
3
-
4
- export default function EmotionChart({ rawEmotions, modelName = "RoBERTa" }) {
5
- const [showAll, setShowAll] = useState(false);
6
-
7
- if (!rawEmotions || rawEmotions.length === 0) {
8
- return <p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>No emotional details available.</p>;
9
- }
10
-
11
- // Sort by score descending
12
- const sorted = [...rawEmotions].sort((a, b) => b.score - a.score);
13
-
14
- // Decide how many to display
15
- const displayedEmotions = showAll ? sorted : sorted.slice(0, 5);
16
-
17
- // Group emotions for coloring
18
- const happySet = new Set(["joy", "amusement", "excitement", "pride", "optimism", "relief", "love", "admiration", "gratitude", "approval"]);
19
- const sadSet = new Set(["sadness", "grief", "remorse", "disappointment", "embarrassment"]);
20
- const frustratedSet = new Set(["anger", "annoyance", "disapproval", "disgust", "fear", "nervousness"]);
21
-
22
- const getBarColor = (label) => {
23
- if (happySet.has(label)) return 'var(--color-happy)';
24
- if (sadSet.has(label)) return 'var(--color-sad)';
25
- if (frustratedSet.has(label)) return 'var(--color-frustrated)';
26
- return 'var(--color-neutral)';
27
- };
28
-
29
- return (
30
- <div className="emotion-chart-container">
31
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
32
- <h4 style={{
33
- fontSize: '0.8rem',
34
- color: 'var(--text-muted)',
35
- textTransform: 'uppercase',
36
- letterSpacing: '0.5px',
37
- display: 'flex',
38
- alignItems: 'center',
39
- gap: '0.3rem'
40
- }}>
41
- <BarChart2 size={12} />
42
- Local {modelName} Model Emotion Breakdown
43
- </h4>
44
- <button
45
- onClick={() => setShowAll(!showAll)}
46
- style={{
47
- background: 'none',
48
- border: 'none',
49
- color: 'var(--primary)',
50
- fontSize: '0.75rem',
51
- cursor: 'pointer',
52
- display: 'flex',
53
- alignItems: 'center',
54
- gap: '0.2rem',
55
- fontWeight: 600
56
- }}
57
- >
58
- {showAll ? (
59
- <>
60
- Show Top 5 <ChevronUp size={12} />
61
- </>
62
- ) : (
63
- <>
64
- Show All 28 <ChevronDown size={12} />
65
- </>
66
- )}
67
- </button>
68
- </div>
69
-
70
- <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
71
- {displayedEmotions.map((item, idx) => {
72
- const percentage = (item.score * 100).toFixed(1);
73
- return (
74
- <div key={idx} className="emotion-bar-row">
75
- <div className="emotion-bar-label" title={item.label}>
76
- {item.label}
77
- </div>
78
- <div className="emotion-bar-track">
79
- <div
80
- className="emotion-bar-fill"
81
- style={{
82
- width: `${percentage}%`,
83
- backgroundColor: getBarColor(item.label),
84
- boxShadow: `0 0 6px ${getBarColor(item.label)}33`
85
- }}
86
- />
87
- </div>
88
- <div className="emotion-bar-value">
89
- {percentage}%
90
- </div>
91
- </div>
92
- );
93
- })}
94
- </div>
95
- </div>
96
- );
97
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/Settings.jsx CHANGED
@@ -1,34 +1,19 @@
1
  import React, { useState } from 'react';
2
- import { Eye, EyeOff, Key, Sparkles, RefreshCw } from 'lucide-react';
3
 
4
  export default function Settings({
5
  apiKey,
6
  setApiKey,
7
- systemPrompt,
8
- setSystemPrompt,
9
  backendStatus,
10
  checkBackendStatus
11
  }) {
12
  const [showKey, setShowKey] = useState(false);
13
 
14
- const getModelStatusText = (status, error) => {
15
- if (status === 'loading') return 'Downloading/Loading...';
16
- if (status === 'ready') return 'Ready';
17
- if (status === 'failed') return `Error: ${error || 'Failed to load model'}`;
18
- return 'Connecting...';
19
- };
20
-
21
- const getModelStatusClass = (status) => {
22
- if (status === 'ready') return 'ready';
23
- if (status === 'failed') return 'failed';
24
- return 'loading';
25
- };
26
-
27
  return (
28
  <div className="settings-drawer">
29
  <div className="settings-group">
30
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
31
- <label style={{ display: 'flex', alignStatus: 'center', gap: '0.5rem' }}>
32
  <Key size={16} color="var(--primary)" />
33
  Gemini API Key
34
  </label>
@@ -63,19 +48,6 @@ export default function Settings({
63
  </div>
64
  </div>
65
 
66
- <div className="settings-group">
67
- <label style={{ display: 'flex', alignStatus: 'center', gap: '0.5rem' }}>
68
- <Sparkles size={16} color="var(--secondary)" />
69
- Chatbot System Instruction
70
- </label>
71
- <textarea
72
- value={systemPrompt}
73
- onChange={(e) => setSystemPrompt(e.target.value)}
74
- placeholder="You are a helpful educational AI assistant..."
75
- rows={3}
76
- />
77
- </div>
78
-
79
  <div style={{
80
  display: 'flex',
81
  justifyContent: 'space-between',
@@ -85,17 +57,19 @@ export default function Settings({
85
  marginTop: '0.5rem'
86
  }}>
87
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
88
- <span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Backend DistilRoBERTa Status:</span>
89
  <span className={`status-badge`} style={{ padding: '0.2rem 0.6rem' }}>
90
- <span className={`status-dot ${getModelStatusClass(backendStatus.roberta_status)}`}></span>
91
- <span style={{ marginLeft: '4px', fontSize: '0.8rem' }}>{getModelStatusText(backendStatus.roberta_status, backendStatus.roberta_error)}</span>
 
 
92
  </span>
93
  </div>
94
  <button
95
  onClick={checkBackendStatus}
96
  className="settings-toggle-btn"
97
  style={{ padding: '0.35rem 0.75rem' }}
98
- title="Refresh Backend Status"
99
  >
100
  <RefreshCw size={14} />
101
  Refresh
 
1
  import React, { useState } from 'react';
2
+ import { Eye, EyeOff, Key, RefreshCw } from 'lucide-react';
3
 
4
  export default function Settings({
5
  apiKey,
6
  setApiKey,
 
 
7
  backendStatus,
8
  checkBackendStatus
9
  }) {
10
  const [showKey, setShowKey] = useState(false);
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  return (
13
  <div className="settings-drawer">
14
  <div className="settings-group">
15
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
16
+ <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
17
  <Key size={16} color="var(--primary)" />
18
  Gemini API Key
19
  </label>
 
48
  </div>
49
  </div>
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  <div style={{
52
  display: 'flex',
53
  justifyContent: 'space-between',
 
57
  marginTop: '0.5rem'
58
  }}>
59
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
60
+ <span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Gemini Connection:</span>
61
  <span className={`status-badge`} style={{ padding: '0.2rem 0.6rem' }}>
62
+ <span className={`status-dot ${backendStatus.gemini_api_key_configured || apiKey ? 'ready' : 'loading'}`}></span>
63
+ <span style={{ marginLeft: '4px', fontSize: '0.8rem' }}>
64
+ {backendStatus.gemini_api_key_configured || apiKey ? 'Configured' : 'Missing API Key'}
65
+ </span>
66
  </span>
67
  </div>
68
  <button
69
  onClick={checkBackendStatus}
70
  className="settings-toggle-btn"
71
  style={{ padding: '0.35rem 0.75rem' }}
72
+ title="Refresh connection status"
73
  >
74
  <RefreshCw size={14} />
75
  Refresh
frontend/src/components/VoiceSessionModal.jsx ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { Mic, X, MicOff, AlertCircle } from 'lucide-react';
3
+
4
+ export default function VoiceSessionModal({ isOpen, onClose, apiKey }) {
5
+ const [status, setStatus] = useState('connecting'); // 'connecting', 'listening', 'speaking', 'error'
6
+ const [errorMessage, setErrorMessage] = useState('');
7
+ const [isMuted, setIsMuted] = useState(false);
8
+
9
+ const wsRef = useRef(null);
10
+ const audioContextRef = useRef(null);
11
+ const playbackContextRef = useRef(null);
12
+ const streamRef = useRef(null);
13
+ const processorRef = useRef(null);
14
+ const nextPlayTimeRef = useRef(0);
15
+ const textTranscriptRef = useRef('');
16
+ const [transcript, setTranscript] = useState('');
17
+
18
+ // Helper: Convert ArrayBuffer to Base64
19
+ const base64ArrayBuffer = (arrayBuffer) => {
20
+ let binary = '';
21
+ const bytes = new Uint8Array(arrayBuffer);
22
+ const len = bytes.byteLength;
23
+ for (let i = 0; i < len; i++) {
24
+ binary += String.fromCharCode(bytes[i]);
25
+ }
26
+ return window.btoa(binary);
27
+ };
28
+
29
+ useEffect(() => {
30
+ if (!isOpen) return;
31
+
32
+ setStatus('connecting');
33
+ setTranscript('');
34
+ textTranscriptRef.current = '';
35
+
36
+ // Determine WebSocket URL
37
+ const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
38
+ const host = window.location.host === 'localhost:5173' ? 'localhost:8000' : window.location.host;
39
+ const wsUrl = `${protocol}${host}/api/live-ws?api_key=${apiKey || ''}`;
40
+
41
+ // Establish WebSocket Connection
42
+ const ws = new WebSocket(wsUrl);
43
+ wsRef.current = ws;
44
+
45
+ ws.onopen = async () => {
46
+ try {
47
+ // Request Microphone access
48
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
49
+ streamRef.current = stream;
50
+
51
+ // Initialize Audio contexts
52
+ audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
53
+ playbackContextRef.current = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
54
+ nextPlayTimeRef.current = playbackContextRef.current.currentTime;
55
+
56
+ // Capture Mic Input
57
+ const source = audioContextRef.current.createMediaStreamSource(stream);
58
+ const processor = audioContextRef.current.createScriptProcessor(2048, 1, 1);
59
+ processorRef.current = processor;
60
+
61
+ processor.onaudioprocess = (e) => {
62
+ if (isMuted) return;
63
+
64
+ const inputData = e.inputBuffer.getChannelData(0);
65
+ // Convert Float32 to Int16 PCM
66
+ const pcmData = new Int16Array(inputData.length);
67
+ for (let i = 0; i < inputData.length; i++) {
68
+ pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 0x7FFF;
69
+ }
70
+
71
+ // Send chunk to server
72
+ if (ws.readyState === WebSocket.OPEN) {
73
+ const base64Audio = base64ArrayBuffer(pcmData.buffer);
74
+ ws.send(JSON.stringify({ type: 'audio', data: base64Audio }));
75
+ }
76
+ };
77
+
78
+ source.connect(processor);
79
+ processor.connect(audioContextRef.current.destination);
80
+
81
+ setStatus('listening');
82
+ } catch (err) {
83
+ console.error("Microphone access failed:", err);
84
+ setStatus('error');
85
+ setErrorMessage("Microphone access is required for voice session. Please allow mic permissions.");
86
+ }
87
+ };
88
+
89
+ ws.onmessage = async (event) => {
90
+ const message = JSON.parse(event.data);
91
+
92
+ if (message.type === 'audio') {
93
+ setStatus('speaking');
94
+
95
+ // Decode base64 24kHz PCM back to Float32 for Web Audio playback
96
+ const binary = window.atob(message.data);
97
+ const bytes = new Uint8Array(binary.length);
98
+ for (let i = 0; i < binary.length; i++) {
99
+ bytes[i] = binary.charCodeAt(i);
100
+ }
101
+
102
+ const int16Data = new Int16Array(bytes.buffer);
103
+ const float32Data = new Float32Array(int16Data.length);
104
+ for (let i = 0; i < int16Data.length; i++) {
105
+ float32Data[i] = int16Data[i] / 0x7FFF;
106
+ }
107
+
108
+ const pContext = playbackContextRef.current;
109
+ if (pContext && pContext.state !== 'suspended') {
110
+ const audioBuffer = pContext.createBuffer(1, float32Data.length, 24000);
111
+ audioBuffer.getChannelData(0).set(float32Data);
112
+
113
+ const bufferSource = pContext.createBufferSource();
114
+ bufferSource.buffer = audioBuffer;
115
+ bufferSource.connect(pContext.destination);
116
+
117
+ // Gapless scheduling
118
+ const startTime = Math.max(pContext.currentTime, nextPlayTimeRef.current);
119
+ bufferSource.start(startTime);
120
+ nextPlayTimeRef.current = startTime + audioBuffer.duration;
121
+ }
122
+ } else if (message.type === 'text') {
123
+ // Handle incoming Socratic tutor speech transcription
124
+ textTranscriptRef.current += message.data;
125
+ setTranscript(textTranscriptRef.current);
126
+ } else if (message.type === 'turn_complete') {
127
+ setStatus('listening');
128
+ textTranscriptRef.current = '';
129
+ }
130
+ };
131
+
132
+ ws.onerror = (err) => {
133
+ console.error("WebSocket error:", err);
134
+ setStatus('error');
135
+ setErrorMessage("Lost connection to Gemini Live server.");
136
+ };
137
+
138
+ ws.onclose = () => {
139
+ setStatus('connecting');
140
+ };
141
+
142
+ return () => {
143
+ // Clean up connections and audio context on unmount
144
+ if (wsRef.current) wsRef.current.close();
145
+ if (processorRef.current) processorRef.current.disconnect();
146
+ if (streamRef.current) {
147
+ streamRef.current.getTracks().forEach(track => track.stop());
148
+ }
149
+ if (audioContextRef.current) audioContextRef.current.close();
150
+ if (playbackContextRef.current) playbackContextRef.current.close();
151
+ };
152
+ }, [isOpen, isMuted]);
153
+
154
+ if (!isOpen) return null;
155
+
156
+ return (
157
+ <div className="voice-modal-backdrop">
158
+ <div className="voice-modal-content">
159
+ <button className="voice-modal-close" onClick={onClose}>
160
+ <X size={20} />
161
+ </button>
162
+
163
+ <div className="voice-modal-header">
164
+ <h2>Socratic Voice Space</h2>
165
+ <p>Real-Time Bidirectional Dialogue</p>
166
+ </div>
167
+
168
+ {/* Pulse Animations and Mic States */}
169
+ <div className="voice-visualizer-container">
170
+ {status === 'connecting' && (
171
+ <div className="voice-status-indicator connecting">
172
+ <div className="pulse-circle" />
173
+ <span>Connecting to Gemini Live...</span>
174
+ </div>
175
+ )}
176
+
177
+ {status === 'listening' && (
178
+ <div className="voice-status-indicator listening">
179
+ <div className="pulse-circle active" />
180
+ <div className="pulse-ring ring-1" />
181
+ <div className="pulse-ring ring-2" />
182
+ <span style={{ color: 'var(--color-happy)' }}>Listening to you... Go ahead and speak!</span>
183
+ </div>
184
+ )}
185
+
186
+ {status === 'speaking' && (
187
+ <div className="voice-status-indicator speaking">
188
+ <div className="pulse-circle active speaking-pulse" />
189
+ <div className="pulse-ring ring-1 speaking-ring" />
190
+ <div className="pulse-ring ring-2 speaking-ring" />
191
+ <span style={{ color: 'var(--secondary)' }}>Socratic Tutor is speaking...</span>
192
+ </div>
193
+ )}
194
+
195
+ {status === 'error' && (
196
+ <div className="voice-status-indicator error" style={{ gap: '0.8rem' }}>
197
+ <AlertCircle size={40} color="var(--color-frustrated)" />
198
+ <p style={{ color: 'var(--color-frustrated)', fontSize: '0.9rem', textAlign: 'center', maxWidth: '300px' }}>
199
+ {errorMessage}
200
+ </p>
201
+ </div>
202
+ )}
203
+ </div>
204
+
205
+ {/* Transcription Display */}
206
+ {status === 'speaking' && transcript && (
207
+ <div className="voice-transcript-box">
208
+ <p>"{transcript}"</p>
209
+ </div>
210
+ )}
211
+
212
+ {/* Control Buttons */}
213
+ <div className="voice-modal-controls">
214
+ <button
215
+ className={`voice-control-btn ${isMuted ? 'muted' : ''}`}
216
+ onClick={() => setIsMuted(!isMuted)}
217
+ disabled={status === 'error' || status === 'connecting'}
218
+ title={isMuted ? "Unmute microphone" : "Mute microphone"}
219
+ >
220
+ {isMuted ? <MicOff size={22} /> : <Mic size={22} />}
221
+ <span>{isMuted ? "Muted" : "Active"}</span>
222
+ </button>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ );
227
+ }