3fc7bf2b97
Build backend images / build content-svc (push) Failing after 3m39s
Build backend images / build file-svc (push) Failing after 52s
Build backend images / build gateway (push) Failing after 58s
Build backend images / build identity-svc (push) Failing after 1m21s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 55s
AI SEO content generator - content-svc: per-tenant OpenAI config (ai_settings) + /v1/ai endpoints (settings GET/PUT, seo-post) with SEO-expert prompt → structured article - admin UI to configure token/base-url/model and generate + save as blog - configurable base URL for restricted networks Full data-driven admin panel - generic /api/admin/resource proxy + reusable AdminResource component - categories/tags/fonts/blogs (CRUD), users (list + ban), plans/slides - AI content section; nav + i18n i18n localization sweep - localized 116 user-facing + studio/editor components to next-intl (fa+en) under the auto.* namespace; merge tooling in scripts/merge-i18n.js Branding + assets - Monoline F logo (LogoMark + favicon) - offline SVG placeholder generator (/api/placeholder), dropped picsum.photos Fixes - JWT issuer mismatch on content/studio (flatrender → flatrender-identity) - missing role claim → [Authorize(Roles="Admin")] now works (RBAC) - Secure cookies broke HTTP sessions → gated behind AUTH_COOKIE_SECURE - Radix RTL via DirectionProvider (right-aligned menus in fa) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
8.7 KiB
C#
178 lines
8.7 KiB
C#
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;
|
|
|
|
/// <summary>Thrown for expected/config errors (missing key, provider error) → mapped to 400 by the controller.</summary>
|
|
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<AiSettings> GetRawAsync(Guid tenantId)
|
|
{
|
|
return await db.AiSettings.FirstOrDefaultAsync(x => x.TenantId == tenantId)
|
|
?? new AiSettings { TenantId = tenantId };
|
|
}
|
|
|
|
public async Task<AiSettingsResponse> 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<AiSettingsResponse> 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<SeoPostResponse> 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 <h2>, <h3>, <p>, <ul><li>, <strong> (no <html>/<body>/<h1>), 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] + "…";
|
|
}
|