ef15fd6247
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
131 lines
5.0 KiB
C#
131 lines
5.0 KiB
C#
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; }
|
|
}
|
|
}
|