diff --git a/backend/app/Http/Controllers/API/V0_5/Cms/PersonalityController.php b/backend/app/Http/Controllers/API/V0_5/Cms/PersonalityController.php index 5d9dfe7ab..0561d81cf 100644 --- a/backend/app/Http/Controllers/API/V0_5/Cms/PersonalityController.php +++ b/backend/app/Http/Controllers/API/V0_5/Cms/PersonalityController.php @@ -26,6 +26,18 @@ class PersonalityController extends Controller { + private const MBTI64_V85_SECTION_PREFIX = 'v8_5_'; + + private const MBTI64_V85_DUPLICATE_LEGACY_SECTION_KEYS = [ + 'meaning', + 'a_t_difference', + 'core_traits', + 'careers_work_style', + 'relationships_communication', + 'strengths_blind_spots', + 'similar_types', + ]; + use RespondsWithNotFound; public function __construct( @@ -1608,15 +1620,27 @@ private function publicSectionPayloads(PersonalityProfile $profile, ?Personality 'section_key' => $sectionKey, 'title' => data_get($payload, 'title') ?? $baseSection['title'] ?? ($definition['title'] ?? null), 'render_variant' => (string) ($section->render_variant ?: ($baseSection['render_variant'] ?? 'rich_text')), - 'body_md' => $section->body_md ?? $baseSection['body_md'] ?? null, + 'body_md' => $this->sanitizeV85PublicBody( + $sectionKey, + $section->body_md ?? $baseSection['body_md'] ?? null + ), 'body_html' => $section->body_html ?? $baseSection['body_html'] ?? null, - 'payload_json' => $payload, + 'payload_json' => $this->sanitizeV85PublicPayload( + $sectionKey, + is_array($payload) ? $payload : null + ), 'sort_order' => (int) ($section->sort_order ?? $baseSection['sort_order'] ?? 0), 'is_enabled' => true, ]); } } + if ($this->hasV85FirstClassSections($sections->keys()->all())) { + foreach (self::MBTI64_V85_DUPLICATE_LEGACY_SECTION_KEYS as $sectionKey) { + $sections->forget($sectionKey); + } + } + return $sections ->sortBy([ ['sort_order', 'asc'], @@ -1626,6 +1650,46 @@ private function publicSectionPayloads(PersonalityProfile $profile, ?Personality ->all(); } + /** + * @param list $sectionKeys + */ + private function hasV85FirstClassSections(array $sectionKeys): bool + { + foreach ($sectionKeys as $sectionKey) { + if (str_starts_with((string) $sectionKey, self::MBTI64_V85_SECTION_PREFIX)) { + return true; + } + } + + return false; + } + + private function sanitizeV85PublicBody(string $sectionKey, mixed $body): mixed + { + if (! str_starts_with($sectionKey, 'v8_5_module_') || ! is_string($body)) { + return $body; + } + + return preg_replace('/\n{2,}Evidence boundary:\n(?:[-*] .+(?:\n|$))+$/u', '', $body) ?? $body; + } + + /** + * @param array|null $payload + * @return array|null + */ + private function sanitizeV85PublicPayload(string $sectionKey, ?array $payload): ?array + { + if (! str_starts_with($sectionKey, 'v8_5_module_') || $payload === null) { + return $payload; + } + + if (array_key_exists('body', $payload)) { + $payload['body'] = $this->sanitizeV85PublicBody($sectionKey, $payload['body']); + } + + return $payload; + } + /** * @return array */ diff --git a/backend/app/Services/Cms/Mbti64CmsRevisionPromotionService.php b/backend/app/Services/Cms/Mbti64CmsRevisionPromotionService.php index 0ffee2380..4e6710238 100644 --- a/backend/app/Services/Cms/Mbti64CmsRevisionPromotionService.php +++ b/backend/app/Services/Cms/Mbti64CmsRevisionPromotionService.php @@ -1284,11 +1284,6 @@ private function v85V5ModuleBody(array $module): ?string $parts[] = implode("\n\n", $paragraphs); } - $evidence = $this->stringList($module['evidence'] ?? null); - if ($evidence !== []) { - $parts[] = "Evidence boundary:\n".$this->markdownList($evidence); - } - $body = trim(implode("\n\n", array_filter($parts, static fn (string $part): bool => trim($part) !== ''))); return $body !== '' ? $body : null; diff --git a/backend/tests/Feature/Console/PersonalityMbti64CmsRevisionPromoteCommandTest.php b/backend/tests/Feature/Console/PersonalityMbti64CmsRevisionPromoteCommandTest.php index af94242a9..8d34583f2 100644 --- a/backend/tests/Feature/Console/PersonalityMbti64CmsRevisionPromoteCommandTest.php +++ b/backend/tests/Feature/Console/PersonalityMbti64CmsRevisionPromoteCommandTest.php @@ -813,6 +813,9 @@ public function test_write_promotes_only_fixed_v8_5_v5_bilingual_sixty_four_subs $this->assertSame('mbti64_v8_5_v5_first_class_render_section', $moduleSection->payload_json['source'] ?? null); $this->assertSame('core-reading', $moduleSection->payload_json['id'] ?? null); $this->assertStringContainsString('INTJ-A', (string) $moduleSection->body_md); + $this->assertStringNotContainsString('Evidence boundary:', (string) $moduleSection->body_md); + $this->assertArrayHasKey('evidence', $moduleSection->payload_json); + $this->assertArrayHasKey('raw', $moduleSection->payload_json); $workDecisionSection = PersonalityProfileVariantSection::query() ->where('personality_profile_variant_id', (int) $targets['zh-CN|INTJ-A']->id) @@ -821,6 +824,50 @@ public function test_write_promotes_only_fixed_v8_5_v5_bilingual_sixty_four_subs $this->assertSame('cards', $workDecisionSection->render_variant); $this->assertSame('mbti64_v8_5_v5_first_class_render_section', $workDecisionSection->payload_json['source'] ?? null); $this->assertArrayHasKey('card', $workDecisionSection->payload_json); + + $detail = $this->getJson('/api/v0.5/personality/intj-a?locale=zh-CN') + ->assertOk() + ->json(); + $sectionKeys = array_values(array_filter(array_map( + static fn (array $section): string => (string) ($section['section_key'] ?? ''), + (array) ($detail['sections'] ?? []) + ))); + $this->assertContains('v8_5_module_01_core_reading', $sectionKeys); + foreach ($this->expectedV85V5FirstClassSectionKeys() as $legacySectionKey) { + $this->assertNotContains($legacySectionKey, $sectionKeys, $legacySectionKey); + } + $apiModule = collect((array) ($detail['sections'] ?? [])) + ->firstWhere('section_key', 'v8_5_module_01_core_reading'); + $this->assertIsArray($apiModule); + $this->assertStringNotContainsString('Evidence boundary:', (string) ($apiModule['body_md'] ?? '')); + $this->assertStringNotContainsString('Evidence boundary:', (string) data_get($apiModule, 'payload_json.body', '')); + $this->assertArrayHasKey('evidence', (array) data_get($apiModule, 'payload_json', [])); + $this->assertArrayHasKey('raw', (array) data_get($apiModule, 'payload_json', [])); + } + + public function test_public_detail_keeps_legacy_sections_when_v8_5_sections_are_absent(): void + { + $targets = $this->seedProjectionTargets(); + PersonalityProfileVariantSection::query()->create([ + 'personality_profile_variant_id' => (int) $targets['zh-CN|INTJ-A']->id, + 'section_key' => 'meaning', + 'render_variant' => 'rich_text', + 'body_md' => 'Legacy meaning remains visible without V8.5 sections.', + 'body_html' => null, + 'payload_json' => ['title' => 'Legacy meaning'], + 'sort_order' => 100, + 'is_enabled' => true, + ]); + + $detail = $this->getJson('/api/v0.5/personality/intj-a?locale=zh-CN') + ->assertOk() + ->json(); + $sectionKeys = array_values(array_filter(array_map( + static fn (array $section): string => (string) ($section['section_key'] ?? ''), + (array) ($detail['sections'] ?? []) + ))); + + $this->assertContains('meaning', $sectionKeys); } public function test_visible_query_backed_three_subset_is_idempotent_after_write(): void