Daniel McKnight NeonClary commited on
Commit
98d14cc
·
unverified ·
1 Parent(s): f11238d

Improve backend logic around user input parsing (#12)

Browse files

* fix: improve canvas confidence scores and insight extraction

Switch insight extraction from fragile JSON parsing to plain-text numbered list format. Add content-driven confidence variability: LLM-extracted insights score 0.75-0.90 based on length, rule-based insights score 0.55-0.70 based on pattern match count.

Made-with: Cursor

* fix: improve orchestrator clarification and LLM response handling

Rewrite _needs_clarification to check keywords before length, preventing false triggers. Add generate_contextual_clarification using LLM for tailored follow-up questions. Wire clarification check into /api/chat. Filter Gemini 2.5 thinking-mode parts from responses. Clean up _clean_response in both Gemini and Ollama clients to preserve Markdown.

Made-with: Cursor

* Address issues noted in review

* Revert JSON prompting and clean up imports

* Refactor input sanitization to log malformed input requests

---------

Co-authored-by: NeonClary <askclary@gmail.com>

multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -194,9 +194,32 @@ async def chat_sequential_enhanced(
194
  rag_stats = session.get_rag_stats()
195
  logger.info(f"Session {session_id} has {rag_stats.get('total_documents', 0)} documents available")
196
 
197
- # Add user message to session (needed for persona ranking)
 
 
 
 
 
 
 
198
  session.append_message("user", message.user_input)
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  # RESTORED: Get intelligently ordered personas based on context
201
  top_personas = await chat_orchestrator.get_top_personas(
202
  session_id=session_id,
@@ -434,4 +457,4 @@ async def ask_question(query: PersonaQuery, request: Request):
434
 
435
  except Exception as e:
436
  logger.error(f"Error in ask endpoint: {str(e)}")
437
- return {"response": "I encountered an error. Please try again."}
 
194
  rag_stats = session.get_rag_stats()
195
  logger.info(f"Session {session_id} has {rag_stats.get('total_documents', 0)} documents available")
196
 
197
+ # Warn if a repeated input message is received
198
+ if all((
199
+ session.messages,
200
+ session.messages[-1].get('role') == 'user',
201
+ session.messages[-1].get('content') == message.user_input
202
+ )):
203
+ # TODO: This should be handled in the front-end input
204
+ logger.warning(f"Repeated user input: {message.user_input}")
205
  session.append_message("user", message.user_input)
206
 
207
+ # Check if the user's message is vague and needs clarification
208
+ if chat_orchestrator._needs_clarification(session, message.user_input):
209
+ clarification = await chat_orchestrator.generate_contextual_clarification(
210
+ message.user_input
211
+ )
212
+ logger.info(f"Clarification triggered for input: {message.user_input!r}")
213
+ return {
214
+ "status": "clarification_needed",
215
+ "message": clarification["question"],
216
+ "suggestions": clarification["suggestions"],
217
+ "session_debug": {
218
+ "session_id": session_id,
219
+ "trigger": "vague_input"
220
+ }
221
+ }
222
+
223
  # RESTORED: Get intelligently ordered personas based on context
224
  top_personas = await chat_orchestrator.get_top_personas(
225
  session_id=session_id,
 
457
 
458
  except Exception as e:
459
  logger.error(f"Error in ask endpoint: {str(e)}")
460
+ return {"response": "I encountered an error. Please try again."}
multi_llm_chatbot_backend/app/config.py CHANGED
@@ -101,8 +101,16 @@ class PersonasConfig(BaseModel):
101
  class OrchestratorConfig(BaseModel):
102
  min_words_without_keywords: int = 6
103
  specific_keywords: List[str] = []
104
- clarification_questions: List[str] = []
105
- clarification_suggestions: List[str] = []
 
 
 
 
 
 
 
 
106
 
107
 
108
  class AuthConfig(BaseModel):
 
101
  class OrchestratorConfig(BaseModel):
102
  min_words_without_keywords: int = 6
103
  specific_keywords: List[str] = []
104
+ clarification_questions: List[str] = [
105
+ "Could you provide more details about what you need help with?"]
106
+ clarification_suggestions: List[str] = [
107
+ "Provide more details about your question."]
108
+
109
+ @model_validator(mode="after")
110
+ def validate_clarificaiton_questions(self):
111
+ if len(self.clarification_questions) < 1:
112
+ raise ValueError("At least one clarification question is required.")
113
+ return self
114
 
115
 
116
  class AuthConfig(BaseModel):
multi_llm_chatbot_backend/app/core/canvas_analysis.py CHANGED
@@ -1,13 +1,10 @@
1
  import re
2
  import json
3
  import logging
4
- from typing import Dict, List, Tuple, Set
5
- from datetime import datetime
6
  from collections import defaultdict
7
 
8
- from app.models.phd_canvas import CanvasInsight, CanvasSection
9
- from app.llm.improved_gemini_client import ImprovedGeminiClient
10
- from app.llm.improved_ollama_client import ImprovedOllamaClient
11
  from app.config import get_settings
12
 
13
  logger = logging.getLogger(__name__)
@@ -123,11 +120,10 @@ class CanvasAnalysisService:
123
 
124
  async def _extract_insights_from_content(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
125
  """Extract actionable insights from content using LLM analysis"""
126
- if not content or len(content.strip()) < 30: # Lowered minimum length
127
  return []
128
 
129
  try:
130
- # Use LLM to extract key insights
131
  app_title = get_settings().app.title
132
  extraction_prompt = f"""
133
  Extract actionable insights from this {app_title} advisor response that would be valuable for a user's progress summary:
@@ -145,7 +141,7 @@ class CanvasAnalysisService:
145
 
146
  Return ONLY the JSON array, no other text.
147
  """
148
-
149
  if self.llm_client:
150
  try:
151
  llm_response = await self.llm_client.generate(
@@ -154,7 +150,7 @@ class CanvasAnalysisService:
154
  temperature=0.3,
155
  max_tokens=500
156
  )
157
-
158
  # Clean the response to extract just the JSON
159
  llm_response = llm_response.strip()
160
  if llm_response.startswith('```json'):
@@ -162,10 +158,10 @@ class CanvasAnalysisService:
162
  if llm_response.endswith('```'):
163
  llm_response = llm_response[:-3]
164
  llm_response = llm_response.strip()
165
-
166
  insights_data = json.loads(llm_response)
167
  insights = []
168
-
169
  for item in insights_data:
170
  if isinstance(item, dict) and "insight" in item:
171
  insight = CanvasInsight(
@@ -178,18 +174,14 @@ class CanvasAnalysisService:
178
  )
179
  insights.append(insight)
180
 
181
- logger.debug(f"LLM extracted {len(insights)} insights from {persona_id}")
182
- return insights
 
 
 
183
 
184
- except json.JSONDecodeError as je:
185
- logger.warning(f"Failed to parse LLM insights response for {persona_id}: {je}")
186
- logger.debug(f"Raw LLM response: {llm_response}")
187
- # Fallback to rule-based extraction
188
- return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
189
  except Exception as llm_error:
190
  logger.warning(f"LLM extraction failed for {persona_id}: {llm_error}")
191
- # Fallback to rule-based extraction
192
- return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
193
 
194
  # Fallback if no LLM available
195
  return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
@@ -216,19 +208,18 @@ class CanvasAnalysisService:
216
  if len(sentence) < 15: # Lowered minimum length
217
  continue
218
 
219
- # Check if sentence contains actionable language
220
- is_actionable = any(re.search(pattern, sentence, re.IGNORECASE) for pattern in actionable_patterns)
221
 
222
- if is_actionable:
223
- # Extract keywords
224
  keywords = self._extract_keywords_from_sentence(sentence)
 
225
 
226
  insight = CanvasInsight(
227
  content=sentence,
228
  source_persona=persona_id,
229
  source_message_id=message_id,
230
  source_chat_session=chat_session_id,
231
- confidence_score=0.6, # Lower confidence for rule-based
232
  keywords=keywords
233
  )
234
  insights.append(insight)
@@ -300,4 +291,4 @@ class CanvasAnalysisService:
300
  seen_content.add(content_key)
301
  unique_insights.append(insight)
302
 
303
- return unique_insights
 
1
  import re
2
  import json
3
  import logging
4
+ from typing import Dict, List
 
5
  from collections import defaultdict
6
 
7
+ from app.models.phd_canvas import CanvasInsight
 
 
8
  from app.config import get_settings
9
 
10
  logger = logging.getLogger(__name__)
 
120
 
121
  async def _extract_insights_from_content(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
122
  """Extract actionable insights from content using LLM analysis"""
123
+ if not content or len(content.strip()) < 30:
124
  return []
125
 
126
  try:
 
127
  app_title = get_settings().app.title
128
  extraction_prompt = f"""
129
  Extract actionable insights from this {app_title} advisor response that would be valuable for a user's progress summary:
 
141
 
142
  Return ONLY the JSON array, no other text.
143
  """
144
+
145
  if self.llm_client:
146
  try:
147
  llm_response = await self.llm_client.generate(
 
150
  temperature=0.3,
151
  max_tokens=500
152
  )
153
+
154
  # Clean the response to extract just the JSON
155
  llm_response = llm_response.strip()
156
  if llm_response.startswith('```json'):
 
158
  if llm_response.endswith('```'):
159
  llm_response = llm_response[:-3]
160
  llm_response = llm_response.strip()
161
+
162
  insights_data = json.loads(llm_response)
163
  insights = []
164
+
165
  for item in insights_data:
166
  if isinstance(item, dict) and "insight" in item:
167
  insight = CanvasInsight(
 
174
  )
175
  insights.append(insight)
176
 
177
+ if insights:
178
+ logger.debug(f"LLM extracted {len(insights)} insights from {persona_id}")
179
+ return insights
180
+
181
+ logger.warning(f"LLM returned no parseable insights for {persona_id}, falling back to rule-based")
182
 
 
 
 
 
 
183
  except Exception as llm_error:
184
  logger.warning(f"LLM extraction failed for {persona_id}: {llm_error}")
 
 
185
 
186
  # Fallback if no LLM available
187
  return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
 
208
  if len(sentence) < 15: # Lowered minimum length
209
  continue
210
 
211
+ match_count = sum(1 for pattern in actionable_patterns if re.search(pattern, sentence, re.IGNORECASE))
 
212
 
213
+ if match_count > 0:
 
214
  keywords = self._extract_keywords_from_sentence(sentence)
215
+ confidence = round(0.55 + min(match_count * 0.05, 0.15), 2)
216
 
217
  insight = CanvasInsight(
218
  content=sentence,
219
  source_persona=persona_id,
220
  source_message_id=message_id,
221
  source_chat_session=chat_session_id,
222
+ confidence_score=confidence,
223
  keywords=keywords
224
  )
225
  insights.append(insight)
 
291
  seen_content.add(content_key)
292
  unique_insights.append(insight)
293
 
294
+ return unique_insights
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -3,8 +3,6 @@ from app.models.persona import Persona
3
  from app.core.session_manager import ConversationContext, get_session_manager
4
  from app.core.context_manager import get_context_manager
5
  from app.core.rag_manager import get_rag_manager
6
- from app.llm.llm_client import LLMClient
7
- from app.models.default_personas import is_valid_persona_id
8
  from app.config import get_settings
9
 
10
  import json
@@ -149,6 +147,7 @@ class ImprovedChatOrchestrator:
149
  # If this is not the first message, probably don't need clarification
150
  user_messages = [msg for msg in session.messages if msg.get('role') == 'user']
151
  if len(user_messages) > 1:
 
152
  return False
153
 
154
  # Check for vague patterns - FIXED to handle "I am" vs "I'm"
@@ -165,47 +164,94 @@ class ImprovedChatOrchestrator:
165
  ]
166
 
167
  orch_cfg = get_settings().orchestrator
168
-
169
  user_lower = user_input.lower().strip()
170
-
171
- logger.info(f"Checking clarification for: '{user_input}' (lowercase: '{user_lower}')")
172
-
173
- for pattern in vague_patterns:
174
- if re.search(pattern, user_lower):
175
- logger.info(f"CLARIFICATION TRIGGERED: Pattern '{pattern}' matched input '{user_input}'")
176
- return True
177
-
178
- # Check if input is too short and vague
179
  word_count = len(user_input.split())
 
 
 
180
  has_specific_keywords = any(
181
  keyword in user_lower for keyword in orch_cfg.specific_keywords
182
  )
183
-
184
- if word_count < orch_cfg.min_words_without_keywords and not has_specific_keywords:
185
- logger.info(f"CLARIFICATION TRIGGERED: Short input ({word_count} words) without specific keywords")
186
- return True
187
-
188
- logger.info(f"NO CLARIFICATION: Input has {word_count} words, specific keywords: {has_specific_keywords}")
189
- return False
 
 
 
 
 
 
 
 
 
 
190
 
191
- async def _generate_clarification_question(self, session: ConversationContext) -> str:
192
  """
193
- Generate a clarification question based on the conversation context.
194
- Questions are driven by config.yaml → orchestrator.clarification_questions.
 
195
  """
196
  orch_cfg = get_settings().orchestrator
197
- questions = orch_cfg.clarification_questions or [
198
- "Could you provide more details about what you need help with?"
199
- ]
200
- # Return the first option for now (could be made smarter with AI)
201
- return questions[0]
202
-
203
- def _get_clarification_suggestions(self) -> List[str]:
204
- """Get suggestions for clarification from config."""
205
- orch_cfg = get_settings().orchestrator
206
- return orch_cfg.clarification_suggestions or [
207
- "Provide more details about your question"
208
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  async def _generate_persona_responses(self, session: ConversationContext, response_length: str = "medium"):
211
  """
 
3
  from app.core.session_manager import ConversationContext, get_session_manager
4
  from app.core.context_manager import get_context_manager
5
  from app.core.rag_manager import get_rag_manager
 
 
6
  from app.config import get_settings
7
 
8
  import json
 
147
  # If this is not the first message, probably don't need clarification
148
  user_messages = [msg for msg in session.messages if msg.get('role') == 'user']
149
  if len(user_messages) > 1:
150
+ logger.info(f"Skipping clarification: session already has {len(user_messages)} user message(s)")
151
  return False
152
 
153
  # Check for vague patterns - FIXED to handle "I am" vs "I'm"
 
164
  ]
165
 
166
  orch_cfg = get_settings().orchestrator
 
167
  user_lower = user_input.lower().strip()
 
 
 
 
 
 
 
 
 
168
  word_count = len(user_input.split())
169
+
170
+ logger.info(f"Checking clarification for: {user_input} ({word_count} words)")
171
+
172
  has_specific_keywords = any(
173
  keyword in user_lower for keyword in orch_cfg.specific_keywords
174
  )
175
+ if has_specific_keywords:
176
+ logger.info("NO CLARIFICATION: input contains specific keywords")
177
+ return False
178
+
179
+ if word_count >= orch_cfg.min_words_without_keywords:
180
+ logger.info(
181
+ f"NO CLARIFICATION: input has {word_count} words "
182
+ f"(>= {orch_cfg.min_words_without_keywords} threshold)")
183
+ return False
184
+
185
+ for pattern in vague_patterns:
186
+ if re.search(pattern, user_lower):
187
+ logger.info(f"CLARIFICATION TRIGGERED: pattern `{pattern}` matched `{user_input}`")
188
+ return True
189
+
190
+ logger.info("CLARIFICATION TRIGGERED: short input (%d words) without specific keywords", word_count)
191
+ return True
192
 
193
+ async def generate_contextual_clarification(self, user_input: str) -> Dict[str, Any]:
194
  """
195
+ Use the LLM to produce a clarification question and clickable
196
+ suggestions that are tailored to what the user actually typed.
197
+ Falls back to the static values in config.yaml if the LLM call fails.
198
  """
199
  orch_cfg = get_settings().orchestrator
200
+
201
+ advisor_list = ", ".join(
202
+ f"{p.name} ({p.id})" for p in self.personas.values()
203
+ )
204
+
205
+ system_prompt = (
206
+ "You are a helpful routing assistant. The user's message is too "
207
+ "vague to send to the advisors. Produce a short clarifying question "
208
+ "and exactly 4 clickable suggestion buttons the user could press.\n\n"
209
+ "Reply ONLY with valid JSON — no markdown, no extra text:\n"
210
+ '{"question": "...", "suggestions": ["...", "...", "...", "..."]}\n\n'
211
+ "Keep the question to one sentence. Each suggestion should be a "
212
+ "complete sentence the user could send as their next message."
213
+ )
214
+
215
+ user_prompt = (
216
+ f"User said: \"{user_input}\"\n"
217
+ f"Available advisors: {advisor_list}\n\n"
218
+ "Generate a clarifying question and 4 suggestion buttons that "
219
+ "relate to what the user said and steer toward the advisors above."
220
+ )
221
+
222
+ try:
223
+ llm = next(iter(self.personas.values())).llm
224
+ raw = await llm.generate(
225
+ system_prompt=system_prompt,
226
+ context=[{"role": "user", "content": user_prompt}],
227
+ temperature=0.4,
228
+ max_tokens=1024,
229
+ )
230
+
231
+ cleaned = raw.strip()
232
+ cleaned = re.sub(r"```(?:json)?", "", cleaned).strip()
233
+
234
+ json_match = re.search(r"\{.*\}", cleaned, re.DOTALL)
235
+ if json_match:
236
+ cleaned = json_match.group(0)
237
+
238
+ parsed = json.loads(cleaned)
239
+ question = parsed.get("question", "").strip()
240
+ suggestions = parsed.get("suggestions", [])
241
+
242
+ if question and isinstance(suggestions, list) and len(suggestions) >= 2:
243
+ logger.info(f"LLM clarification generated for: {user_input}")
244
+ return {"question": question, "suggestions": suggestions[:4]}
245
+
246
+ except Exception as e:
247
+ logger.error(f"LLM clarification failed, using config fallback: {e}")
248
+
249
+ fallback_questions = orch_cfg.clarification_questions
250
+ fallback_suggestions = orch_cfg.clarification_suggestions
251
+ return {
252
+ "question": fallback_questions[0],
253
+ "suggestions": fallback_suggestions,
254
+ }
255
 
256
  async def _generate_persona_responses(self, session: ConversationContext, response_length: str = "medium"):
257
  """
multi_llm_chatbot_backend/app/llm/improved_gemini_client.py CHANGED
@@ -1,4 +1,6 @@
1
  import httpx
 
 
2
  from typing import List
3
  from app.llm.llm_client import LLMClient
4
  from app.core.context_manager import get_context_manager
@@ -91,7 +93,12 @@ class ImprovedGeminiClient(LLMClient):
91
  logger.error(f"Invalid candidate structure: {candidate}")
92
  return "I apologize, but I received an unexpected response format. Please try again."
93
 
94
- text = candidate["content"]["parts"][0].get("text", "").strip()
 
 
 
 
 
95
 
96
  if not text:
97
  logger.warning("Empty response from Gemini")
@@ -110,21 +117,9 @@ class ImprovedGeminiClient(LLMClient):
110
  return "I encountered an unexpected error. Please try again."
111
 
112
  def _clean_response(self, response: str) -> str:
113
- """Clean up response text"""
114
- # Remove common issues
115
- """response = response.strip()
116
-
117
- # Remove duplicate spaces and normalize
118
- response = ' '.join(response.split())
119
-
120
- return response"""
121
- response = response.strip()
122
-
123
- # Preserve line breaks for Markdown; only trim right-side spaces and collapse 3+ blank lines
124
  response = response.replace("\r\n", "\n").replace("\r", "\n")
125
  lines = [ln.rstrip() for ln in response.split("\n")]
126
-
127
- import re
128
  response = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
129
 
130
  return response
 
1
  import httpx
2
+ import os
3
+ import re
4
  from typing import List
5
  from app.llm.llm_client import LLMClient
6
  from app.core.context_manager import get_context_manager
 
93
  logger.error(f"Invalid candidate structure: {candidate}")
94
  return "I apologize, but I received an unexpected response format. Please try again."
95
 
96
+ parts = candidate["content"]["parts"]
97
+ text = "\n\n".join(
98
+ p.get("text", "")
99
+ for p in parts
100
+ if not p.get("thought") and p.get("text", "").strip()
101
+ ).strip()
102
 
103
  if not text:
104
  logger.warning("Empty response from Gemini")
 
117
  return "I encountered an unexpected error. Please try again."
118
 
119
  def _clean_response(self, response: str) -> str:
120
+ """Clean up response text, preserving Markdown formatting."""
 
 
 
 
 
 
 
 
 
 
121
  response = response.replace("\r\n", "\n").replace("\r", "\n")
122
  lines = [ln.rstrip() for ln in response.split("\n")]
 
 
123
  response = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
124
 
125
  return response
multi_llm_chatbot_backend/app/llm/improved_ollama_client.py CHANGED
@@ -94,11 +94,6 @@ class ImprovedOllamaClient(LLMClient):
94
  for pattern in fluff_patterns:
95
  response = response.replace(pattern, "").strip()
96
 
97
- """# Normalize whitespace
98
- response = ' '.join(response.split())
99
-
100
- return response"""
101
- # Preserve Markdown line breaks; trim right-side spaces; collapse very long blank runs
102
  response = response.replace("\r\n", "\n").replace("\r", "\n")
103
  lines = [ln.rstrip() for ln in response.split("\n")]
104
 
 
94
  for pattern in fluff_patterns:
95
  response = response.replace(pattern, "").strip()
96
 
 
 
 
 
 
97
  response = response.replace("\r\n", "\n").replace("\r", "\n")
98
  lines = [ln.rstrip() for ln in response.split("\n")]
99