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