Files
administration/Controllers/SearchController.cs
2025-09-03 20:17:50 +02:00

505 lines
23 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//// Controllers/SearchController.cs
//using Microsoft.AspNetCore.Mvc;
//using Microsoft.Extensions.Logging;
//using System.Net.Http.Headers;
//using System.Text;
//using System.Text.Json;
//using System.Text.Json.Serialization;
//using System.Text.RegularExpressions;
//namespace Administration.Controllers
//{
// [ApiController]
// [Route("api/ai")]
// public class SearchController : ControllerBase
// {
// private readonly IHttpClientFactory _httpFactory;
// private readonly IConfiguration _cfg;
// private readonly ILogger<SearchController> _log;
// public SearchController(IHttpClientFactory httpFactory, IConfiguration cfg, ILogger<SearchController> log)
// {
// _httpFactory = httpFactory;
// _cfg = cfg;
// _log = log;
// }
// // =========================
// // DTOs
// // =========================
// public sealed class AiSelectRequest
// {
// public string Query { get; set; } = "";
// public List<string> Allowed { get; set; } = new();
// public bool Debug { get; set; } = false;
// }
// public sealed class AiSelectResponse
// {
// [JsonPropertyName("names")]
// public List<string> Names { get; set; } = new();
// [JsonPropertyName("debug")]
// public object? Debug { get; set; }
// }
// // =========================
// // POST /api/ai/select
// // =========================
// [HttpPost("select")]
// public async Task<IActionResult> Select([FromBody] AiSelectRequest body)
// {
// if (body is null) return BadRequest(new { error = "Body requis." });
// if (string.IsNullOrWhiteSpace(body.Query)) return BadRequest(new { error = "Query requis." });
// if (body.Allowed is null || body.Allowed.Count == 0)
// {
// _log.LogWarning("AI Select: Allowed vide pour query '{Query}'", body.Query);
// return Ok(new AiSelectResponse { Names = new() });
// }
// // Normalisation unique
// var allowed = body.Allowed
// .Where(a => !string.IsNullOrWhiteSpace(a))
// .Select(a => a.Trim())
// .Distinct(StringComparer.OrdinalIgnoreCase)
// .ToList();
// // --- 1) Raccourcis instantanés : catégories & synonymes riches
// var catHits = TryCategoryExpand(body.Query, allowed);
// if (catHits.Count > 0)
// return Ok(new AiSelectResponse { Names = SortByAllowedOrder(catHits, allowed) });
// // --- 2) Match "classique" tolérant (FR/EN, accents, pluriels)
// var classicHits = ClassicMatch(body.Query, allowed);
// if (classicHits.Count > 0)
// return Ok(new AiSelectResponse { Names = SortByAllowedOrder(classicHits, allowed) });
// // --- 3) Heuristique "prompt" -> on tente l'IA (court)
// var ollamaUrl = _cfg["Ollama:Url"] ?? "http://ollama:11434";
// var model = _cfg["Ollama:Model"] ?? "qwen2.5:1.5b-instruct";
// // prompt ultra directif pour forcer un tableau JSON plat
// var prompt =
// $"Liste autorisée: [{string.Join(",", allowed.Select(a => $"\"{a}\""))}].\n" +
// $"Requête: \"{body.Query}\".\n" +
// "Réponds UNIQUEMENT par un tableau JSON plat des éléments EXACTEMENT issus de la liste autorisée. " +
// "Exemple: [\"Tomate\",\"Oignon\"]. Interdit: objets {name:...}, clés ingredients:, texte hors JSON.";
// var client = _httpFactory.CreateClient();
// client.Timeout = Timeout.InfiniteTimeSpan; // on contrôle avec CTS
// var payload = new
// {
// model,
// prompt,
// stream = false,
// format = "json",
// keep_alive = "10m",
// options = new
// {
// temperature = 0.0,
// num_ctx = 64,
// num_predict = 64
// }
// };
// var req = new HttpRequestMessage(HttpMethod.Post, $"{ollamaUrl.TrimEnd('/')}/api/generate");
// req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// _log.LogInformation("AI Select ▶ Query='{Query}', AllowedCount={AllowedCount}, Model={Model}, Url={Url}",
// body.Query, allowed.Count, model, ollamaUrl);
// if (body.Debug)
// {
// _log.LogDebug("AI Select ▶ Prompt envoyé:\n{Prompt}", payload.prompt);
// }
// // Si la requête ressemble à une phrase libre -> 4s, sinon 2s
// bool prompty = body.Query.Length >= 18
// || Regex.IsMatch(body.Query, @"\b(avec|sans|pour|idée|idées|recette|faire|préparer|quoi|envie)\b", RegexOptions.IgnoreCase);
// using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(prompty ? 4 : 2));
// try
// {
// using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
// var raw = await resp.Content.ReadAsStringAsync();
// _log.LogInformation("AI Select ◀ HTTP {Status} ({Reason})", (int)resp.StatusCode, resp.ReasonPhrase);
// _log.LogDebug("AI Select ◀ RAW:\n{Raw}", raw);
// if (!resp.IsSuccessStatusCode)
// {
// _log.LogWarning("AI Select: statut non OK, on renvoie lerreur au client.");
// return StatusCode((int)resp.StatusCode, new
// {
// error = "IA error",
// raw,
// url = $"{ollamaUrl.TrimEnd('/')}/api/generate"
// });
// }
// // Extraire "response" puis convertir en liste de chaînes
// using var doc = JsonDocument.Parse(raw);
// var responseText = doc.RootElement.TryGetProperty("response", out var rEl)
// ? (rEl.GetString() ?? "")
// : "";
// var iaNames = ExtractNames(responseText);
// var mapped = MapToAllowed(iaNames, allowed);
// return Ok(new AiSelectResponse
// {
// Names = SortByAllowedOrder(mapped, allowed),
// Debug = body.Debug ? new { sent = payload, url = $"{ollamaUrl.TrimEnd('/')}/api/generate", raw } : null
// });
// }
// catch (TaskCanceledException)
// {
// _log.LogWarning("AI Select: timeout côté Ollama ({Url})", ollamaUrl);
// return StatusCode(504, new { error = "Timeout IA" });
// }
// catch (Exception ex)
// {
// _log.LogError(ex, "AI Select: exception pendant lappel Ollama.");
// return StatusCode(500, new { error = ex.Message });
// }
// }
// // =========================
// // Helpers — Normalisation
// // =========================
// static string RemoveDiacritics(string s)
// {
// if (string.IsNullOrEmpty(s)) return "";
// var formD = s.Normalize(NormalizationForm.FormD);
// var sb = new StringBuilder(formD.Length);
// foreach (var ch in formD)
// {
// var uc = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(ch);
// if (uc != System.Globalization.UnicodeCategory.NonSpacingMark)
// sb.Append(ch);
// }
// return sb.ToString().Normalize(NormalizationForm.FormC);
// }
// static string Key(string? s)
// {
// s ??= "";
// var noAcc = RemoveDiacritics(s).ToLowerInvariant();
// // retire ponctuation + normalise espaces
// noAcc = Regex.Replace(noAcc, @"[^\p{L}\p{Nd}\s]", " ");
// noAcc = Regex.Replace(noAcc, @"\s+", " ").Trim();
// // singulier approximatif (enlève 's' final si pertinent)
// if (noAcc.EndsWith("s") && noAcc.Length > 3) noAcc = noAcc[..^1];
// return noAcc;
// }
// static List<string> SortByAllowedOrder(IEnumerable<string> items, List<string> allowed)
// {
// var set = new HashSet<string>(items, StringComparer.OrdinalIgnoreCase);
// var ordered = new List<string>();
// foreach (var a in allowed)
// if (set.Contains(a)) ordered.Add(a);
// return ordered;
// }
// // =========================
// // Helpers — Matching local
// // =========================
// // Synonymes simples EN -> FR (complète librement)
// static readonly Dictionary<string, string[]> EN_FR_SYNONYMS = new(StringComparer.OrdinalIgnoreCase)
// {
// ["meat"] = new[] { "viande", "viandes", "boeuf", "bœuf", "porc", "poulet", "agneau", "veau", "saucisse", "jambon" },
// ["fish"] = new[] { "poisson", "saumon", "thon", "cabillaud", "lieu", "filet de lieu", "colin", "merlu" },
// ["spicy"] = new[] { "piquant", "épicé", "piment", "harissa", "sriracha", "curry", "paprika", "poivre", "gingembre" },
// ["fresh"] = new[] { "frais", "fraîche", "fraîches", "réfrigéré", "perissable", "périssable" },
// ["dairy"] = new[] { "laitier", "laitiers", "lait", "beurre", "fromage", "crème", "yaourt" },
// ["herbs"] = new[] { "herbes", "persil", "ciboulette", "basilic", "menthe", "aneth", "romarin", "thym" },
// ["sauce"] = new[] { "sauce", "sauces", "condiment", "condiments", "mayonnaise", "ketchup", "moutarde", "soja", "sauce soja", "BBQ" },
// ["veggie"] = new[] { "légume", "légumes", "tomate", "oignon", "carotte", "brocoli", "courgette", "poivron", "ail", "salade" },
// ["protein"] = new[] { "protéine", "protéines", "oeuf", "œuf", "tofu", "tempeh", "pois chiches", "lentilles", "haricots", "poulet", "fromage" },
// };
// static List<string> ClassicMatch(string query, List<string> allowed)
// {
// var nq = Key(query);
// var tokens = nq.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// // EN->FR élargit
// var expanded = new HashSet<string>(tokens);
// foreach (var t in tokens)
// if (EN_FR_SYNONYMS.TryGetValue(t, out var frs))
// foreach (var fr in frs) expanded.Add(Key(fr));
// var hits = new List<string>();
// foreach (var a in allowed)
// {
// if (AnyTokenMatches(a, expanded))
// hits.Add(a);
// }
// return hits.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
// }
// // =========================
// // Catégories & règles
// // =========================
// sealed class CategoryRule
// {
// public string Name { get; init; } = "";
// public string[] Triggers { get; init; } = Array.Empty<string>();
// public string[] Must { get; init; } = Array.Empty<string>();
// public string[] Any { get; init; } = Array.Empty<string>();
// public string[] Not { get; init; } = Array.Empty<string>();
// }
// static readonly List<CategoryRule> CATEGORY_RULES = new()
// {
// new CategoryRule {
// Name = "legumes",
// Triggers = new[]{"legume","légume","légumes","vegetable","veggie","crudité"},
// Any = new[]{
// "oignon","carotte","pomme de terre","brocoli","courgette",
// "poivron","tomate","ail","salade","champignon","haricot vert","épinard"
// },
// Not = new[]{
// // viande/charcuterie
// "saucisse","porc","poulet","boeuf","bœuf","agneau","veau","jambon","lardon","charcuterie","filet",
// // poisson/mer
// "poisson","saumon","thon","lieu","cabillaud","merlu","colin","crevette","moule",
// // laitier/fromage/œuf
// "fromage","crème","lait","yaourt","œuf","oeuf","beurre"
// }
// },
// new CategoryRule {
// Name = "fruits",
// Triggers = new[]{"fruit","fruits","dessert","sucré"},
// Any = new[]{"pomme","banane","orange","citron","citron vert","fraise","poire","raisin","tomate"}
// },
// new CategoryRule {
// Name = "epices",
// Triggers = new[]{"epice","épice","epices","épices","spice","spicy","assaisonnement"},
// Any = new[]{"cumin","paprika","curcuma","vadouvan","curry","poivre","piment","gingembre","muscade","thym"}
// },
// new CategoryRule {
// Name = "herbes",
// Triggers = new[]{"herbe","herbes","aromatique","aromatiques"},
// Any = new[]{"persil","ciboulette","basilic","menthe","aneth","romarin","thym","coriandre"}
// },
// new CategoryRule {
// Name = "laitiers",
// Triggers = new[]{"laitier","laitiers","dairy","fromager","crémier"},
// Any = new[]{"lait","beurre","fromage","crème","yaourt","crème aigre","mozzarella","parmesan","lait de coco"}
// },
// new CategoryRule {
// Name = "viandes",
// Triggers = new[]{"viande","viandes","carné","meat","steak","charcuterie"},
// Any = new[]{"boeuf","bœuf","porc","poulet","dinde","agneau","veau","saucisse","jambon","lardons","filet de porc"}
// },
// new CategoryRule {
// Name = "poissons",
// Triggers = new[]{"poisson","poissons","seafood","poissonnerie","poissonnier"},
// Any = new[]{"saumon","thon","lieu","filet de lieu","cabillaud","merlu","colin","crevette","moule"}
// },
// new CategoryRule {
// Name = "proteines",
// Triggers = new[]{"protéine","protéines","protein","proteins"},
// Any = new[]{"oeuf","œuf","tofu","tempeh","pois chiches","lentilles","haricots","saucisse","jambon","poulet","filet de porc","fromage"}
// },
// new CategoryRule {
// Name = "frais",
// Triggers = new[]{"frais","fraiche","fraîche","fraîches","réfrigéré","frigo","perissable","périssable"},
// Any = new[]{"salade","tomate","concombre","yaourt","fromage","beurre","crème","herbes","viande","poisson"}
// },
// new CategoryRule {
// Name = "piquant",
// Triggers = new[]{"piquant","pimenté","fort","épicé","spicy","hot"},
// Any = new[]{"piment","harissa","sriracha","tabasco","curry","paprika","gingembre","poivre","pâte de curry","pâte de curry vert"}
// },
// new CategoryRule {
// Name = "sauces",
// Triggers = new[]{"sauce","sauces","condiment","condiments","topping"},
// Any = new[]{"mayonnaise","ketchup","moutarde","soja","sauce soja","worcestershire","BBQ","harissa","sriracha","pâte de curry","pâte de curry vert","lait de coco","crème"}
// },
// new CategoryRule {
// Name = "condiments",
// Triggers = new[]{"condiment","condiments","pickles","vinaigre","cornichon"},
// Any = new[]{"moutarde","vinaigre","huile","cornichon","câpres","poivre","sel"}
// },
// new CategoryRule {
// Name = "cereales",
// Triggers = new[]{"céréale","céréales","grains","cereal"},
// Any = new[]{"riz","quinoa","semoule","boulgour","avoine","farine"}
// },
// new CategoryRule {
// Name = "pates",
// Triggers = new[]{"pate","pâtes","nouilles","pasta","spaghetti"},
// Any = new[]{"pâtes","nouilles","nouilles de riz","spaghetti","penne","farfalle"}
// },
// new CategoryRule {
// Name = "sandwich",
// Triggers = new[]{"sandwich","sandwichs","sandwiches","wrap","tacos","kebab","burger"},
// Any = new[]{"pain","salade","tomate","oignon","fromage","jambon","saucisse","poulet","sauce","cornichon"}
// },
// new CategoryRule {
// Name = "sucre",
// Triggers = new[]{"sucre","sucré","dessert","patisserie","pâtisserie","gâteau"},
// Any = new[]{"sucre","chocolat","vanille","beurre","farine","lait","œuf","oeuf","pomme","banane","crème"}
// },
// };
// static List<string> TryCategoryExpand(string query, List<string> allowed)
// {
// var nq = Key(query);
// var hits = new List<string>();
// foreach (var rule in CATEGORY_RULES)
// {
// if (!rule.Triggers.Any(t => nq.Contains(Key(t)))) continue;
// var allowedHits = allowed.Where(a =>
// {
// // normalisé dans les helpers
// bool anyOk = rule.Any.Length == 0 || AnyTokenMatches(a, rule.Any);
// bool mustOk = rule.Must.Length == 0 || AllTokensMatch(a, rule.Must);
// bool notOk = rule.Not.Length > 0 && AnyTokenMatches(a, rule.Not);
// return anyOk && mustOk && !notOk;
// });
// hits.AddRange(allowedHits);
// }
// return hits.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
// }
// // =========================
// // Helpers — Parsing IA
// // =========================
// static List<string> ExtractNames(string responseText)
// {
// // 1) tableau JSON brut -> ["...","..."]
// try
// {
// var arr = JsonSerializer.Deserialize<string[]>(responseText);
// if (arr is not null && arr.Length > 0)
// return arr.Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// }
// catch { /* not an array */ }
// // 2) objet { "ingredients": [...] } ou { "ingrédients": [...] }
// try
// {
// using var inner = JsonDocument.Parse(responseText);
// if (inner.RootElement.TryGetProperty("ingredients", out var arr1) && arr1.ValueKind == JsonValueKind.Array)
// return arr1.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// if (inner.RootElement.TryGetProperty("ingrédients", out var arr2) && arr2.ValueKind == JsonValueKind.Array)
// return arr2.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// }
// catch { /* not an object */ }
// // 3) JSON encodé dans une string (rare mais vu chez Qwen)
// var mObj = Regex.Match(responseText, @"\{[\s\S]*\}");
// if (mObj.Success)
// {
// try
// {
// using var objDoc = JsonDocument.Parse(mObj.Value);
// if (objDoc.RootElement.TryGetProperty("ingredients", out var arr1) && arr1.ValueKind == JsonValueKind.Array)
// return arr1.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// if (objDoc.RootElement.TryGetProperty("ingrédients", out var arr2) && arr2.ValueKind == JsonValueKind.Array)
// return arr2.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// }
// catch { /* ignore */ }
// }
// // 4) fallback : extrait le premier [...] plausible
// var mArr = Regex.Match(responseText, @"\[[\s\S]*?\]");
// if (mArr.Success)
// {
// try
// {
// var arr = JsonSerializer.Deserialize<string[]>(mArr.Value);
// if (arr is not null && arr.Length > 0)
// return arr.Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
// }
// catch { /* ignore */ }
// }
// return new List<string>();
// }
// static List<string> MapToAllowed(IEnumerable<string> names, List<string> allowed)
// {
// var mapAllowed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// foreach (var a in allowed)
// {
// var k = Key(a);
// if (!mapAllowed.ContainsKey(k))
// mapAllowed[k] = a;
// }
// var result = new List<string>();
// var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// foreach (var n in names)
// {
// var k = Key(n);
// if (mapAllowed.TryGetValue(k, out var official))
// {
// if (seen.Add(official))
// result.Add(official);
// }
// }
// return result;
// }
// // --- Helpers de matching précis ---
// static bool ContainsPhraseOrWord(string haystack, string token)
// {
// // les deux sont déjà normalisés via Key() avant appel
// if (string.IsNullOrWhiteSpace(token)) return false;
// if (token.Contains(' '))
// {
// // multi-mots : on garde le contains sur la phrase normalisée
// return haystack.Contains(token);
// }
// // 1 mot : on exige un match sur "mot entier"
// // ex: "oignon" ne doit pas faire matcher "saucisse de porc ... aux oignons caramélisés"
// var words = haystack.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// foreach (var w in words)
// if (w.Equals(token, StringComparison.Ordinal)) return true;
// return false;
// }
// static bool AnyTokenMatches(string haystack, IEnumerable<string> tokens)
// {
// var h = Key(haystack);
// foreach (var t in tokens)
// {
// var tok = Key(t);
// if (ContainsPhraseOrWord(h, tok)) return true;
// }
// return false;
// }
// static bool AllTokensMatch(string haystack, IEnumerable<string> tokens)
// {
// var h = Key(haystack);
// foreach (var t in tokens)
// {
// var tok = Key(t);
// if (!ContainsPhraseOrWord(h, tok)) return false;
// }
// return true;
// }
// }
//}