using System.Text.Json; using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Public; using Meezi.Infrastructure.Data; namespace Meezi.API.Services; public interface ICoffeeAdvisorService { Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync( CoffeeAdvisorRequest request, CancellationToken cancellationToken = default); } public class CoffeeAdvisorService : ICoffeeAdvisorService { private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; private readonly AppDbContext _db; private readonly IOpenAiChatService _openAi; private readonly ILogger _logger; public CoffeeAdvisorService( AppDbContext db, IOpenAiChatService openAi, ILogger logger) { _db = db; _openAi = openAi; _logger = logger; } public async Task<(CoffeeAdvisorResultDto? Data, string? ErrorCode, string? Message)> RecommendAsync( CoffeeAdvisorRequest request, CancellationToken cancellationToken = default) { var purpose = request.Purpose?.Trim(); if (string.IsNullOrWhiteSpace(purpose) || purpose.Length < 3) return (null, "INVALID_REQUEST", "Describe what you need (at least 3 characters)."); if (!await _openAi.IsConfiguredForCoffeeAdvisorAsync(cancellationToken)) return (null, "AI_NOT_CONFIGURED", "Coffee advisor is not available right now."); var menuLines = await LoadMenuContextAsync(request.CafeSlug, cancellationToken); var systemPrompt = """ You are a specialty coffee advisor for Iranian cafés. Respond ONLY with valid JSON (no markdown). Schema: { "summary": string (1-2 sentences in Persian), "picks": [ { "name": string, "reason": string (Persian), "menuItemId": string|null } ] } Rules: suggest 1-3 drinks; prefer items from the menu list when provided; match the guest's purpose (energy, relax, meeting, dessert pairing, etc.); be concise and friendly in Persian. """; var userPrompt = $""" Guest purpose: {purpose} {(menuLines.Count > 0 ? "Café menu (id | name | description):\n" + string.Join("\n", menuLines) : "No specific café menu — suggest classic café drinks.")} """; string? json; try { json = await _openAi.CompleteJsonAsync(systemPrompt, userPrompt, cancellationToken); } catch (Exception ex) { _logger.LogWarning(ex, "Coffee advisor OpenAI call failed"); return (null, "AI_FAILED", "Could not get a recommendation. Try again later."); } if (string.IsNullOrWhiteSpace(json)) return (null, "AI_FAILED", "Could not get a recommendation. Try again later."); try { var parsed = JsonSerializer.Deserialize(json, JsonOpts); if (parsed is null || string.IsNullOrWhiteSpace(parsed.Summary)) return (null, "AI_FAILED", "Invalid advisor response."); var picks = (parsed.Picks ?? []) .Where(p => !string.IsNullOrWhiteSpace(p.Name)) .Take(3) .Select(p => new CoffeeAdvisorPickDto( p.Name!.Trim(), p.Reason?.Trim() ?? "", string.IsNullOrWhiteSpace(p.MenuItemId) ? null : p.MenuItemId.Trim())) .ToList(); return (new CoffeeAdvisorResultDto(parsed.Summary.Trim(), picks), null, null); } catch (JsonException ex) { _logger.LogWarning(ex, "Coffee advisor JSON parse failed"); return (null, "AI_FAILED", "Could not parse advisor response."); } } private async Task> LoadMenuContextAsync(string? slug, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(slug)) return []; var cafeId = await _db.Cafes.AsNoTracking() .Where(c => c.Slug == slug.Trim() && c.DeletedAt == null) .Select(c => c.Id) .FirstOrDefaultAsync(cancellationToken); if (cafeId is null) return []; var items = await _db.MenuItems.AsNoTracking() .Where(i => i.CafeId == cafeId && i.IsAvailable && i.DeletedAt == null) .OrderBy(i => i.Name) .Take(40) .Select(i => new { i.Id, i.Name, i.Description }) .ToListAsync(cancellationToken); return items .Select(i => $"{i.Id} | {i.Name} | {(string.IsNullOrWhiteSpace(i.Description) ? "-" : i.Description)}") .ToList(); } private sealed class AdvisorJson { public string? Summary { get; set; } public List? Picks { get; set; } } private sealed class AdvisorPickJson { public string? Name { get; set; } public string? Reason { get; set; } public string? MenuItemId { get; set; } } }