Spaces:
Running
Running
Girish Jeswani commited on
Commit ·
5542d2b
1
Parent(s): 634624b
fix json format bug
Browse files
multi_llm_chatbot_backend/app/core/canvas_analysis.py
CHANGED
|
@@ -68,15 +68,32 @@ class CanvasAnalysisService:
|
|
| 68 |
insights = []
|
| 69 |
|
| 70 |
for message in messages:
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
for response in message.get("responses", []):
|
| 74 |
persona_insights = await self._extract_insights_from_persona_response(
|
| 75 |
response, message.get("id"), chat_session_id
|
| 76 |
)
|
| 77 |
insights.extend(persona_insights)
|
|
|
|
| 78 |
elif message.get("role") == "assistant" and message.get("content"):
|
| 79 |
-
# Handle
|
| 80 |
persona_insights = await self._extract_insights_from_content(
|
| 81 |
message.get("content", ""), "assistant",
|
| 82 |
message.get("id"), chat_session_id
|
|
@@ -85,13 +102,15 @@ class CanvasAnalysisService:
|
|
| 85 |
|
| 86 |
# Remove duplicates and low-confidence insights
|
| 87 |
unique_insights = self._deduplicate_insights(insights)
|
| 88 |
-
high_confidence_insights = [i for i in unique_insights if i.confidence_score >= 0.
|
| 89 |
|
| 90 |
logger.info(f"Extracted {len(high_confidence_insights)} insights from {len(messages)} messages")
|
| 91 |
return high_confidence_insights
|
| 92 |
|
| 93 |
except Exception as e:
|
| 94 |
logger.error(f"Error extracting insights from messages: {e}")
|
|
|
|
|
|
|
| 95 |
return []
|
| 96 |
|
| 97 |
async def _extract_insights_from_persona_response(self, response: Dict, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
|
@@ -103,7 +122,7 @@ class CanvasAnalysisService:
|
|
| 103 |
|
| 104 |
async def _extract_insights_from_content(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
| 105 |
"""Extract actionable insights from content using LLM analysis"""
|
| 106 |
-
if not content or len(content.strip()) <
|
| 107 |
return []
|
| 108 |
|
| 109 |
try:
|
|
@@ -126,15 +145,23 @@ class CanvasAnalysisService:
|
|
| 126 |
"""
|
| 127 |
|
| 128 |
if self.llm_client:
|
| 129 |
-
llm_response = await self.llm_client.generate(
|
| 130 |
-
system_prompt="You are an expert at extracting actionable PhD guidance from advisor responses.",
|
| 131 |
-
context=[{"role": "user", "content": extraction_prompt}],
|
| 132 |
-
temperature=0.3,
|
| 133 |
-
max_tokens=500
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
try:
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
insights = []
|
| 139 |
|
| 140 |
for item in insights_data:
|
|
@@ -149,10 +176,16 @@ class CanvasAnalysisService:
|
|
| 149 |
)
|
| 150 |
insights.append(insight)
|
| 151 |
|
|
|
|
| 152 |
return insights
|
| 153 |
|
| 154 |
-
except json.JSONDecodeError:
|
| 155 |
-
logger.warning(f"Failed to parse LLM insights response for {persona_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
# Fallback to rule-based extraction
|
| 157 |
return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
|
| 158 |
|
|
@@ -178,7 +211,7 @@ class CanvasAnalysisService:
|
|
| 178 |
|
| 179 |
for sentence in sentences:
|
| 180 |
sentence = sentence.strip()
|
| 181 |
-
if len(sentence) <
|
| 182 |
continue
|
| 183 |
|
| 184 |
# Check if sentence contains actionable language
|
|
@@ -198,6 +231,7 @@ class CanvasAnalysisService:
|
|
| 198 |
)
|
| 199 |
insights.append(insight)
|
| 200 |
|
|
|
|
| 201 |
return insights[:3] # Limit to 3 insights per message to avoid noise
|
| 202 |
|
| 203 |
def _extract_keywords_from_sentence(self, sentence: str) -> List[str]:
|
|
@@ -226,157 +260,42 @@ class CanvasAnalysisService:
|
|
| 226 |
return dict(categorized)
|
| 227 |
|
| 228 |
def _determine_section(self, insight: CanvasInsight) -> str:
|
| 229 |
-
"""Determine which
|
| 230 |
content_lower = insight.content.lower()
|
| 231 |
-
keywords_lower = [
|
| 232 |
all_text = content_lower + " " + " ".join(keywords_lower)
|
| 233 |
|
| 234 |
# Score each section based on keyword matches
|
| 235 |
section_scores = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
"theorist": {"theoretical_framework": 2, "literature_review": 1},
|
| 247 |
-
"pragmatist": {"next_steps": 2, "research_progress": 1},
|
| 248 |
-
"socratic": {"theoretical_framework": 1, "challenges_obstacles": 1},
|
| 249 |
-
"motivator": {"motivation_mindset": 2, "career_development": 1},
|
| 250 |
-
"critic": {"challenges_obstacles": 1, "writing_communication": 1},
|
| 251 |
-
"empathetic": {"motivation_mindset": 2},
|
| 252 |
-
"visionary": {"career_development": 1, "research_progress": 1}
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
if insight.source_persona in persona_bonuses:
|
| 256 |
-
score += persona_bonuses[insight.source_persona].get(section_key, 0)
|
| 257 |
-
|
| 258 |
-
section_scores[section_key] = score
|
| 259 |
-
|
| 260 |
-
# Return section with highest score, default to general category
|
| 261 |
-
best_section = max(section_scores, key=section_scores.get) if section_scores else "research_progress"
|
| 262 |
-
|
| 263 |
-
# If no keywords matched, categorize by persona type
|
| 264 |
-
if section_scores[best_section] == 0:
|
| 265 |
-
persona_defaults = {
|
| 266 |
-
"methodologist": "methodology",
|
| 267 |
-
"theorist": "theoretical_framework",
|
| 268 |
-
"pragmatist": "next_steps",
|
| 269 |
-
"socratic": "theoretical_framework",
|
| 270 |
-
"motivator": "motivation_mindset",
|
| 271 |
-
"critic": "challenges_obstacles",
|
| 272 |
-
"empathetic": "motivation_mindset",
|
| 273 |
-
"visionary": "career_development",
|
| 274 |
-
"storyteller": "writing_communication",
|
| 275 |
-
"minimalist": "next_steps"
|
| 276 |
-
}
|
| 277 |
-
best_section = persona_defaults.get(insight.source_persona, "research_progress")
|
| 278 |
-
|
| 279 |
-
return best_section
|
| 280 |
|
| 281 |
def _deduplicate_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 282 |
-
"""Remove duplicate
|
| 283 |
if not insights:
|
| 284 |
-
return
|
| 285 |
|
| 286 |
unique_insights = []
|
| 287 |
-
|
| 288 |
|
| 289 |
for insight in insights:
|
| 290 |
# Simple deduplication based on content similarity
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
# Check for substantial overlap with existing insights
|
| 294 |
-
is_duplicate = False
|
| 295 |
-
for seen_content in seen_contents:
|
| 296 |
-
if self._calculate_similarity(content_normalized, seen_content) > 0.8:
|
| 297 |
-
is_duplicate = True
|
| 298 |
-
break
|
| 299 |
|
| 300 |
-
if not
|
|
|
|
| 301 |
unique_insights.append(insight)
|
| 302 |
-
seen_contents.add(content_normalized)
|
| 303 |
-
|
| 304 |
-
return unique_insights
|
| 305 |
-
|
| 306 |
-
def _calculate_similarity(self, text1: str, text2: str) -> float:
|
| 307 |
-
"""Calculate simple similarity between two texts"""
|
| 308 |
-
words1 = set(text1.split())
|
| 309 |
-
words2 = set(text2.split())
|
| 310 |
|
| 311 |
-
|
| 312 |
-
return 0.0
|
| 313 |
-
|
| 314 |
-
intersection = words1.intersection(words2)
|
| 315 |
-
union = words1.union(words2)
|
| 316 |
-
|
| 317 |
-
return len(intersection) / len(union) if union else 0.0
|
| 318 |
-
|
| 319 |
-
def prioritize_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 320 |
-
"""Prioritize insights based on relevance and actionability"""
|
| 321 |
-
|
| 322 |
-
def calculate_priority_score(insight: CanvasInsight) -> float:
|
| 323 |
-
score = insight.confidence_score
|
| 324 |
-
|
| 325 |
-
# Boost score for actionable language
|
| 326 |
-
actionable_terms = ["should", "need to", "must", "plan", "goal", "deadline", "complete"]
|
| 327 |
-
content_lower = insight.content.lower()
|
| 328 |
-
for term in actionable_terms:
|
| 329 |
-
if term in content_lower:
|
| 330 |
-
score += 0.1
|
| 331 |
-
|
| 332 |
-
# Boost score for specific vs generic advice
|
| 333 |
-
specific_terms = ["chapter", "data", "analysis", "method", "theory", "timeline"]
|
| 334 |
-
for term in specific_terms:
|
| 335 |
-
if term in content_lower:
|
| 336 |
-
score += 0.05
|
| 337 |
-
|
| 338 |
-
# Boost score for certain personas
|
| 339 |
-
persona_boosts = {
|
| 340 |
-
"pragmatist": 0.1, # Action-oriented insights are valuable
|
| 341 |
-
"methodologist": 0.08, # Methodological insights are important
|
| 342 |
-
"critic": 0.06 # Critical feedback is valuable
|
| 343 |
-
}
|
| 344 |
-
score += persona_boosts.get(insight.source_persona, 0)
|
| 345 |
-
|
| 346 |
-
return min(score, 1.0) # Cap at 1.0
|
| 347 |
-
|
| 348 |
-
# Calculate scores and sort
|
| 349 |
-
for insight in insights:
|
| 350 |
-
insight.confidence_score = calculate_priority_score(insight)
|
| 351 |
-
|
| 352 |
-
return sorted(insights, key=lambda x: x.confidence_score, reverse=True)
|
| 353 |
-
|
| 354 |
-
async def generate_section_summary(self, section: CanvasSection) -> str:
|
| 355 |
-
"""Generate a summary for a canvas section using LLM"""
|
| 356 |
-
if not section.insights or not self.llm_client:
|
| 357 |
-
return f"Key insights for {section.title}"
|
| 358 |
-
|
| 359 |
-
try:
|
| 360 |
-
insights_text = "\n".join([f"- {insight.content}" for insight in section.insights])
|
| 361 |
-
|
| 362 |
-
prompt = f"""
|
| 363 |
-
Create a 1-2 sentence summary of these PhD insights for the section "{section.title}":
|
| 364 |
-
|
| 365 |
-
{insights_text}
|
| 366 |
-
|
| 367 |
-
The summary should be concise, professional, and suitable for sharing with an advisor.
|
| 368 |
-
Return only the summary text, no other content.
|
| 369 |
-
"""
|
| 370 |
-
|
| 371 |
-
summary = await self.llm_client.generate(
|
| 372 |
-
system_prompt="You create concise summaries of academic insights.",
|
| 373 |
-
context=[{"role": "user", "content": prompt}],
|
| 374 |
-
temperature=0.2,
|
| 375 |
-
max_tokens=100
|
| 376 |
-
)
|
| 377 |
-
|
| 378 |
-
return summary.strip()
|
| 379 |
-
|
| 380 |
-
except Exception as e:
|
| 381 |
-
logger.error(f"Error generating section summary: {e}")
|
| 382 |
-
return f"Key insights and guidance for {section.title}"
|
|
|
|
| 68 |
insights = []
|
| 69 |
|
| 70 |
for message in messages:
|
| 71 |
+
# Handle the actual stored message format from your database
|
| 72 |
+
msg_type = message.get("type", "")
|
| 73 |
+
|
| 74 |
+
if msg_type == "advisor":
|
| 75 |
+
# This is the format used in your database
|
| 76 |
+
content = message.get("content", "")
|
| 77 |
+
persona_id = message.get("advisorName", message.get("persona", "advisor"))
|
| 78 |
+
message_id = message.get("id", str(message.get("_id", "")))
|
| 79 |
+
|
| 80 |
+
if content and len(content.strip()) > 20: # Only process substantial content
|
| 81 |
+
persona_insights = await self._extract_insights_from_content(
|
| 82 |
+
content, persona_id, message_id, chat_session_id
|
| 83 |
+
)
|
| 84 |
+
insights.extend(persona_insights)
|
| 85 |
+
logger.debug(f"Extracted {len(persona_insights)} insights from {persona_id} message")
|
| 86 |
+
|
| 87 |
+
elif msg_type == "assistant" and "responses" in message:
|
| 88 |
+
# Handle multi-persona responses (if this format exists)
|
| 89 |
for response in message.get("responses", []):
|
| 90 |
persona_insights = await self._extract_insights_from_persona_response(
|
| 91 |
response, message.get("id"), chat_session_id
|
| 92 |
)
|
| 93 |
insights.extend(persona_insights)
|
| 94 |
+
|
| 95 |
elif message.get("role") == "assistant" and message.get("content"):
|
| 96 |
+
# Handle converted message format (from export functions)
|
| 97 |
persona_insights = await self._extract_insights_from_content(
|
| 98 |
message.get("content", ""), "assistant",
|
| 99 |
message.get("id"), chat_session_id
|
|
|
|
| 102 |
|
| 103 |
# Remove duplicates and low-confidence insights
|
| 104 |
unique_insights = self._deduplicate_insights(insights)
|
| 105 |
+
high_confidence_insights = [i for i in unique_insights if i.confidence_score >= 0.5] # Lowered threshold
|
| 106 |
|
| 107 |
logger.info(f"Extracted {len(high_confidence_insights)} insights from {len(messages)} messages")
|
| 108 |
return high_confidence_insights
|
| 109 |
|
| 110 |
except Exception as e:
|
| 111 |
logger.error(f"Error extracting insights from messages: {e}")
|
| 112 |
+
import traceback
|
| 113 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 114 |
return []
|
| 115 |
|
| 116 |
async def _extract_insights_from_persona_response(self, response: Dict, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
|
|
|
| 122 |
|
| 123 |
async def _extract_insights_from_content(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
| 124 |
"""Extract actionable insights from content using LLM analysis"""
|
| 125 |
+
if not content or len(content.strip()) < 30: # Lowered minimum length
|
| 126 |
return []
|
| 127 |
|
| 128 |
try:
|
|
|
|
| 145 |
"""
|
| 146 |
|
| 147 |
if self.llm_client:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
try:
|
| 149 |
+
llm_response = await self.llm_client.generate(
|
| 150 |
+
system_prompt="You are an expert at extracting actionable PhD guidance from advisor responses.",
|
| 151 |
+
context=[{"role": "user", "content": extraction_prompt}],
|
| 152 |
+
temperature=0.3,
|
| 153 |
+
max_tokens=500
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Clean the response to extract just the JSON
|
| 157 |
+
llm_response = llm_response.strip()
|
| 158 |
+
if llm_response.startswith('```json'):
|
| 159 |
+
llm_response = llm_response[7:]
|
| 160 |
+
if llm_response.endswith('```'):
|
| 161 |
+
llm_response = llm_response[:-3]
|
| 162 |
+
llm_response = llm_response.strip()
|
| 163 |
+
|
| 164 |
+
insights_data = json.loads(llm_response)
|
| 165 |
insights = []
|
| 166 |
|
| 167 |
for item in insights_data:
|
|
|
|
| 176 |
)
|
| 177 |
insights.append(insight)
|
| 178 |
|
| 179 |
+
logger.debug(f"LLM extracted {len(insights)} insights from {persona_id}")
|
| 180 |
return insights
|
| 181 |
|
| 182 |
+
except json.JSONDecodeError as je:
|
| 183 |
+
logger.warning(f"Failed to parse LLM insights response for {persona_id}: {je}")
|
| 184 |
+
logger.debug(f"Raw LLM response: {llm_response}")
|
| 185 |
+
# Fallback to rule-based extraction
|
| 186 |
+
return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
|
| 187 |
+
except Exception as llm_error:
|
| 188 |
+
logger.warning(f"LLM extraction failed for {persona_id}: {llm_error}")
|
| 189 |
# Fallback to rule-based extraction
|
| 190 |
return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
|
| 191 |
|
|
|
|
| 211 |
|
| 212 |
for sentence in sentences:
|
| 213 |
sentence = sentence.strip()
|
| 214 |
+
if len(sentence) < 15: # Lowered minimum length
|
| 215 |
continue
|
| 216 |
|
| 217 |
# Check if sentence contains actionable language
|
|
|
|
| 231 |
)
|
| 232 |
insights.append(insight)
|
| 233 |
|
| 234 |
+
logger.debug(f"Rule-based extracted {len(insights)} insights from {persona_id}")
|
| 235 |
return insights[:3] # Limit to 3 insights per message to avoid noise
|
| 236 |
|
| 237 |
def _extract_keywords_from_sentence(self, sentence: str) -> List[str]:
|
|
|
|
| 260 |
return dict(categorized)
|
| 261 |
|
| 262 |
def _determine_section(self, insight: CanvasInsight) -> str:
|
| 263 |
+
"""Determine which section an insight belongs to"""
|
| 264 |
content_lower = insight.content.lower()
|
| 265 |
+
keywords_lower = [k.lower() for k in insight.keywords]
|
| 266 |
all_text = content_lower + " " + " ".join(keywords_lower)
|
| 267 |
|
| 268 |
# Score each section based on keyword matches
|
| 269 |
section_scores = {}
|
| 270 |
+
for section, keywords in self.section_keywords.items():
|
| 271 |
+
score = sum(1 for keyword in keywords if keyword in all_text)
|
| 272 |
+
if score > 0:
|
| 273 |
+
section_scores[section] = score
|
| 274 |
|
| 275 |
+
# Return the section with highest score, or default
|
| 276 |
+
if section_scores:
|
| 277 |
+
return max(section_scores, key=section_scores.get)
|
| 278 |
+
else:
|
| 279 |
+
return "general_notes" # Default section
|
| 280 |
+
|
| 281 |
+
def prioritize_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 282 |
+
"""Sort insights by priority (confidence score and recency)"""
|
| 283 |
+
return sorted(insights, key=lambda x: (x.confidence_score, x.extracted_at), reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
def _deduplicate_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 286 |
+
"""Remove duplicate or very similar insights"""
|
| 287 |
if not insights:
|
| 288 |
+
return insights
|
| 289 |
|
| 290 |
unique_insights = []
|
| 291 |
+
seen_content = set()
|
| 292 |
|
| 293 |
for insight in insights:
|
| 294 |
# Simple deduplication based on content similarity
|
| 295 |
+
content_key = insight.content.lower().strip()[:100] # First 100 chars
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
+
if content_key not in seen_content:
|
| 298 |
+
seen_content.add(content_key)
|
| 299 |
unique_insights.append(insight)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
return unique_insights
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|