diff --git a/.gitignore b/.gitignore index 59643e9..93a9aef 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/ logs /.tools TestResults/ +/copilot-worktrees diff --git a/Directory.Packages.props b/Directory.Packages.props index fbb1d3c..ed08a76 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,7 +50,7 @@ - + diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIServiceCollectionExtensions.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIServiceCollectionExtensions.cs index 0117d92..d362552 100644 --- a/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIServiceCollectionExtensions.cs +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/AIServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MS.Microservice.AI.Abstractions; using MS.Microservice.AI.Core; +using MS.Microservice.AI.Core.Images; namespace Microsoft.Extensions.DependencyInjection; @@ -111,6 +113,56 @@ public static IServiceCollection AddAICostAccounting(this IServiceCollection ser return services; } + /// + /// Registers the word-image prompt generation pipeline and the end-to-end + /// . + /// Requires AddMicroserviceAI to have been called first (provides + /// and ). + /// + /// The application service collection. + /// + /// The chat scenario key for resolving the prompt-planning model from configuration. + /// Uses AI:Models:Chat:{scenario} in appsettings.json. + /// When null, defaults to "ImagePromptPlanning". + /// + /// The service collection for chaining. + /// + /// + /// Configure the prompt-planning model and image generation model in appsettings.json: + /// + /// + /// "AI": { + /// "Models": { + /// "Chat": { + /// "ImagePromptPlanning": { + /// "Provider": "OpenAI", + /// "Model": "gpt-4.1-mini" + /// } + /// }, + /// "ImageGeneration": { + /// "Default": { + /// "Provider": "OpenAI", + /// "Model": "gpt-image-1", + /// "Size": "1024x1024" + /// } + /// } + /// } + /// } + /// + /// + public static IServiceCollection AddImagePromptPipeline(this IServiceCollection services, string? scenario = null) + { + services.TryAddSingleton(sp => + new PlanGeneratorClient( + sp.GetRequiredService(), + sp.GetRequiredService>(), + scenario ?? PlanGeneratorClient.DefaultScenario)); + + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + private static void ConfigureValidatedOptions(IServiceCollection services, IConfigurationSection? section) where TOptions : class where TValidator : class, IValidateOptions diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Analysis/SentenceSemanticAnalyzer.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Analysis/SentenceSemanticAnalyzer.cs new file mode 100644 index 0000000..59c1d35 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Analysis/SentenceSemanticAnalyzer.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; + +namespace MS.Microservice.AI.Core.Images.Analysis; + +/// +/// Deterministic sentence-level semantic analysis. +/// Used to identify prohibition, safety warnings, classroom mentions, and action keywords +/// without requiring an LLM call. +/// +public static partial class SentenceSemanticAnalyzer +{ + public static bool IsProhibitive(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return ProhibitiveRegex().IsMatch(text); + } + + public static bool IsCareful(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return CarefulRegex().IsMatch(text); + } + + public static bool MentionsClassroom(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return ClassroomRegex().IsMatch(text); + } + + public static bool MentionsRunning(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + return RunningRegex().IsMatch(text); + } + + /// + /// Returns true when 'a' and 'b' share significant word overlap (>50%). + /// + public static bool HasSignificantOverlap(string? a, string? b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + return false; + + var wordsA = ToWordSet(a); + var wordsB = ToWordSet(b); + var intersection = wordsA.Intersect(wordsB, StringComparer.OrdinalIgnoreCase).Count(); + return intersection >= Math.Min(wordsA.Count, wordsB.Count) / 2; + } + + private static HashSet ToWordSet(string text) => + text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(w => w.Trim().ToLowerInvariant()) + .Where(w => w.Length > 2) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + [GeneratedRegex(@"\b(don't|do not|never|no)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ProhibitiveRegex(); + + [GeneratedRegex(@"\b(be careful|watch out|look out)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex CarefulRegex(); + + [GeneratedRegex(@"\b(classroom|class|school)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ClassroomRegex(); + + [GeneratedRegex(@"\b(run|runs|running)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex RunningRegex(); +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/EducationalFlashcardPromptBuilder.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/EducationalFlashcardPromptBuilder.cs new file mode 100644 index 0000000..185835f --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/EducationalFlashcardPromptBuilder.cs @@ -0,0 +1,187 @@ +using MS.Microservice.AI.Core.Images.Analysis; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Building; + +/// +/// Builds the rich, constraint-heavy educational flashcard prompt. +/// This prompt is stored in the database for traceability but is NOT sent to Qwen directly +/// (use for the actual image generation call). +/// +public static class EducationalFlashcardPromptBuilder +{ + public static string Build(WordImageInput input, WordImagePromptPlan? plan) + { + var mainSubject = PromptNormalizer.NormalizeValue(plan?.MainSubject, input.MeaningHint, input.TargetText); + var supportingVisual = PromptNormalizer.NormalizeValue(plan?.SupportingVisual); + var actionOrGesture = PromptNormalizer.NormalizeValue(plan?.ActionOrGesture); + var sceneSetting = PromptNormalizer.NormalizeSceneSetting(plan?.SceneSetting, input.ContentType); + var backgroundHint = PromptNormalizer.NormalizeValue(plan?.BackgroundHint); + var allowVisibleText = input.ContentType == WordImageCardType.Alphabet && plan?.AllowVisibleText == true; + var overlayText = allowVisibleText ? PromptNormalizer.NormalizeOverlayText(plan?.OverlayText, input.TargetText) : string.Empty; + + var sections = new List + { + "A simple 4:3 horizontal educational illustration.", + "Bright cheerful children's storybook illustration style, semi-detailed friendly faces, clean smooth lines, flat soft colors with slightly higher brightness, gentle daylight, clean educational scene.", + "Use a light, fresh, sunny color palette with clear but soft colors, minimal shadows, no dark or muddy tones.", + "The image must contain zero visible text: no letters, no words, no numbers, no punctuation, no captions, no titles, no labels, no signs, no speech bubbles, and no readable markings.", + "All people must wear age-appropriate everyday clothing and appropriate footwear. Use sneakers or closed-toe shoes in school/public scenes, socks or slippers in home scenes. Strictly no bare feet.", + "Use a balanced medium-wide composition. Keep all important people and objects fully inside the frame with clear safe margins. No cropped heads, cut-off hands, cut-off feet, missing limbs, or body parts touching the image edges." + }; + + BuildCardTypeSections(sections, input, plan, mainSubject, supportingVisual, actionOrGesture, sceneSetting, overlayText); + + if (!string.IsNullOrWhiteSpace(backgroundHint)) + sections.Add($"Background hint: {backgroundHint}. Keep it minimal and non-decorative."); + + if (allowVisibleText) + sections.Add($"No visible text other than the exact target letter \"{overlayText}\"."); + else + sections.Add("Do not render any visible words, captions, labels, punctuation, example sentences, or decorative typography inside the image."); + + if (input.ContentType != WordImageCardType.Alphabet) + sections.Add("Objects that could contain text, such as whiteboards, blackboards, books, notebooks, worksheets, posters, signs, menus, screens, or labels, may appear only when useful for the scene, and they must be completely blank with no visible letters, words, numbers, punctuation, symbols, or readable markings."); + + sections.Add("Do not create a poster, wallpaper, portrait, fashion illustration, lifestyle scene, or decorative character artwork."); + sections.Add("No flowers, plants, toys, floor clutter, stars, sparkles, stickers, complex furniture, dramatic lighting, cinematic angles, ornate clothing, or beauty-shot styling."); + sections.Add("No flags, flag symbols, national emblems, political symbols, military elements, maps with borders, violent, sexual, hateful, disturbing, or adult content."); + sections.Add("All people must wear appropriate footwear (shoes, sneakers, or socks) matching the scene — strictly no bare feet."); + sections.Add("Keep visible body parts complete and all action-related hands and feet clearly visible. Use a medium-wide composition with safe margins. Never crop at joints, leave important limbs outside the frame, or touch body parts to image edges."); + sections.Add("Ensure the visual content accurately represents and is directly related to the intended meaning — avoid generic, random, or unrelated imagery."); + + if (plan?.NegativeElements?.Count > 0) + sections.Add($"Also avoid: {string.Join(", ", plan.NegativeElements)}."); + + return string.Join(" ", sections.Where(s => !string.IsNullOrWhiteSpace(s))); + } + + private static void BuildCardTypeSections( + List sections, WordImageInput input, WordImagePromptPlan? plan, + string mainSubject, string supportingVisual, string actionOrGesture, + string sceneSetting, string overlayText) + { + switch (input.ContentType) + { + case WordImageCardType.Alphabet: + sections.Add("Plain white or off-white background with a thin soft pastel border."); + sections.Add($"Show the exact target letter \"{overlayText}\" as a large central learning element in a clean bold sans-serif style."); + sections.Add($"Below it, show one simple flat illustration of {mainSubject}."); + if (!string.IsNullOrWhiteSpace(supportingVisual)) + sections.Add($"Use {supportingVisual} only if it helps explain the letter-object connection."); + sections.Add("Keep all elements large, centered, and easy to recognize."); + break; + + case WordImageCardType.Word: + sections.Add("Create a text-free teaching card illustration that explains the target meaning visually."); + if (!string.IsNullOrWhiteSpace(input.MeaningHint)) + sections.Add($"The image should clearly communicate the meaning \"{input.MeaningHint}\"."); + sections.Add($"Show one large central subject: {mainSubject}."); + if (!string.IsNullOrWhiteSpace(supportingVisual)) + sections.Add($"If needed, add only one small supporting visual: {supportingVisual}."); + if (!string.IsNullOrWhiteSpace(sceneSetting)) + sections.Add($"Set the scene in a simple, recognizable environment: {sceneSetting}. Keep the background clean and minimal — just enough to give context."); + else + sections.Add("Use a clean, simple background — a plain light gradient or a minimal environmental hint appropriate to the word's meaning. Avoid completely empty white backgrounds unless the word has no spatial context."); + sections.Add("Do not add excessive room clutter, decorative filler, or complex background details."); + break; + + case WordImageCardType.Sentence: + BuildSentenceSections(sections, input, plan, mainSubject, supportingVisual, actionOrGesture, sceneSetting); + break; + + case WordImageCardType.Phrase: + BuildPhraseSections(sections, input, plan, mainSubject, actionOrGesture, sceneSetting); + break; + + default: + sections.Add("Create a classroom-friendly text-free visual metaphor for the target concept."); + sections.Add($"Main visual focus: {mainSubject}."); + if (!string.IsNullOrWhiteSpace(sceneSetting)) + sections.Add($"Minimal scene hint: {sceneSetting}."); + sections.Add("Keep the background plain, with no room layout, wall decorations, or extra props, and make the concept immediately understandable."); + break; + } + } + + private static void BuildSentenceSections( + List sections, WordImageInput input, WordImagePromptPlan? plan, + string mainSubject, string supportingVisual, string actionOrGesture, string sceneSetting) + { + sections.Add("Create a text-free teaching illustration that communicates the complete sentence meaning through a clear everyday event."); + sections.Add("The image must show all key semantic parts of the sentence, not just a generic gesture, emotion, or portrait."); + + if (!string.IsNullOrWhiteSpace(input.MeaningHint)) + sections.Add($"The image should clearly communicate the meaning \"{input.MeaningHint}\"."); + + sections.Add($"Main visual meaning: {mainSubject}."); + + if (!string.IsNullOrWhiteSpace(plan?.PrimaryActor)) + sections.Add($"Primary actor: {plan.PrimaryActor}."); + if (!string.IsNullOrWhiteSpace(plan?.SecondaryActor)) + sections.Add($"Secondary actor: {plan.SecondaryActor}."); + if (!string.IsNullOrWhiteSpace(plan?.RequiredAction)) + sections.Add($"Required visible action: {plan.RequiredAction}."); + if (!string.IsNullOrWhiteSpace(plan?.ProhibitedAction)) + sections.Add($"Forbidden action that must still be visible: {plan.ProhibitedAction}."); + if (!string.IsNullOrWhiteSpace(plan?.WarningCue)) + sections.Add($"Non-text warning cue: {plan.WarningCue}."); + if (!string.IsNullOrWhiteSpace(plan?.SafetyCue)) + sections.Add($"Mild safety cue: {plan.SafetyCue}. Keep it safe and child-friendly, with no injury, no falling, and no accident."); + if (!string.IsNullOrWhiteSpace(actionOrGesture)) + sections.Add($"Main action or gesture: {actionOrGesture}."); + if (!string.IsNullOrWhiteSpace(supportingVisual)) + sections.Add($"Supporting visual: {supportingVisual}."); + + if (!string.IsNullOrWhiteSpace(sceneSetting)) + { + sections.Add($"Scene setting: {sceneSetting}."); + if (plan?.SettingCues?.Count > 0) + sections.Add($"Include these simple text-free environmental cues to make the setting immediately recognizable: {string.Join(", ", plan.SettingCues)}."); + else + sections.Add("Include 2 to 4 simple environmental cues that make the location immediately recognizable."); + } + else + { + sections.Add("Use a simple, recognizable everyday environment that directly matches the sentence context. Do not use a blank studio background for sentence cards."); + } + + if (plan?.MustShow?.Count > 0) + sections.Add($"The image must visibly include: {string.Join("; ", plan.MustShow)}."); + + SentenceSemanticRulesProvider.AddRules(sections, input); + + sections.Add("Use a balanced event composition, not a single static portrait. The scene should clearly show what is happening, where it happens, and why it matters."); + sections.Add("Use only necessary props and background objects that help explain the sentence meaning. Keep the scene clean and uncluttered."); + } + + private static void BuildPhraseSections( + List sections, WordImageInput input, WordImagePromptPlan? plan, + string mainSubject, string actionOrGesture, string sceneSetting) + { + sections.Add("Create a simple text-free teaching scene that clearly shows the target expression through body language, action, and context."); + + if (!string.IsNullOrWhiteSpace(actionOrGesture)) + sections.Add($"Focus on this main action or gesture: {actionOrGesture}."); + else + sections.Add($"Main visual focus: {mainSubject}."); + + if (!string.IsNullOrWhiteSpace(sceneSetting)) + { + sections.Add($"Scene setting: {sceneSetting}."); + if (plan?.SettingCues?.Count > 0) + sections.Add($"Include these simple text-free environmental cues: {string.Join(", ", plan.SettingCues)}."); + } + else + { + sections.Add("Use a simple recognizable everyday setting that supports the phrase. Avoid a completely empty white background unless the phrase is abstract."); + } + + if (plan?.MustShow?.Count > 0) + sections.Add($"The image must visibly include: {string.Join("; ", plan.MustShow)}."); + + sections.Add("Use a clean composition with all important people and objects fully inside the frame. No cropped figures or missing limbs."); + sections.Add("Use only necessary props; avoid clutter and decorative filler."); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/QwenSafePromptBuilder.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/QwenSafePromptBuilder.cs new file mode 100644 index 0000000..4610e74 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/QwenSafePromptBuilder.cs @@ -0,0 +1,139 @@ +using MS.Microservice.AI.Core.Images.Analysis; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Building; + +/// +/// Builds a Qwen-safe positive-only image prompt. +/// Contains zero negative language and zero sensitive words — designed to pass +/// DashScope/Qwen content filters that scan prompt text for keywords even in negation form. +/// +public static class QwenSafePromptBuilder +{ + public static string Build(WordImageInput input, WordImagePromptPlan? plan) + { + var parts = new List(); + + // Fixed style header + parts.Add("A simple 4:3 horizontal illustration in bright cheerful children's storybook style with clean smooth lines and flat soft colors."); + + if (input.ContentType == WordImageCardType.Alphabet) + { + BuildAlphabetCard(parts, input, plan); + } + else + { + parts.Add("All boards books signs and labels in the scene are completely blank and unmarked."); + + if (input.ContentType == WordImageCardType.Word) + BuildWordCard(parts, input, plan); + else + BuildEventCard(parts, input, plan); + } + + // Positive composition guidance + if (input.ContentType != WordImageCardType.Alphabet) + { + parts.Add("Medium-wide balanced composition with all characters fully visible from head to toe."); + parts.Add("Characters wear everyday clothing and appropriate shoes suitable for the scene."); + parts.Add("Comfortable margins around all subjects, nothing touching the frame edges."); + } + + parts.Add("Cheerful warm atmosphere with gentle daylight and soft fresh colors."); + + return string.Join(" ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); + } + + private static void BuildAlphabetCard(List parts, WordImageInput input, WordImagePromptPlan? plan) + { + parts.Add($"The capital letter {input.TargetText} displayed large and centered in bold sans-serif on a plain light pastel background."); + var cleanSubject = PromptSanitizer.Clean(plan?.MainSubject); + if (!string.IsNullOrWhiteSpace(cleanSubject)) + parts.Add($"Below the letter, a simple flat illustration of {cleanSubject}."); + parts.Add("Clean centered composition with soft pastel border."); + } + + private static void BuildWordCard(List parts, WordImageInput input, WordImagePromptPlan? plan) + { + var meaning = !string.IsNullOrWhiteSpace(input.MeaningHint) ? input.MeaningHint : input.TargetText; + parts.Add($"A clear visual representation of the concept {meaning}."); + + var cleanMain = PromptSanitizer.Clean(plan?.MainSubject); + if (!string.IsNullOrWhiteSpace(cleanMain)) + parts.Add($"The main focus is {cleanMain}."); + + var cleanScene = PromptSanitizer.Clean(plan?.SceneSetting); + if (!string.IsNullOrWhiteSpace(cleanScene)) + parts.Add($"Set in {cleanScene}."); + + var cleanSupport = PromptSanitizer.Clean(plan?.SupportingVisual); + if (!string.IsNullOrWhiteSpace(cleanSupport)) + parts.Add($"Accompanied by {cleanSupport}."); + } + + private static void BuildEventCard(List parts, WordImageInput input, WordImagePromptPlan? plan) + { + // Main subject + var cleanMain = PromptSanitizer.Clean(plan?.MainSubject); + if (!string.IsNullOrWhiteSpace(cleanMain)) + parts.Add(cleanMain.TrimEnd('.') + "."); + + // Primary actor + var cleanActor = PromptSanitizer.Clean(plan?.PrimaryActor); + if (!string.IsNullOrWhiteSpace(cleanActor)) + parts.Add(cleanActor.TrimEnd('.') + "."); + + // Required action + var cleanAction = PromptSanitizer.Clean(plan?.RequiredAction); + if (!string.IsNullOrWhiteSpace(cleanAction)) + parts.Add(cleanAction.TrimEnd('.') + "."); + + // ProhibitedAction (shown positively, deduped from RequiredAction) + var cleanProhibited = PromptSanitizer.Clean(plan?.ProhibitedAction); + if (!string.IsNullOrWhiteSpace(cleanProhibited) && + !SentenceSemanticAnalyzer.HasSignificantOverlap(cleanProhibited, plan?.RequiredAction)) + parts.Add(cleanProhibited.TrimEnd('.') + "."); + + // Warning cue + var cleanWarning = PromptSanitizer.Clean(plan?.WarningCue); + if (!string.IsNullOrWhiteSpace(cleanWarning)) + parts.Add(cleanWarning.TrimEnd('.') + "."); + + // Safety cue + var cleanSafety = PromptSanitizer.Clean(plan?.SafetyCue); + if (!string.IsNullOrWhiteSpace(cleanSafety)) + parts.Add(cleanSafety.TrimEnd('.') + "."); + + // Secondary actor + var cleanSecondary = PromptSanitizer.Clean(plan?.SecondaryActor); + if (!string.IsNullOrWhiteSpace(cleanSecondary)) + parts.Add(cleanSecondary.TrimEnd('.') + "."); + + // Action or gesture + var cleanGesture = PromptSanitizer.Clean(plan?.ActionOrGesture); + if (!string.IsNullOrWhiteSpace(cleanGesture)) + parts.Add(cleanGesture.TrimEnd('.') + "."); + + // Scene setting + var cleanScene = PromptSanitizer.Clean(plan?.SceneSetting); + if (!string.IsNullOrWhiteSpace(cleanScene)) + { + parts.Add($"The scene takes place in {cleanScene}."); + if (plan?.SettingCues?.Count > 0) + { + var cues = plan.SettingCues + .Select(c => PromptSanitizer.Clean(c)) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList(); + if (cues.Count > 0) + parts.Add($"Recognizable details include {string.Join(", ", cues)}."); + } + } + + // Supporting visual + var cleanSupport = PromptSanitizer.Clean(plan?.SupportingVisual); + if (!string.IsNullOrWhiteSpace(cleanSupport)) + parts.Add($"Additional detail: {cleanSupport}."); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/SentenceSemanticRulesProvider.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/SentenceSemanticRulesProvider.cs new file mode 100644 index 0000000..8e7e6c7 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Building/SentenceSemanticRulesProvider.cs @@ -0,0 +1,28 @@ +using MS.Microservice.AI.Core.Images.Analysis; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Building; + +/// +/// Provides sentence-specific deterministic semantic rules for injection into the final rich prompt. +/// +internal static class SentenceSemanticRulesProvider +{ + public static void AddRules(List sections, WordImageInput input) + { + var text = input.TargetText ?? string.Empty; + + if (SentenceSemanticAnalyzer.IsProhibitive(text)) + sections.Add("This is a negative or prohibitive sentence. The image must show BOTH the prohibited action itself and a clear non-text warning or stopping cue. Do not show only a person raising a hand."); + + if (SentenceSemanticAnalyzer.IsCareful(text)) + sections.Add("Because the sentence is a safety warning, include a mild everyday safety reason in the scene. Keep it safe and child-friendly: no injury, no falling, no accident, no frightening danger."); + + if (SentenceSemanticAnalyzer.MentionsRunning(text)) + sections.Add("The action 'run' must be visually obvious: show a child in a running pose, with one foot lifted, body leaning forward, or clear movement between objects. Do not replace running with standing still."); + + if (SentenceSemanticAnalyzer.MentionsClassroom(text)) + sections.Add("The classroom must be immediately recognizable: include simple desks, chairs, windows, classroom floor layout, and a completely blank board. All boards and papers must remain blank with no visible writing."); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptNormalizer.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptNormalizer.cs new file mode 100644 index 0000000..96bd742 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptNormalizer.cs @@ -0,0 +1,65 @@ +namespace MS.Microservice.AI.Core.Images.Helpers; + +public static class PromptNormalizer +{ + public static string NormalizeValue(params string?[] values) + { + foreach (var value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim().TrimEnd('.'); + } + return string.Empty; + } + + public static string NormalizeSceneSetting(string? sceneSetting, string contentType) + { + var normalized = NormalizeValue(sceneSetting); + if (string.IsNullOrWhiteSpace(normalized)) + return string.Empty; + + // Alphabet cards use plain backgrounds for letter clarity + if (contentType == Models.WordImageCardType.Alphabet) + return string.Empty; + + // Only filter truly problematic scene descriptions + var lower = normalized.ToLowerInvariant(); + string[] blocked = ["messy", "cluttered", "chaotic", "dark room", "crowded"]; + + return blocked.Any(k => lower.Contains(k, StringComparison.Ordinal)) + ? string.Empty + : normalized; + } + + public static string NormalizeOverlayText(string? overlayText, string fallback) + { + return !string.IsNullOrWhiteSpace(overlayText) ? overlayText.Trim() : fallback.Trim(); + } + + public static void AddDistinct(List list, string value) + { + if (string.IsNullOrWhiteSpace(value)) return; + if (!list.Any(item => string.Equals(item.Trim(), value.Trim(), StringComparison.OrdinalIgnoreCase))) + list.Add(value.Trim()); + } + + public static void NormalizeList(List list, int maxCount) + { + var normalized = list + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim().TrimEnd('.')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(maxCount) + .ToList(); + list.Clear(); + list.AddRange(normalized); + } + + public static bool ContainsAny(IEnumerable? values, params string[] keywords) + { + if (values == null) return false; + return values.Any(value => + keywords.Any(keyword => + value.Contains(keyword, StringComparison.OrdinalIgnoreCase))); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptSanitizer.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptSanitizer.cs new file mode 100644 index 0000000..2cef0a6 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Helpers/PromptSanitizer.cs @@ -0,0 +1,93 @@ +using System.Text.RegularExpressions; + +namespace MS.Microservice.AI.Core.Images.Helpers; + +/// +/// Strips negative and sensitive language from text, leaving only positive visual descriptions. +/// Designed to prevent Qwen/DashScope content-filter rejection triggered by sensitive keywords +/// even when they appear in negation form (e.g., "no violence" still triggers "violence"). +/// +public static partial class PromptSanitizer +{ + /// + /// Strips negative and sensitive language, returning a clean positive-only phrase or null. + /// + public static string? Clean(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var cleaned = StripNegationPhrases(text); + cleaned = RemoveSensitiveWords(cleaned); + cleaned = CleanupArtifacts(cleaned); + + return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; + } + + private static string StripNegationPhrases(string text) + { + var cleaned = text; + cleaned = NegationRegex().Replace(cleaned, ""); + cleaned = NeverRegex().Replace(cleaned, ""); + cleaned = WithoutRegex().Replace(cleaned, ""); + cleaned = DontRegex().Replace(cleaned, ""); + cleaned = DoNotRegex().Replace(cleaned, ""); + cleaned = NotRegex().Replace(cleaned, ""); + return cleaned; + } + + private static string RemoveSensitiveWords(string text) + { + var cleaned = text; + foreach (var word in SensitiveWords) + { + var pattern = @"\b" + Regex.Escape(word) + @"\b"; + cleaned = Regex.Replace(cleaned, pattern, "", RegexOptions.IgnoreCase); + } + return cleaned; + } + + private static string CleanupArtifacts(string text) + { + var cleaned = Regex.Replace(text, @"\s{2,}", " "); + cleaned = Regex.Replace(cleaned, @"^[,;.\s]+", ""); + cleaned = Regex.Replace(cleaned, @"[,;.\s]+$", ""); + cleaned = Regex.Replace(cleaned, @"\s*,\s*,\s*", ", "); + cleaned = Regex.Replace(cleaned, @"^\s*and\s+", "", RegexOptions.IgnoreCase); + cleaned = Regex.Replace(cleaned, @"\s+and\s*$", "", RegexOptions.IgnoreCase); + return cleaned.Trim(); + } + + private static readonly string[] SensitiveWords = + [ + "strictly", "avoid", "forbidden", "prohibited", "must not", "should not", + "barefoot", "bare feet", "naked", + "violence", "violent", "sexual", "hateful", "disturbing", "adult content", + "blood", "injury", "injured", "wound", "hurt", + "accident", "falling", "crash", "danger", "dangerous", "frightening", + "weapon", "military", "gun", "knife", + "death", "dead", "dying", "kill", + "crying in pain", "scream", "terror", "horror", + "cropped", "cut-off", "cut off", "decapitated", "missing limbs", + "partial", "floating torso", "limbless", + "political", "religious symbol", "flag", "national emblem" + ]; + + [GeneratedRegex(@"\bno\s+\w+(\s+\w+){0,3}", RegexOptions.IgnoreCase)] + private static partial Regex NegationRegex(); + + [GeneratedRegex(@"\bnever\s+\w+(\s+\w+){0,3}", RegexOptions.IgnoreCase)] + private static partial Regex NeverRegex(); + + [GeneratedRegex(@"\bwithout\s+\w+(\s+\w+){0,3}", RegexOptions.IgnoreCase)] + private static partial Regex WithoutRegex(); + + [GeneratedRegex(@"\bdon't\s+\w+(\s+\w+){0,3}", RegexOptions.IgnoreCase)] + private static partial Regex DontRegex(); + + [GeneratedRegex(@"\bdo not\s+\w+(\s+\w+){0,3}", RegexOptions.IgnoreCase)] + private static partial Regex DoNotRegex(); + + [GeneratedRegex(@"\bnot\s+\w+(\s+\w+){0,2}", RegexOptions.IgnoreCase)] + private static partial Regex NotRegex(); +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/IPlanGeneratorClient.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/IPlanGeneratorClient.cs new file mode 100644 index 0000000..f0f62ae --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/IPlanGeneratorClient.cs @@ -0,0 +1,31 @@ +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images; + +/// +/// Abstraction for LLM-based visual plan generation. +/// The default implementation uses the chat client +/// from MS.Microservice.AI.Abstractions. +/// Host projects may provide a custom implementation for provider-specific behavior. +/// +public interface IPlanGeneratorClient +{ + /// + /// Sends a system + user message pair and deserializes the response as JSON of type . + /// + /// + /// Optional explicit model name. When null, the implementation resolves the model + /// via the configured scenario (e.g. AI:Models:Chat:ImagePromptPlanning). + /// + Task SendAsJsonAsync(string systemPrompt, string userMessage, string? model, CancellationToken ct = default) where T : class; + + /// + /// Generates an alphabet-card visual plan. + /// + Task GenerateAlphabetPlanAsync(WordImageInput input, CancellationToken ct = default); + + /// + /// Generates a visual plan for word / phrase / sentence cards. + /// + Task GenerateVisualPlanAsync(WordImageInput input, CancellationToken ct = default); +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/ImageGenerationOrchestrator.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/ImageGenerationOrchestrator.cs new file mode 100644 index 0000000..e9ea8ba --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/ImageGenerationOrchestrator.cs @@ -0,0 +1,135 @@ +using MS.Microservice.AI.Abstractions; +using Microsoft.Extensions.Logging; + +namespace MS.Microservice.AI.Core.Images; + +/// +/// The complete result of an end-to-end image generation from raw educational text. +/// +/// The full constraint-heavy prompt (for DB storage / tracing). +/// The Qwen-safe positive-only prompt (for actual generation). +/// The generated image(s) from the provider. +public sealed record ImageGenerationResult( + string? RichPrompt, + string? SafePrompt, + AIImageResponse ImageResponse); + +/// +/// Bridges the word-image prompt planning pipeline with the framework's +/// to provide a single-call, +/// end-to-end educational image generation service. +/// +/// +/// Typical usage: +/// +/// var result = await orchestrator.GenerateFromTextAsync("Be careful! Don't run in the classroom."); +/// // result.RichPrompt → store in DB for traceability +/// // result.SafePrompt → the prompt actually sent to the image provider +/// // result.ImageResponse.Images → the generated images +/// +/// +/// +/// Initializes a new instance of . +/// +/// The prompt planning pipeline. +/// The image generation client. +/// The logger instance. +public class ImageGenerationOrchestrator( + WordImagePromptPipeline promptPipeline, + IAIImageGenerationClient imageClient, + ILogger logger) +{ + private readonly WordImagePromptPipeline promptPipeline = promptPipeline; + private readonly IAIImageGenerationClient imageClient = imageClient; + private readonly ILogger logger = logger; + + /// + /// Generates an educational image from raw text input (word, phrase, or sentence). + /// The pipeline: raw text → LLM visual plan → safe prompt → image generation. + /// + /// + /// The raw educational text, e.g. "apple", "Keep off the grass.", or + /// "Be careful! Don't run in the classroom.". + /// Supports optional meaning hints in parentheses: "apple (fruit)". + /// + /// + /// Optional overrides for the image generation request (provider, model, size, quality, etc.). + /// When null, the default scenario from AI:Models:ImageGeneration is used. + /// + /// Cancellation token. + /// + /// The complete result containing the rich prompt (for DB), safe prompt, and generated images. + /// If prompt planning fails, falls back to using the raw text as the image prompt. + /// + public async Task GenerateFromTextAsync( + string wordText, + AIImageGenerationRequest? generationOverrides = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(wordText); + + // Step 1: Generate prompts through the planning pipeline + string? richPrompt = null; + string? safePrompt = null; + + try + { + (richPrompt, safePrompt) = await promptPipeline + .GeneratePromptsAsync(wordText, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Prompt planning failed for '{WordText}', falling back to raw text.", wordText); + } + + // Step 2: Build the image generation request using the safe prompt + var prompt = !string.IsNullOrWhiteSpace(safePrompt) ? safePrompt : wordText; + + var imageRequest = new AIImageGenerationRequest + { + Prompt = prompt, + Provider = generationOverrides?.Provider, + Model = generationOverrides?.Model, + Scenario = generationOverrides?.Scenario, + RequestId = generationOverrides?.RequestId, + Count = generationOverrides?.Count, + Size = generationOverrides?.Size, + Quality = generationOverrides?.Quality, + ResponseFormat = generationOverrides?.ResponseFormat, + Timeout = generationOverrides?.Timeout, + Metadata = generationOverrides?.Metadata, + }; + + // Step 3: Generate the image + var imageResponse = await imageClient + .GenerateAsync(imageRequest, cancellationToken) + .ConfigureAwait(false); + + logger.LogInformation( + "Image generated for '{WordText}': {ImageCount} image(s) via {Provider}/{Model}", + wordText, + imageResponse.Images.Count, + imageResponse.Provider, + imageResponse.Model); + + return new ImageGenerationResult(richPrompt, safePrompt, imageResponse); + } + + /// + /// Generates only the prompts (rich + safe) without calling the image generation API. + /// Useful for pre-validation, cost estimation, or when image generation is deferred. + /// + /// The raw educational text. + /// Cancellation token. + /// A tuple of (richPrompt, safePrompt). Either may be null on failure. + public async Task<(string? RichPrompt, string? SafePrompt)> GeneratePromptsOnlyAsync( + string wordText, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(wordText); + return await promptPipeline + .GeneratePromptsAsync(wordText, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageCardType.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageCardType.cs new file mode 100644 index 0000000..f89cb63 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageCardType.cs @@ -0,0 +1,10 @@ +namespace MS.Microservice.AI.Core.Images.Models; + +public static class WordImageCardType +{ + public const string Alphabet = "alphabet"; + public const string Word = "word"; + public const string Phrase = "phrase"; + public const string Sentence = "sentence"; + public const string Abstract = "abstract"; +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageInput.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageInput.cs new file mode 100644 index 0000000..86b2b1b --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageInput.cs @@ -0,0 +1,6 @@ +namespace MS.Microservice.AI.Core.Images.Models; + +/// +/// Parsed input for word image prompt generation. +/// +public sealed record WordImageInput(string RawInput, string TargetText, string? MeaningHint, string ContentType); diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImagePromptPlan.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImagePromptPlan.cs new file mode 100644 index 0000000..f331930 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImagePromptPlan.cs @@ -0,0 +1,30 @@ +namespace MS.Microservice.AI.Core.Images.Models; + +/// +/// The final assembled plan that drives prompt building. +/// Merged from LLM output + deterministic enrichment + validation/repair. +/// +public sealed class WordImagePromptPlan +{ + public string? MainSubject { get; set; } + public string? SupportingVisual { get; set; } + public string? ActionOrGesture { get; set; } + public string? SceneSetting { get; set; } + public string? BackgroundHint { get; set; } + public string? OverlayText { get; set; } + public bool AllowVisibleText { get; set; } + public bool ReserveTextOverlayArea { get; set; } + public List? NegativeElements { get; set; } + + // Semantic completeness fields + public string? SentenceIntent { get; set; } + public string? PrimaryActor { get; set; } + public string? SecondaryActor { get; set; } + public string? RequiredAction { get; set; } + public string? ProhibitedAction { get; set; } + public string? WarningCue { get; set; } + public string? SafetyCue { get; set; } + public List? SettingCues { get; set; } + public List? MustShow { get; set; } + public List? MustNotShow { get; set; } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageVisualPlan.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageVisualPlan.cs new file mode 100644 index 0000000..eae9351 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Models/WordImageVisualPlan.cs @@ -0,0 +1,28 @@ +namespace MS.Microservice.AI.Core.Images.Models; + +/// +/// Raw visual plan returned by the LLM planner. +/// Enriched and validated before being merged into . +/// +public sealed class WordImageVisualPlan +{ + public string? VisualMeaning { get; set; } + public string? MainSubject { get; set; } + public string? SupportingVisual { get; set; } + public string? ActionOrGesture { get; set; } + public string? SceneSetting { get; set; } + public string? BackgroundHint { get; set; } + public List? NegativeElements { get; set; } + + // Semantic completeness fields + public string? SentenceIntent { get; set; } + public string? PrimaryActor { get; set; } + public string? SecondaryActor { get; set; } + public string? RequiredAction { get; set; } + public string? ProhibitedAction { get; set; } + public string? WarningCue { get; set; } + public string? SafetyCue { get; set; } + public List? SettingCues { get; set; } + public List? MustShow { get; set; } + public List? MustNotShow { get; set; } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanEnricher.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanEnricher.cs new file mode 100644 index 0000000..6c92a80 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanEnricher.cs @@ -0,0 +1,68 @@ +using MS.Microservice.AI.Core.Images.Analysis; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Pipeline; + +/// +/// Applies deterministic rules to enrich the LLM-generated visual plan +/// before it passes through validation and prompt building. +/// +public static class VisualPlanEnricher +{ + public static void Enrich(WordImageInput input, WordImageVisualPlan plan) + { + var text = input.TargetText ?? string.Empty; + + plan.MustShow ??= []; + plan.MustNotShow ??= []; + plan.SettingCues ??= []; + + if (input.ContentType is not (WordImageCardType.Sentence or WordImageCardType.Phrase)) + return; + + if (SentenceSemanticAnalyzer.IsProhibitive(text)) + { + PromptNormalizer.AddDistinct(plan.MustShow, "the forbidden action itself must be clearly visible"); + PromptNormalizer.AddDistinct(plan.MustShow, "a clear non-text warning or stopping cue"); + plan.SentenceIntent = string.IsNullOrWhiteSpace(plan.SentenceIntent) ? "prohibition" : plan.SentenceIntent; + } + + if (SentenceSemanticAnalyzer.IsCareful(text)) + { + PromptNormalizer.AddDistinct(plan.MustShow, "a mild safety reason that explains why someone should be careful"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "injury"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "falling accident"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "frightening danger"); + plan.SafetyCue = string.IsNullOrWhiteSpace(plan.SafetyCue) + ? "near an everyday obstacle, but safe and child-friendly" + : plan.SafetyCue; + } + + if (SentenceSemanticAnalyzer.MentionsRunning(text)) + { + plan.RequiredAction = string.IsNullOrWhiteSpace(plan.RequiredAction) + ? "a child running or about to run" + : plan.RequiredAction; + + if (SentenceSemanticAnalyzer.IsProhibitive(text)) + plan.ProhibitedAction = string.IsNullOrWhiteSpace(plan.ProhibitedAction) ? "running" : plan.ProhibitedAction; + + PromptNormalizer.AddDistinct(plan.MustShow, "a child clearly running or about to run, with a visible running pose"); + } + + if (SentenceSemanticAnalyzer.MentionsClassroom(text)) + { + plan.SceneSetting = string.IsNullOrWhiteSpace(plan.SceneSetting) ? "a bright classroom" : plan.SceneSetting; + PromptNormalizer.AddDistinct(plan.SettingCues, "desks"); + PromptNormalizer.AddDistinct(plan.SettingCues, "chairs"); + PromptNormalizer.AddDistinct(plan.SettingCues, "windows"); + PromptNormalizer.AddDistinct(plan.SettingCues, "blank board"); + PromptNormalizer.AddDistinct(plan.MustShow, "a recognizable classroom environment"); + } + + PromptNormalizer.NormalizeList(plan.MustShow, 8); + PromptNormalizer.NormalizeList(plan.MustNotShow, 8); + PromptNormalizer.NormalizeList(plan.SettingCues, 4); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanRepairer.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanRepairer.cs new file mode 100644 index 0000000..a1b2bdf --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanRepairer.cs @@ -0,0 +1,66 @@ +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Pipeline; + +/// +/// Attempts to repair a visual plan that failed validation by injecting +/// missing required elements based on the issue descriptions. +/// +public static class VisualPlanRepairer +{ + public static void Repair(WordImageInput input, WordImageVisualPlan plan, List issues) + { + plan.MustShow ??= []; + plan.SettingCues ??= []; + plan.MustNotShow ??= []; + + foreach (var issue in issues) + { + if (issue.Contains("running", StringComparison.OrdinalIgnoreCase) || + issue.Contains("forbidden", StringComparison.OrdinalIgnoreCase) || + issue.Contains("prohibited", StringComparison.OrdinalIgnoreCase)) + { + plan.RequiredAction = string.IsNullOrWhiteSpace(plan.RequiredAction) + ? "a child clearly running or about to run" + : plan.RequiredAction; + plan.ProhibitedAction = string.IsNullOrWhiteSpace(plan.ProhibitedAction) ? "running" : plan.ProhibitedAction; + PromptNormalizer.AddDistinct(plan.MustShow, "a child clearly running or about to run, with a visible running pose"); + } + + if (issue.Contains("warning", StringComparison.OrdinalIgnoreCase) || + issue.Contains("stopping", StringComparison.OrdinalIgnoreCase)) + { + plan.WarningCue = string.IsNullOrWhiteSpace(plan.WarningCue) + ? "another person raises an open palm to warn or stop the running child" + : plan.WarningCue; + PromptNormalizer.AddDistinct(plan.MustShow, "another person giving a clear non-text warning or stopping gesture"); + } + + if (issue.Contains("safety", StringComparison.OrdinalIgnoreCase)) + { + plan.SafetyCue = string.IsNullOrWhiteSpace(plan.SafetyCue) + ? "the running child is close to desks or chairs, showing why running is unsafe" + : plan.SafetyCue; + PromptNormalizer.AddDistinct(plan.MustShow, "a mild safety reason such as nearby desks or chairs, with no injury or accident"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "injury"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "falling"); + PromptNormalizer.AddDistinct(plan.MustNotShow, "accident"); + } + + if (issue.Contains("classroom", StringComparison.OrdinalIgnoreCase)) + { + plan.SceneSetting = "a bright classroom"; + PromptNormalizer.AddDistinct(plan.SettingCues, "desks"); + PromptNormalizer.AddDistinct(plan.SettingCues, "chairs"); + PromptNormalizer.AddDistinct(plan.SettingCues, "windows"); + PromptNormalizer.AddDistinct(plan.SettingCues, "blank board"); + PromptNormalizer.AddDistinct(plan.MustShow, "a recognizable classroom environment"); + } + } + + PromptNormalizer.NormalizeList(plan.MustShow, 8); + PromptNormalizer.NormalizeList(plan.MustNotShow, 8); + PromptNormalizer.NormalizeList(plan.SettingCues, 4); + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanValidator.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanValidator.cs new file mode 100644 index 0000000..78226b2 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/Pipeline/VisualPlanValidator.cs @@ -0,0 +1,58 @@ +using MS.Microservice.AI.Core.Images.Analysis; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Images.Pipeline; + +/// +/// Validates that the visual plan satisfies semantic requirements derived from the input text. +/// Returns a list of human-readable issue descriptions. An empty list means the plan passes. +/// +public static class VisualPlanValidator +{ + public static List Validate(WordImageInput input, WordImageVisualPlan plan) + { + var issues = new List(); + var text = input.TargetText ?? string.Empty; + + if (input.ContentType is not (WordImageCardType.Sentence or WordImageCardType.Phrase)) + return issues; + + if (SentenceSemanticAnalyzer.IsProhibitive(text)) + { + if (string.IsNullOrWhiteSpace(plan.ProhibitedAction) && + !PromptNormalizer.ContainsAny(plan.MustShow, "forbidden", "prohibited", "running", "run")) + issues.Add("Missing visible forbidden/prohibited action."); + + if (string.IsNullOrWhiteSpace(plan.WarningCue) && + !PromptNormalizer.ContainsAny(plan.MustShow, "warning", "stopping", "stop")) + issues.Add("Missing non-text warning or stopping cue."); + } + + if (SentenceSemanticAnalyzer.IsCareful(text)) + { + if (string.IsNullOrWhiteSpace(plan.SafetyCue) && + !PromptNormalizer.ContainsAny(plan.MustShow, "safety", "careful", "obstacle")) + issues.Add("Missing mild safety reason for 'Be careful'."); + } + + if (SentenceSemanticAnalyzer.MentionsClassroom(text)) + { + if (string.IsNullOrWhiteSpace(plan.SceneSetting) || + !PromptNormalizer.ContainsAny([plan.SceneSetting], "classroom", "school")) + issues.Add("Missing recognizable classroom setting."); + + if (plan.SettingCues == null || plan.SettingCues.Count < 2) + issues.Add("Missing classroom environmental cues such as desks, chairs, windows, or blank board."); + } + + if (SentenceSemanticAnalyzer.MentionsRunning(text)) + { + if (string.IsNullOrWhiteSpace(plan.RequiredAction) && + !PromptNormalizer.ContainsAny(plan.MustShow, "run", "running")) + issues.Add("Missing visible running action."); + } + + return issues; + } +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/PlanGeneratorClient.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/PlanGeneratorClient.cs new file mode 100644 index 0000000..9616097 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/PlanGeneratorClient.cs @@ -0,0 +1,279 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using MS.Microservice.AI.Abstractions; +using MS.Microservice.AI.Core.Images.Models; +using Microsoft.Extensions.Logging; + +namespace MS.Microservice.AI.Core.Images; + +/// +/// Default implementation of that uses +/// to call an LLM for visual plan generation. +/// The prompts are designed for educational flashcard illustrations. +/// +/// +/// +/// Model resolution follows the framework's scenario-based pattern: +/// set AI:Models:Chat:ImagePromptPlanning in configuration +/// to control which provider/model is used for prompt planning. +/// +/// +/// The scenario can be customized via the constructor. When +/// returns a non-null value, it is used as a +/// direct model override; otherwise, the configured scenario is used. +/// +/// +public partial class PlanGeneratorClient : IPlanGeneratorClient +{ + /// The default scenario key for image prompt planning model resolution. + public const string DefaultScenario = "ImagePromptPlanning"; + + private readonly IAIChatClient chatClient; + private readonly ILogger logger; + private readonly string scenario; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + /// + /// Initializes a new instance of . + /// + /// The chat client for LLM calls. + /// The logger instance. + /// + /// The chat scenario key used to resolve the model via + /// AI:Models:Chat:{scenario} in application configuration. + /// Defaults to . + /// + public PlanGeneratorClient(IAIChatClient chatClient, ILogger logger, string scenario = DefaultScenario) + { + this.chatClient = chatClient; + this.logger = logger; + this.scenario = scenario; + } + + /// + public async Task SendAsJsonAsync(string systemPrompt, string userMessage, string? model, CancellationToken ct = default) + where T : class + { + // When a specific model override is provided, use it directly. + // Otherwise, rely on scenario-based resolution via IAIModelResolver. + var hasModelOverride = !string.IsNullOrWhiteSpace(model); + + var request = new AIChatRequest + { + Messages = + [ + new AIChatMessage("system", systemPrompt), + new AIChatMessage("user", userMessage) + ], + Model = hasModelOverride ? model : null, + Scenario = hasModelOverride ? null : scenario + }; + + var label = hasModelOverride ? model! : $"scenario:{scenario}"; + + AIChatResponse response; + try + { + response = await chatClient.GetResponseAsync(request, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "LLM call failed for {ModelOrScenario}", label); + return null; + } + + var text = response.Text; + if (string.IsNullOrWhiteSpace(text)) + { + logger.LogWarning("LLM returned empty response for {ModelOrScenario}", label); + return null; + } + + var json = ExtractOutputJson(text); + if (json is null) + { + logger.LogWarning("Could not extract JSON from LLM response. Raw text: {RawText}", text[..Math.Min(text.Length, 500)]); + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException ex) + { + logger.LogError(ex, "Failed to deserialize LLM response as {Type}. JSON: {Json}", typeof(T).Name, json); + return null; + } + } + + /// + public async Task GenerateAlphabetPlanAsync(WordImageInput input, CancellationToken ct = default) + { + var plannerPrompt = string.Join("\n", + [ + "You are planning semantic details for an English-learning alphabet flashcard illustration.", + "The application has already decided the card type. You must not change the card type.", + "Your output is not the final image prompt. Return only a compact JSON object wrapped in .", + string.Empty, + "Hard rules:", + "1. Prioritize classroom clarity over beauty.", + "2. Keep the scene minimal, centered, and easy for children to understand.", + "3. The target letter should be the central visible text element of the flashcard.", + "4. If people are needed, depict ordinary modern Chinese individuals with plain contemporary clothing. People must wear appropriate footwear (shoes or socks) — never barefoot.", + "5. If people appear, show the full body from head to toe — including complete hands and feet. Never crop at ankles, wrists, waist, or neck.", + "6. Do not add decorative props, scenic filler, glamour styling, poster composition, or political elements.", + "7. Keep every field short and concrete.", + string.Empty, + "Return JSON with this schema:", + "{", + " \"mainSubject\": \"primary thing the image should show\",", + " \"supportingVisual\": \"optional supporting object, empty string if unnecessary\",", + " \"sceneSetting\": \"optional minimal setting, empty string if unnecessary\",", + " \"backgroundHint\": \"optional background hint, empty string if unnecessary\",", + " \"overlayText\": \"exact target letter\",", + " \"allowVisibleText\": true,", + " \"reserveTextOverlayArea\": false,", + " \"negativeElements\": [\"short item\", \"short item\"]", + "}", + string.Empty, + "- Output only {json}." + ]); + + var payload = new + { + input.RawInput, + input.TargetText, + input.MeaningHint, + CardType = input.ContentType + }; + + var plan = await SendAsJsonAsync( + plannerPrompt, + JsonSerializer.Serialize(payload, JsonOptions), + ResolveModel(), + ct).ConfigureAwait(false); + + if (plan is null) return null; + + plan.AllowVisibleText = true; + plan.ReserveTextOverlayArea = false; + plan.OverlayText = input.TargetText.Trim(); + plan.NegativeElements = plan.NegativeElements? + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(6) + .ToList(); + + return plan; + } + + /// + public async Task GenerateVisualPlanAsync(WordImageInput input, CancellationToken ct = default) + { + var plannerPrompt = string.Join(Environment.NewLine, + [ + "You are planning semantic visual details for an educational illustration.", + "The final image will contain ZERO visible text — no letters, words, numbers, punctuation, captions, titles, labels, signs, or readable markings.", + "Your job is to translate the language meaning into a complete purely visual event.", + "The application has already decided the card type. You must not change the card type.", + "Your output is not the final image prompt. Return only a compact JSON object wrapped in .", + string.Empty, + "Hard rules:", + "1. Prioritize semantic clarity for children over beauty.", + "2. For Word cards, show the target meaning as one clear central subject with minimal context.", + "3. For Phrase and Sentence cards, show a complete everyday situation, not just a single static pose.", + "4. Describe only visual elements — no mention of text, letters, writing, captions, labels, typography, or readable symbols.", + "5. If people are needed, depict ordinary modern Chinese individuals with plain contemporary clothing.", + " - For Chinese children: use natural black or deep brown hair and Asian facial features.", + " - People must wear appropriate footwear: sneakers or closed-toe shoes in school/public scenes, socks or slippers in home scenes. Never barefoot.", + "6. Compose the image as a medium-wide shot or full-body shot. Keep heads, hands, arms, legs, and feet fully inside the frame when visible. Leave clear safe margins around all important people and objects.", + "7. Scene setting should describe a simple recognizable environment that matches the meaning, such as a classroom, home, kitchen, park, playground, street, shop, bus stop, school hallway, or library.", + "8. The setting must be recognizable within one second using 2 to 4 simple text-free environmental cues.", + "9. Educational settings like classrooms are allowed and encouraged when the meaning involves learning, school, classroom behavior, or study. Use desks, chairs, windows, and a blank board as cues.", + "10. Objects that could contain text, such as boards, books, notebooks, worksheets, posters, signs, menus, screens, or labels, may appear only when useful for the scene, and they must be completely blank with no visible writing.", + "11. For negative or prohibitive sentences such as 'Don't X', 'Do not X', 'No X', or 'Never X', the image must show BOTH:", + " a) the forbidden action X clearly recognizable;", + " b) a non-text warning, stopping, or disapproval cue.", + " Do not represent a prohibition only with a raised hand, angry face, or empty stop gesture. The forbidden action itself must be visible.", + "12. For safety-warning sentences such as 'Be careful', 'Watch out', or 'Look out', include a mild safety reason in the scene, such as being close to a desk, chair, step, wet floor, road edge, or everyday obstacle. Keep it safe and child-friendly: no injury, no falling, no accident.", + "13. Avoid decorative filler, glamour styling, poster composition, political elements, flags, maps, national emblems, military symbols, weapons, religious symbols, or controversial imagery.", + "14. Keep every field short, concrete, and visual.", + string.Empty, + "Return JSON with this schema:", + "{", + " \"visualMeaning\": \"one sentence describing the complete visual event\",", + " \"sentenceIntent\": \"statement | action | warning | prohibition | question | emotion | other\",", + " \"mainSubject\": \"primary thing the image should show\",", + " \"primaryActor\": \"main actor, empty string if unnecessary\",", + " \"secondaryActor\": \"supporting actor, empty string if unnecessary\",", + " \"requiredAction\": \"required visible action, empty string if unnecessary\",", + " \"prohibitedAction\": \"forbidden action for negative/prohibition sentences, empty string if none\",", + " \"warningCue\": \"non-text warning or stopping cue, empty string if unnecessary\",", + " \"safetyCue\": \"mild safety reason, empty string if unnecessary\",", + " \"supportingVisual\": \"optional supporting object, empty string if unnecessary\",", + " \"actionOrGesture\": \"main gesture or action, empty string if unnecessary\",", + " \"sceneSetting\": \"simple recognizable setting, empty string only if no setting is useful\",", + " \"settingCues\": [\"2 to 4 simple text-free environmental cues\"],", + " \"backgroundHint\": \"brief background hint, empty string if unnecessary\",", + " \"mustShow\": [\"required visible evidence\", \"required visible evidence\"],", + " \"mustNotShow\": [\"short item\", \"short item\"],", + " \"negativeElements\": [\"short item\", \"short item\"]", + "}", + string.Empty, + "For the sentence 'Be careful! Don't run in the classroom.', a good plan must include:", + "- a child clearly running or about to run between classroom desks;", + "- another person giving a non-text warning or stop gesture;", + "- a recognizable classroom with desks, chairs, windows, and a blank board;", + "- a mild safety cue such as the running child being near desks or chairs;", + "- no injury, no falling, no accident, no visible text.", + string.Empty, + "- negativeElements must contain 0 to 8 short items.", + "- mustShow must contain the visual evidence needed to understand the meaning.", + "- Output only {json}." + ]); + + var payload = new + { + Meaning = string.IsNullOrWhiteSpace(input.MeaningHint) ? input.TargetText : input.MeaningHint, + CardType = input.ContentType + }; + + return await SendAsJsonAsync( + plannerPrompt, + JsonSerializer.Serialize(payload, JsonOptions), + ResolveModel(), + ct).ConfigureAwait(false); + } + + // ── Helpers ── + + /// + /// Extracts the JSON payload from an <Output>...</Output> wrapper + /// in the LLM response text. + /// + internal static string? ExtractOutputJson(string text) + { + var match = OutputTagRegex().Match(text); + return match.Success ? match.Groups[1].Value.Trim() : null; + } + + /// + /// Resolves the model identifier to use for visual plan generation. + /// Returns null by default, which triggers scenario-based resolution + /// via AI:Models:Chat:{scenario} in application configuration. + /// Override in derived classes to return a specific model name for direct override. + /// + protected virtual string? ResolveModel() => null; + + [GeneratedRegex(@"\s*([\s\S]*?)\s*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex OutputTagRegex(); +} diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/README.md b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/README.md new file mode 100644 index 0000000..cdd96e9 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/README.md @@ -0,0 +1,358 @@ +# MS.Microservice.AI — Image Generation Pipeline + +英语教学卡片图片 Prompt 生成类库。将原始文本输入(单词、短语、句子)转换为高质量的生图 prompt,支持两路输出: + +- **Rich prompt** — 含完整约束、否定词、语义分析,存入数据库用于追溯 +- **Safe prompt** — 纯正向、零敏感词,直接发送给文生图 API + +该模块已集成到 `MS.Microservice.AI.Core` 框架中,与现有的 `IAIImageGenerationClient` / `IAIImageGenerationProvider` 体系无缝协作。 + +--- + +## 与框架的集成架构 + +``` + ┌──────────────────────────────┐ + │ appsettings.json │ + │ AI:Models:Chat: │ + │ ImagePromptPlanning │ + │ AI:Models:ImageGeneration: │ + │ Default │ + └──────────────┬───────────────┘ + │ + ┌─────────────────────────────┼─────────────────────────┐ + │ ▼ │ + │ ┌──────────────────────────────────────────────┐ │ + │ │ ImageGenerationOrchestrator │ │ + │ │ GenerateFromTextAsync(wordText) → result │ │ + │ └────────┬──────────────────┬──────────────────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌────────────────┐ ┌──────────────────────┐ │ + │ │ WordImagePrompt │ │ IAIImageGeneration │ │ + │ │ Pipeline │ │ Client │ │ + │ │ (prompt 生成) │ │ (Routing Client) │ │ + │ └───────┬────────┘ └──────────┬───────────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌────────────────┐ ┌──────────────────────┐ │ + │ │ PlanGenerator │ │ IAIModelResolver │ │ + │ │ Client │ │ (scenario → model) │ │ + │ │ (IAIChatClient) │ └──────────┬───────────┘ │ + │ └───────┬────────┘ │ │ + │ │ ▼ │ + │ │ ┌──────────────────────┐ │ + │ │ │ IAIProviderFactory │ │ + │ │ │ .GetRequiredImage- │ │ + │ │ │ GenerationProvider() │ │ + │ │ └──────────┬───────────┘ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌────────────────┐ ┌──────────────────────┐ │ + │ │ RoutingAIChat │ │ OpenAICompatible- │ │ + │ │ Client │ │ ImageGeneration- │ │ + │ │ (scenario: │ │ ProviderBase │ │ + │ │ ImagePrompt │ │ (images/generations) │ │ + │ │ Planning) │ └──────────────────────┘ │ + │ └────────────────┘ │ + │ │ + │ MS.Microservice.AI.Core │ + └─────────────────────────────────────────────────────┘ +``` + +**关键集成点:** + +| 层级 | 组件 | 作用 | +|---|---|---| +| **编排层** | `ImageGenerationOrchestrator` | 一站式:文本 → prompt → 图片 | +| **Prompt 层** | `WordImagePromptPipeline` | LLM 视觉规划 + prompt 构建 | +| **Chat 路由** | `IAIChatClient` → `RoutingAIChatClient` | 通过 scenario `ImagePromptPlanning` 解析 LLM 模型 | +| **Image 路由** | `IAIImageGenerationClient` → `RoutingAIImageGenerationClient` | 通过 scenario `Default` 解析生图模型 | +| **Provider 层** | `OpenAICompatibleImageGenerationProviderBase` | 调用 `/v1/images/generations` API | + +--- + +## 配置 + +在 `appsettings.json` 中配置 prompt 规划模型(Chat capability)和图片生成模型(ImageGeneration capability): + +```json +{ + "AI": { + "DefaultProvider": "OpenAI", + "Providers": { + "OpenAI": { + "ApiKey": "", + "BaseAddress": "https://api.openai.com/v1/", + "TimeoutSeconds": 120 + }, + "Qwen": { + "ApiKey": "", + "BaseAddress": "https://dashscope.aliyuncs.com/compatible-mode/v1/", + "TimeoutSeconds": 120 + } + }, + "Models": { + "Chat": { + "ImagePromptPlanning": { + "Provider": "OpenAI", + "Model": "gpt-4.1-mini", + "TimeoutSeconds": 60 + } + }, + "ImageGeneration": { + "Default": { + "Provider": "OpenAI", + "Model": "gpt-image-1", + "Size": "1024x1024", + "Quality": "standard" + } + } + } + } +} +``` + +### Scenario 说明 + +| Scenario | Capability | 用途 | +|---|---|---| +| `ImagePromptPlanning` | Chat | LLM 视觉规划(可自定义,传入 `AddImagePromptPipeline("MyScenario")`) | +| `Default` | ImageGeneration | 实际图片生成(可通过 `AIImageGenerationRequest.Scenario` 覆盖) | + +--- + +## 项目结构 + +``` +Images/ +├── Models/ # 纯数据模型 +│ ├── WordImageCardType.cs # 卡片类型常量 +│ ├── WordImageInput.cs # 解析后的输入 +│ ├── WordImagePromptPlan.cs # 最终合并计划 +│ └── WordImageVisualPlan.cs # LLM 原始输出的结构化视觉计划 +│ +├── IPlanGeneratorClient.cs # LLM 视觉规划抽象接口 +├── PlanGeneratorClient.cs # 默认实现(使用 IAIChatClient + scenario) +├── WordImagePromptPipeline.cs # 主编排器:Parse → Plan → Enrich → Build +├── ImageGenerationOrchestrator.cs # 一站式编排器:文本 → prompt → 图片 +│ +├── Pipeline/ # 计划处理管线 +│ ├── VisualPlanEnricher.cs # 确定性规则补强 +│ ├── VisualPlanValidator.cs # 语义完整性校验 +│ └── VisualPlanRepairer.cs # 校验失败自动修复 +│ +├── Building/ # Prompt 组装 +│ ├── EducationalFlashcardPromptBuilder.cs # Rich prompt +│ ├── QwenSafePromptBuilder.cs # Safe prompt +│ └── SentenceSemanticRulesProvider.cs # 句子级语义规则 +│ +├── Analysis/ # 语义分析 +│ └── SentenceSemanticAnalyzer.cs # 正则分析 +│ +└── Helpers/ # 工具类 + ├── PromptSanitizer.cs # 负向词 & 敏感词清洗 + └── PromptNormalizer.cs # 值/场景/文本规范化 +``` + +## 数据流 + +``` +输入: "Be careful! Don't run in the classroom." + │ + ▼ +Parse() → WordImageInput { + RawInput: "Be careful! Don't run in the classroom." + TargetText: "Be careful! Don't run in the classroom." + MeaningHint: null + ContentType: "sentence" +} + │ + ▼ +IPlanGeneratorClient.GenerateVisualPlanAsync() → WordImageVisualPlan { + visualMeaning: "A child running between desks while a teacher gestures to stop..." + sentenceIntent: "prohibition" + primaryActor: "child" + secondaryActor: "teacher" + requiredAction: "..." + prohibitedAction: "running" + warningCue: "teacher raises palm in stopping gesture" + safetyCue: "child near desks showing why running is unsafe" + sceneSetting: "a bright classroom" + settingCues: ["desks", "chairs", "windows", "blank board"] + ... +} + │ + ▼ +VisualPlanEnricher.Enrich() ← 确定性规则注入 mustShow/mustNotShow + │ + ▼ +VisualPlanValidator.Validate() ← 检查禁止句/安全句/教室/跑步是否完备 + │ (如有缺失) + ▼ +VisualPlanRepairer.Repair() ← 自动修复缺失项 + │ + ▼ +MergeVisualPlan() → WordImagePromptPlan ← 合并 LLM 输出 + 规则补强 + 修复结果 + │ + ├──▶ EducationalFlashcardPromptBuilder.Build() → richPrompt (存 DB) + │ + └──▶ QwenSafePromptBuilder.Build() → safePrompt (发 Qwen) +``` + +## 双 Prompt 对比 + +以 `Keep off the grass.` 为例: + +| 维度 | Rich Prompt (存 DB) | Safe Prompt (发 Qwen) | +|---|---|---| +| 长度 | ~500 words | ~80 words | +| 风格 | 约束式 ("No...", "Avoid...", "Strictly...") | 纯正向描述 | +| 敏感词 | 包含 (barefoot, injury, violent, sexual...) | 零 | +| 用途 | 调试追溯、审计 | 实际生图 | + +### Rich Prompt 片段 +> ...No flags, flag symbols, national emblems, political symbols, military elements, maps with borders, violent, sexual, hateful, disturbing, or adult content. All people must wear appropriate footwear — strictly no bare feet... + +### Safe Prompt 片段 +> A simple 4:3 horizontal illustration in bright cheerful children's storybook style... A child walks beside a grassy lawn while a nearby adult gently signals to stay on the path... The scene takes place in a park path next to a lawn. Recognizable details include paved walkway, green grass area... + +--- + +## 宿主项目集成 + +### 方式一:一站式编排器(推荐) + +使用 `ImageGenerationOrchestrator` 一步完成"文本 → prompt → 图片"的完整流程: + +```csharp +// Program.cs — DI 注册 +builder.Services.AddMicroserviceAI(builder.Configuration) + .AddOpenAI() // 或其他 Provider + .Services + .AddImagePromptPipeline(); // 注册 prompt pipeline + orchestrator + +// YourService.cs — 使用 +public class FlashcardService +{ + private readonly ImageGenerationOrchestrator orchestrator; + + public FlashcardService(ImageGenerationOrchestrator orchestrator) + { + this.orchestrator = orchestrator; + } + + public async Task GenerateCardImageAsync(string wordText) + { + // 一步到位:文本 → prompt 规划 → 图片生成 + var result = await orchestrator.GenerateFromTextAsync(wordText); + + // result.RichPrompt → 存入数据库用于追溯 + // result.SafePrompt → 实际发送给图片 API 的 prompt + // result.ImageResponse.Images → 生成的图片列表 + return result; + } + + // 仅生成 prompt(不调用图片 API) + public async Task<(string?, string?)> PreviewPromptAsync(string wordText) + { + return await orchestrator.GeneratePromptsOnlyAsync(wordText); + } +} +``` + +### 方式二:分步调用 + +直接使用 `WordImagePromptPipeline`,自行控制图片生成: + +```csharp +public class ResourceGenerationService +{ + private readonly WordImagePromptPipeline pipeline; + private readonly IAIImageGenerationClient imageClient; + + public ResourceGenerationService( + WordImagePromptPipeline pipeline, + IAIImageGenerationClient imageClient) + { + this.pipeline = pipeline; + this.imageClient = imageClient; + } + + public async ValueTask GenerateSafeImagePromptAsync(string content) + => await pipeline.GenerateSafePromptAsync(content); + + public async ValueTask<(string?, string?)> GenerateImageCoreAsync(string content) + { + var (rich, safe) = await pipeline.GeneratePromptsAsync(content); + var imageResponse = await imageClient.GenerateAsync(new AIImageGenerationRequest + { + Prompt = safe!, + Size = "1024x1024", + }); + // ... upload, resize ... + return (imageResponse.Images.FirstOrDefault()?.Url, rich); + } +} +``` + +## 关键设计决策 + +### 1. 为什么使用 Scenario 而非硬编码 Model + +`PlanGeneratorClient` 通过 `AIChatRequest.Scenario` 触发框架的 `IAIModelResolver` 模型解析,而非硬编码模型名。这意味着: + +- 与框架其他 capability(Chat, TTS, ImageGeneration)使用**同一配置模式** +- 模型切换只需修改 `appsettings.json`,无需重新编译 +- 支持 per-environment 配置(开发/测试用 cheap model,生产用 full model) + +```csharp +// 默认 scenario = "ImagePromptPlanning" +services.AddImagePromptPipeline(); + +// 或使用自定义 scenario +services.AddImagePromptPipeline("MyCustomImagePlanner"); +``` + +### 2. 为什么 Safe Prompt 不能简单去掉否定词 + +Qwen/DashScope 的内容审核是基于 prompt **文本本身**做关键词扫描的,不看语义意图。以下 prompt 仍然会被拦截: + +``` +Do not show any violence, blood, or weapons. +``` + +因为 `violence`, `blood`, `weapons` 三个词出现在了文本中,即使前面有 "Do not show"。 + +`QwenSafePromptBuilder` 使用 `PromptSanitizer` 完全剔除所有敏感词,并用 `CleanNegativeLanguage` 将否定约束转换为正向描述。 + +### 3. 为什么 LLM Planner + 确定性规则 + +LLM 规划的视觉计划可能遗漏关键语义元素(例如忘记画禁止动作、忘记画制止线索)。`VisualPlanEnricher` 用正则表达式检测句子类型,强制注入缺失的 `mustShow`/`mustNotShow` 约束。`VisualPlanValidator` 在 prompt 组装前做最终检查,`VisualPlanRepairer` 自动修复问题。 + +### 4. 为什么需要 IPlanGeneratorClient 抽象 + +将 LLM 调用抽象为接口的好处: +- 类库不依赖 `Azure.AI.OpenAI` / `OpenAI.Chat` 等重型包 +- 可替换 LLM 提供商(Azure OpenAI → 其他) +- 单元测试可用 mock 实现 + +### 5. 为什么引入 ImageGenerationOrchestrator + +`ImageGenerationOrchestrator` 将"prompt 规划"和"图片生成"两个步骤编排为一个原子操作: + +| 无编排器 | 有编排器 | +|---|---| +| 手动调用 `WordImagePromptPipeline` → 得到 prompt | `orchestrator.GenerateFromTextAsync(text)` → 得到图片 | +| 手动构建 `AIImageGenerationRequest` → 调用 `IAIImageGenerationClient` | 一站式调用,统一的错误处理和 fallback | +| 需自行管理 rich prompt 存储 | `ImageGenerationResult` 同时返回 rich prompt + 图片 | + +## 依赖 + +| 包 | 用途 | +|---|---| +| `Microsoft.Extensions.Logging.Abstractions` | 日志 | +| `MS.Microservice.AI.Abstractions` | `IAIChatClient`, `IAIImageGenerationClient`, `AIImageGenerationRequest`, etc. | +| `MS.Microservice.AI.Core` | `RoutingAIChatClient`, `RoutingAIImageGenerationClient`, `IAIModelResolver` | + +`IPlanGeneratorClient` 的默认实现 `PlanGeneratorClient` 已内置在 Core 项目中。宿主项目无需额外提供实现。 diff --git a/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/WordImagePromptPipeline.cs b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/WordImagePromptPipeline.cs new file mode 100644 index 0000000..188a121 --- /dev/null +++ b/MS.Microservice.AI/src/MS.Microservice.AI.Core/Images/WordImagePromptPipeline.cs @@ -0,0 +1,210 @@ +using MS.Microservice.AI.Core.Images.Building; +using MS.Microservice.AI.Core.Images.Helpers; +using MS.Microservice.AI.Core.Images.Models; +using MS.Microservice.AI.Core.Images.Pipeline; +using Microsoft.Extensions.Logging; +using System.Text.RegularExpressions; +using MS.Microservice.AI.Core.Images; + +namespace MS.Microservice.AI.Core.Images; + +/// +/// Main entry point for word-image prompt generation. +/// Orchestrates parsing, LLM-based plan generation, enrichment, validation, repair, +/// and final prompt assembly into both rich (DB) and safe (Qwen) forms. +/// +public class WordImagePromptPipeline +{ + private readonly IPlanGeneratorClient planClient; + private readonly ILogger logger; + + public WordImagePromptPipeline(IPlanGeneratorClient planClient, ILogger logger) + { + this.planClient = planClient; + this.logger = logger; + } + + /// + /// Runs the full pipeline and returns both the rich prompt (for DB storage) + /// and the Qwen-safe prompt (for image generation). + /// + public async Task<(string? RichPrompt, string? SafePrompt)> GeneratePromptsAsync(string wordText, CancellationToken ct = default) + { + var input = Parse(wordText); + WordImagePromptPlan? promptPlan = null; + + try + { + promptPlan = await GeneratePlanAsync(input, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to build image prompt plan for {WordText}, fallback prompt will be used.", wordText); + } + + var richPrompt = EducationalFlashcardPromptBuilder.Build(input, promptPlan); + var safePrompt = QwenSafePromptBuilder.Build(input, promptPlan); + + logger.LogInformation("Image prompt plan for {WordText}: {@PromptPlan}", wordText, promptPlan); + logger.LogInformation("Qwen-safe prompt for {WordText}: {SafePrompt}", wordText, safePrompt); + + return (richPrompt, safePrompt); + } + + /// + /// Returns only the Qwen-safe prompt (for callers that send directly to DashScope). + /// + public async Task GenerateSafePromptAsync(string wordText, CancellationToken ct = default) + { + var (_, safe) = await GeneratePromptsAsync(wordText, ct); + return safe; + } + + /// + /// Returns only the rich prompt (for DB storage or debugging). + /// + public async Task GenerateRichPromptAsync(string wordText, CancellationToken ct = default) + { + var (rich, _) = await GeneratePromptsAsync(wordText, ct); + return rich; + } + + // ── Plan generation ── + + private async Task GeneratePlanAsync(WordImageInput input, CancellationToken ct) + { + if (input.ContentType == WordImageCardType.Alphabet) + return await planClient.GenerateAlphabetPlanAsync(input, ct); + + return await GenerateVisualPlanWithPipelineAsync(input, ct); + } + + private async Task GenerateVisualPlanWithPipelineAsync(WordImageInput input, CancellationToken ct) + { + var visualPlan = await planClient.GenerateVisualPlanAsync(input, ct); + if (visualPlan == null) return null; + + // Step 1: Deterministic enrichment + VisualPlanEnricher.Enrich(input, visualPlan); + + // Step 2: Semantic validation + var issues = VisualPlanValidator.Validate(input, visualPlan); + if (issues.Count > 0) + { + logger.LogWarning("Visual plan has semantic issues for {WordText}: {Issues}", input.RawInput, string.Join("; ", issues)); + VisualPlanRepairer.Repair(input, visualPlan, issues); + } + + // Step 3: Merge into final plan + return MergeVisualPlan(input, visualPlan); + } + + private static WordImagePromptPlan MergeVisualPlan(WordImageInput input, WordImageVisualPlan visualPlan) + { + // Build negative elements + var negativeElements = visualPlan.NegativeElements? + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? []; + + negativeElements.AddRange(HardcodedNegativeElements); + + if (visualPlan.MustNotShow?.Count > 0) + { + foreach (var item in visualPlan.MustNotShow.Where(i => !string.IsNullOrWhiteSpace(i))) + PromptNormalizer.AddDistinct(negativeElements, item.Trim()); + } + + PromptNormalizer.NormalizeList(negativeElements, 30); + + return new WordImagePromptPlan + { + MainSubject = PromptNormalizer.NormalizeValue(visualPlan.VisualMeaning, visualPlan.MainSubject), + SupportingVisual = visualPlan.SupportingVisual, + ActionOrGesture = visualPlan.ActionOrGesture, + SceneSetting = visualPlan.SceneSetting, + BackgroundHint = visualPlan.BackgroundHint, + OverlayText = string.Empty, + AllowVisibleText = false, + ReserveTextOverlayArea = false, + NegativeElements = negativeElements, + + SentenceIntent = visualPlan.SentenceIntent, + PrimaryActor = visualPlan.PrimaryActor, + SecondaryActor = visualPlan.SecondaryActor, + RequiredAction = visualPlan.RequiredAction, + ProhibitedAction = visualPlan.ProhibitedAction, + WarningCue = visualPlan.WarningCue, + SafetyCue = visualPlan.SafetyCue, + SettingCues = visualPlan.SettingCues, + MustShow = visualPlan.MustShow, + MustNotShow = visualPlan.MustNotShow + }; + } + + // ── Input parsing ── + + internal static WordImageInput Parse(string rawInput) + { + var normalized = rawInput.Trim(); + var targetText = normalized; + string? meaningHint = null; + + var bracketIndex = normalized.LastIndexOf('('); + if (bracketIndex > 0 && normalized.EndsWith(')')) + { + targetText = normalized[..bracketIndex].Trim(); + meaningHint = normalized[(bracketIndex + 1)..^1].Trim(); + } + + return new WordImageInput(normalized, targetText, meaningHint, InferCardType(targetText)); + } + + internal static string InferCardType(string targetText) + { + if (Regex.IsMatch(targetText, @"^[A-Za-z]$")) + return WordImageCardType.Alphabet; + + var trimmed = targetText.Trim(); + var wordCount = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; + var hasSentencePunctuation = Regex.IsMatch(trimmed, @"[!?.,/:;]"); + + if (wordCount >= 4 || hasSentencePunctuation) + return WordImageCardType.Sentence; + + if (wordCount >= 2) + return WordImageCardType.Phrase; + + if (Regex.IsMatch(trimmed, @"^[A-Za-z][A-Za-z'’-]*$")) + return WordImageCardType.Word; + + return WordImageCardType.Abstract; + } + + // ── Hardcoded negative elements (merged with any plan-level negatives) ── + + private static readonly string[] HardcodedNegativeElements = + [ + // Eye constraints + "dot eyes", "beady eyes", "tiny black-dot eyes", "over-simplified facial features", "no iris", "no eye whites", + // Hair and ethnicity + "blonde hair on Chinese children", "red hair on Chinese children", "light colored hair on Asian children", + // Semantic failure + "generic standing pose", "empty stop gesture without the forbidden action", "standing still instead of running", + "unrelated action", "unclear scene", "blank studio background for sentence cards", + // Clothing & footwear + "barefoot", "bare feet", "no shoes", "no footwear", "naked feet", "bare toes", "inappropriate clothing", + // Body completeness + "cropped head", "cropped hands", "cropped feet", "cut-off hands", "cut-off feet", + "limbs outside the frame", "body parts touching image edges", "extreme close-up", "portrait close-up", + "half body", "head only", "partial face", "cropped figure", "cut-off at shoulders", + "missing lower face", "decapitated look", "missing feet", "missing hands", + "cut-off at ankles", "cut-off at wrists", "floating torso", "no legs", + // Safety exaggeration + "injury", "falling", "accident", "blood", "crying in pain", "frightening danger", + // Sensitive content + "national flags", "flag symbols", "maps with political borders", "military uniforms", + "weapons", "political symbols", "religious symbols", "controversial imagery" + ]; +} diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DependencyInjectionTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DependencyInjectionTests.cs index 7e54585..82f3be6 100644 --- a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DependencyInjectionTests.cs +++ b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/DependencyInjectionTests.cs @@ -1,9 +1,13 @@ using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using MS.Microservice.AI.Abstractions; using MS.Microservice.AI.Core; +using MS.Microservice.AI.Core.Images; +using MS.Microservice.AI.Core.Images.Models; namespace MS.Microservice.AI.Core.Tests; @@ -69,4 +73,109 @@ public void AddMicroserviceAI_ShouldRegisterRoutingClient_AndProviderPipeline() options.Models.ImageGeneration.Should().ContainKey("Default"); options.Models.ImageEdit.Should().ContainKey("Default"); } + + [Fact] + public void AddImagePromptPipeline_ShouldRegisterPlanGeneratorClient_AsSingleton() + { + var services = new ServiceCollection(); + RegisterLoggerStub(services); + services.AddSingleton(new FakeChatClient()); + + services.AddImagePromptPipeline(); + + using var provider = services.BuildServiceProvider(); + var client1 = provider.GetRequiredService(); + var client2 = provider.GetRequiredService(); + + client1.Should().BeOfType(); + client1.Should().BeSameAs(client2); // Singleton + } + + [Fact] + public void AddImagePromptPipeline_ShouldRegisterWordImagePromptPipeline_AsTransient() + { + var services = new ServiceCollection(); + RegisterLoggerStub(services); + services.AddSingleton(new FakeChatClient()); + + services.AddImagePromptPipeline(); + + using var provider = services.BuildServiceProvider(); + var pipeline1 = provider.GetRequiredService(); + var pipeline2 = provider.GetRequiredService(); + + pipeline1.Should().NotBeNull(); + pipeline1.Should().NotBeSameAs(pipeline2); // Transient + } + + [Fact] + public async Task AddImagePromptPipeline_ShouldUseCustomScenario_WhenProvided() + { + var fakeChat = new FakeChatClient(); + var services = new ServiceCollection(); + RegisterLoggerStub(services); + services.AddSingleton(fakeChat); + + services.AddImagePromptPipeline("MyPromptScenario"); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var input = new WordImageInput("A", "A", "letter A", WordImageCardType.Alphabet); + await client.GenerateAlphabetPlanAsync(input); + + fakeChat.LastScenario.Should().Be("MyPromptScenario"); + fakeChat.LastModel.Should().BeNull(); + } + + [Fact] + public async Task AddImagePromptPipeline_ShouldUseDefaultScenario_WhenNullProvided() + { + var fakeChat = new FakeChatClient(); + var services = new ServiceCollection(); + RegisterLoggerStub(services); + services.AddSingleton(fakeChat); + + services.AddImagePromptPipeline(scenario: null!); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + var input = new WordImageInput("A", "A", "letter A", WordImageCardType.Alphabet); + await client.GenerateAlphabetPlanAsync(input); + + fakeChat.LastScenario.Should().Be(PlanGeneratorClient.DefaultScenario); + } + + // ── Test doubles ── + + private static void RegisterLoggerStub(IServiceCollection services) + { + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + } + + private sealed class FakeChatClient : IAIChatClient + { + public string? LastModel { get; private set; } + public string? LastScenario { get; private set; } + + public ValueTask GetResponseAsync( + AIChatRequest request, CancellationToken cancellationToken = default) + { + LastModel = request.Model; + LastScenario = request.Scenario; + return ValueTask.FromResult(new AIChatResponse + { + Provider = "fake", + Model = "fake", + Text = "{\"mainSubject\":\"test\"}" + }); + } + + public IAsyncEnumerable StreamAsync( + AIChatRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } } \ No newline at end of file diff --git a/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/PlanGeneratorClientTests.cs b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/PlanGeneratorClientTests.cs new file mode 100644 index 0000000..0cca066 --- /dev/null +++ b/MS.Microservice.AI/test/MS.Microservice.AI.Core.Tests/PlanGeneratorClientTests.cs @@ -0,0 +1,412 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MS.Microservice.AI.Abstractions; +using MS.Microservice.AI.Core.Images; +using MS.Microservice.AI.Core.Images.Models; + +namespace MS.Microservice.AI.Core.Tests; + +public sealed class PlanGeneratorClientTests +{ + // ═══════════════════════════════════════════════════════════════ + // ExtractOutputJson + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public void ExtractOutputJson_ShouldReturnJson_WhenOutputTagPresent() + { + var text = "{\"key\":\"value\"}"; + + var result = PlanGeneratorClient.ExtractOutputJson(text); + + result.Should().Be("{\"key\":\"value\"}"); + } + + [Fact] + public void ExtractOutputJson_ShouldReturnNull_WhenOutputTagMissing() + { + var text = "Some plain text without output tags."; + + var result = PlanGeneratorClient.ExtractOutputJson(text); + + result.Should().BeNull(); + } + + [Fact] + public void ExtractOutputJson_ShouldHandleWhitespaceAndNewlines() + { + var text = "\n {\"a\": 1}\n"; + + var result = PlanGeneratorClient.ExtractOutputJson(text); + + result.Should().Be("{\"a\": 1}"); + } + + [Fact] + public void ExtractOutputJson_ShouldBeCaseInsensitive() + { + var text = "{\"x\":1}"; + + var result = PlanGeneratorClient.ExtractOutputJson(text); + + result.Should().Be("{\"x\":1}"); + } + + [Fact] + public void ExtractOutputJson_ShouldReturnNull_ForEmptyText() + { + var result = PlanGeneratorClient.ExtractOutputJson(""); + + result.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════ + // SendAsJsonAsync + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task SendAsJsonAsync_ShouldDeserialize_WhenValidJsonInOutput() + { + var chatClient = new FakeChatClient( + "{\"mainSubject\":\"apple\",\"supportingVisual\":\"tree\"}"); + var client = CreateClient(chatClient); + + var result = await client.SendAsJsonAsync( + "system", "user", "test-model"); + + result.Should().NotBeNull(); + result!.MainSubject.Should().Be("apple"); + result.SupportingVisual.Should().Be("tree"); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldReturnNull_WhenResponseTextEmpty() + { + var chatClient = new FakeChatClient(""); + var client = CreateClient(chatClient); + + var result = await client.SendAsJsonAsync( + "system", "user", "test-model"); + + result.Should().BeNull(); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldReturnNull_WhenOutputTagMissing() + { + var chatClient = new FakeChatClient("plain text response"); + var client = CreateClient(chatClient); + + var result = await client.SendAsJsonAsync( + "system", "user", "test-model"); + + result.Should().BeNull(); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldReturnNull_WhenJsonMalformed() + { + var chatClient = new FakeChatClient("{invalid json}"); + var client = CreateClient(chatClient); + + var result = await client.SendAsJsonAsync( + "system", "user", "test-model"); + + result.Should().BeNull(); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldReturnNull_WhenChatClientThrows() + { + var chatClient = new FakeChatClient(new InvalidOperationException("API down")); + var client = CreateClient(chatClient); + + var result = await client.SendAsJsonAsync( + "system", "user", "test-model"); + + result.Should().BeNull(); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldPassModelToRequest() + { + var chatClient = new FakeChatClient("{}"); + var client = CreateClient(chatClient); + + await client.SendAsJsonAsync( + "sys", "usr", "custom-model"); + + chatClient.LastRequest!.Model.Should().Be("custom-model"); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldPassSystemAndUserMessages() + { + var chatClient = new FakeChatClient("{}"); + var client = CreateClient(chatClient); + + await client.SendAsJsonAsync( + "sys-prompt", "user-prompt", "model"); + + chatClient.LastRequest!.Messages.Should().HaveCount(2); + chatClient.LastRequest!.Messages[0].Role.Should().Be("system"); + chatClient.LastRequest!.Messages[0].Content.Should().Be("sys-prompt"); + chatClient.LastRequest!.Messages[1].Role.Should().Be("user"); + chatClient.LastRequest!.Messages[1].Content.Should().Be("user-prompt"); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldPropagateCancellation() + { + var chatClient = new FakeChatClient( + (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return new AIChatResponse + { + Provider = "test", + Model = "test", + Text = "{}" + }; + }); + var client = CreateClient(chatClient); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = async () => await client.SendAsJsonAsync( + "sys", "usr", "model", cts.Token); + + await act.Should().ThrowAsync(); + } + + // ═══════════════════════════════════════════════════════════════ + // GenerateAlphabetPlanAsync + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task GenerateAlphabetPlanAsync_ShouldApplyPostProcessing() + { + var json = JsonSerializer.Serialize(new WordImagePromptPlan + { + MainSubject = "ant", + NegativeElements = ["barefoot", "", " barefoot ", " ", null!, "dot eyes", "beady eyes", "third"] + }); + var chatClient = new FakeChatClient($"{json}"); + var client = CreateClient(chatClient); + + var input = new WordImageInput("A", "A", "letter A", WordImageCardType.Alphabet); + var plan = await client.GenerateAlphabetPlanAsync(input); + + plan.Should().NotBeNull(); + plan!.AllowVisibleText.Should().BeTrue(); + plan.ReserveTextOverlayArea.Should().BeFalse(); + plan.OverlayText.Should().Be("A"); + plan.NegativeElements.Should().NotBeNull(); + plan.NegativeElements!.Should().HaveCountLessThanOrEqualTo(6); + plan.NegativeElements.Should().OnlyContain(item => !string.IsNullOrWhiteSpace(item)); + plan.NegativeElements.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task GenerateAlphabetPlanAsync_ShouldReturnNull_WhenLlmFails() + { + var chatClient = new FakeChatClient(""); + var client = CreateClient(chatClient); + + var input = new WordImageInput("B", "B", "letter B", WordImageCardType.Alphabet); + var plan = await client.GenerateAlphabetPlanAsync(input); + + plan.Should().BeNull(); + } + + // ═══════════════════════════════════════════════════════════════ + // GenerateVisualPlanAsync + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public async Task GenerateVisualPlanAsync_ShouldReturnVisualPlan_OnSuccess() + { + var json = JsonSerializer.Serialize(new WordImageVisualPlan + { + VisualMeaning = "A child running in a classroom", + MainSubject = "child running", + SentenceIntent = "prohibition", + SettingCues = ["desks", "chairs", "windows"] + }); + var chatClient = new FakeChatClient($"{json}"); + var client = CreateClient(chatClient); + + var input = new WordImageInput( + "Don't run!", "Don't run!", null, WordImageCardType.Sentence); + var plan = await client.GenerateVisualPlanAsync(input); + + plan.Should().NotBeNull(); + plan!.VisualMeaning.Should().Be("A child running in a classroom"); + plan.SentenceIntent.Should().Be("prohibition"); + } + + [Fact] + public async Task GenerateVisualPlanAsync_ShouldUseMeaningHint_WhenProvided() + { + string? capturedUserMessage = null; + var chatClient = new FakeChatClient((req, _) => + { + capturedUserMessage = req.Messages[1].Content; + return new AIChatResponse + { + Provider = "test", Model = "test", + Text = "{}" + }; + }); + var client = CreateClient(chatClient); + + var input = new WordImageInput( + "apple (fruit)", "apple", "fruit", WordImageCardType.Word); + await client.GenerateVisualPlanAsync(input); + + // The JSON payload should contain "fruit" as the Meaning field (camelCase from System.Text.Json) + capturedUserMessage.Should().NotBeNull(); + capturedUserMessage.Should().Contain("\"meaning\":\"fruit\""); + } + + // ═══════════════════════════════════════════════════════════════ + // ResolveModel / Scenario + // ═══════════════════════════════════════════════════════════════ + + [Fact] + public void ResolveModel_ShouldReturnNull_ByDefault() + { + var client = new TestablePlanGeneratorClient( + new FakeChatClient("{}"), + NullLogger.Instance); + + client.ResolveModelPublic().Should().BeNull(); + } + + [Fact] + public void Constructor_ShouldDefaultScenario_ToImagePromptPlanning() + { + var client = new TestablePlanGeneratorClient( + new FakeChatClient("{}"), + NullLogger.Instance); + + client.GetScenario().Should().Be(PlanGeneratorClient.DefaultScenario); + } + + [Fact] + public void Constructor_ShouldUseProvidedScenario() + { + var client = new TestablePlanGeneratorClient( + new FakeChatClient("{}"), + NullLogger.Instance, + "MyCustomScenario"); + + client.GetScenario().Should().Be("MyCustomScenario"); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldSetScenario_WhenModelIsNull() + { + var chatClient = new FakeChatClient("{}"); + var client = CreateClient(chatClient, scenario: "TestScenario"); + + await client.SendAsJsonAsync( + "sys", "usr", model: null); + + chatClient.LastRequest!.Scenario.Should().Be("TestScenario"); + chatClient.LastRequest!.Model.Should().BeNull(); + } + + [Fact] + public async Task SendAsJsonAsync_ShouldSetModel_WhenModelIsProvided() + { + var chatClient = new FakeChatClient("{}"); + var client = CreateClient(chatClient); + + await client.SendAsJsonAsync( + "sys", "usr", "direct-model-override"); + + chatClient.LastRequest!.Model.Should().Be("direct-model-override"); + } + + // ═══════════════════════════════════════════════════════════════ + // Test doubles + // ═══════════════════════════════════════════════════════════════ + + private static PlanGeneratorClient CreateClient(IAIChatClient chatClient, string scenario = "test-scenario") + { + return new PlanGeneratorClient(chatClient, NullLogger.Instance, scenario); + } + + /// + /// Exposes protected members of for testing. + /// + private sealed class TestablePlanGeneratorClient : PlanGeneratorClient + { + public TestablePlanGeneratorClient( + IAIChatClient chatClient, ILogger logger, string scenario = PlanGeneratorClient.DefaultScenario) + : base(chatClient, logger, scenario) { } + + public string? ResolveModelPublic() => ResolveModel(); + + /// Exposes the scenario stored in the base class. + public string GetScenario() + { + // Access the private 'scenario' field via reflection or a workaround. + // Since we control the test, we verify through behavior (SendAsJsonAsync). + // This is a convenience for constructor-scenario tests. + return typeof(PlanGeneratorClient) + .GetField("scenario", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(this) as string ?? string.Empty; + } + } + + private sealed class FakeChatClient : IAIChatClient + { + private readonly string? _staticResponse; + private readonly Exception? _exception; + private readonly Func? _factory; + + public AIChatRequest? LastRequest { get; private set; } + + public FakeChatClient(string staticResponse) + { + _staticResponse = staticResponse; + } + + public FakeChatClient(Exception exception) + { + _exception = exception; + } + + public FakeChatClient(Func factory) + { + _factory = factory; + } + + public ValueTask GetResponseAsync( + AIChatRequest request, CancellationToken cancellationToken = default) + { + LastRequest = request; + + if (_exception is not null) + throw _exception; + + if (_factory is not null) + return ValueTask.FromResult(_factory(request, cancellationToken)); + + return ValueTask.FromResult(new AIChatResponse + { + Provider = "fake", + Model = request.Model ?? request.Scenario ?? "unknown", + Text = _staticResponse ?? string.Empty + }); + } + + public IAsyncEnumerable StreamAsync( + AIChatRequest request, CancellationToken cancellationToken = default) + => throw new NotSupportedException("Streaming not used by PlanGeneratorClient"); + } +}