// Services/Implementation/AiAnalysisService.cs using Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Model; using Services.AIViewModel; using Services.Interaces; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Services.Implemnetation { public class AiAnalysisService : IAiAnalysisService, IDisposable { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly SurveyContext _context; private readonly string _apiKey; private readonly string _model; private readonly SemaphoreSlim _rateLimiter; private bool _disposed; private const string ClaudeApiUrl = "https://api.anthropic.com/v1/messages"; private const string AnthropicVersion = "2023-06-01"; private const int MaxRetries = 3; private const int BaseDelayMs = 1000; public AiAnalysisService( IConfiguration configuration, ILogger logger, SurveyContext context) { _configuration = configuration; _logger = logger; _context = context; _apiKey = _configuration["Claude:ApiKey"] ?? throw new InvalidOperationException("Claude:ApiKey is not configured in appsettings.json"); _model = _configuration["Claude:Model"] ?? "claude-sonnet-4-20250514"; // Configure HttpClient _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; _httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey); _httpClient.DefaultRequestHeaders.Add("anthropic-version", AnthropicVersion); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); // Rate limiter: max 5 concurrent requests to Claude _rateLimiter = new SemaphoreSlim(5, 5); _logger.LogInformation("AiAnalysisService initialized with Claude API (model: {Model})", _model); } #region Core Claude API Communication /// /// Sends a message to Claude API with retry logic and rate limiting. /// private async Task SendClaudeRequestAsync(string systemPrompt, string userPrompt, float temperature = 0.3f, int maxTokens = 2048) { await _rateLimiter.WaitAsync(); try { for (int attempt = 1; attempt <= MaxRetries; attempt++) { try { var requestBody = new { model = _model, max_tokens = maxTokens, temperature = temperature, system = systemPrompt, messages = new[] { new { role = "user", content = userPrompt } } }; var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await _httpClient.PostAsync(ClaudeApiUrl, content); if (response.IsSuccessStatusCode) { var responseJson = await response.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(responseJson); // Extract text from Claude's response content array if (doc.RootElement.TryGetProperty("content", out var contentArray) && contentArray.ValueKind == JsonValueKind.Array) { foreach (var block in contentArray.EnumerateArray()) { if (block.TryGetProperty("type", out var type) && type.GetString() == "text" && block.TryGetProperty("text", out var text)) { return text.GetString() ?? string.Empty; } } } _logger.LogWarning("Claude response had no text content block. Raw: {Raw}", responseJson.Length > 500 ? responseJson[..500] : responseJson); return string.Empty; } // Handle rate limiting (429) if ((int)response.StatusCode == 429) { var delay = BaseDelayMs * (int)Math.Pow(2, attempt - 1); _logger.LogWarning("Claude API rate limited (attempt {Attempt}/{Max}). Retrying in {Delay}ms", attempt, MaxRetries, delay); await Task.Delay(delay); continue; } // Handle overloaded (529) if ((int)response.StatusCode == 529) { var delay = BaseDelayMs * (int)Math.Pow(2, attempt); _logger.LogWarning("Claude API overloaded (attempt {Attempt}/{Max}). Retrying in {Delay}ms", attempt, MaxRetries, delay); await Task.Delay(delay); continue; } // Other errors var errorBody = await response.Content.ReadAsStringAsync(); _logger.LogError("Claude API error {StatusCode} (attempt {Attempt}): {Error}", (int)response.StatusCode, attempt, errorBody.Length > 500 ? errorBody[..500] : errorBody); if (attempt == MaxRetries) throw new HttpRequestException($"Claude API returned {(int)response.StatusCode} after {MaxRetries} attempts"); await Task.Delay(BaseDelayMs * attempt); } catch (TaskCanceledException) when (attempt < MaxRetries) { _logger.LogWarning("Claude API timeout (attempt {Attempt}/{Max})", attempt, MaxRetries); await Task.Delay(BaseDelayMs * attempt); } catch (HttpRequestException) when (attempt < MaxRetries) { _logger.LogWarning("Claude API connection error (attempt {Attempt}/{Max})", attempt, MaxRetries); await Task.Delay(BaseDelayMs * attempt); } } throw new HttpRequestException("Claude API request failed after all retry attempts"); } finally { _rateLimiter.Release(); } } #endregion #region Core Analysis Methods public async Task AnalyzeSentimentAsync(string text) { try { if (string.IsNullOrWhiteSpace(text)) return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.0 }; var systemPrompt = @"You are a sentiment analysis engine for workplace mental health surveys. Respond ONLY with a valid JSON object. No markdown, no explanation, no code fences."; var userPrompt = $@"Analyze the sentiment of this workplace survey response: ""{text}"" Return this exact JSON structure: {{ ""sentiment"": ""Positive"", ""confidenceScore"": 0.85, ""positiveScore"": 0.85, ""negativeScore"": 0.10, ""neutralScore"": 0.05 }} Rules: - sentiment must be exactly ""Positive"", ""Negative"", or ""Neutral"" - All scores must be between 0.0 and 1.0 - Scores should approximately sum to 1.0 - confidenceScore = the highest of the three scores"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); var doc = ParseLenientJson(response); if (doc == null) { _logger.LogWarning("Failed to parse sentiment response. Raw: {Raw}", Truncate(response)); return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.5 }; } var root = doc.RootElement; return new SentimentAnalysisResult { Sentiment = GetStringProp(root, "sentiment", "Neutral"), ConfidenceScore = GetDoubleProp(root, "confidenceScore", 0.5), PositiveScore = GetDoubleProp(root, "positiveScore", 0.0), NegativeScore = GetDoubleProp(root, "negativeScore", 0.0), NeutralScore = GetDoubleProp(root, "neutralScore", 0.0), AnalyzedAt = DateTime.UtcNow }; } catch (Exception ex) { _logger.LogError(ex, "Error analyzing sentiment"); return new SentimentAnalysisResult { Sentiment = "Neutral", ConfidenceScore = 0.0 }; } } public async Task ExtractKeyPhrasesAsync(string text) { try { if (string.IsNullOrWhiteSpace(text)) return new KeyPhrasesResult(); var systemPrompt = @"You are a key phrase extraction engine for workplace mental health surveys. Respond ONLY with a valid JSON object. No markdown, no explanation, no code fences."; var userPrompt = $@"Extract key phrases from this workplace survey response: ""{text}"" Return this exact JSON structure: {{ ""keyPhrases"": [""phrase1"", ""phrase2""], ""workplaceFactors"": [""factor1"", ""factor2""], ""emotionalIndicators"": [""indicator1"", ""indicator2""] }} Rules: - keyPhrases: all significant noun phrases and concepts (5-15 phrases) - workplaceFactors: only phrases related to work environment, management, teams, deadlines, meetings, projects, colleagues, workload - emotionalIndicators: only phrases indicating emotional state (stress, anxiety, satisfaction, motivation, frustration, burnout, happiness, exhaustion)"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.2f); var doc = ParseLenientJson(response); if (doc == null) { _logger.LogWarning("Failed to parse key phrases response. Raw: {Raw}", Truncate(response)); return new KeyPhrasesResult(); } var root = doc.RootElement; return new KeyPhrasesResult { KeyPhrases = GetStringArray(root, "keyPhrases"), WorkplaceFactors = GetStringArray(root, "workplaceFactors"), EmotionalIndicators = GetStringArray(root, "emotionalIndicators"), ExtractedAt = DateTime.UtcNow }; } catch (Exception ex) { _logger.LogError(ex, "Error extracting key phrases"); return new KeyPhrasesResult(); } } public async Task AnonymizeTextAsync(string text) { try { if (string.IsNullOrWhiteSpace(text)) return string.Empty; var systemPrompt = @"You are a PII anonymization engine. Your job is to replace personally identifiable information in text with placeholder tags. Return ONLY the anonymized text — nothing else."; var userPrompt = $@"Anonymize this text by replacing PII with these tags: - Person names → [NAME] - Email addresses → [EMAIL] - Phone numbers → [PHONE] - Physical addresses → [ADDRESS] - Company names (if identifying specific small company) → [ORG] - Any other identifying info → [REDACTED] Keep all non-PII text exactly as-is. Do NOT add explanations or formatting. Text: ""{text}"""; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.0f); // Clean up: remove any quotes Claude might wrap the response in var cleaned = response.Trim().Trim('"'); return string.IsNullOrWhiteSpace(cleaned) ? text : cleaned; } catch (Exception ex) { _logger.LogError(ex, "Error anonymizing text"); return text; // Return original if anonymization fails } } public async Task> DetectEntitiesAsync(string text) { try { if (string.IsNullOrWhiteSpace(text)) return new List(); var systemPrompt = @"You are a named entity recognition engine. Respond ONLY with a valid JSON array. No markdown, no explanation."; var userPrompt = $@"Detect named entities in this text and categorize them: ""{text}"" Return a JSON array of strings in ""Category: Entity"" format: [""Person: John Smith"", ""Organization: Acme Corp"", ""Location: New York"", ""Role: Manager""] Categories: Person, Organization, Location, Role, Department, Date, Event"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); return DeserializeLenient>(response) ?? new List(); } catch (Exception ex) { _logger.LogError(ex, "Error detecting entities"); return new List(); } } #endregion #region Risk Assessment Methods public async Task AssessMentalHealthRiskAsync(string anonymizedText, string questionContext) { try { var systemPrompt = @"You are a clinical workplace psychologist specializing in occupational mental health risk assessment. You analyze employee survey responses to identify mental health risk factors. CRITICAL: Respond ONLY with a single valid JSON object. No markdown, no code fences, no explanation text."; var userPrompt = $@"Assess the mental health risk level of this anonymized workplace survey response. Survey Question: {questionContext} Employee Response: {anonymizedText} Evaluate for: - Signs of burnout, exhaustion, or chronic stress - Anxiety or depression indicators - Work-life balance deterioration - Social withdrawal or conflict - Self-harm or crisis indicators (escalate to Critical) - Protective factors (coping mechanisms, social support, positive outlook) Return this exact JSON structure: {{ ""riskLevel"": ""Low"", ""riskScore"": 0.25, ""riskIndicators"": [""specific concern 1"", ""specific concern 2""], ""protectiveFactors"": [""positive factor 1"", ""positive factor 2""], ""requiresImmediateAttention"": false, ""recommendedAction"": ""specific actionable recommendation"" }} Rules: - riskLevel: exactly ""Low"", ""Moderate"", ""High"", or ""Critical"" - riskScore: 0.0 (no risk) to 1.0 (maximum risk) - Low: 0.0–0.25, Moderate: 0.26–0.50, High: 0.51–0.75, Critical: 0.76–1.0 - requiresImmediateAttention: true ONLY for Critical or if self-harm/crisis indicators present - recommendedAction: professional, specific, actionable (not generic)"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.2f); var doc = ParseLenientJson(response); if (doc == null) { _logger.LogWarning("Failed to parse risk assessment. Raw: {Raw}", Truncate(response)); return DefaultRiskAssessment("Unable to parse AI response"); } var root = doc.RootElement; var riskLevelStr = GetStringProp(root, "riskLevel", "Moderate"); return new MentalHealthRiskAssessment { RiskLevel = Enum.TryParse(riskLevelStr, true, out var rl) ? rl : RiskLevel.Moderate, RiskScore = Math.Clamp(GetDoubleProp(root, "riskScore", 0.5), 0.0, 1.0), RiskIndicators = GetStringArray(root, "riskIndicators"), ProtectiveFactors = GetStringArray(root, "protectiveFactors"), RequiresImmediateAttention = GetBoolProp(root, "requiresImmediateAttention", false), RecommendedAction = GetStringProp(root, "recommendedAction", "Manual review recommended"), AssessedAt = DateTime.UtcNow }; } catch (Exception ex) { _logger.LogError(ex, "Error assessing mental health risk"); return DefaultRiskAssessment($"Analysis error: {ex.Message}"); } } public async Task> GenerateWorkplaceInsightsAsync(string anonymizedText, string questionContext) { try { var systemPrompt = @"You are a workplace mental health consultant specializing in organizational wellness intervention design. CRITICAL: Respond ONLY with a single valid JSON object. No markdown, no code fences, no explanation."; var userPrompt = $@"Analyze this workplace survey response and identify actionable workplace insights. Survey Question: {questionContext} Employee Response: {anonymizedText} Identify issues across these domains: - Work-Life Balance, Workload Management, Team Dynamics - Leadership/Management, Communication, Job Satisfaction - Stress Management, Career Development, Work Environment - Organizational Culture, Mental Health Support, Burnout Prevention Return this exact JSON: {{ ""insights"": [ {{ ""category"": ""Work-Life Balance"", ""issue"": ""specific issue identified from response"", ""recommendedIntervention"": ""specific, actionable organizational intervention"", ""priority"": 3, ""affectedAreas"": [""Employee Wellbeing"", ""Productivity""] }} ] }} Rules: - 1–5 insights maximum (only genuinely identified issues) - priority: 1 (critical) to 5 (minor) - recommendedIntervention: professional, specific, implementable by HR/management - Do NOT invent issues not supported by the response text"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f); var doc = ParseLenientJson(response); if (doc == null) { _logger.LogWarning("Failed to parse insights. Raw: {Raw}", Truncate(response)); return new List(); } var root = doc.RootElement; if (!root.TryGetProperty("insights", out var insightsEl) || insightsEl.ValueKind != JsonValueKind.Array) return new List(); var insights = new List(); foreach (var item in insightsEl.EnumerateArray()) { insights.Add(new WorkplaceInsight { Category = GetStringProp(item, "category", "General"), Issue = GetStringProp(item, "issue", ""), RecommendedIntervention = GetStringProp(item, "recommendedIntervention", ""), Priority = Math.Clamp(GetIntProp(item, "priority", 3), 1, 5), AffectedAreas = GetStringArray(item, "affectedAreas"), IdentifiedAt = DateTime.UtcNow }); } return insights; } catch (Exception ex) { _logger.LogError(ex, "Error generating workplace insights"); return new List(); } } public async Task GenerateExecutiveSummaryAsync(List analysisResults) { try { var totalResponses = analysisResults.Count; var highRiskCount = analysisResults.Count(r => r.RiskAssessment?.RiskLevel >= RiskLevel.High); var criticalCount = analysisResults.Count(r => r.RiskAssessment?.RiskLevel == RiskLevel.Critical); var positiveCount = analysisResults.Count(r => r.SentimentAnalysis?.Sentiment == "Positive"); var negativeCount = analysisResults.Count(r => r.SentimentAnalysis?.Sentiment == "Negative"); var topCategories = analysisResults .SelectMany(r => r.Insights) .GroupBy(i => i.Category) .OrderByDescending(g => g.Count()) .Take(5) .Select(g => $"{g.Key}: {g.Count()} instances") .ToList(); var topKeyPhrases = analysisResults .Where(r => r.KeyPhrases != null) .SelectMany(r => r.KeyPhrases!.KeyPhrases) .GroupBy(k => k.ToLower()) .OrderByDescending(g => g.Count()) .Take(8) .Select(g => g.Key) .ToList(); var systemPrompt = @"You are a senior organizational psychologist writing executive briefings for C-level leadership. Your writing is precise, data-driven, professional, and actionable. No fluff."; var userPrompt = $@"Create a professional executive summary for this workplace mental health survey analysis. DATA: - Total analyzed responses: {totalResponses} - Positive sentiment: {positiveCount} ({(totalResponses > 0 ? (positiveCount * 100.0 / totalResponses).ToString("F1") : "0")}%) - Negative sentiment: {negativeCount} ({(totalResponses > 0 ? (negativeCount * 100.0 / totalResponses).ToString("F1") : "0")}%) - High risk responses: {highRiskCount} - Critical risk responses: {criticalCount} - Top workplace issues: {string.Join(", ", topCategories)} - Recurring themes: {string.Join(", ", topKeyPhrases)} Write the summary with these sections: 1. OVERALL ASSESSMENT (2-3 sentences, overall mental health posture) 2. KEY FINDINGS (3-5 bullet points, data-backed) 3. AREAS OF CONCERN (prioritized list with risk implications) 4. POSITIVE INDICATORS (strengths to preserve) 5. IMMEDIATE ACTIONS (3-5 specific, implementable recommendations) 6. STRATEGIC RECOMMENDATIONS (long-term organizational improvements) Keep it under 600 words. Professional tone suitable for board presentation."; return await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f, 3000); } catch (Exception ex) { _logger.LogError(ex, "Error generating executive summary"); return "Executive summary generation failed. Please retry the analysis."; } } public async Task> CategorizeResponseAsync(string anonymizedText) { try { var systemPrompt = @"You are a response categorization engine for workplace surveys. Respond ONLY with a JSON array of strings. No markdown, no explanation."; var userPrompt = $@"Categorize this workplace mental health response into applicable themes: ""{anonymizedText}"" Choose ALL that apply from: - Work-Life Balance - Workload Management - Team Dynamics - Leadership/Management - Communication Issues - Job Satisfaction - Stress Management - Career Development - Work Environment - Organizational Culture - Mental Health Support - Burnout Prevention Return as JSON array: [""Category1"", ""Category2""]"; var response = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.1f); return DeserializeLenient>(response) ?? new List(); } catch (Exception ex) { _logger.LogError(ex, "Error categorizing response"); return new List(); } } #endregion #region Composite Analysis Methods public async Task AnalyzeCompleteResponseAsync(AnalysisRequest request) { try { // Step 1: Check if already analyzed in DB var cached = await LoadResponseAnalysisFromDbAsync(request.ResponseId, request.QuestionId); if (cached != null) { _logger.LogInformation("Loaded cached analysis for ResponseId: {ResponseId}, QuestionId: {QuestionId}", request.ResponseId, request.QuestionId); return cached; } // Step 2: Not in DB — run full Claude analysis _logger.LogInformation("No cached analysis found. Calling Claude API for ResponseId: {ResponseId}, QuestionId: {QuestionId}", request.ResponseId, request.QuestionId); var result = new ResponseAnalysisResult { ResponseId = request.ResponseId, QuestionId = request.QuestionId, QuestionText = request.QuestionText, ResponseText = request.ResponseText, AnalyzedAt = DateTime.UtcNow }; // Anonymize the text result.AnonymizedResponseText = await AnonymizeTextAsync(request.ResponseText); // Claude API calls if (request.IncludeSentimentAnalysis) { result.SentimentAnalysis = await AnalyzeSentimentAsync(result.AnonymizedResponseText); } if (request.IncludeKeyPhraseExtraction) { result.KeyPhrases = await ExtractKeyPhrasesAsync(result.AnonymizedResponseText); } if (request.IncludeRiskAssessment) { result.RiskAssessment = await AssessMentalHealthRiskAsync(result.AnonymizedResponseText, request.QuestionText); } if (request.IncludeWorkplaceInsights) { result.Insights = await GenerateWorkplaceInsightsAsync(result.AnonymizedResponseText, request.QuestionText); } result.IsAnalysisComplete = true; // Step 3: Save to DB for future use await SaveResponseAnalysisToDbAsync(result); return result; } catch (Exception ex) { _logger.LogError(ex, "Error in complete response analysis for ResponseId: {ResponseId}", request.ResponseId); throw; } } public async Task> AnalyzeQuestionResponsesAsync(int questionId, List requests) { var results = new List(); foreach (var request in requests) { try { results.Add(await AnalyzeCompleteResponseAsync(request)); } catch (Exception ex) { _logger.LogError(ex, "Error analyzing response {ResponseId} for question {QuestionId}", request.ResponseId, questionId); } } return results; } /// /// Builds a combined analysis text from a ResponseDetail, including both text responses /// and selected answer texts for checkbox/radio/multiple choice questions. /// private string BuildAnalysisText(ResponseDetail detail) { var parts = new List(); // Include text response if present if (!string.IsNullOrWhiteSpace(detail.TextResponse)) { parts.Add(detail.TextResponse); } // Include selected answer texts for non-text questions if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any()) { foreach (var ra in detail.ResponseAnswers) { // Navigate through the Answer entity to get the text if (ra.Answer != null && !string.IsNullOrWhiteSpace(ra.Answer.Text)) { parts.Add(ra.Answer.Text); } } } return string.Join(". ", parts); } public async Task GenerateQuestionnaireOverviewAsync(int questionnaireId) { try { // Get all responses — NOW including ResponseAnswers -> Answer for selected answer text var responses = await _context.Responses .Include(r => r.Questionnaire) .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.ResponseAnswers) .ThenInclude(ra => ra.Answer) // <-- KEY FIX: load Answer.Text .Where(r => r.QuestionnaireId == questionnaireId) .ToListAsync(); if (!responses.Any()) { return new QuestionnaireAnalysisOverview { QuestionnaireId = questionnaireId, QuestionnaireTitle = "No responses found", TotalResponses = 0 }; } // Analyze ALL response types (text + checkbox + radio + etc.) var analysisResults = new List(); foreach (var response in responses) { foreach (var detail in response.ResponseDetails) { var analysisText = BuildAnalysisText(detail); if (!string.IsNullOrWhiteSpace(analysisText)) { var request = new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }; var analysisResult = await AnalyzeCompleteResponseAsync(request); analysisResults.Add(analysisResult); } } } // Generate overview var overview = new QuestionnaireAnalysisOverview { QuestionnaireId = questionnaireId, QuestionnaireTitle = responses.First().Questionnaire?.Title ?? "Unknown", TotalResponses = responses.Count, AnalyzedResponses = analysisResults.Count, LastAnalyzedAt = DateTime.UtcNow }; if (analysisResults.Any()) { // Calculate sentiment distribution var sentimentResults = analysisResults.Where(r => r.SentimentAnalysis != null).ToList(); if (sentimentResults.Any()) { overview.OverallPositiveSentiment = sentimentResults.Average(r => r.SentimentAnalysis!.PositiveScore); overview.OverallNegativeSentiment = sentimentResults.Average(r => r.SentimentAnalysis!.NegativeScore); overview.OverallNeutralSentiment = sentimentResults.Average(r => r.SentimentAnalysis!.NeutralScore); } // Calculate risk distribution var riskResults = analysisResults.Where(r => r.RiskAssessment != null).ToList(); if (riskResults.Any()) { overview.LowRiskResponses = riskResults.Count(r => r.RiskAssessment!.RiskLevel == RiskLevel.Low); overview.ModerateRiskResponses = riskResults.Count(r => r.RiskAssessment!.RiskLevel == RiskLevel.Moderate); overview.HighRiskResponses = riskResults.Count(r => r.RiskAssessment!.RiskLevel == RiskLevel.High); overview.CriticalRiskResponses = riskResults.Count(r => r.RiskAssessment!.RiskLevel == RiskLevel.Critical); } // Get top workplace issues overview.TopWorkplaceIssues = analysisResults .SelectMany(r => r.Insights) .GroupBy(i => i.Category) .OrderByDescending(g => g.Count()) .Take(5) .Select(g => g.First()) .ToList(); // Get common key phrases overview.MostCommonKeyPhrases = analysisResults .Where(r => r.KeyPhrases != null) .SelectMany(r => r.KeyPhrases!.KeyPhrases) .GroupBy(k => k) .OrderByDescending(g => g.Count()) .Take(10) .Select(g => g.Key) .ToList(); // Generate executive summary overview.ExecutiveSummary = await GenerateExecutiveSummaryAsync(analysisResults); } return overview; } catch (Exception ex) { _logger.LogError(ex, "Error generating questionnaire overview for QuestionnaireId: {QuestionnaireId}", questionnaireId); throw; } } public async Task> BatchAnalyzeResponsesAsync(List requests) { var results = new List(); foreach (var request in requests) { try { // AnalyzeCompleteResponseAsync already checks DB first // So cached ones return instantly, only new ones hit Claude var result = await AnalyzeCompleteResponseAsync(request); results.Add(result); } catch (Exception ex) { _logger.LogError(ex, "Error analyzing response {ResponseId} for question {QuestionId}", request.ResponseId, request.QuestionId); } } return results; } #endregion #region Mental Health Intelligence public async Task> IdentifyHighRiskResponsesAsync(int questionnaireId) { try { // Read from DB — no API calls var highRiskEntities = await _context.ResponseAnalyses .Where(ra => _context.Responses .Where(r => r.QuestionnaireId == questionnaireId) .Select(r => r.Id) .Contains(ra.ResponseId)) .Where(ra => ra.RiskLevel == "High" || ra.RiskLevel == "Critical" || ra.RequiresImmediateAttention) .OrderByDescending(ra => ra.RiskScore) .ToListAsync(); // Convert entities to view models var results = new List(); foreach (var entity in highRiskEntities) { var loaded = await LoadResponseAnalysisFromDbAsync(entity.ResponseId, entity.QuestionId); if (loaded != null) { results.Add(loaded); } } return results; } catch (Exception ex) { _logger.LogError(ex, "Error identifying high risk responses for QuestionnaireId: {QuestionnaireId}", questionnaireId); throw; } } public async Task> AnalyzeMentalHealthTrendsAsync(int questionnaireId, DateTime fromDate, DateTime toDate) { try { var responses = await _context.Responses .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) .Where(r => r.QuestionnaireId == questionnaireId && r.SubmissionDate >= fromDate && r.SubmissionDate <= toDate) .OrderBy(r => r.SubmissionDate) .ToListAsync(); return new List { new WorkplaceInsight { Category = "Trend Analysis", Issue = $"Analysis of {responses.Count} responses from {fromDate:yyyy-MM-dd} to {toDate:yyyy-MM-dd}", RecommendedIntervention = "Regular monitoring and follow-up assessments recommended", Priority = 3, IdentifiedAt = DateTime.UtcNow } }; } catch (Exception ex) { _logger.LogError(ex, "Error analyzing trends for {QuestionnaireId}", questionnaireId); throw; } } public async Task> CompareTeamMentalHealthAsync( int questionnaireId, List teamIdentifiers) { var result = new Dictionary(); foreach (var team in teamIdentifiers) result[team] = await GenerateQuestionnaireOverviewAsync(questionnaireId); return result; } public async Task> GenerateInterventionRecommendationsAsync(int questionnaireId) { try { var overview = await GenerateQuestionnaireOverviewAsync(questionnaireId); return overview.TopWorkplaceIssues; } catch (Exception ex) { _logger.LogError(ex, "Error generating intervention recommendations for QuestionnaireId: {QuestionnaireId}", questionnaireId); throw; } } #endregion #region Reporting public async Task GenerateDetailedAnalysisReportAsync(int questionnaireId) { try { // Both of these now read from DB var overview = await GenerateQuestionnaireOverviewAsync(questionnaireId); var highRiskResponses = await IdentifyHighRiskResponsesAsync(questionnaireId); var report = new StringBuilder(); report.AppendLine($"# Mental Health Analysis Report"); report.AppendLine($"**Questionnaire:** {overview.QuestionnaireTitle}"); report.AppendLine($"**Analysis Date:** {DateTime.Now:yyyy-MM-dd HH:mm}"); report.AppendLine($"**Total Responses:** {overview.TotalResponses}"); report.AppendLine($"**Analyzed Responses:** {overview.AnalyzedResponses}"); report.AppendLine(); report.AppendLine("## Executive Summary"); report.AppendLine(overview.ExecutiveSummary); report.AppendLine(); report.AppendLine("## Risk Distribution"); report.AppendLine($"- Low Risk: {overview.LowRiskResponses}"); report.AppendLine($"- Moderate Risk: {overview.ModerateRiskResponses}"); report.AppendLine($"- High Risk: {overview.HighRiskResponses}"); report.AppendLine($"- Critical Risk: {overview.CriticalRiskResponses}"); report.AppendLine(); if (highRiskResponses.Any()) { report.AppendLine("## High Risk Responses Requiring Attention"); report.AppendLine($"Found {highRiskResponses.Count} responses requiring immediate attention."); report.AppendLine(); } report.AppendLine("## Top Workplace Issues"); foreach (var issue in overview.TopWorkplaceIssues.Take(5)) { report.AppendLine($"- **{issue.Category}:** {issue.Issue}"); report.AppendLine($" - Recommended Action: {issue.RecommendedIntervention}"); } return report.ToString(); } catch (Exception ex) { _logger.LogError(ex, "Error generating detailed analysis report for QuestionnaireId: {QuestionnaireId}", questionnaireId); throw; } } public async Task> ExportAnonymizedAnalysisAsync(int questionnaireId) { try { var responses = await _context.Responses .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.Question) .Include(r => r.ResponseDetails) .ThenInclude(rd => rd.ResponseAnswers) .ThenInclude(ra => ra.Answer) .Where(r => r.QuestionnaireId == questionnaireId) .ToListAsync(); var results = new List(); foreach (var response in responses) { foreach (var detail in response.ResponseDetails) { var analysisText = BuildAnalysisText(detail); if (!string.IsNullOrWhiteSpace(analysisText)) { var request = new AnalysisRequest { ResponseId = response.Id, QuestionId = detail.QuestionId, ResponseText = analysisText, QuestionText = detail.Question?.Text ?? "" }; var result = await AnalyzeCompleteResponseAsync(request); results.Add(result); } } } return results; } catch (Exception ex) { _logger.LogError(ex, "Error exporting anonymized analysis for QuestionnaireId: {QuestionnaireId}", questionnaireId); throw; } } public async Task GenerateManagementDashboardAsync(int questionnaireId) { return await GenerateQuestionnaireOverviewAsync(questionnaireId); } #endregion #region Service Health public async Task TestClaudeConnectionAsync() { try { var response = await SendClaudeRequestAsync( "You are a health check endpoint. Respond with exactly: OK", "Health check. Respond with exactly: OK", 0.0f, 10); return !string.IsNullOrWhiteSpace(response); } catch (Exception ex) { _logger.LogError(ex, "Claude connection test failed"); return false; } } public Task ValidateAnalysisRequestAsync(AnalysisRequest request) { return Task.FromResult( !string.IsNullOrWhiteSpace(request.ResponseText) && request.ResponseId >= 0 && request.QuestionId >= 0); } public async Task> GetServiceHealthStatusAsync() { return new Dictionary { ["Claude"] = await TestClaudeConnectionAsync() }; } #endregion #region Private Helpers /// /// Extracts analyzable text from a ResponseDetail (text responses + checkbox selections). /// private static string ExtractAnalyzableText(ResponseDetail detail) { if (!string.IsNullOrWhiteSpace(detail.TextResponse)) return detail.TextResponse; if (detail.QuestionType == QuestionType.CheckBox && detail.ResponseAnswers.Any()) { var selectedAnswers = detail.ResponseAnswers .Select(ra => detail.Question?.Answers?.FirstOrDefault(a => a.Id == ra.AnswerId)?.Text) .Where(text => !string.IsNullOrEmpty(text)) .ToList(); if (selectedAnswers.Any()) { return $"Multiple Selection Question: {detail.Question?.Text}\n" + $"Selected Options: {string.Join(", ", selectedAnswers)}\n" + "Analyze these selected workplace factors for mental health implications and patterns."; } } return string.Empty; } private static MentalHealthRiskAssessment DefaultRiskAssessment(string reason) { return new MentalHealthRiskAssessment { RiskLevel = RiskLevel.Moderate, RiskScore = 0.5, RiskIndicators = new List { reason }, ProtectiveFactors = new List(), RequiresImmediateAttention = false, RecommendedAction = "Manual review recommended due to analysis error", AssessedAt = DateTime.UtcNow }; } private static string Truncate(string? s, int max = 800) => s == null ? "" : s.Length > max ? s[..max] : s; #endregion #region JSON Parsing Helpers private static readonly Regex JsonObjectRegex = new( @"(\{(?:[^{}]|(?\{)|(?<-o>\}))*(?(o)(?!))\})", RegexOptions.Singleline | RegexOptions.Compiled); private static JsonDocument? ParseLenientJson(string? content) { if (string.IsNullOrWhiteSpace(content)) return null; var json = content.Trim(); // Strip markdown code fences if (json.StartsWith("```")) json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); // Try direct parse first if (json.StartsWith("{")) { try { return JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); } catch { } } // Try regex extraction var match = JsonObjectRegex.Match(json); if (!match.Success) return null; try { return JsonDocument.Parse(match.Value, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); } catch { return null; } } private static T? DeserializeLenient(string? content) { if (string.IsNullOrWhiteSpace(content)) return default; var json = content.Trim(); if (json.StartsWith("```")) json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); try { return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, AllowTrailingCommas = true, NumberHandling = JsonNumberHandling.AllowReadingFromString }); } catch { // Try extracting JSON object/array if (typeof(T) == typeof(List) && json.Contains("[")) { var start = json.IndexOf('['); var end = json.LastIndexOf(']'); if (start >= 0 && end > start) { try { return JsonSerializer.Deserialize(json[start..(end + 1)]); } catch { } } } return default; } } private static string GetStringProp(JsonElement el, string name, string fallback) => el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String ? prop.GetString() ?? fallback : fallback; private static double GetDoubleProp(JsonElement el, string name, double fallback) { if (!el.TryGetProperty(name, out var prop)) return fallback; if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d)) return d; if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), out var d2)) return d2; return fallback; } private static int GetIntProp(JsonElement el, string name, int fallback) { if (!el.TryGetProperty(name, out var prop)) return fallback; if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i)) return i; if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out var i2)) return i2; return fallback; } private static bool GetBoolProp(JsonElement el, string name, bool fallback) { if (!el.TryGetProperty(name, out var prop)) return fallback; if (prop.ValueKind == JsonValueKind.True) return true; if (prop.ValueKind == JsonValueKind.False) return false; if (prop.ValueKind == JsonValueKind.String && bool.TryParse(prop.GetString(), out var b)) return b; return fallback; } private static List GetStringArray(JsonElement el, string name) { if (!el.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Array) return new List(); return prop.EnumerateArray() .Select(x => x.GetString()) .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s!) .ToList(); } #endregion #region IDisposable public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) { _httpClient?.Dispose(); _rateLimiter?.Dispose(); _context?.Dispose(); } _disposed = true; } } private async Task SaveResponseAnalysisToDbAsync(ResponseAnalysisResult result) { try { // Detach any tracked entities to avoid conflicts var trackedEntities = _context.ChangeTracker.Entries() .Where(e => e.Entity.ResponseId == result.ResponseId && e.Entity.QuestionId == result.QuestionId) .ToList(); foreach (var tracked in trackedEntities) { tracked.State = EntityState.Detached; } // Check if already exists var existing = await _context.ResponseAnalyses .AsNoTracking() .FirstOrDefaultAsync(ra => ra.ResponseId == result.ResponseId && ra.QuestionId == result.QuestionId); if (existing != null) { // Update existing existing.AnonymizedText = result.AnonymizedResponseText; existing.SentimentLabel = result.SentimentAnalysis?.Sentiment ?? "Neutral"; existing.SentimentConfidence = result.SentimentAnalysis?.ConfidenceScore ?? 0; existing.PositiveScore = result.SentimentAnalysis?.PositiveScore ?? 0; existing.NegativeScore = result.SentimentAnalysis?.NegativeScore ?? 0; existing.NeutralScore = result.SentimentAnalysis?.NeutralScore ?? 0; existing.RiskLevel = result.RiskAssessment?.RiskLevel.ToString() ?? "Low"; existing.RiskScore = result.RiskAssessment?.RiskScore ?? 0; existing.RequiresImmediateAttention = result.RiskAssessment?.RequiresImmediateAttention ?? false; existing.RecommendedAction = result.RiskAssessment?.RecommendedAction ?? ""; existing.RiskIndicatorsJson = JsonSerializer.Serialize(result.RiskAssessment?.RiskIndicators ?? new List()); existing.ProtectiveFactorsJson = JsonSerializer.Serialize(result.RiskAssessment?.ProtectiveFactors ?? new List()); existing.KeyPhrasesJson = JsonSerializer.Serialize(result.KeyPhrases?.KeyPhrases ?? new List()); existing.WorkplaceFactorsJson = JsonSerializer.Serialize(result.KeyPhrases?.WorkplaceFactors ?? new List()); existing.EmotionalIndicatorsJson = JsonSerializer.Serialize(result.KeyPhrases?.EmotionalIndicators ?? new List()); existing.InsightsJson = JsonSerializer.Serialize(result.Insights); existing.AnalyzedAt = DateTime.UtcNow; _context.ResponseAnalyses.Attach(existing); _context.Entry(existing).State = EntityState.Modified; } else { // Create new var entity = new Model.ResponseAnalysis { ResponseId = result.ResponseId, QuestionId = result.QuestionId, QuestionText = result.QuestionText, AnonymizedText = result.AnonymizedResponseText, SentimentLabel = result.SentimentAnalysis?.Sentiment ?? "Neutral", SentimentConfidence = result.SentimentAnalysis?.ConfidenceScore ?? 0, PositiveScore = result.SentimentAnalysis?.PositiveScore ?? 0, NegativeScore = result.SentimentAnalysis?.NegativeScore ?? 0, NeutralScore = result.SentimentAnalysis?.NeutralScore ?? 0, RiskLevel = result.RiskAssessment?.RiskLevel.ToString() ?? "Low", RiskScore = result.RiskAssessment?.RiskScore ?? 0, RequiresImmediateAttention = result.RiskAssessment?.RequiresImmediateAttention ?? false, RecommendedAction = result.RiskAssessment?.RecommendedAction ?? "", RiskIndicatorsJson = JsonSerializer.Serialize(result.RiskAssessment?.RiskIndicators ?? new List()), ProtectiveFactorsJson = JsonSerializer.Serialize(result.RiskAssessment?.ProtectiveFactors ?? new List()), KeyPhrasesJson = JsonSerializer.Serialize(result.KeyPhrases?.KeyPhrases ?? new List()), WorkplaceFactorsJson = JsonSerializer.Serialize(result.KeyPhrases?.WorkplaceFactors ?? new List()), EmotionalIndicatorsJson = JsonSerializer.Serialize(result.KeyPhrases?.EmotionalIndicators ?? new List()), InsightsJson = JsonSerializer.Serialize(result.Insights), AnalyzedAt = DateTime.UtcNow }; _context.ResponseAnalyses.Add(entity); } await _context.SaveChangesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Error saving analysis to DB for ResponseId: {ResponseId}, QuestionId: {QuestionId}", result.ResponseId, result.QuestionId); } } private async Task LoadResponseAnalysisFromDbAsync(int responseId, int questionId) { try { var entity = await _context.ResponseAnalyses .AsNoTracking() .FirstOrDefaultAsync(ra => ra.ResponseId == responseId && ra.QuestionId == questionId); if (entity == null) return null; return new ResponseAnalysisResult { ResponseId = entity.ResponseId, QuestionId = entity.QuestionId, QuestionText = entity.QuestionText, AnonymizedResponseText = entity.AnonymizedText, SentimentAnalysis = new SentimentAnalysisResult { Sentiment = entity.SentimentLabel, ConfidenceScore = entity.SentimentConfidence, PositiveScore = entity.PositiveScore, NegativeScore = entity.NegativeScore, NeutralScore = entity.NeutralScore, AnalyzedAt = entity.AnalyzedAt }, KeyPhrases = new KeyPhrasesResult { KeyPhrases = JsonSerializer.Deserialize>(entity.KeyPhrasesJson) ?? new List(), WorkplaceFactors = JsonSerializer.Deserialize>(entity.WorkplaceFactorsJson) ?? new List(), EmotionalIndicators = JsonSerializer.Deserialize>(entity.EmotionalIndicatorsJson) ?? new List(), ExtractedAt = entity.AnalyzedAt }, RiskAssessment = new MentalHealthRiskAssessment { RiskLevel = Enum.TryParse(entity.RiskLevel, out var rl) ? rl : RiskLevel.Low, RiskScore = entity.RiskScore, RequiresImmediateAttention = entity.RequiresImmediateAttention, RecommendedAction = entity.RecommendedAction, RiskIndicators = JsonSerializer.Deserialize>(entity.RiskIndicatorsJson) ?? new List(), ProtectiveFactors = JsonSerializer.Deserialize>(entity.ProtectiveFactorsJson) ?? new List(), AssessedAt = entity.AnalyzedAt }, Insights = JsonSerializer.Deserialize>(entity.InsightsJson) ?? new List(), AnalyzedAt = entity.AnalyzedAt, IsAnalysisComplete = true }; } catch (Exception ex) { _logger.LogError(ex, "Error loading analysis from DB for ResponseId: {ResponseId}, QuestionId: {QuestionId}", responseId, questionId); return null; } } private async Task SaveSnapshotToDbAsync(QuestionnaireAnalysisOverview overview) { try { var existing = await _context.QuestionnaireAnalysisSnapshots .FirstOrDefaultAsync(s => s.QuestionnaireId == overview.QuestionnaireId); if (existing != null) { existing.TotalResponses = overview.TotalResponses; existing.AnalyzedResponses = overview.AnalyzedResponses; existing.OverallPositiveSentiment = overview.OverallPositiveSentiment; existing.OverallNegativeSentiment = overview.OverallNegativeSentiment; existing.OverallNeutralSentiment = overview.OverallNeutralSentiment; existing.LowRiskCount = overview.LowRiskResponses; existing.ModerateRiskCount = overview.ModerateRiskResponses; existing.HighRiskCount = overview.HighRiskResponses; existing.CriticalRiskCount = overview.CriticalRiskResponses; existing.ExecutiveSummary = overview.ExecutiveSummary; existing.TopWorkplaceIssuesJson = JsonSerializer.Serialize(overview.TopWorkplaceIssues); existing.MostCommonKeyPhrasesJson = JsonSerializer.Serialize(overview.MostCommonKeyPhrases); existing.GeneratedAt = DateTime.UtcNow; _context.QuestionnaireAnalysisSnapshots.Update(existing); } else { var entity = new Model.QuestionnaireAnalysisSnapshot { QuestionnaireId = overview.QuestionnaireId, TotalResponses = overview.TotalResponses, AnalyzedResponses = overview.AnalyzedResponses, OverallPositiveSentiment = overview.OverallPositiveSentiment, OverallNegativeSentiment = overview.OverallNegativeSentiment, OverallNeutralSentiment = overview.OverallNeutralSentiment, LowRiskCount = overview.LowRiskResponses, ModerateRiskCount = overview.ModerateRiskResponses, HighRiskCount = overview.HighRiskResponses, CriticalRiskCount = overview.CriticalRiskResponses, ExecutiveSummary = overview.ExecutiveSummary, TopWorkplaceIssuesJson = JsonSerializer.Serialize(overview.TopWorkplaceIssues), MostCommonKeyPhrasesJson = JsonSerializer.Serialize(overview.MostCommonKeyPhrases), GeneratedAt = DateTime.UtcNow }; _context.QuestionnaireAnalysisSnapshots.Add(entity); } await _context.SaveChangesAsync(); _logger.LogInformation("Saved snapshot for QuestionnaireId: {QuestionnaireId}", overview.QuestionnaireId); } catch (Exception ex) { _logger.LogError(ex, "Error saving snapshot for QuestionnaireId: {QuestionnaireId}", overview.QuestionnaireId); } } private async Task BuildAnalysisTextAsync(ResponseDetail detail) { try { var questionText = detail.Question?.Text ?? "Unknown question"; var questionType = detail.QuestionType; // For text-based questions, use TextResponse directly if (questionType == QuestionType.Text || questionType == QuestionType.Open_ended) { return !string.IsNullOrWhiteSpace(detail.TextResponse) ? detail.TextResponse : null; } // For slider, use the numeric value with context if (questionType == QuestionType.Slider) { if (!string.IsNullOrWhiteSpace(detail.TextResponse)) { return $"Question: {questionText}\nAnswer: {detail.TextResponse}"; } return null; } // For Rating, use TextResponse if available if (questionType == QuestionType.Rating) { if (!string.IsNullOrWhiteSpace(detail.TextResponse)) { return $"Rating Question: {questionText}\nRating given: {detail.TextResponse}"; } return null; } // For TrueFalse, get the selected answer if (questionType == QuestionType.TrueFalse) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"True/False Question: {questionText}\nAnswer: {selectedAnswers.First()}"; } return null; } // For Multiple Choice (single selection) if (questionType == QuestionType.Multiple_choice) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); var otherText = !string.IsNullOrWhiteSpace(detail.TextResponse) ? $"\nAdditional comment: {detail.TextResponse}" : ""; if (selectedAnswers.Any()) { return $"Multiple Choice Question: {questionText}\nSelected: {string.Join(", ", selectedAnswers)}{otherText}"; } return null; } // For CheckBox (multiple selection) if (questionType == QuestionType.CheckBox) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); var otherText = !string.IsNullOrWhiteSpace(detail.TextResponse) ? $"\nAdditional comment: {detail.TextResponse}" : ""; if (selectedAnswers.Any()) { return $"Multiple Selection Question: {questionText}\nSelected Options: {string.Join(", ", selectedAnswers)}{otherText}\nAnalyze these selected workplace factors for mental health implications and patterns."; } return null; } // For Likert scale if (questionType == QuestionType.Likert) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"Likert Scale Question: {questionText}\nResponse: {string.Join(", ", selectedAnswers)}"; } return null; } // For Matrix if (questionType == QuestionType.Matrix) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"Matrix Question: {questionText}\nResponses: {string.Join("; ", selectedAnswers)}"; } return null; } // For Ranking if (questionType == QuestionType.Ranking) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"Ranking Question: {questionText}\nRanked order: {string.Join(" > ", selectedAnswers)}"; } return null; } // For Demographic if (questionType == QuestionType.Demographic) { if (!string.IsNullOrWhiteSpace(detail.TextResponse)) { return $"Demographic Question: {questionText}\nAnswer: {detail.TextResponse}"; } var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"Demographic Question: {questionText}\nAnswer: {string.Join(", ", selectedAnswers)}"; } return null; } // For Image-based questions if (questionType == QuestionType.Image) { var selectedAnswers = await GetSelectedAnswerTextsAsync(detail); if (selectedAnswers.Any()) { return $"Image Selection Question: {questionText}\nSelected: {string.Join(", ", selectedAnswers)}"; } return null; } // Fallback — use TextResponse if available if (!string.IsNullOrWhiteSpace(detail.TextResponse)) { return detail.TextResponse; } return null; } catch (Exception ex) { _logger.LogError(ex, "Error building analysis text for ResponseDetail {DetailId}", detail.Id); return null; } } private async Task> GetSelectedAnswerTextsAsync(ResponseDetail detail) { try { if (detail.ResponseAnswers == null || !detail.ResponseAnswers.Any()) { // Load from DB if not included var answerIds = await _context.Set() .Where(ra => ra.ResponseDetailId == detail.Id) .Select(ra => ra.AnswerId) .ToListAsync(); if (!answerIds.Any()) return new List(); var answerTexts = await _context.Set() .Where(a => answerIds.Contains(a.Id)) .Select(a => a.Text ?? "") .Where(t => !string.IsNullOrWhiteSpace(t)) .ToListAsync(); return answerTexts; } // Use already-loaded ResponseAnswers var ids = detail.ResponseAnswers.Select(ra => ra.AnswerId).ToList(); var texts = await _context.Set() .Where(a => ids.Contains(a.Id)) .Select(a => a.Text ?? "") .Where(t => !string.IsNullOrWhiteSpace(t)) .ToListAsync(); return texts; } catch (Exception ex) { _logger.LogError(ex, "Error getting selected answers for ResponseDetail {DetailId}", detail.Id); return new List(); } } private async Task> AnalyzeAllResponsesInOneCallAsync( List<(int ResponseId, int QuestionId, string QuestionText, string AnswerText)> qaItems) { var results = new List(); if (!qaItems.Any()) return results; try { var qaBlock = new StringBuilder(); for (int i = 0; i < qaItems.Count; i++) { qaBlock.AppendLine($"[Response {i + 1}]"); qaBlock.AppendLine($"Question: {qaItems[i].QuestionText}"); qaBlock.AppendLine($"Answer: {qaItems[i].AnswerText}"); qaBlock.AppendLine(); } var systemPrompt = @"You are a workplace mental health professional. Always respond with a single valid JSON array only. No markdown, no code fences, no explanations."; var userPrompt = $@"Analyze each of the following survey responses individually. For EACH response, provide: 1. Sentiment (Positive, Negative, Neutral, Mixed) 2. Sentiment confidence scores (positive, negative, neutral — each 0.0 to 1.0, must sum to ~1.0) 3. Risk Level (Low, Moderate, High, Critical) 4. Risk Score (0.0 to 1.0) 5. Requires Immediate Attention (true/false) 6. Recommended Action 7. Risk Indicators (list of concerns) 8. Protective Factors (list of positives) 9. Key Phrases 10. Workplace Factors 11. Emotional Indicators 12. Workplace Insights — each with category, issue, recommended intervention, priority (1-5), affected areas Survey Responses: {qaBlock} Return a JSON array with one object per response in the same order: [ {{ ""responseIndex"": 0, ""sentiment"": ""Neutral"", ""positiveScore"": 0.05, ""negativeScore"": 0.05, ""neutralScore"": 0.90, ""riskLevel"": ""Low"", ""riskScore"": 0.1, ""requiresImmediateAttention"": false, ""recommendedAction"": ""specific action"", ""riskIndicators"": [""indicator1""], ""protectiveFactors"": [""factor1""], ""keyPhrases"": [""phrase1""], ""workplaceFactors"": [""factor1""], ""emotionalIndicators"": [""indicator1""], ""insights"": [ {{ ""category"": ""Category Name"", ""issue"": ""specific issue"", ""recommendedIntervention"": ""specific intervention"", ""priority"": 3, ""affectedAreas"": [""area1""] }} ] }} ] Important: - Analyze EACH response individually based on its question context - Return exactly one object per response in the same order - Respond with ONLY the JSON array"; _logger.LogInformation("Sending combined analysis request for {Count} responses to Claude API", qaItems.Count); var content = await SendClaudeRequestAsync(systemPrompt, userPrompt, 0.3f, 8000); _logger.LogInformation("Received combined analysis response from Claude API"); var parsedResults = ParseCombinedAnalysisResponse(content, qaItems); results.AddRange(parsedResults); } catch (Exception ex) { _logger.LogError(ex, "Error in combined analysis call. Falling back to individual analysis."); foreach (var item in qaItems) { try { var request = new AnalysisRequest { ResponseId = item.ResponseId, QuestionId = item.QuestionId, ResponseText = item.AnswerText, QuestionText = item.QuestionText }; results.Add(await AnalyzeCompleteResponseAsync(request)); } catch (Exception innerEx) { _logger.LogError(innerEx, "Fallback analysis failed for QuestionId: {QuestionId}", item.QuestionId); } } } return results; } private List ParseCombinedAnalysisResponse( string content, List<(int ResponseId, int QuestionId, string QuestionText, string AnswerText)> qaItems) { var results = new List(); try { var json = content?.Trim() ?? ""; if (json.StartsWith("```")) json = Regex.Replace(json, @"^```(?:json)?\s*|\s*```$", "", RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim(); using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); var array = doc.RootElement; if (array.ValueKind != JsonValueKind.Array) { _logger.LogWarning("Combined analysis response is not a JSON array"); return results; } int index = 0; foreach (var item in array.EnumerateArray()) { if (index >= qaItems.Count) break; var qa = qaItems[index]; var result = new ResponseAnalysisResult { ResponseId = qa.ResponseId, QuestionId = qa.QuestionId, QuestionText = qa.QuestionText, ResponseText = qa.AnswerText, AnonymizedResponseText = qa.AnswerText, AnalyzedAt = DateTime.UtcNow, IsAnalysisComplete = true }; // Sentiment result.SentimentAnalysis = new SentimentAnalysisResult { Sentiment = GetStringProp(item, "sentiment", "Neutral"), PositiveScore = GetDoubleProp(item, "positiveScore", 0), NegativeScore = GetDoubleProp(item, "negativeScore", 0), NeutralScore = GetDoubleProp(item, "neutralScore", 0), ConfidenceScore = Math.Max(GetDoubleProp(item, "positiveScore", 0), Math.Max(GetDoubleProp(item, "negativeScore", 0), GetDoubleProp(item, "neutralScore", 0))), AnalyzedAt = DateTime.UtcNow }; // Risk var riskLevelStr = GetStringProp(item, "riskLevel", "Low"); result.RiskAssessment = new MentalHealthRiskAssessment { RiskLevel = Enum.TryParse(riskLevelStr, true, out var rle) ? rle : RiskLevel.Low, RiskScore = Math.Clamp(GetDoubleProp(item, "riskScore", 0), 0.0, 1.0), RequiresImmediateAttention = GetBoolProp(item, "requiresImmediateAttention", false), RecommendedAction = GetStringProp(item, "recommendedAction", ""), RiskIndicators = GetStringArray(item, "riskIndicators"), ProtectiveFactors = GetStringArray(item, "protectiveFactors"), AssessedAt = DateTime.UtcNow }; // Key Phrases result.KeyPhrases = new KeyPhrasesResult { KeyPhrases = GetStringArray(item, "keyPhrases"), WorkplaceFactors = GetStringArray(item, "workplaceFactors"), EmotionalIndicators = GetStringArray(item, "emotionalIndicators"), ExtractedAt = DateTime.UtcNow }; // Insights var insights = new List(); if (item.TryGetProperty("insights", out var insArr) && insArr.ValueKind == JsonValueKind.Array) { foreach (var ins in insArr.EnumerateArray()) { insights.Add(new WorkplaceInsight { Category = GetStringProp(ins, "category", "General"), Issue = GetStringProp(ins, "issue", ""), RecommendedIntervention = GetStringProp(ins, "recommendedIntervention", ""), Priority = Math.Clamp(GetIntProp(ins, "priority", 3), 1, 5), AffectedAreas = GetStringArray(ins, "affectedAreas"), IdentifiedAt = DateTime.UtcNow }); } } result.Insights = insights; results.Add(result); index++; } _logger.LogInformation("Successfully parsed {Count} results from combined analysis", results.Count); } catch (Exception ex) { _logger.LogError(ex, "Error parsing combined analysis response. Raw (first 1000): {Raw}", content?.Length > 1000 ? content[..1000] : content); } return results; } #endregion } }