using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using FlatRender.ContentSvc.Domain.Entities; using FlatRender.ContentSvc.Infrastructure.Data; using FlatRender.ContentSvc.Models; using Microsoft.EntityFrameworkCore; namespace FlatRender.ContentSvc.Application.Services; /// Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller. public class AiConfigException(string message) : Exception(message); public class AiContentService(ContentDbContext db, IHttpClientFactory httpFactory) { public static readonly Guid DefaultTenant = Guid.Parse("00000000-0000-0000-0000-000000000001"); // ── Settings ────────────────────────────────────────────────────────────── public async Task GetRawAsync(Guid tenantId) { return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId) ?? new AiSettings { TenantId = tenantId }; } public async Task GetSettingsAsync(Guid tenantId) { var s = await GetRawAsync(tenantId); var key = s.ApiKey; var has = !string.IsNullOrWhiteSpace(key); var masked = has ? $"••••••••{key![Math.Max(0, key.Length - 4)..]}" : null; return new AiSettingsResponse(s.Provider, s.BaseUrl, s.Model, s.Enabled, has, masked, s.UpdatedAt == default ? null : s.UpdatedAt); } public async Task UpdateSettingsAsync(Guid tenantId, UpdateAiSettingsRequest req) { var s = await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId); var isNew = s is null; s ??= new AiSettings { TenantId = tenantId }; if (req.Provider is { } p) s.Provider = p; if (req.BaseUrl is { } b && !string.IsNullOrWhiteSpace(b)) s.BaseUrl = b.TrimEnd('/'); if (req.Model is { } m && !string.IsNullOrWhiteSpace(m)) s.Model = m; if (req.Enabled is { } e) s.Enabled = e; // ApiKey: null = leave unchanged; non-null (incl. "") = set/clear. if (req.ApiKey is not null) s.ApiKey = string.IsNullOrWhiteSpace(req.ApiKey) ? null : req.ApiKey.Trim(); s.UpdatedAt = DateTime.UtcNow; if (isNew) db.AiSettings.Add(s); await db.SaveChangesAsync(); return await GetSettingsAsync(tenantId); } // ── Generation ────────────────────────────────────────────────────────────── public async Task GenerateSeoPostAsync(Guid tenantId, GenerateSeoPostRequest req, CancellationToken ct) { var s = await GetRawAsync(tenantId); if (!s.Enabled) throw new AiConfigException("AI generation is disabled. Enable it in AI settings."); if (string.IsNullOrWhiteSpace(s.ApiKey)) throw new AiConfigException("No OpenAI API key configured. Add one in AI settings."); if (string.IsNullOrWhiteSpace(req.Description)) throw new AiConfigException("A description is required."); var locale = (req.Locale ?? "fa").ToLowerInvariant(); var langName = locale == "en" ? "English" : "Persian (Farsi)"; var system = "You are a senior SEO content strategist and copywriter. Given a product/page description and metadata, " + "write an original, engaging, well-structured, SEO-optimized article. " + "Return ONLY a single valid JSON object (no markdown, no code fences) with EXACTLY these keys: " + "title, slug, meta_title, meta_description, keywords, short_description, content_html. " + "Rules: " + $"write all human-readable text in {langName}; " + "slug must be a short lowercase ASCII (a-z, 0-9, hyphens) URL slug derived from the topic, even when the article is in Persian; " + "meta_title <= 60 characters; meta_description <= 160 characters and compelling; " + "keywords = array of 5-8 relevant search keywords; " + "short_description = 1-2 sentence summary; " + "content_html = semantic HTML using

,

,

,

  • , (no //

    ), 500-900 words, " + "naturally incorporating the keywords, with a short intro, scannable sections, and a closing call to action."; var sb = new StringBuilder(); sb.AppendLine("Write an SEO article for the following:"); if (!string.IsNullOrWhiteSpace(req.Title)) sb.AppendLine($"Working title: {req.Title}"); if (!string.IsNullOrWhiteSpace(req.Type)) sb.AppendLine($"Content type: {req.Type}"); if (req.Tags is { Length: > 0 }) sb.AppendLine($"Tags: {string.Join(", ", req.Tags)}"); if (!string.IsNullOrWhiteSpace(req.Keyword)) sb.AppendLine($"Primary target keyword: {req.Keyword}"); if (!string.IsNullOrWhiteSpace(req.Audience)) sb.AppendLine($"Target audience: {req.Audience}"); sb.AppendLine($"Description / brief:\n{req.Description}"); var payload = new { model = s.Model, messages = new object[] { new { role = "system", content = system }, new { role = "user", content = sb.ToString() }, }, temperature = 0.7, response_format = new { type = "json_object" }, }; var http = httpFactory.CreateClient("openai"); http.Timeout = TimeSpan.FromSeconds(90); using var msg = new HttpRequestMessage(HttpMethod.Post, $"{s.BaseUrl.TrimEnd('/')}/chat/completions") { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), }; msg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", s.ApiKey); HttpResponseMessage resp; try { resp = await http.SendAsync(msg, ct); } catch (Exception ex) { throw new AiConfigException($"Could not reach the AI provider: {ex.Message}"); } var body = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) throw new AiConfigException($"AI provider returned {(int)resp.StatusCode}: {Truncate(body, 300)}"); string contentJson; try { using var doc = JsonDocument.Parse(body); contentJson = doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? ""; } catch (Exception ex) { throw new AiConfigException($"Unexpected AI response shape: {ex.Message}"); } return ParsePost(contentJson, req); } private static SeoPostResponse ParsePost(string contentJson, GenerateSeoPostRequest req) { try { using var doc = JsonDocument.Parse(contentJson); var r = doc.RootElement; string Str(string k) => r.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : ""; string[] Keywords() { if (r.TryGetProperty("keywords", out var v)) { if (v.ValueKind == JsonValueKind.Array) return v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!).ToArray(); if (v.ValueKind == JsonValueKind.String) return v.GetString()!.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } return []; } var title = Str("title"); if (string.IsNullOrWhiteSpace(title)) title = req.Title ?? "Untitled"; var slug = Slugify(Str("slug")); if (string.IsNullOrWhiteSpace(slug)) slug = Slugify(req.Keyword ?? title); if (string.IsNullOrWhiteSpace(slug)) slug = "post"; return new SeoPostResponse( title, slug, Str("meta_title") is { Length: > 0 } mt ? mt : title, Str("meta_description"), Keywords(), Str("short_description"), Str("content_html") ); } catch (Exception ex) { throw new AiConfigException($"Could not parse AI content as JSON: {ex.Message}"); } } private static string Slugify(string s) { if (string.IsNullOrWhiteSpace(s)) return ""; s = s.Trim().ToLowerInvariant(); s = Regex.Replace(s, @"[^a-z0-9]+", "-").Trim('-'); return s.Length > 80 ? s[..80].Trim('-') : s; } private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n] + "…"; }