Files
meezi/src/Meezi.API/Services/CoffeeAdvisorService.cs
T

131 lines
5.0 KiB
C#
Raw Normal View History

2026-05-27 21:33:48 +03:30
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<CoffeeAdvisorService> _logger;
public CoffeeAdvisorService(
AppDbContext db,
IOpenAiChatService openAi,
ILogger<CoffeeAdvisorService> 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<AdvisorJson>(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<IReadOnlyList<string>> 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<AdvisorPickJson>? Picks { get; set; }
}
private sealed class AdvisorPickJson
{
public string? Name { get; set; }
public string? Reason { get; set; }
public string? MenuItemId { get; set; }
}
}