From 4aa86d7c0259c8ad970a0f66424205e6c0daa951 Mon Sep 17 00:00:00 2001 From: rabitis99 Date: Sun, 22 Mar 2026 12:32:03 +0900 Subject: [PATCH] refactor(semantic): finalize prompt engine semantic layer architecture What changed: - Restore clear domain vs application boundaries and align responsibilities across layers. - Replace null-to-default-new patterns with explicit construction and stricter initialization contracts. - Split ObjectiveMappingRegistry responsibilities and consolidate ActionGroup handling through a single PromptSpecFactory path. - Apply fail-fast semantics for category seeding and ActionGroup resolution; remove silent drops and empty-map masking. - Formalize strict (require) vs permissive (find) APIs across semantic resolution. - Remove static IntentDefinitionDataSource and IntentDictionary entry points in favor of injectable providers. Why: - The semantic layer mixed persistence concerns with application orchestration, hid misconfiguration behind defaults, and allowed inconsistent ActionGroup wiring. Static singletons and silent fallbacks made behavior environment-dependent and hard to test or evolve safely. Impact: - Failures surface at startup or at the boundary where data is wrong, improving operational predictability. - Dependency injection makes components testable in isolation and keeps a single source of truth for intent and action semantics. - Tighter contracts reduce duplicate code paths and make future schema or registry changes localized. Constraints: - No intentional change to external product-facing prompt text or user-visible API contracts beyond stricter validation and earlier failure modes. --- .../legacy/IntentDefaultsResolver.java | 8 +- .../IntentBasedAxisDefaultsResolver.java | 12 +- .../canonical/CanonicalActionRegistry.java | 28 ++- .../DefaultCanonicalActionRegistry.java | 3 +- .../semantic/CategorySemanticProfile.java | 19 +- .../CategorySemanticProfileSeedSource.java | 22 +-- .../semantic/ConfirmedSemanticAxes.java | 10 +- .../semantic/IntentDefinitionDataSource.java | 42 +---- .../IntentDefinitionEntriesProvider.java | 3 +- .../IntentDefinitionProviderAssembly.java | 56 ++++++ .../domain/semantic/IntentDictionary.java | 170 ++++++++--------- ...tentResolutionDefaultsEntriesProvider.java | 3 +- ...efaultCategorySemanticProfileRegistry.java | 118 +++++++++--- ...aultCategorySemanticProfileSeedSource.java | 15 +- .../service/spec/PromptSpecFactory.java | 28 ++- .../config/PromptDomainConfig.java | 7 + ...atePromptFromConfirmedAxesServiceTest.java | 4 +- .../legacy/DomainFinalizerTest.java | 5 +- .../legacy/IntentDefaultsResolverTest.java | 5 +- .../legacy/RoutingRuleEngineTest.java | 20 +- .../canonical/CanonicalActionMappingTest.java | 37 ++++ ...emanticProfileSeedSourceAssemblerTest.java | 34 ++-- ...firmedSemanticAxesFindActionGroupTest.java | 69 +++++++ .../IntentDefinitionDataSourceTest.java | 89 +++++++++ .../domain/semantic/IntentDictionaryTest.java | 52 ++++++ ...rySemanticProfileRegistryFailFastTest.java | 85 +++++++++ .../policy/SemanticStructurePhase5Test.java | 116 +++++++++++- .../service/spec/PromptSpecFactoryTest.java | 172 ++++++++++++++++++ 28 files changed, 1012 insertions(+), 220 deletions(-) create mode 100644 sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionProviderAssembly.java create mode 100644 sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxesFindActionGroupTest.java create mode 100644 sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSourceTest.java create mode 100644 sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionaryTest.java create mode 100644 sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistryFailFastTest.java create mode 100644 sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactoryTest.java diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolver.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolver.java index fb256f4e..ed14844a 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolver.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolver.java @@ -12,6 +12,12 @@ /** legacy 라우팅용 Intent 기본값 */ public class IntentDefaultsResolver { + private final IntentDictionary intentDictionary; + + public IntentDefaultsResolver(IntentDictionary intentDictionary) { + this.intentDictionary = Objects.requireNonNull(intentDictionary, "intentDictionary"); + } + public IntentDefaults resolve(UnifiedGeneratePromptCommand command) { Objects.requireNonNull(command, "command must not be null"); ActionIntent intent = command.intent(); @@ -19,7 +25,7 @@ public IntentDefaults resolve(UnifiedGeneratePromptCommand command) { intent = ActionIntent.GENERATE; } - var defaults = IntentDictionary.getResolutionDefaults(intent); + var defaults = intentDictionary.getResolutionDefaults(intent); Optional domainAffinity = Optional.empty(); EngineProfile recommendedProfile = EngineProfile.QUALITY_PIPELINE; diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/semantic/IntentBasedAxisDefaultsResolver.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/semantic/IntentBasedAxisDefaultsResolver.java index 75f68250..4812b83d 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/semantic/IntentBasedAxisDefaultsResolver.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/application/semantic/IntentBasedAxisDefaultsResolver.java @@ -9,12 +9,20 @@ import org.example.sharedprompts.domain.prompt.domain.value.objective.PromptObjective; import org.springframework.stereotype.Component; +import java.util.Objects; + /** Intent·프로필·jsonSchema 기반 objective/outputNeeds/taskDomain 해석 (단일 책임) */ @Component public class IntentBasedAxisDefaultsResolver { + private final IntentDictionary intentDictionary; + + public IntentBasedAxisDefaultsResolver(IntentDictionary intentDictionary) { + this.intentDictionary = Objects.requireNonNull(intentDictionary, "intentDictionary"); + } + public PromptObjective resolveObjective(ActionIntent intent, boolean hasJsonSchema, boolean extractionRequestMode) { - var defaults = IntentDictionary.getResolutionDefaults(intent); + var defaults = intentDictionary.getResolutionDefaults(intent); org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptObjective apiObjective = defaults.defaultObjective(); if (hasJsonSchema && (extractionRequestMode || intent == ActionIntent.EXTRACT)) { apiObjective = org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptObjective.EXTRACTION; @@ -23,7 +31,7 @@ public PromptObjective resolveObjective(ActionIntent intent, boolean hasJsonSche } public OutputNeeds resolveOutputNeeds(ActionIntent intent, boolean hasJsonSchema) { - var defaults = IntentDictionary.getResolutionDefaults(intent); + var defaults = intentDictionary.getResolutionDefaults(intent); if (hasJsonSchema) { return OutputNeeds.JSON_SCHEMA_REQUIRED; } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionRegistry.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionRegistry.java index ccb719b1..9c490f41 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionRegistry.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionRegistry.java @@ -2,17 +2,41 @@ import org.example.sharedprompts.domain.prompt.common.enums.action.ActionTypeInterface; +import java.util.Objects; import java.util.Optional; /** ActionType → ActionGroup(capability) 매핑 레지스트리. */ public interface CanonicalActionRegistry { - /** ActionType을 ActionGroup으로 변환. */ + /** + * Permissive lookup: empty when {@code actionType} is null or no group can be resolved + * (e.g. unknown stable key with no definition). Prefer {@link #requireActionGroup(ActionTypeInterface)} + * for curated policy/seed lists where absence is a configuration error. + */ Optional toCanonical(ActionTypeInterface actionType); - /** stable key로 ActionGroup 조회. */ + /** + * Permissive: stable key may be missing from {@link org.example.sharedprompts.domain.prompt.common.enums.action.registry.ActionTypeRegistry}. + */ Optional findByKey(String stableKey); + /** + * Strict: curated taxonomy / seed / policy paths must resolve a capability. Failure is an + * {@link IllegalArgumentException} with the action stable key in the message. + */ + default ActionGroup requireActionGroup(ActionTypeInterface actionType) { + Objects.requireNonNull(actionType, "actionType"); + return toCanonical(actionType) + .orElseThrow( + () -> + new IllegalArgumentException( + "Missing ActionGroup for action type stableKey=" + + actionType.key() + + " (class=" + + actionType.getClass().getName() + + "). Definition-first resolution failed; ensure getActionGroup() is non-null or the stable key is registered.")); + } + /** 두 ActionType이 동일 capability(ActionGroup)인지 비교. */ default boolean sameCanonicalCapability(ActionTypeInterface a, ActionTypeInterface b) { if (a == null || b == null) { diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/DefaultCanonicalActionRegistry.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/DefaultCanonicalActionRegistry.java index 4b11afc3..370cb1ed 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/DefaultCanonicalActionRegistry.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/DefaultCanonicalActionRegistry.java @@ -19,7 +19,8 @@ public DefaultCanonicalActionRegistry(ActionTypeRegistry actionTypeRegistry) { /** * Definition-first: canonical meaning comes from the ActionType's own {@link ActionTypeInterface#getActionGroup()}. - * No registry re-query — registry is used only for {@link #findByKey(String)} when resolving by stable key. + * Registry is used only for {@link #findByKey(String)} when the definition has no group. Empty result is allowed + * for open-ended lookups; curated seeds/policies should use {@link CanonicalActionRegistry#requireActionGroup(ActionTypeInterface)}. */ @Override public Optional toCanonical(ActionTypeInterface actionType) { diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfile.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfile.java index 653be9dc..059fce3c 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfile.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfile.java @@ -65,13 +65,28 @@ default SemanticFitLevel getIntentFitLevel(ActionIntent intent) { * so profiles without action group data remain valid. */ default List getCompatibleActionGroupsForIntent(ActionIntent intent) { + if (intent == null) { + return Collections.emptyList(); + } List actions = getCompatibleActionsForIntent(intent); if (actions == null || actions.isEmpty()) { return Collections.emptyList(); } return actions.stream() - .map(ActionTypeInterface::getActionGroup) - .filter(g -> g != null) + .map( + a -> { + ActionGroup g = a.getActionGroup(); + if (g == null) { + throw new IllegalStateException( + "Compatible action without ActionGroup for category " + + getCategory().name() + + ", intent " + + intent.name() + + ", actionStableKey=" + + a.key()); + } + return g; + }) .distinct() .collect(Collectors.toList()); } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSource.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSource.java index 8009c1fc..a26c3aeb 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSource.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSource.java @@ -3,8 +3,6 @@ import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptCategory; import org.example.sharedprompts.domain.prompt.domain.semantic.seed.CategorySemanticProfileSeedDefinitions; -import java.util.Objects; -import java.util.Optional; import java.util.Set; /** @@ -14,26 +12,16 @@ public interface CategorySemanticProfileSeedSource { /** - * Categories the registry will assemble. Each must succeed {@link #requireSeed(PromptCategory)}. - * Default: full {@link PromptCategory#canonicalSemanticProfileCategories()}. Override together with {@link #getSeed} - * when intentionally wiring a subset (otherwise default iteration will call {@code requireSeed} for missing categories). + * Categories the registry will assemble. Each must return a non-null seed from {@link #requireSeed(PromptCategory)}. + * Default: {@link CategorySemanticProfileSeedDefinitions#canonicalProfileCategories()}. Override to assemble a subset only. */ default Set profileCategoriesForRegistry() { return CategorySemanticProfileSeedDefinitions.canonicalProfileCategories(); } /** - * Resolves seed for {@code category} (after {@link PromptCategory#canonical()}). Missing registration is a configuration error. + * Required seed for {@code category} (after {@link PromptCategory#canonical()}). + * Absence is a configuration error, not an optional outcome. */ - default CategorySemanticProfileSeed requireSeed(PromptCategory category) { - Objects.requireNonNull(category, "category"); - PromptCategory canonical = category.canonical(); - return getSeed(canonical).orElseThrow(() -> new IllegalStateException( - "Missing CategorySemanticProfileSeed for category: " + canonical.name())); - } - - /** - * Optional seed when the category may legitimately lack one (e.g. not in this source). Prefer {@link #requireSeed(PromptCategory)} for profile assembly. - */ - Optional getSeed(PromptCategory category); + CategorySemanticProfileSeed requireSeed(PromptCategory category); } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxes.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxes.java index f0520bb3..43d7ed2d 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxes.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxes.java @@ -56,8 +56,14 @@ public record ConfirmedSemanticAxes( recommendationHints = recommendationHints != null ? List.copyOf(recommendationHints) : List.of(); } - /** Resolve action group for prompt assembly / resolution. Use registry when building prompts. */ - public Optional actionGroup(CanonicalActionRegistry registry) { + /** + * Permissive lookup: optional axes may omit {@link #actionType}; then no capability group exists for rendering. + * Uses {@link CanonicalActionRegistry#toCanonical(ActionTypeInterface)} — empty when type is absent or lookup fails. + * + *

{@link org.example.sharedprompts.domain.prompt.domain.service.spec.PromptSpecFactory} applies a stricter rule: + * when {@link #actionType} is present, the registry must resolve a group (no direct enum read there). + */ + public Optional findActionGroup(CanonicalActionRegistry registry) { Objects.requireNonNull(registry, "registry"); return actionType.flatMap(registry::toCanonical); } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSource.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSource.java index 03250363..2624b28c 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSource.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSource.java @@ -8,11 +8,11 @@ import java.util.Objects; /** - * Aggregates intent definitions/defaults provided by smaller providers. + * Aggregates intent definitions/defaults from injected providers (merge, duplicate detection). *

- * IntentDictionary keeps responsibility for fail-fast completeness validation and indexing. - * Inject custom provider lists via the constructor for tests or alternate wiring; production - * static aggregation uses {@link #definitionsEntries()} / {@link #resolutionDefaultEntries()}. + * Does not own provider registration; use {@link IntentDefinitionProviderAssembly} for the + * production catalog or pass custom lists for tests/alternate wiring. + * {@link IntentDictionary} performs completeness validation and indexing on top of collected entries. */ public final class IntentDefinitionDataSource { @@ -20,31 +20,6 @@ record IntentDefinitionEntry(ActionIntent intent, IntentDefinition definition) { record IntentResolutionDefaultEntry(ActionIntent intent, IntentResolutionDefaults defaults) {} - private static final List DEFAULT_DEFINITIONS_PROVIDERS = List.of( - new IntentCreationIntentDefinitionsProvider(), - new IntentModificationIntentDefinitionsProvider(), - new IntentAnalysisIntentDefinitionsProvider(), - new IntentExplanationIntentDefinitionsProvider(), - new IntentPlanningIntentDefinitionsProvider(), - new IntentDecisionIntentDefinitionsProvider(), - new IntentResearchIntentDefinitionsProvider(), - new IntentExtractionIntentDefinitionsProvider() - ); - - private static final List DEFAULT_RESOLUTION_DEFAULTS_PROVIDERS = List.of( - new IntentCreationIntentResolutionDefaultsProvider(), - new IntentModificationIntentResolutionDefaultsProvider(), - new IntentAnalysisIntentResolutionDefaultsProvider(), - new IntentExplanationIntentResolutionDefaultsProvider(), - new IntentPlanningIntentResolutionDefaultsProvider(), - new IntentDecisionIntentResolutionDefaultsProvider(), - new IntentResearchIntentResolutionDefaultsProvider(), - new IntentExtractionIntentResolutionDefaultsProvider() - ); - - private static final IntentDefinitionDataSource DEFAULT = new IntentDefinitionDataSource( - DEFAULT_DEFINITIONS_PROVIDERS, DEFAULT_RESOLUTION_DEFAULTS_PROVIDERS); - private final List definitionsProviders; private final List resolutionDefaultsProviders; @@ -55,14 +30,6 @@ public IntentDefinitionDataSource( this.resolutionDefaultsProviders = List.copyOf(Objects.requireNonNull(resolutionDefaultsProviders)); } - static List definitionsEntries() { - return DEFAULT.collectDefinitionsEntries(); - } - - static List resolutionDefaultEntries() { - return DEFAULT.collectResolutionDefaultEntries(); - } - public List collectDefinitionsEntries() { Map defs = new HashMap<>(); for (IntentDefinitionEntriesProvider provider : definitionsProviders) { @@ -97,4 +64,3 @@ public List collectResolutionDefaultEntries() { .toList(); } } - diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionEntriesProvider.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionEntriesProvider.java index 579c983c..55573a61 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionEntriesProvider.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionEntriesProvider.java @@ -5,7 +5,8 @@ /** * Provides intent definition entries. *

- * Data ownership lives in providers; {@link IntentDefinitionDataSource} only aggregates. + * Data ownership lives in providers; {@link IntentDefinitionDataSource} merges entries. + * Production registration: {@link IntentDefinitionProviderAssembly}. */ public interface IntentDefinitionEntriesProvider { diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionProviderAssembly.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionProviderAssembly.java new file mode 100644 index 00000000..f6a93ba6 --- /dev/null +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionProviderAssembly.java @@ -0,0 +1,56 @@ +package org.example.sharedprompts.domain.prompt.domain.semantic; + +import java.util.List; + +/** + * Production composition of intent definition / resolution-defaults providers. + *

+ * {@link IntentDefinitionDataSource} only merges and validates; this class is the single + * explicit registration point for which providers participate in the shipped catalog. + * Order is stable: creation → modification → analysis → explanation → planning → decision → + * research → extraction (matches prior {@code IntentDefinitionDataSource} static lists). + */ +public final class IntentDefinitionProviderAssembly { + + private IntentDefinitionProviderAssembly() {} + + public static List productionDefinitionsProviders() { + return List.of( + new IntentCreationIntentDefinitionsProvider(), + new IntentModificationIntentDefinitionsProvider(), + new IntentAnalysisIntentDefinitionsProvider(), + new IntentExplanationIntentDefinitionsProvider(), + new IntentPlanningIntentDefinitionsProvider(), + new IntentDecisionIntentDefinitionsProvider(), + new IntentResearchIntentDefinitionsProvider(), + new IntentExtractionIntentDefinitionsProvider()); + } + + public static List productionResolutionDefaultsProviders() { + return List.of( + new IntentCreationIntentResolutionDefaultsProvider(), + new IntentModificationIntentResolutionDefaultsProvider(), + new IntentAnalysisIntentResolutionDefaultsProvider(), + new IntentExplanationIntentResolutionDefaultsProvider(), + new IntentPlanningIntentResolutionDefaultsProvider(), + new IntentDecisionIntentResolutionDefaultsProvider(), + new IntentResearchIntentResolutionDefaultsProvider(), + new IntentExtractionIntentResolutionDefaultsProvider()); + } + + /** + * Full production {@link IntentDefinitionDataSource} (same merge result as before this refactor). + */ + public static IntentDefinitionDataSource productionIntentDefinitionDataSource() { + return new IntentDefinitionDataSource( + productionDefinitionsProviders(), productionResolutionDefaultsProviders()); + } + + /** + * Production {@link IntentDictionary}: indexes and validates completeness over + * {@link #productionIntentDefinitionDataSource()}. + */ + public static IntentDictionary productionIntentDictionary() { + return new IntentDictionary(productionIntentDefinitionDataSource()); + } +} diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionary.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionary.java index f2f7a663..fffb9d3c 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionary.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionary.java @@ -1,81 +1,89 @@ -package org.example.sharedprompts.domain.prompt.domain.semantic; - -import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; - -import java.util.Collections; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -/** - * 공식 Intent 사전: 각 {@link ActionIntent}의 정식 의미와 해석 기본값을 제공한다. - *

- * 실제 데이터(definition/default) 소유는 {@link IntentDefinitionDataSource}로 분리하고, - * 이 클래스는 “인덱싱/검증/조회”에만 집중한다. - */ -public final class IntentDictionary { - - private static final Map DEFINITIONS; - - /** - * Intent별 해석 기본값(objective, output needs, response shape). - * 의미 해석의 기준 소스. - */ - private static final Map RESOLUTION_DEFAULTS; - - static { - Map defs = new HashMap<>(); - for (IntentDefinitionDataSource.IntentDefinitionEntry e : IntentDefinitionDataSource.definitionsEntries()) { - defs.put(e.intent(), e.definition()); - } - - Map res = new HashMap<>(); - for (IntentDefinitionDataSource.IntentResolutionDefaultEntry e : IntentDefinitionDataSource.resolutionDefaultEntries()) { - res.put(e.intent(), e.defaults()); - } - - // fail-fast: “등록 결과” 기준으로 completeness를 강제한다. - EnumSet allIntents = EnumSet.allOf(ActionIntent.class); - if (!defs.keySet().containsAll(allIntents) || !res.keySet().containsAll(allIntents)) { - EnumSet missingDefinitions = EnumSet.copyOf(allIntents); - missingDefinitions.removeAll(defs.keySet()); - EnumSet missingDefaults = EnumSet.copyOf(allIntents); - missingDefaults.removeAll(res.keySet()); - throw new IllegalStateException( - "IntentDictionary is incomplete. missingDefinitions=" + missingDefinitions - + ", missingDefaults=" + missingDefaults - ); - } - - DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(defs)); - RESOLUTION_DEFAULTS = Collections.unmodifiableMap(new HashMap<>(res)); - } - - private IntentDictionary() {} - - /** - * Resolution defaults for the given intent. - * semantic resolution flow에서 사용한다. - */ - public static IntentResolutionDefaults getResolutionDefaults(ActionIntent intent) { - IntentResolutionDefaults d = RESOLUTION_DEFAULTS.get(intent); - if (d == null) { - throw new IllegalArgumentException("No resolution defaults for: " + intent); - } - return d; - } - - public static Optional get(ActionIntent intent) { - return Optional.ofNullable(DEFINITIONS.get(intent)); - } - - public static IntentDefinition getOrThrow(ActionIntent intent) { - IntentDefinition d = DEFINITIONS.get(intent); - if (d == null) { - throw new IllegalArgumentException("No IntentDefinition for: " + intent); - } - return d; - } -} - +package org.example.sharedprompts.domain.prompt.domain.semantic; + +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * 공식 Intent 사전: 각 {@link ActionIntent}의 정식 의미와 해석 기본값을 제공한다. + *

+ * Definition/default 데이터는 {@link IntentDefinitionDataSource}가 집계하고, + * 운영 구성은 {@link IntentDefinitionProviderAssembly}가 조합한다. + * 이 클래스는 인덱싱·completeness 검증·조회만 담당한다. + */ +public final class IntentDictionary { + + private final Map definitions; + + /** + * Intent별 해석 기본값(objective, output needs, response shape). + * 의미 해석의 기준 소스. + */ + private final Map resolutionDefaults; + + public IntentDictionary(IntentDefinitionDataSource dataSource) { + Objects.requireNonNull(dataSource, "dataSource"); + Map defs = new HashMap<>(); + for (IntentDefinitionDataSource.IntentDefinitionEntry e : dataSource.collectDefinitionsEntries()) { + defs.put(e.intent(), e.definition()); + } + + Map res = new HashMap<>(); + for (IntentDefinitionDataSource.IntentResolutionDefaultEntry e : dataSource.collectResolutionDefaultEntries()) { + res.put(e.intent(), e.defaults()); + } + + validateIntentCoverageCompleteness(defs, res); + + this.definitions = Collections.unmodifiableMap(new HashMap<>(defs)); + this.resolutionDefaults = Collections.unmodifiableMap(new HashMap<>(res)); + } + + /** + * fail-fast: “등록 결과” 기준으로 모든 {@link ActionIntent}에 정의·기본값이 있는지 검증한다. + * (패키지 동일 테스트에서 축소 {@link IntentDefinitionDataSource} 조합 검증에 사용.) + */ + static void validateIntentCoverageCompleteness( + Map defs, + Map res) { + EnumSet allIntents = EnumSet.allOf(ActionIntent.class); + if (!defs.keySet().containsAll(allIntents) || !res.keySet().containsAll(allIntents)) { + EnumSet missingDefinitions = EnumSet.copyOf(allIntents); + missingDefinitions.removeAll(defs.keySet()); + EnumSet missingDefaults = EnumSet.copyOf(allIntents); + missingDefaults.removeAll(res.keySet()); + throw new IllegalStateException( + "IntentDictionary is incomplete. missingDefinitions=" + missingDefinitions + + ", missingDefaults=" + missingDefaults); + } + } + + /** + * Resolution defaults for the given intent. + * semantic resolution flow에서 사용한다. + */ + public IntentResolutionDefaults getResolutionDefaults(ActionIntent intent) { + IntentResolutionDefaults d = resolutionDefaults.get(intent); + if (d == null) { + throw new IllegalArgumentException("No resolution defaults for: " + intent); + } + return d; + } + + public Optional get(ActionIntent intent) { + return Optional.ofNullable(definitions.get(intent)); + } + + public IntentDefinition getOrThrow(ActionIntent intent) { + IntentDefinition d = definitions.get(intent); + if (d == null) { + throw new IllegalArgumentException("No IntentDefinition for: " + intent); + } + return d; + } +} diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentResolutionDefaultsEntriesProvider.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentResolutionDefaultsEntriesProvider.java index 83c4dc6d..2137c1c7 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentResolutionDefaultsEntriesProvider.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentResolutionDefaultsEntriesProvider.java @@ -5,7 +5,8 @@ /** * Provides intent resolution-default entries (objective / output needs / response shape). *

- * Data ownership lives in providers; {@link IntentDefinitionDataSource} only aggregates. + * Data ownership lives in providers; {@link IntentDefinitionDataSource} merges entries. + * Production registration: {@link IntentDefinitionProviderAssembly}. */ public interface IntentResolutionDefaultsEntriesProvider { diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistry.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistry.java index 3dd338d5..e7b8cc39 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistry.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistry.java @@ -10,8 +10,6 @@ import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeed; import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeedSource; import org.example.sharedprompts.domain.prompt.domain.semantic.policy.compatibility.CompatibilityPolicySource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.Collections; import java.util.HashMap; @@ -32,8 +30,6 @@ */ public class DefaultCategorySemanticProfileRegistry implements CategorySemanticProfileRegistry { - private static final Logger log = LoggerFactory.getLogger(DefaultCategorySemanticProfileRegistry.class); - private final Map profiles; private final CanonicalActionRegistry canonicalActionRegistry; private final CompatibilityPolicySource compatibilityPolicySource; @@ -83,27 +79,73 @@ private CategorySemanticProfile buildProfile(CategorySemanticProfileSeed seed) { prepared.groupMap()); } - private Map> toActionGroupMap(Map> actions) { + /** + * Strict: every action listed under an intent in a category seed must resolve to an {@link ActionGroup}; + * silent drops are not allowed (misconfiguration must fail fast at registry construction). + */ + private Map> toActionGroupMap( + PromptCategory category, Map> actions) { if (actions == null || actions.isEmpty()) return Map.of(); Map> out = new HashMap<>(); for (Map.Entry> e : actions.entrySet()) { - List groups = e.getValue().stream() - .map(canonicalActionRegistry::toCanonical) - .filter(Optional::isPresent) - .map(Optional::get) + ActionIntent intent = e.getKey(); + List actionList = e.getValue(); + if (actionList == null || actionList.isEmpty()) { + throw new IllegalArgumentException( + "Category semantic profile seed contains empty action list for category=" + + category.name() + + ", intent=" + + intent.name()); + } + List groups = actionList.stream() + .map(a -> requireActionGroupForCategorySeed(category, intent, a)) .distinct() .toList(); - if (!groups.isEmpty()) out.put(e.getKey(), groups); + assertIntentHasResolvedActionGroups(category, intent, groups); + out.put(intent, groups); } return out; } + /** + * Package-private for tests: same invariant as {@link #toActionGroupMap(PromptCategory, Map)} after mapping. + */ + static void assertIntentHasResolvedActionGroups( + PromptCategory category, ActionIntent intent, List groups) { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(intent, "intent"); + Objects.requireNonNull(groups, "groups"); + if (groups.isEmpty()) { + throw new IllegalArgumentException( + "No ActionGroup resolved for category=" + + category.name() + + ", intent=" + + intent.name()); + } + } + + private ActionGroup requireActionGroupForCategorySeed( + PromptCategory category, ActionIntent intent, ActionTypeInterface actionType) { + try { + return canonicalActionRegistry.requireActionGroup(actionType); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException( + "Category semantic profile seed assembly: cannot resolve ActionGroup for category=" + + category.name() + + ", intent=" + + intent.name() + + ", actionStableKey=" + + actionType.key(), + ex); + } + } + /** * When compatibilityPolicySource returns non-empty for (category, intent), those group keys are used; * otherwise action-derived groups from the seed are used. */ private GroupMapAndActions prepare(PromptCategory category, Map> actions) { - Map> fromActions = toActionGroupMap(actions); + Map> fromActions = toActionGroupMap(category, actions); if (compatibilityPolicySource == null) { return new GroupMapAndActions(fromActions, actions != null ? actions : Map.of()); } @@ -112,11 +154,9 @@ private GroupMapAndActions prepare(PromptCategory category, Map keys = compatibilityPolicySource.getCompatibleGroupKeys(category, intent); if (!keys.isEmpty()) { - List fromSource = resolveGroupKeys(keys); - if (!fromSource.isEmpty()) { - groupMap.put(intent, fromSource); - continue; - } + List fromSource = requireResolvedCompatibilityActionGroups(category, intent, keys); + groupMap.put(intent, fromSource); + continue; } if (fromActions.containsKey(intent)) { groupMap.put(intent, fromActions.get(intent)); @@ -125,27 +165,45 @@ private GroupMapAndActions prepare(PromptCategory category, Map resolveGroupKeys(List keys) { - if (keys == null || keys.isEmpty()) return List.of(); + /** + * Resolves every compatibility key strictly; invalid or blank entries are configuration errors, not absences. + */ + private static List requireResolvedCompatibilityActionGroups( + PromptCategory category, + ActionIntent intent, + List keys) { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(intent, "intent"); + Objects.requireNonNull(keys, "keys"); return keys.stream() - .map(this::parseActionGroup) - .filter(Optional::isPresent) - .map(Optional::get) + .map(k -> requireActionGroupForCompatibility(category, intent, k)) .distinct() .toList(); } - private Optional parseActionGroup(String key) { - if (key == null || key.isBlank()) return Optional.empty(); - String trimmed = key.trim(); + private static ActionGroup requireActionGroupForCompatibility( + PromptCategory category, + ActionIntent intent, + String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + throw new IllegalArgumentException( + "Blank or null ActionGroup key in compatibility policy for category " + + category.name() + + ", intent " + + intent.name()); + } + String trimmed = rawKey.trim(); try { - return Optional.of(ActionGroup.valueOf(trimmed)); + return ActionGroup.valueOf(trimmed); } catch (IllegalArgumentException e) { - log.warn( - "Ignoring invalid compatibility policy ActionGroup key '{}': {}", - trimmed, - e.toString()); - return Optional.empty(); + throw new IllegalArgumentException( + "Invalid ActionGroup key '" + + trimmed + + "' for category " + + category.name() + + ", intent " + + intent.name(), + e); } } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileSeedSource.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileSeedSource.java index 3cc58bfb..543de675 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileSeedSource.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileSeedSource.java @@ -11,7 +11,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.function.Supplier; @@ -81,7 +80,8 @@ public CategorySemanticProfileSeed requireSeed(PromptCategory category) { PromptCategory canonical = category.canonical(); Supplier supplier = seedSuppliers.get(canonical); if (supplier == null) { - throw new IllegalStateException("Missing CategorySemanticProfileSeed for category: " + canonical.name()); + throw new IllegalStateException( + "Missing CategorySemanticProfileSeed for category: " + canonical.name() + "; no seed supplier registered"); } CategorySemanticProfileSeed seed = supplier.get(); if (seed == null) { @@ -90,15 +90,4 @@ public CategorySemanticProfileSeed requireSeed(PromptCategory category) { } return seed; } - - @Override - public Optional getSeed(PromptCategory category) { - Objects.requireNonNull(category, "category"); - PromptCategory canonical = category.canonical(); - Supplier supplier = seedSuppliers.get(canonical); - if (supplier == null) { - return Optional.empty(); - } - return Optional.ofNullable(supplier.get()); - } } diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactory.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactory.java index df9db1f2..466e02ea 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactory.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactory.java @@ -38,6 +38,12 @@ *

Objective별 분기(priority, rubric, constraints, sections 등)는 * {@link ObjectiveRegistry}를 통해 조회한 {@link ObjectiveProfile}에 위임한다. * switch/case 없음, 새 Objective 추가 시 이 클래스 수정 불필요(OCP). + * + *

ActionGroup for {@link RuleContext}: resolved only via {@link CanonicalActionRegistry} + * ({@link ConfirmedSemanticAxes#findActionGroup(CanonicalActionRegistry)}). There is no parallel read of + * {@link org.example.sharedprompts.domain.prompt.common.enums.action.ActionTypeInterface#getActionGroup()} here; + * when an action type is present on confirmed axes, the registry must resolve a capability or construction fails + * (strict, definition-first). */ public class PromptSpecFactory { @@ -61,7 +67,7 @@ public PromptSpecFactory(ObjectiveRegistry objectiveRegistry, // GuidelineBundleBuilder 선택(구체 구현/조합 책임)은 조합 계층에서 수행해야 한다. this.guidelineBundleBuilder = Objects.requireNonNull(guidelineBundleBuilder, "guidelineBundleBuilder must not be null"); this.roleDescriptorPort = roleDescriptorPort; - this.canonicalActionRegistry = canonicalActionRegistry; + this.canonicalActionRegistry = Objects.requireNonNull(canonicalActionRegistry, "canonicalActionRegistry"); } /** @@ -89,10 +95,7 @@ public PromptSpec createFromConfirmedAxes( ExperienceLevel level = axes.experienceLevel() != null ? axes.experienceLevel() : ExperienceLevel.INTERMEDIATE; Constraints constraints = profile.constraints(level); OutputContract outputContract = profile.outputContract(jsonSchema, constraints.getMaxLength()); - Optional actionGroup = canonicalActionRegistry != null - ? axes.actionGroup(canonicalActionRegistry) - .or(() -> axes.actionType().flatMap(at -> Optional.ofNullable(at.getActionGroup()))) - : axes.actionType().flatMap(at -> Optional.ofNullable(at.getActionGroup())); + Optional actionGroup = resolveActionGroupForRuleContext(axes); RuleContext ruleContext = RuleContext.of( effectiveTaskDomain, profile.objective().name(), @@ -131,6 +134,21 @@ public PromptSpec createFromConfirmedAxes( .build(); } + /** + * Single resolution path: {@link ConfirmedSemanticAxes#findActionGroup(CanonicalActionRegistry)} only. + * If {@code axes} carries an action type, the registry must resolve an {@link ActionGroup} (no enum-field fallback). + */ + private Optional resolveActionGroupForRuleContext(ConfirmedSemanticAxes axes) { + Optional actionGroup = axes.findActionGroup(canonicalActionRegistry); + if (axes.actionType().isPresent() && actionGroup.isEmpty()) { + throw new IllegalArgumentException( + "CanonicalActionRegistry did not resolve ActionGroup for confirmed axes (stableKey=" + + axes.actionType().get().key() + + "). Use registry-only resolution; ensure the action is registered and resolvable."); + } + return actionGroup; + } + // ── 섹션 빌드 ───────────────────────────────────────────────────────── private List buildSections( diff --git a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/infrastructure/config/PromptDomainConfig.java b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/infrastructure/config/PromptDomainConfig.java index f7da2e2a..4e5ad1fe 100644 --- a/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/infrastructure/config/PromptDomainConfig.java +++ b/sharedPrompts/src/main/java/org/example/sharedprompts/domain/prompt/infrastructure/config/PromptDomainConfig.java @@ -19,6 +19,8 @@ import org.example.sharedprompts.domain.prompt.common.guideline.bundle.GuidelineBundleBuilder; import org.example.sharedprompts.domain.prompt.domain.verification.guideline.DefaultGuidelineRuleChecker; import org.example.sharedprompts.domain.prompt.domain.verification.guideline.GuidelineVerifier; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDefinitionProviderAssembly; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDictionary; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -35,6 +37,11 @@ @Import({ActionTypeRegistryConfig.class, ResolutionConfig.class, ObservabilityConfig.class, AuditConfig.class}) public class PromptDomainConfig { + @Bean + public IntentDictionary intentDictionary() { + return IntentDefinitionProviderAssembly.productionIntentDictionary(); + } + @Bean public ObjectiveRegistry objectiveRegistry() { // DefaultObjectiveRegistry 생성자에서 validate() 호출 — 모든 PromptObjective에 프로파일 등록 여부 검증 (등록 누락 시 기동 시점 IllegalStateException) diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/GeneratePromptFromConfirmedAxesServiceTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/GeneratePromptFromConfirmedAxesServiceTest.java index ea84c0dd..1ddd17fc 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/GeneratePromptFromConfirmedAxesServiceTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/GeneratePromptFromConfirmedAxesServiceTest.java @@ -21,6 +21,7 @@ import org.example.sharedprompts.domain.prompt.common.enums.style.StyleType; import org.example.sharedprompts.domain.prompt.common.enums.style.ToneType; import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileRegistry; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDefinitionProviderAssembly; import org.example.sharedprompts.domain.prompt.application.exception.SemanticResolutionException; import org.example.sharedprompts.domain.prompt.domain.semantic.ConfirmedSemanticAxes; import org.example.sharedprompts.domain.prompt.domain.semantic.SemanticValidationResult; @@ -54,7 +55,8 @@ void setUp() { confirmedAxesMapper = new ConfirmedAxesMapper(); profileRegistry = mock(CategorySemanticProfileRegistry.class); validationService = mock(SemanticValidationService.class); - axisDefaultsResolver = new IntentBasedAxisDefaultsResolver(); + axisDefaultsResolver = new IntentBasedAxisDefaultsResolver( + IntentDefinitionProviderAssembly.productionIntentDictionary()); generatePromptUseCase = mock(GeneratePromptUseCase.class); axisSourcePolicy = mock(AxisSourcePolicy.class); resultBuilder = mock(UnifiedGeneratePromptResultBuilder.class); diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/DomainFinalizerTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/DomainFinalizerTest.java index b4e23d4d..5437343c 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/DomainFinalizerTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/DomainFinalizerTest.java @@ -11,6 +11,7 @@ import org.example.sharedprompts.domain.prompt.common.enums.style.ToneType; import org.example.sharedprompts.domain.prompt.domain.resolutions.DomainResolverPort; import org.example.sharedprompts.domain.prompt.domain.resolutions.ResolvedDomain; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDefinitionProviderAssembly; import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDictionary; import org.junit.jupiter.api.Test; @@ -20,6 +21,8 @@ class DomainFinalizerTest { + private final IntentDictionary intentDictionary = IntentDefinitionProviderAssembly.productionIntentDictionary(); + private DomainResolutionService domainResolutionService() { return new DomainResolutionService(new DomainResolverPort() { @Override @@ -61,7 +64,7 @@ private UnifiedGeneratePromptCommand command(PromptCategory category) { void should_use_intent_affinity_and_resolver_result() { DomainFinalizer finalizer = new DomainFinalizer(domainResolutionService()); - var resolutionDefaults = IntentDictionary.getResolutionDefaults(ActionIntent.GENERATE); + var resolutionDefaults = intentDictionary.getResolutionDefaults(ActionIntent.GENERATE); IntentDefaults defaults = new IntentDefaults( ActionIntent.GENERATE, resolutionDefaults.defaultObjective(), diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolverTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolverTest.java index 2c8d4d01..2407625e 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolverTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/IntentDefaultsResolverTest.java @@ -10,13 +10,16 @@ import org.example.sharedprompts.domain.prompt.common.enums.output.OutputNeeds; import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptObjective; import org.example.sharedprompts.domain.prompt.common.enums.output.ResponseShape; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDefinitionProviderAssembly; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDictionary; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class IntentDefaultsResolverTest { - private final IntentDefaultsResolver resolver = new IntentDefaultsResolver(); + private final IntentDictionary intentDictionary = IntentDefinitionProviderAssembly.productionIntentDictionary(); + private final IntentDefaultsResolver resolver = new IntentDefaultsResolver(intentDictionary); private UnifiedGeneratePromptCommand command(ActionIntent intent, PromptCategory category, String input) { return UnifiedGeneratePromptCommand.of( diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/RoutingRuleEngineTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/RoutingRuleEngineTest.java index 5987f2b8..9034d74d 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/RoutingRuleEngineTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/application/engine/generation/legacy/RoutingRuleEngineTest.java @@ -6,13 +6,12 @@ import org.example.sharedprompts.domain.prompt.common.enums.output.OutputNeeds; import org.example.sharedprompts.domain.prompt.common.enums.output.ResponseShape; import org.example.sharedprompts.domain.prompt.common.enums.request.RequestMode; -import org.example.sharedprompts.domain.prompt.common.enums.role.core.CoreRoleType; -import org.example.sharedprompts.domain.prompt.common.enums.role.metadata.DomainRoleType; import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptCategory; import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptObjective; import org.example.sharedprompts.domain.prompt.common.enums.semantic.TaskDomain; import org.example.sharedprompts.domain.prompt.common.enums.style.ToneType; +import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDefinitionProviderAssembly; import org.example.sharedprompts.domain.prompt.domain.semantic.IntentDictionary; import org.junit.jupiter.api.Test; @@ -22,11 +21,17 @@ class RoutingRuleEngineTest { + private final IntentDictionary intentDictionary = IntentDefinitionProviderAssembly.productionIntentDictionary(); + private UnifiedGeneratePromptCommand baseCommand(ActionIntent intent, String jsonSchema) { + return baseCommand(intent, jsonSchema, PromptCategory.ETC); + } + + private UnifiedGeneratePromptCommand baseCommand(ActionIntent intent, String jsonSchema, PromptCategory category) { return UnifiedGeneratePromptCommand.of( 1L, RequestMode.SIMPLE, - PromptCategory.ETC, + category, intent, null, "input", @@ -46,7 +51,7 @@ private UnifiedGeneratePromptCommand baseCommand(ActionIntent intent, String jso } private IntentDefaults baseDefaults(ActionIntent intent, TaskDomain affinity) { - var resolutionDefaults = IntentDictionary.getResolutionDefaults(intent); + var resolutionDefaults = intentDictionary.getResolutionDefaults(intent); return new IntentDefaults( intent, resolutionDefaults.defaultObjective(), @@ -95,7 +100,7 @@ void higher_priority_rule_should_win() { RoutingOverrides overrides = engine.apply(command, defaults); assertThat(overrides.objectiveOverride()).isEqualTo(PromptObjective.REASONING); - assertThat(overrides.appliedRuleIds()).containsExactly("low", "high"); + assertThat(overrides.appliedRuleIds()).containsExactly("high", "low"); } @Test @@ -130,13 +135,14 @@ void more_specific_rule_should_win_when_priority_equal() { engine.registerRule(generic); engine.registerRule(specific); - UnifiedGeneratePromptCommand command = baseCommand(ActionIntent.EXTRACT, "{}"); + UnifiedGeneratePromptCommand command = + baseCommand(ActionIntent.EXTRACT, "{}", PromptCategory.ANALYSIS); IntentDefaults defaults = baseDefaults(ActionIntent.EXTRACT, TaskDomain.ANALYTICAL); RoutingOverrides overrides = engine.apply(command, defaults); assertThat(overrides.outputNeedsOverride()).isEqualTo(OutputNeeds.JSON_SCHEMA_REQUIRED); - assertThat(overrides.appliedRuleIds()).containsExactly("generic", "specific"); + assertThat(overrides.appliedRuleIds()).containsExactly("specific", "generic"); } @Test diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionMappingTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionMappingTest.java index 0462db8e..9107ecc1 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionMappingTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/common/enums/action/canonical/CanonicalActionMappingTest.java @@ -16,6 +16,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Ensures every ActionType maps to an action group; deserialization and key stability unchanged. @@ -103,6 +104,42 @@ void sameCanonicalCapability() { assertThat(registry.toCanonical(b)).contains(ActionGroup.TEXT_REVISION); } + @Test + @DisplayName("requireActionGroup fails fast when definition and registry cannot resolve a group") + void requireActionGroupFailsFastWhenUnresolvable() { + ActionTypeInterface bad = + new ActionTypeInterface() { + @Override + public String key() { + return "FAKE.ACTION.NOT_IN_REGISTRY"; + } + + @Override + public String getDisplayNameKo() { + return ""; + } + + @Override + public String getDisplayNameEn() { + return ""; + } + + @Override + public String getDisplayNameJa() { + return ""; + } + + @Override + public ActionGroup getActionGroup() { + return null; + } + }; + assertThat(registry.toCanonical(bad)).isEmpty(); + assertThatThrownBy(() -> registry.requireActionGroup(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("FAKE.ACTION.NOT_IN_REGISTRY"); + } + @Test @DisplayName("no duplicate keys in catalog (sanity for registry build)") void noDuplicateKeys() { diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSourceAssemblerTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSourceAssemblerTest.java index add6c7d5..9f0cce45 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSourceAssemblerTest.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/CategorySemanticProfileSeedSourceAssemblerTest.java @@ -20,7 +20,6 @@ import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -35,6 +34,19 @@ class CategorySemanticProfileSeedSourceAssemblerTest { private static final List>> CATALOG = DeserializerEnumTestUtils.getActionTypeEnums(); + @Test + @DisplayName("Every profileCategoriesForRegistry entry gets a built profile when seeds are complete") + void registryHasProfileForEachProfileCategoryWhenSeedsComplete() { + ActionTypeRegistry actionRegistry = new ActionTypeRegistry(CATALOG); + CanonicalActionRegistry canonical = new DefaultCanonicalActionRegistry(actionRegistry); + CategorySemanticProfileSeedSource source = new DefaultCategorySemanticProfileSeedSource(); + DefaultCategorySemanticProfileRegistry registry = + new DefaultCategorySemanticProfileRegistry(canonical, null, source); + for (PromptCategory c : source.profileCategoriesForRegistry()) { + assertThat(registry.getProfile(c)).as("profile for %s", c).isPresent(); + } + } + @Test @DisplayName("Registry assembles profiles from seed source, not inline data") void registryAssemblesFromSeedSource() { @@ -68,17 +80,17 @@ void swappingSeedSourceChangesAssembledContent() { // Custom source: same structure but different role order for WRITING+GENERATE (or different fallback) CategorySemanticProfileSeedSource customSource = new CategorySemanticProfileSeedSource() { @Override - public Optional getSeed(PromptCategory category) { - if (category != PromptCategory.WRITING) { - return defaultSource.getSeed(category); + public CategorySemanticProfileSeed requireSeed(PromptCategory category) { + if (category.canonical() != PromptCategory.WRITING) { + return defaultSource.requireSeed(category); } - CategorySemanticProfileSeed original = defaultSource.getSeed(category).orElseThrow(); + CategorySemanticProfileSeed original = defaultSource.requireSeed(category); Map> roles = new java.util.HashMap<>(original.rolesByIntent()); roles.put(ActionIntent.GENERATE, List.of(WritingRoleType.TECHNICAL_WRITER, WritingRoleType.EDITOR)); - return Optional.of(new CategorySemanticProfileSeed( + return new CategorySemanticProfileSeed( original.category(), original.taskDomain(), original.allowedIntents(), original.intentFitLevels(), roles, original.actionsByIntent(), original.discouragedTonesByIntent(), original.discouragedStylesByIntent(), - ActionIntent.REWRITE, original.fallbackCandidates())); + ActionIntent.REWRITE, original.fallbackCandidates()); } }; DefaultCategorySemanticProfileRegistry registryCustom = @@ -113,11 +125,11 @@ void compatibilitySourceOverrideReflectedInProfile() { @DisplayName("Default seed source covers all canonical profile categories; EXTRACTION has no seed") void defaultSourceReturnsSeedsForProfileCategoriesOnly() { CategorySemanticProfileSeedSource source = new DefaultCategorySemanticProfileSeedSource(); - assertThat(source.getSeed(PromptCategory.EXTRACTION)).isEmpty(); assertThatThrownBy(() -> source.requireSeed(PromptCategory.EXTRACTION)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("EXTRACTION") - .hasMessageContaining("Missing CategorySemanticProfileSeed"); + .hasMessageContaining("Missing CategorySemanticProfileSeed") + .hasMessageContaining("no seed supplier registered"); assertThat(source.requireSeed(PromptCategory.WRITING).category()).isEqualTo(PromptCategory.WRITING); assertThat(source.requireSeed(PromptCategory.DESIGN).category()).isEqualTo(PromptCategory.DESIGN); assertThat(source.requireSeed(PromptCategory.ETC).category()).isEqualTo(PromptCategory.ETC); @@ -136,8 +148,8 @@ public Set profileCategoriesForRegistry() { } @Override - public Optional getSeed(PromptCategory category) { - return fullSource.getSeed(category); + public CategorySemanticProfileSeed requireSeed(PromptCategory category) { + return fullSource.requireSeed(category); } }; DefaultCategorySemanticProfileRegistry registry = diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxesFindActionGroupTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxesFindActionGroupTest.java new file mode 100644 index 00000000..3eafcd5f --- /dev/null +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/ConfirmedSemanticAxesFindActionGroupTest.java @@ -0,0 +1,69 @@ +package org.example.sharedprompts.domain.prompt.domain.semantic; + +import org.example.sharedprompts.domain.prompt.common.enums.DeserializerEnumTestUtils; +import org.example.sharedprompts.domain.prompt.common.enums.action.registry.ActionTypeRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.ActionGroup; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.CanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.DefaultCanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.category.writing.WritingActionType; +import org.example.sharedprompts.domain.prompt.common.enums.engine.LanguageType; +import org.example.sharedprompts.domain.prompt.common.enums.experience.ExperienceLevel; +import org.example.sharedprompts.domain.prompt.common.enums.output.OutputNeeds; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptCategory; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.TaskDomain; +import org.example.sharedprompts.domain.prompt.common.enums.style.StyleType; +import org.example.sharedprompts.domain.prompt.common.enums.style.ToneType; +import org.example.sharedprompts.domain.prompt.domain.value.objective.PromptObjective; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Permissive path: axes may omit action type; {@link ConfirmedSemanticAxes#findActionGroup} stays empty without failing. + */ +@DisplayName("ConfirmedSemanticAxes findActionGroup (permissive)") +class ConfirmedSemanticAxesFindActionGroupTest { + + private final CanonicalActionRegistry canonical = + new DefaultCanonicalActionRegistry(new ActionTypeRegistry(DeserializerEnumTestUtils.getActionTypeEnums())); + + @Test + @DisplayName("findActionGroup is empty when action type is absent (intent-only axes)") + void findActionGroupEmptyWhenActionTypeAbsent() { + ConfirmedSemanticAxes axes = + ConfirmedSemanticAxes.builder() + .category(PromptCategory.WRITING) + .taskDomain(TaskDomain.CREATIVE) + .intent(ActionIntent.GENERATE) + .objective(PromptObjective.FACTUAL) + .outputNeeds(OutputNeeds.FREE_FORM) + .tone(ToneType.NEUTRAL) + .style(StyleType.NARRATIVE) + .language(LanguageType.KOREAN) + .experienceLevel(ExperienceLevel.INTERMEDIATE) + .build(); + assertThat(axes.actionType()).isEmpty(); + assertThat(axes.findActionGroup(canonical)).isEmpty(); + } + + @Test + @DisplayName("findActionGroup resolves when action type is present") + void findActionGroupPresentWhenActionTypeSet() { + ConfirmedSemanticAxes axes = + ConfirmedSemanticAxes.builder() + .category(PromptCategory.WRITING) + .taskDomain(TaskDomain.CREATIVE) + .intent(ActionIntent.GENERATE) + .objective(PromptObjective.FACTUAL) + .outputNeeds(OutputNeeds.FREE_FORM) + .actionType(WritingActionType.ARTICLE_WRITING) + .tone(ToneType.NEUTRAL) + .style(StyleType.NARRATIVE) + .language(LanguageType.KOREAN) + .experienceLevel(ExperienceLevel.INTERMEDIATE) + .build(); + assertThat(axes.findActionGroup(canonical)).contains(ActionGroup.LONG_FORM_WRITING); + } +} diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSourceTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSourceTest.java new file mode 100644 index 00000000..ed1ab87a --- /dev/null +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDefinitionDataSourceTest.java @@ -0,0 +1,89 @@ +package org.example.sharedprompts.domain.prompt.domain.semantic; + +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IntentDefinitionDataSourceTest { + + @Test + void productionDataSource_coversEveryActionIntent_forDefinitionsAndDefaults() { + IntentDefinitionDataSource ds = + IntentDefinitionProviderAssembly.productionIntentDefinitionDataSource(); + var defs = ds.collectDefinitionsEntries(); + var defaults = ds.collectResolutionDefaultEntries(); + + assertThat(defs).hasSize(ActionIntent.values().length); + assertThat(defaults).hasSize(ActionIntent.values().length); + + assertThat(defs.stream().map(IntentDefinitionDataSource.IntentDefinitionEntry::intent).toList()) + .containsExactlyInAnyOrder(ActionIntent.values()); + assertThat(defaults.stream().map(IntentDefinitionDataSource.IntentResolutionDefaultEntry::intent).toList()) + .containsExactlyInAnyOrder(ActionIntent.values()); + } + + @Test + void reducedDataSource_canBeBuiltWithSubsetOfProviders() { + IntentDefinitionDataSource ds = new IntentDefinitionDataSource( + List.of(new IntentCreationIntentDefinitionsProvider()), + List.of(new IntentCreationIntentResolutionDefaultsProvider())); + + assertThat(ds.collectDefinitionsEntries()).isNotEmpty(); + assertThat(ds.collectResolutionDefaultEntries()).isNotEmpty(); + } + + @Test + void duplicateIntentAcrossProviders_failsFastOnMerge() { + IntentDefinitionDataSource ds = new IntentDefinitionDataSource( + List.of( + new IntentCreationIntentDefinitionsProvider(), + new IntentCreationIntentDefinitionsProvider()), + List.of()); + + assertThatThrownBy(ds::collectDefinitionsEntries) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate IntentDefinition"); + } + + @Test + void duplicateResolutionDefaultsAcrossProviders_failsFastOnMerge() { + IntentDefinitionDataSource ds = new IntentDefinitionDataSource( + List.of(), + List.of( + new IntentCreationIntentResolutionDefaultsProvider(), + new IntentCreationIntentResolutionDefaultsProvider())); + + assertThatThrownBy(ds::collectResolutionDefaultEntries) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Duplicate IntentResolutionDefaults"); + } + + @Test + void incompleteMaps_failIntentDictionaryCompletenessCheck() { + IntentDefinitionDataSource ds = new IntentDefinitionDataSource( + List.of(new IntentCreationIntentDefinitionsProvider()), + List.of(new IntentCreationIntentResolutionDefaultsProvider())); + + Map defs = new HashMap<>(); + for (IntentDefinitionDataSource.IntentDefinitionEntry e : ds.collectDefinitionsEntries()) { + defs.put(e.intent(), e.definition()); + } + Map res = new HashMap<>(); + for (IntentDefinitionDataSource.IntentResolutionDefaultEntry e : ds.collectResolutionDefaultEntries()) { + res.put(e.intent(), e.defaults()); + } + + assertThat(defs.keySet()).isNotEqualTo(EnumSet.allOf(ActionIntent.class)); + + assertThatThrownBy(() -> IntentDictionary.validateIntentCoverageCompleteness(defs, res)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("IntentDictionary is incomplete"); + } +} diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionaryTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionaryTest.java new file mode 100644 index 00000000..7a6fb073 --- /dev/null +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/IntentDictionaryTest.java @@ -0,0 +1,52 @@ +package org.example.sharedprompts.domain.prompt.domain.semantic; + +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IntentDictionaryTest { + + @Test + void productionAssembly_buildsFullDictionary_withStableLookups() { + IntentDictionary dict = IntentDefinitionProviderAssembly.productionIntentDictionary(); + + assertThat(dict.get(ActionIntent.GENERATE)).isPresent(); + assertThat(dict.getResolutionDefaults(ActionIntent.GENERATE).defaultObjective()).isNotNull(); + } + + @Test + void reducedDataSource_canBeWrapped_whenComplete() { + IntentDefinitionDataSource ds = new IntentDefinitionDataSource( + IntentDefinitionProviderAssembly.productionDefinitionsProviders(), + IntentDefinitionProviderAssembly.productionResolutionDefaultsProviders()); + IntentDictionary dict = new IntentDictionary(ds); + + assertThat(dict.getOrThrow(ActionIntent.PLAN)).isNotNull(); + } + + @Test + void incompleteDataSource_failsFastOnConstruction() { + IntentDefinitionDataSource incomplete = new IntentDefinitionDataSource( + List.of(new IntentCreationIntentDefinitionsProvider()), + List.of(new IntentCreationIntentResolutionDefaultsProvider())); + + assertThatThrownBy(() -> new IntentDictionary(incomplete)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("IntentDictionary is incomplete"); + } + + @Test + void distinctInstances_doNotShareMutableState() { + IntentDictionary a = IntentDefinitionProviderAssembly.productionIntentDictionary(); + IntentDictionary b = IntentDefinitionProviderAssembly.productionIntentDictionary(); + + assertThat(a).isNotSameAs(b); + assertThat(a.getResolutionDefaults(ActionIntent.GENERATE).defaultObjective()) + .isEqualTo(b.getResolutionDefaults(ActionIntent.GENERATE).defaultObjective()); + } +} + \ No newline at end of file diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistryFailFastTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistryFailFastTest.java new file mode 100644 index 00000000..8e3b7fdb --- /dev/null +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/impl/DefaultCategorySemanticProfileRegistryFailFastTest.java @@ -0,0 +1,85 @@ +package org.example.sharedprompts.domain.prompt.domain.semantic.impl; + +import org.example.sharedprompts.domain.prompt.common.enums.action.ActionTypeInterface; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.CanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.DefaultCanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.registry.ActionTypeRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptCategory; +import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeed; +import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeedSource; +import org.example.sharedprompts.domain.prompt.common.enums.DeserializerEnumTestUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Fail-fast for {@link DefaultCategorySemanticProfileRegistry} intent → action group mapping + * (no silent drop when an intent key exists but has no resolvable capability groups). + */ +@DisplayName("DefaultCategorySemanticProfileRegistry toActionGroupMap fail-fast") +class DefaultCategorySemanticProfileRegistryFailFastTest { + + private static final List>> CATALOG = DeserializerEnumTestUtils.getActionTypeEnums(); + + @Test + @DisplayName("throws when intent has empty action list (category + intent in message)") + void throwsWhenIntentHasEmptyActionList() { + CanonicalActionRegistry canonical = + new DefaultCanonicalActionRegistry(new ActionTypeRegistry(CATALOG)); + CategorySemanticProfileSeed base = + new DefaultCategorySemanticProfileSeedSource().requireSeed(PromptCategory.WRITING); + Map> actions = new HashMap<>(base.actionsByIntent()); + actions.put(ActionIntent.GENERATE, List.of()); + CategorySemanticProfileSeed badSeed = + new CategorySemanticProfileSeed( + base.category(), + base.taskDomain(), + base.allowedIntents(), + base.intentFitLevels(), + base.rolesByIntent(), + actions, + base.discouragedTonesByIntent(), + base.discouragedStylesByIntent(), + base.fallbackIntent(), + base.fallbackCandidates()); + + CategorySemanticProfileSeedSource source = + new CategorySemanticProfileSeedSource() { + @Override + public Set profileCategoriesForRegistry() { + return Set.of(PromptCategory.WRITING); + } + + @Override + public CategorySemanticProfileSeed requireSeed(PromptCategory category) { + return badSeed; + } + }; + + assertThatThrownBy(() -> new DefaultCategorySemanticProfileRegistry(canonical, null, source)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Category semantic profile seed contains empty action list") + .hasMessageContaining("WRITING") + .hasMessageContaining("GENERATE"); + } + + @Test + @DisplayName("throws when resolved ActionGroup list is empty (category + intent in message)") + void throwsWhenResolvedActionGroupListEmpty() { + assertThatThrownBy( + () -> + DefaultCategorySemanticProfileRegistry.assertIntentHasResolvedActionGroups( + PromptCategory.WRITING, ActionIntent.GENERATE, List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("No ActionGroup resolved") + .hasMessageContaining("WRITING") + .hasMessageContaining("GENERATE"); + } +} diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/policy/SemanticStructurePhase5Test.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/policy/SemanticStructurePhase5Test.java index 77c19dae..dfe400b5 100644 --- a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/policy/SemanticStructurePhase5Test.java +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/semantic/policy/SemanticStructurePhase5Test.java @@ -14,8 +14,12 @@ import org.example.sharedprompts.domain.prompt.domain.resolutions.ObjectiveResolverPort; import org.example.sharedprompts.domain.prompt.domain.resolutions.DefaultObjectiveHeuristicInferencePolicy; import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfile; +import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeed; +import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeedDefinition; import org.example.sharedprompts.domain.prompt.domain.semantic.CategorySemanticProfileSeedSource; import org.example.sharedprompts.domain.prompt.domain.semantic.RecommendationResult; +import org.example.sharedprompts.domain.prompt.domain.semantic.seed.CategorySemanticProfileSeedDefinitions; +import org.example.sharedprompts.domain.prompt.domain.semantic.seed.WritingSemanticProfileSeed; import org.example.sharedprompts.domain.prompt.domain.semantic.impl.DefaultCategorySemanticProfileRegistry; import org.example.sharedprompts.domain.prompt.domain.semantic.impl.DefaultCategorySemanticProfileSeedSource; import org.example.sharedprompts.domain.prompt.domain.semantic.policy.compatibility.CompatibilityPolicySource; @@ -32,6 +36,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -176,6 +182,105 @@ void objectivePolicySourceStableKeyOnly() { assertThat(source.getDomainDefault(TaskDomain.ANALYTICAL)).isEqualTo(PromptObjective.ANALYTICAL); } + @Test + @DisplayName("Profile registry fail-fast when compatibility policy lists an invalid ActionGroup key") + void profileRegistryFailFastWhenCompatibilityActionGroupKeyInvalid() { + ActionTypeRegistry registry = new ActionTypeRegistry(CATALOG); + CanonicalActionRegistry canonical = new DefaultCanonicalActionRegistry(registry); + CompatibilityPolicySource bad = new DefaultCompatibilityPolicySource( + Map.of("WRITING+GENERATE", List.of("NOT_A_REAL_ACTION_GROUP_ENUM"))); + assertThatThrownBy( + () -> new DefaultCategorySemanticProfileRegistry( + canonical, bad, new DefaultCategorySemanticProfileSeedSource())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("NOT_A_REAL_ACTION_GROUP_ENUM") + .hasMessageContaining("WRITING") + .hasMessageContaining("GENERATE") + .hasCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Profile registry fail-fast when seed lists an action that does not resolve to ActionGroup") + void profileRegistryFailFastWhenSeedActionDoesNotResolveToActionGroup() { + ActionTypeRegistry actionRegistry = new ActionTypeRegistry(CATALOG); + CanonicalActionRegistry canonical = new DefaultCanonicalActionRegistry(actionRegistry); + ActionTypeInterface unresolvable = + new ActionTypeInterface() { + @Override + public String key() { + return "FAKE.SEED.ACTION.UNRESOLVABLE"; + } + + @Override + public String getDisplayNameKo() { + return ""; + } + + @Override + public String getDisplayNameEn() { + return ""; + } + + @Override + public String getDisplayNameJa() { + return ""; + } + + @Override + public ActionGroup getActionGroup() { + return null; + } + }; + CategorySemanticProfileSeed writing = WritingSemanticProfileSeed.DEFINITION.seedSupplier().get(); + Map> actions = new HashMap<>(writing.actionsByIntent()); + actions.put(ActionIntent.GENERATE, List.of(unresolvable)); + CategorySemanticProfileSeed poisoned = + new CategorySemanticProfileSeed( + writing.category(), + writing.taskDomain(), + writing.allowedIntents(), + writing.intentFitLevels(), + writing.rolesByIntent(), + actions, + writing.discouragedTonesByIntent(), + writing.discouragedStylesByIntent(), + writing.fallbackIntent(), + writing.fallbackCandidates()); + List defs = + new ArrayList<>(CategorySemanticProfileSeedDefinitions.defaultDefinitions()); + for (int i = 0; i < defs.size(); i++) { + if (defs.get(i).category() == PromptCategory.WRITING) { + defs.set( + i, + new CategorySemanticProfileSeedDefinition(PromptCategory.WRITING, () -> poisoned)); + break; + } + } + CategorySemanticProfileSeedSource badSource = new DefaultCategorySemanticProfileSeedSource(defs); + assertThatThrownBy(() -> new DefaultCategorySemanticProfileRegistry(canonical, null, badSource)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Category semantic profile seed assembly") + .hasMessageContaining("WRITING") + .hasMessageContaining("GENERATE") + .hasMessageContaining("FAKE.SEED.ACTION.UNRESOLVABLE"); + } + + @Test + @DisplayName("Profile registry fail-fast when compatibility policy lists a blank ActionGroup key") + void profileRegistryFailFastWhenCompatibilityActionGroupKeyBlank() { + ActionTypeRegistry registry = new ActionTypeRegistry(CATALOG); + CanonicalActionRegistry canonical = new DefaultCanonicalActionRegistry(registry); + CompatibilityPolicySource bad = new DefaultCompatibilityPolicySource( + Map.of("WRITING+GENERATE", List.of("LONG_FORM_WRITING", " "))); + assertThatThrownBy( + () -> new DefaultCategorySemanticProfileRegistry( + canonical, bad, new DefaultCategorySemanticProfileSeedSource())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Blank or null ActionGroup key") + .hasMessageContaining("WRITING") + .hasMessageContaining("GENERATE"); + } + @Test @DisplayName("Profile registry fail-fast when seed source omits a profile category") void profileRegistryFailFastWhenSeedIncomplete() { @@ -183,8 +288,12 @@ void profileRegistryFailFastWhenSeedIncomplete() { CanonicalActionRegistry canonical = new DefaultCanonicalActionRegistry(registry); DefaultCategorySemanticProfileSeedSource fullSource = new DefaultCategorySemanticProfileSeedSource(); CategorySemanticProfileSeedSource partialSource = category -> { - if (category == PromptCategory.WRITING) return java.util.Optional.empty(); - return fullSource.getSeed(category); + if (category.canonical() == PromptCategory.WRITING) { + throw new IllegalStateException( + "Missing CategorySemanticProfileSeed for category: " + category.canonical().name() + + "; no seed supplier registered"); + } + return fullSource.requireSeed(category); }; DefaultCategorySemanticProfileRegistry registryFull = new DefaultCategorySemanticProfileRegistry(canonical, null, fullSource); @@ -193,7 +302,8 @@ void profileRegistryFailFastWhenSeedIncomplete() { assertThatThrownBy(() -> new DefaultCategorySemanticProfileRegistry(canonical, null, partialSource)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("WRITING") - .hasMessageContaining("Missing CategorySemanticProfileSeed"); + .hasMessageContaining("Missing CategorySemanticProfileSeed") + .hasMessageContaining("no seed supplier registered"); } @Test diff --git a/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactoryTest.java b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactoryTest.java new file mode 100644 index 00000000..dcae3e18 --- /dev/null +++ b/sharedPrompts/src/test/java/org/example/sharedprompts/domain/prompt/domain/service/spec/PromptSpecFactoryTest.java @@ -0,0 +1,172 @@ +package org.example.sharedprompts.domain.prompt.domain.service.spec; + +import org.example.sharedprompts.domain.prompt.common.enums.DeserializerEnumTestUtils; +import org.example.sharedprompts.domain.prompt.common.enums.action.ActionTypeInterface; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.CanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.canonical.DefaultCanonicalActionRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.action.category.writing.WritingActionType; +import org.example.sharedprompts.domain.prompt.common.enums.action.registry.ActionTypeRegistry; +import org.example.sharedprompts.domain.prompt.common.enums.engine.LanguageType; +import org.example.sharedprompts.domain.prompt.common.enums.experience.ExperienceLevel; +import org.example.sharedprompts.domain.prompt.common.enums.output.OutputNeeds; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.ActionIntent; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.PromptCategory; +import org.example.sharedprompts.domain.prompt.common.enums.semantic.TaskDomain; +import org.example.sharedprompts.domain.prompt.common.enums.style.StyleType; +import org.example.sharedprompts.domain.prompt.common.enums.style.ToneType; +import org.example.sharedprompts.domain.prompt.common.guideline.bundle.GuidelineBundleBuilder; +import org.example.sharedprompts.domain.prompt.domain.objective.ObjectiveRegistry; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.AnalyticalObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.CreativeObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.ExtractionObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.FactualObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.PlanningObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.profiles.ReasoningObjectiveProfile; +import org.example.sharedprompts.domain.prompt.domain.objective.registry.DefaultObjectiveRegistry; +import org.example.sharedprompts.domain.prompt.domain.policy.strategy.StrategyBundlePolicy; +import org.example.sharedprompts.domain.prompt.domain.resolutions.ObjectiveResolverPort; +import org.example.sharedprompts.domain.prompt.domain.semantic.ConfirmedSemanticAxes; +import org.example.sharedprompts.domain.prompt.domain.value.objective.PromptObjective; +import org.example.sharedprompts.domain.prompt.domain.model.spec.PromptSpec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * ActionGroup resolution for {@link PromptSpecFactory#createFromConfirmedAxes} uses + * {@link CanonicalActionRegistry} only (no parallel {@link ActionTypeInterface#getActionGroup()} fallback). + */ +@DisplayName("PromptSpecFactory ActionGroup (registry-only)") +class PromptSpecFactoryTest { + + private ObjectiveRegistry objectiveRegistry; + private StrategyBundlePolicy strategyBundlePolicy; + private ObjectiveResolverPort objectiveResolver; + private GuidelineBundleBuilder guidelineBundleBuilder; + private CanonicalActionRegistry productionRegistry; + + @BeforeEach + void setUp() { + objectiveRegistry = + new DefaultObjectiveRegistry( + List.of( + new FactualObjectiveProfile(), + new ReasoningObjectiveProfile(), + new ExtractionObjectiveProfile(), + new PlanningObjectiveProfile(), + new CreativeObjectiveProfile(), + new AnalyticalObjectiveProfile())); + strategyBundlePolicy = new StrategyBundlePolicy(objectiveRegistry); + objectiveResolver = mock(ObjectiveResolverPort.class); + when(objectiveResolver.resolve(any(), any())).thenReturn(PromptObjective.FACTUAL); + guidelineBundleBuilder = new GuidelineBundleBuilder(); + productionRegistry = + new DefaultCanonicalActionRegistry( + new ActionTypeRegistry(DeserializerEnumTestUtils.getActionTypeEnums())); + } + + @Test + @DisplayName("createFromConfirmedAxes succeeds when action type resolves via CanonicalActionRegistry") + void succeedsWhenActionResolvesViaRegistry() { + PromptSpecFactory factory = + new PromptSpecFactory( + objectiveRegistry, + strategyBundlePolicy, + objectiveResolver, + guidelineBundleBuilder, + null, + productionRegistry); + + ConfirmedSemanticAxes axes = + ConfirmedSemanticAxes.builder() + .category(PromptCategory.WRITING) + .taskDomain(TaskDomain.CREATIVE) + .intent(ActionIntent.GENERATE) + .objective(PromptObjective.FACTUAL) + .outputNeeds(OutputNeeds.FREE_FORM) + .actionType(WritingActionType.ARTICLE_WRITING) + .tone(ToneType.NEUTRAL) + .style(StyleType.NARRATIVE) + .language(LanguageType.KOREAN) + .experienceLevel(ExperienceLevel.INTERMEDIATE) + .build(); + + PromptSpec spec = factory.createFromConfirmedAxes(axes, "hello", null); + assertThat(spec).isNotNull(); + assertThat(spec.getObjective()).isEqualTo(PromptObjective.FACTUAL); + } + + @Test + @DisplayName("createFromConfirmedAxes succeeds when action type is absent (no capability group required)") + void succeedsWhenActionTypeAbsent() { + PromptSpecFactory factory = + new PromptSpecFactory( + objectiveRegistry, + strategyBundlePolicy, + objectiveResolver, + guidelineBundleBuilder, + null, + productionRegistry); + + ConfirmedSemanticAxes axes = + ConfirmedSemanticAxes.builder() + .category(PromptCategory.WRITING) + .taskDomain(TaskDomain.CREATIVE) + .intent(ActionIntent.GENERATE) + .objective(PromptObjective.FACTUAL) + .outputNeeds(OutputNeeds.FREE_FORM) + .tone(ToneType.NEUTRAL) + .style(StyleType.NARRATIVE) + .language(LanguageType.KOREAN) + .experienceLevel(ExperienceLevel.INTERMEDIATE) + .build(); + + PromptSpec spec = factory.createFromConfirmedAxes(axes, "hello", null); + assertThat(spec).isNotNull(); + assertThat(spec.getActionType()).isNull(); + } + + @Test + @DisplayName("createFromConfirmedAxes fails when action is present but registry does not resolve ActionGroup") + void failsWhenRegistryDoesNotResolveActionGroup() { + CanonicalActionRegistry emptyResolution = mock(CanonicalActionRegistry.class); + when(emptyResolution.toCanonical(any(ActionTypeInterface.class))).thenReturn(Optional.empty()); + + PromptSpecFactory factory = + new PromptSpecFactory( + objectiveRegistry, + strategyBundlePolicy, + objectiveResolver, + guidelineBundleBuilder, + null, + emptyResolution); + + ConfirmedSemanticAxes axes = + ConfirmedSemanticAxes.builder() + .category(PromptCategory.WRITING) + .taskDomain(TaskDomain.CREATIVE) + .intent(ActionIntent.GENERATE) + .objective(PromptObjective.FACTUAL) + .outputNeeds(OutputNeeds.FREE_FORM) + .actionType(WritingActionType.ARTICLE_WRITING) + .tone(ToneType.NEUTRAL) + .style(StyleType.NARRATIVE) + .language(LanguageType.KOREAN) + .experienceLevel(ExperienceLevel.INTERMEDIATE) + .build(); + + assertThatThrownBy(() -> factory.createFromConfirmedAxes(axes, "hello", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CanonicalActionRegistry did not resolve ActionGroup") + .hasMessageContaining(WritingActionType.ARTICLE_WRITING.key()); + } +}