//// 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 _log; // public SearchController(IHttpClientFactory httpFactory, IConfiguration cfg, ILogger log) // { // _httpFactory = httpFactory; // _cfg = cfg; // _log = log; // } // // ========================= // // DTOs // // ========================= // public sealed class AiSelectRequest // { // public string Query { get; set; } = ""; // public List Allowed { get; set; } = new(); // public bool Debug { get; set; } = false; // } // public sealed class AiSelectResponse // { // [JsonPropertyName("names")] // public List Names { get; set; } = new(); // [JsonPropertyName("debug")] // public object? Debug { get; set; } // } // // ========================= // // POST /api/ai/select // // ========================= // [HttpPost("select")] // public async Task 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 l’erreur 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 l’appel 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 SortByAllowedOrder(IEnumerable items, List allowed) // { // var set = new HashSet(items, StringComparer.OrdinalIgnoreCase); // var ordered = new List(); // 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 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 ClassicMatch(string query, List allowed) // { // var nq = Key(query); // var tokens = nq.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); // // EN->FR élargit // var expanded = new HashSet(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(); // 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(); // public string[] Must { get; init; } = Array.Empty(); // public string[] Any { get; init; } = Array.Empty(); // public string[] Not { get; init; } = Array.Empty(); // } // static readonly List 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 TryCategoryExpand(string query, List allowed) // { // var nq = Key(query); // var hits = new List(); // 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 ExtractNames(string responseText) // { // // 1) tableau JSON brut -> ["...","..."] // try // { // var arr = JsonSerializer.Deserialize(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(mArr.Value); // if (arr is not null && arr.Length > 0) // return arr.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(); // } // catch { /* ignore */ } // } // return new List(); // } // static List MapToAllowed(IEnumerable names, List allowed) // { // var mapAllowed = new Dictionary(StringComparer.OrdinalIgnoreCase); // foreach (var a in allowed) // { // var k = Key(a); // if (!mapAllowed.ContainsKey(k)) // mapAllowed[k] = a; // } // var result = new List(); // var seen = new HashSet(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 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 tokens) // { // var h = Key(haystack); // foreach (var t in tokens) // { // var tok = Key(t); // if (!ContainsPhraseOrWord(h, tok)) return false; // } // return true; // } // } //}