585 lines
No EOL
26 KiB
C#
585 lines
No EOL
26 KiB
C#
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Services.AIViewModel;
|
|
using Services.Interaces;
|
|
using Data;
|
|
using Model;
|
|
using System.Net.Http;
|
|
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 UserTrajectoryService : IUserTrajectoryService
|
|
{
|
|
private readonly SurveyContext _context;
|
|
private readonly ILogger<UserTrajectoryService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly string _claudeApiKey;
|
|
private readonly string _claudeModel;
|
|
|
|
public UserTrajectoryService(
|
|
IConfiguration configuration,
|
|
ILogger<UserTrajectoryService> logger,
|
|
SurveyContext context)
|
|
{
|
|
_logger = logger;
|
|
_context = context;
|
|
|
|
// Claude API configuration
|
|
_claudeApiKey = configuration["Claude:ApiKey"]
|
|
?? throw new ArgumentNullException("Claude:ApiKey is missing from configuration");
|
|
_claudeModel = configuration["Claude:Model"] ?? "claude-sonnet-4-20250514";
|
|
|
|
_httpClient = new HttpClient();
|
|
_httpClient.DefaultRequestHeaders.Add("x-api-key", _claudeApiKey);
|
|
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
|
|
|
_logger.LogInformation("UserTrajectoryService initialized with Claude API (Model: {Model})", _claudeModel);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// PUBLIC METHODS
|
|
// ═══════════════════════════════════════════
|
|
|
|
public async Task<UserTrajectoryAnalysis> GetOrAnalyzeTrajectoryAsync(string userEmail)
|
|
{
|
|
try
|
|
{
|
|
// 1. Count current responses
|
|
var currentResponses = await GetUserResponses(userEmail);
|
|
var currentCount = currentResponses.Count;
|
|
|
|
if (currentCount == 0)
|
|
return CreateEmptyResult();
|
|
|
|
// 2. Check cache
|
|
var cache = await _context.UserTrajectoryCaches
|
|
.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
|
|
|
if (cache != null && cache.AnalyzedResponseCount == currentCount)
|
|
{
|
|
// Cache is fresh — return it
|
|
_logger.LogInformation("Returning cached trajectory for {Email} ({Count} responses)", userEmail, currentCount);
|
|
return DeserializeResult(cache.TrajectoryJson);
|
|
}
|
|
|
|
// 3. Need to analyze
|
|
UserTrajectoryAnalysis result;
|
|
|
|
if (cache == null)
|
|
{
|
|
// First-time analysis — send ALL responses
|
|
_logger.LogInformation("First trajectory analysis for {Email} ({Count} responses)", userEmail, currentCount);
|
|
var responseText = BuildFullResponseText(currentResponses);
|
|
result = await CallClaudeFullAnalysis(responseText, currentCount);
|
|
}
|
|
else
|
|
{
|
|
// Incremental — send only NEW responses + previous summary
|
|
var newResponses = currentResponses
|
|
.Where(r => r.SubmissionDate > cache.LastResponseDate)
|
|
.OrderBy(r => r.SubmissionDate)
|
|
.ToList();
|
|
|
|
_logger.LogInformation("Incremental trajectory for {Email} ({New} new of {Total} total)",
|
|
userEmail, newResponses.Count, currentCount);
|
|
|
|
var newResponseText = BuildFullResponseText(newResponses);
|
|
result = await CallClaudeIncrementalAnalysis(
|
|
newResponseText, newResponses.Count,
|
|
cache.PreviousSummary ?? "", currentCount);
|
|
|
|
result.IsIncremental = true;
|
|
}
|
|
|
|
result.TotalResponsesAnalyzed = currentCount;
|
|
result.AnalyzedAt = DateTime.UtcNow;
|
|
|
|
// 4. Save to cache
|
|
var latestDate = currentResponses.Max(r => r.SubmissionDate);
|
|
var json = SerializeResult(result);
|
|
var summary = BuildSummaryForCache(result);
|
|
|
|
if (cache == null)
|
|
{
|
|
_context.UserTrajectoryCaches.Add(new UserTrajectoryCache
|
|
{
|
|
UserEmail = userEmail,
|
|
AnalyzedResponseCount = currentCount,
|
|
LastResponseDate = latestDate,
|
|
TrajectoryJson = json,
|
|
PreviousSummary = summary,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
else
|
|
{
|
|
cache.AnalyzedResponseCount = currentCount;
|
|
cache.LastResponseDate = latestDate;
|
|
cache.TrajectoryJson = json;
|
|
cache.PreviousSummary = summary;
|
|
cache.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error analyzing trajectory for {Email}", userEmail);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<UserTrajectoryAnalysis> ForceReanalyzeTrajectoryAsync(string userEmail)
|
|
{
|
|
var cache = await _context.UserTrajectoryCaches
|
|
.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
|
|
|
if (cache != null)
|
|
{
|
|
_context.UserTrajectoryCaches.Remove(cache);
|
|
await _context.SaveChangesAsync();
|
|
}
|
|
|
|
return await GetOrAnalyzeTrajectoryAsync(userEmail);
|
|
}
|
|
|
|
public async Task<(bool HasCache, bool IsStale, int CachedCount, int CurrentCount)> CheckCacheStatusAsync(string userEmail)
|
|
{
|
|
var currentCount = await _context.Responses.CountAsync(r => r.UserEmail == userEmail);
|
|
var cache = await _context.UserTrajectoryCaches.FirstOrDefaultAsync(c => c.UserEmail == userEmail);
|
|
|
|
if (cache == null)
|
|
return (false, true, 0, currentCount);
|
|
|
|
var isStale = cache.AnalyzedResponseCount < currentCount;
|
|
return (true, isStale, cache.AnalyzedResponseCount, currentCount);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// DATA BUILDING — Complete response data
|
|
// ═══════════════════════════════════════════
|
|
|
|
private async Task<List<Response>> GetUserResponses(string userEmail)
|
|
{
|
|
return await _context.Responses
|
|
.Include(r => r.Questionnaire)
|
|
.ThenInclude(q => q.Questions)
|
|
.ThenInclude(q => q.Answers)
|
|
.Include(r => r.ResponseDetails)
|
|
.ThenInclude(rd => rd.Question)
|
|
.ThenInclude(q => q.Answers)
|
|
.Include(r => r.ResponseDetails)
|
|
.ThenInclude(rd => rd.ResponseAnswers)
|
|
.ThenInclude(ra => ra.Answer)
|
|
.Where(r => r.UserEmail == userEmail)
|
|
.OrderBy(r => r.SubmissionDate)
|
|
.ToListAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a complete text representation of all responses.
|
|
/// Includes ALL questions with types, available options, and user answers.
|
|
/// NO personal data (name, email) is included.
|
|
/// </summary>
|
|
private string BuildFullResponseText(List<Response> responses)
|
|
{
|
|
var sb = new StringBuilder();
|
|
int responseNum = 0;
|
|
|
|
foreach (var response in responses)
|
|
{
|
|
responseNum++;
|
|
sb.AppendLine($"--- Response {responseNum}: \"{response.Questionnaire?.Title ?? "Unknown Survey"}\" (Submitted: {response.SubmissionDate:MMMM dd, yyyy}) ---");
|
|
sb.AppendLine();
|
|
|
|
var questions = response.Questionnaire?.Questions?.OrderBy(q => q.Id).ToList()
|
|
?? new List<Question>();
|
|
|
|
int qNum = 0;
|
|
foreach (var question in questions)
|
|
{
|
|
qNum++;
|
|
sb.AppendLine($" Q{qNum}: {question.Text} [{question.Type}]");
|
|
|
|
// Show available options for non-text questions
|
|
if (question.Answers != null && question.Answers.Any())
|
|
{
|
|
var optionTexts = question.Answers.Select(a => a.Text).ToList();
|
|
sb.AppendLine($" Options: {string.Join(", ", optionTexts)}");
|
|
}
|
|
|
|
// Find the user's response detail for this question
|
|
var detail = response.ResponseDetails?.FirstOrDefault(d => d.QuestionId == question.Id);
|
|
|
|
if (detail == null)
|
|
{
|
|
sb.AppendLine($" → (No response recorded)");
|
|
}
|
|
else
|
|
{
|
|
// Text response
|
|
if (!string.IsNullOrWhiteSpace(detail.TextResponse))
|
|
{
|
|
sb.AppendLine($" → Text Answer: \"{detail.TextResponse}\"");
|
|
}
|
|
|
|
// Selected answers (checkbox, radio, multiple choice)
|
|
if (detail.ResponseAnswers != null && detail.ResponseAnswers.Any())
|
|
{
|
|
var selectedTexts = detail.ResponseAnswers
|
|
.Where(ra => ra.Answer != null)
|
|
.Select(ra => ra.Answer!.Text)
|
|
.ToList();
|
|
|
|
if (selectedTexts.Any())
|
|
{
|
|
sb.AppendLine($" → Selected: {string.Join(", ", selectedTexts)}");
|
|
}
|
|
}
|
|
|
|
// Other/custom text
|
|
if (!string.IsNullOrWhiteSpace(detail.OtherText))
|
|
{
|
|
sb.AppendLine($" → Custom Response: \"{detail.OtherText}\"");
|
|
}
|
|
|
|
// Status
|
|
if (detail.Status == ResponseStatus.Skipped)
|
|
{
|
|
sb.AppendLine($" → (Skipped{(string.IsNullOrEmpty(detail.SkipReason) ? "" : $": {detail.SkipReason}")})");
|
|
}
|
|
else if (detail.Status == ResponseStatus.Shown)
|
|
{
|
|
sb.AppendLine($" → (Shown but not answered)");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine();
|
|
}
|
|
|
|
sb.AppendLine();
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// CLAUDE API CALLS
|
|
// ═══════════════════════════════════════════
|
|
|
|
private async Task<UserTrajectoryAnalysis> CallClaudeFullAnalysis(string responseText, int responseCount)
|
|
{
|
|
var isMultiple = responseCount > 1;
|
|
var trajectoryInstruction = isMultiple
|
|
? "Analyze the TRAJECTORY of this employee's mental health over time. Compare responses chronologically. Determine if their wellbeing is improving, stable, declining, or fluctuating."
|
|
: "This is a SINGLE initial assessment. Analyze the employee's current mental health state as a baseline evaluation. Set trajectoryDirection to 'Initial'.";
|
|
|
|
var systemPrompt = "You are a senior workplace mental health consultant. You analyze anonymized employee survey responses to assess mental health trajectories over time. You NEVER receive personal data — only survey questions, answer options, and the employee's responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences, no explanations outside the JSON.";
|
|
|
|
var userPrompt = $@"{trajectoryInstruction}
|
|
|
|
Employee Survey Data ({responseCount} response{(responseCount > 1 ? "s" : "")}):
|
|
|
|
{responseText}
|
|
|
|
Respond with this exact JSON structure:
|
|
{{
|
|
""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating|Initial"",
|
|
""trajectoryScore"": 0,
|
|
""scoreChange"": 0,
|
|
""overallRiskLevel"": ""Low|Moderate|High|Critical"",
|
|
""executiveSummary"": ""2-3 sentence overview"",
|
|
""detailedAnalysis"": ""Detailed paragraph with specific observations"",
|
|
""responseSnapshots"": [
|
|
{{
|
|
""responseDate"": ""MMMM dd, yyyy"",
|
|
""questionnaireName"": ""name"",
|
|
""wellnessScore"": 0,
|
|
""riskLevel"": ""Low|Moderate|High|Critical"",
|
|
""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"",
|
|
""keyThemes"": [""theme1"", ""theme2""],
|
|
""briefSummary"": ""One sentence""
|
|
}}
|
|
],
|
|
""patternInsights"": [
|
|
{{
|
|
""pattern"": ""Description of cross-response pattern"",
|
|
""severity"": ""High|Medium|Low"",
|
|
""firstSeen"": ""date"",
|
|
""stillPresent"": true
|
|
}}
|
|
],
|
|
""strengthFactors"": [
|
|
{{ ""factor"": ""Positive observation"" }}
|
|
],
|
|
""concernFactors"": [
|
|
{{ ""concern"": ""Concern description"", ""urgency"": ""Immediate|Monitor|Low"" }}
|
|
],
|
|
""recommendations"": [
|
|
{{ ""action"": ""Specific action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }}
|
|
],
|
|
""timelineNarrative"": ""A professional narrative describing the employee's mental health journey suitable for case reports""
|
|
}}
|
|
|
|
IMPORTANT: trajectoryScore and wellnessScore must be integers 0-100 where 100 is excellent mental health. scoreChange is the difference between first and last response scores (positive = improvement). Provide at least 2 patternInsights, 2 strengthFactors, 2 concernFactors, and 3 recommendations.";
|
|
|
|
return await CallClaudeApi(systemPrompt, userPrompt);
|
|
}
|
|
|
|
private async Task<UserTrajectoryAnalysis> CallClaudeIncrementalAnalysis(
|
|
string newResponseText, int newCount, string previousSummary, int totalCount)
|
|
{
|
|
var systemPrompt = "You are a senior workplace mental health consultant. You are updating an existing employee trajectory analysis with new survey data. You NEVER receive personal data — only survey questions, answer options, and responses. Always respond with a SINGLE valid JSON object. No markdown, no code fences.";
|
|
|
|
var userPrompt = $@"PREVIOUS ANALYSIS SUMMARY (based on {totalCount - newCount} earlier responses):
|
|
{previousSummary}
|
|
|
|
NEW SURVEY DATA ({newCount} new response{(newCount > 1 ? "s" : "")} — total is now {totalCount}):
|
|
|
|
{newResponseText}
|
|
|
|
Update the trajectory analysis incorporating this new data with the existing history. The trajectoryScore and scoreChange should reflect the FULL trajectory (all {totalCount} responses), not just the new ones.
|
|
|
|
Respond with this exact JSON structure:
|
|
{{
|
|
""trajectoryDirection"": ""Improving|Stable|Declining|Fluctuating"",
|
|
""trajectoryScore"": 0,
|
|
""scoreChange"": 0,
|
|
""overallRiskLevel"": ""Low|Moderate|High|Critical"",
|
|
""executiveSummary"": ""2-3 sentence overview of full trajectory"",
|
|
""detailedAnalysis"": ""Detailed paragraph incorporating both old and new data"",
|
|
""responseSnapshots"": [
|
|
{{
|
|
""responseDate"": ""date"",
|
|
""questionnaireName"": ""name"",
|
|
""wellnessScore"": 0,
|
|
""riskLevel"": ""Low|Moderate|High|Critical"",
|
|
""sentimentLabel"": ""Positive|Negative|Mixed|Neutral"",
|
|
""keyThemes"": [""theme1""],
|
|
""briefSummary"": ""One sentence""
|
|
}}
|
|
],
|
|
""patternInsights"": [
|
|
{{ ""pattern"": ""description"", ""severity"": ""High|Medium|Low"", ""firstSeen"": ""date"", ""stillPresent"": true }}
|
|
],
|
|
""strengthFactors"": [{{ ""factor"": ""observation"" }}],
|
|
""concernFactors"": [{{ ""concern"": ""description"", ""urgency"": ""Immediate|Monitor|Low"" }}],
|
|
""recommendations"": [{{ ""action"": ""action"", ""priority"": ""Urgent|High|Normal"", ""category"": ""Workplace|Personal|Professional Support"" }}],
|
|
""timelineNarrative"": ""Updated professional narrative for full trajectory""
|
|
}}
|
|
|
|
IMPORTANT: responseSnapshots should include entries for ALL {totalCount} responses (use the previous summary to reconstruct earlier snapshots). Scores are 0-100 integers.";
|
|
|
|
return await CallClaudeApi(systemPrompt, userPrompt);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// CLAUDE HTTP API CALL
|
|
// ═══════════════════════════════════════════
|
|
|
|
private async Task<UserTrajectoryAnalysis> CallClaudeApi(string systemPrompt, string userPrompt)
|
|
{
|
|
var requestBody = new
|
|
{
|
|
model = _claudeModel,
|
|
max_tokens = 4096,
|
|
system = systemPrompt,
|
|
messages = new[]
|
|
{
|
|
new { role = "user", content = userPrompt }
|
|
}
|
|
};
|
|
|
|
var jsonRequest = JsonSerializer.Serialize(requestBody);
|
|
var httpContent = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
|
|
|
|
var httpResponse = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", httpContent);
|
|
|
|
if (!httpResponse.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await httpResponse.Content.ReadAsStringAsync();
|
|
_logger.LogError("Claude API error {StatusCode}: {Error}", httpResponse.StatusCode, errorBody);
|
|
throw new Exception($"Claude API returned {httpResponse.StatusCode}: {errorBody}");
|
|
}
|
|
|
|
var responseJson = await httpResponse.Content.ReadAsStringAsync();
|
|
_logger.LogInformation("Claude API response length: {Length} chars", responseJson.Length);
|
|
|
|
// Extract the text content from Claude's response
|
|
var claudeResponse = JsonSerializer.Deserialize<JsonElement>(responseJson);
|
|
var contentArray = claudeResponse.GetProperty("content");
|
|
var textContent = "";
|
|
|
|
foreach (var block in contentArray.EnumerateArray())
|
|
{
|
|
if (block.GetProperty("type").GetString() == "text")
|
|
{
|
|
textContent = block.GetProperty("text").GetString() ?? "";
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(textContent))
|
|
{
|
|
_logger.LogWarning("Claude returned empty text content");
|
|
return CreateFallbackResult();
|
|
}
|
|
|
|
// Parse the JSON from Claude's text response
|
|
var result = DeserializeLenient<UserTrajectoryAnalysis>(textContent, out var error);
|
|
|
|
if (result == null)
|
|
{
|
|
_logger.LogWarning("Failed to parse trajectory JSON from Claude: {Error}. Raw (first 1000): {Raw}",
|
|
error, textContent.Length > 1000 ? textContent[..1000] : textContent);
|
|
return CreateFallbackResult();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// CACHE HELPERS
|
|
// ═══════════════════════════════════════════
|
|
|
|
private string BuildSummaryForCache(UserTrajectoryAnalysis result)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Trajectory: {result.TrajectoryDirection} | Score: {result.TrajectoryScore}/100 | Risk: {result.OverallRiskLevel}");
|
|
sb.AppendLine($"Summary: {result.ExecutiveSummary}");
|
|
|
|
if (result.ResponseSnapshots.Any())
|
|
{
|
|
sb.AppendLine("Response History:");
|
|
foreach (var snap in result.ResponseSnapshots)
|
|
{
|
|
sb.AppendLine($" - {snap.ResponseDate} ({snap.QuestionnaireName}): Wellness {snap.WellnessScore}/100, {snap.RiskLevel} risk, {snap.SentimentLabel} sentiment. {snap.BriefSummary}");
|
|
}
|
|
}
|
|
|
|
if (result.PatternInsights.Any())
|
|
{
|
|
sb.AppendLine("Key Patterns: " + string.Join("; ", result.PatternInsights.Select(p => $"{p.Pattern} ({p.Severity})")));
|
|
}
|
|
|
|
if (result.ConcernFactors.Any())
|
|
{
|
|
sb.AppendLine("Concerns: " + string.Join("; ", result.ConcernFactors.Select(c => $"{c.Concern} ({c.Urgency})")));
|
|
}
|
|
|
|
if (result.StrengthFactors.Any())
|
|
{
|
|
sb.AppendLine("Strengths: " + string.Join("; ", result.StrengthFactors.Select(s => s.Factor)));
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private UserTrajectoryAnalysis CreateEmptyResult()
|
|
{
|
|
return new UserTrajectoryAnalysis
|
|
{
|
|
TrajectoryDirection = "Initial",
|
|
TrajectoryScore = 0,
|
|
OverallRiskLevel = "Low",
|
|
ExecutiveSummary = "No survey responses found for this user.",
|
|
AnalyzedAt = DateTime.UtcNow
|
|
};
|
|
}
|
|
|
|
private UserTrajectoryAnalysis CreateFallbackResult()
|
|
{
|
|
return new UserTrajectoryAnalysis
|
|
{
|
|
TrajectoryDirection = "Initial",
|
|
TrajectoryScore = 50,
|
|
OverallRiskLevel = "Moderate",
|
|
ExecutiveSummary = "Analysis could not be fully parsed. Please try re-analyzing.",
|
|
DetailedAnalysis = "The AI response could not be parsed into the expected format. Please use the 'Re-analyze' option to try again.",
|
|
AnalyzedAt = DateTime.UtcNow
|
|
};
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// JSON SERIALIZATION
|
|
// ═══════════════════════════════════════════
|
|
|
|
private static readonly JsonSerializerOptions _jsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
AllowTrailingCommas = true,
|
|
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
private string SerializeResult(UserTrajectoryAnalysis result)
|
|
{
|
|
return JsonSerializer.Serialize(result, _jsonOptions);
|
|
}
|
|
|
|
private UserTrajectoryAnalysis DeserializeResult(string json)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<UserTrajectoryAnalysis>(json, _jsonOptions)
|
|
?? new UserTrajectoryAnalysis();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to deserialize cached trajectory JSON");
|
|
return new UserTrajectoryAnalysis
|
|
{
|
|
ExecutiveSummary = "Cached data could not be loaded. Please re-analyze."
|
|
};
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// JSON PARSE HELPERS
|
|
// ═══════════════════════════════════════════
|
|
|
|
private static readonly Regex JsonObjectRegex =
|
|
new(@"(\{(?:[^{}]|(?<o>\{)|(?<-o>\}))*(?(o)(?!))\})",
|
|
RegexOptions.Singleline | RegexOptions.Compiled);
|
|
|
|
private static T? DeserializeLenient<T>(string content, out string? error)
|
|
{
|
|
error = null;
|
|
var json = content?.Trim() ?? "";
|
|
|
|
// Strip markdown fences
|
|
if (json.StartsWith("```"))
|
|
{
|
|
json = Regex.Replace(json, "^```(?:json)?\\s*|\\s*```$", "",
|
|
RegexOptions.IgnoreCase | RegexOptions.Singleline).Trim();
|
|
}
|
|
|
|
// Try direct parse first
|
|
if (json.StartsWith("{") && json.EndsWith("}"))
|
|
{
|
|
try { return JsonSerializer.Deserialize<T>(json, _jsonOptions); }
|
|
catch (Exception ex) { error = ex.Message; }
|
|
}
|
|
|
|
// Try regex extraction
|
|
var m = JsonObjectRegex.Match(json);
|
|
if (m.Success)
|
|
{
|
|
try { return JsonSerializer.Deserialize<T>(m.Value, _jsonOptions); }
|
|
catch (Exception ex) { error = ex.Message; }
|
|
}
|
|
|
|
error ??= "No valid JSON object found in response.";
|
|
return default;
|
|
}
|
|
}
|
|
} |