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] + "…";
}