From 133ea11ba74eda84a38155a672a4acb0a301b891 Mon Sep 17 00:00:00 2001 From: fermatmind <17551046983@163.com> Date: Wed, 1 Jul 2026 11:22:51 +0800 Subject: [PATCH] Add MBTI64 V8.5 search freshness gate --- ...ityAgentPostPromotionSearchGateCommand.php | 150 +++++++++++++++++- ...gentPostPromotionSearchGateCommandTest.php | 102 ++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/backend/app/Console/Commands/PersonalityAgentPostPromotionSearchGateCommand.php b/backend/app/Console/Commands/PersonalityAgentPostPromotionSearchGateCommand.php index 1b268e961..885b86feb 100644 --- a/backend/app/Console/Commands/PersonalityAgentPostPromotionSearchGateCommand.php +++ b/backend/app/Console/Commands/PersonalityAgentPostPromotionSearchGateCommand.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; +use App\Models\PersonalityProfile; use App\Services\SeoIntel\SearchChannelQueue\SearchChannelQueuePlanner; use Illuminate\Console\Command; use Illuminate\Support\Carbon; @@ -16,6 +17,14 @@ final class PersonalityAgentPostPromotionSearchGateCommand extends Command { private const SCHEMA_VERSION = 'personality-agent-post-promotion-search-gate.v1'; + private const V8_5_V5_BILINGUAL_64_ARTIFACT = 'MBTI64-ZH32-EN32-V8_5-V5-BILINGUAL-PACKAGE-QA-01'; + + private const V8_5_V5_BILINGUAL_64_PACKAGE_VERSION = 'mbti64_zh32_en32_v8_5_v5_bilingual_v1'; + + private const V8_5_V5_BILINGUAL_64_PACKAGE_FILE_SHA256 = 'a0fd058b82ec40940b8c92546c461086d3bfca7a4b0521aeb92e5cc8b0517b67'; + + private const V8_5_V5_BILINGUAL_64_EMBEDDED_PACKAGE_SHA256 = '13ec2c55caf2cf7b48650739fabadfb09ec4a02214cb1af99d5e47d8af2499d8'; + private const SURFACE_PATHS = [ 'sitemap' => '/sitemap.xml', 'llms' => '/llms.txt', @@ -48,6 +57,7 @@ final class PersonalityAgentPostPromotionSearchGateCommand extends Command {--channel=indexnow : Search Channel to dry-run; v1 supports indexnow} {--urls= : Comma-separated canonical URLs/paths or path to JSON list} {--package= : Optional agent/promotion package JSON containing target URLs} + {--v8-5-v5-bilingual-64 : Require the fixed 64 MBTI64 V8.5/V5 bilingual package and URL set} {--base-url= : Public site base URL; defaults to seo_intel.public_canonical_host} {--timeout=10 : Public HTTP timeout seconds}'; @@ -70,6 +80,11 @@ public function handle(SearchChannelQueuePlanner $planner): int return $this->finish($this->payload('NO_GO_SURFACE_OR_SAFETY', ['target_urls_missing'])); } + $fixedSubsetIssues = $this->fixedV85V5Bilingual64Issues($targets); + if ($fixedSubsetIssues !== []) { + return $this->finish($this->payload('NO_GO_SAFETY_VIOLATION', $fixedSubsetIssues)); + } + $surfaceTexts = $this->fetchSurfaceTexts($baseUrl); $results = []; $issues = []; @@ -111,6 +126,7 @@ public function handle(SearchChannelQueuePlanner $planner): int 'generated_at' => Carbon::now('UTC')->toIso8601String(), 'channel' => $channel, 'base_url' => $baseUrl, + 'contract' => $this->contractSummary($targets), 'target_count' => count($targets), 'targets' => $results, 'counts' => [ @@ -148,7 +164,9 @@ private function targets(string $baseUrl): array if ($path !== null) { $decoded = json_decode((string) file_get_contents($path), true); if (is_array($decoded)) { - $values = array_merge($values, $this->collectTargetValues($decoded)); + $values = array_merge($values, $this->v85V5Bilingual64Requested() + ? $this->collectRecommendationTargetUrls($decoded) + : $this->collectTargetValues($decoded)); } } } @@ -165,6 +183,136 @@ private function targets(string $baseUrl): array return array_values($targets); } + private function v85V5Bilingual64Requested(): bool + { + return (bool) $this->option('v8-5-v5-bilingual-64'); + } + + /** + * @param array $payload + * @return list + */ + private function collectRecommendationTargetUrls(array $payload): array + { + $recommendations = is_array($payload['recommendations'] ?? null) + ? array_values((array) $payload['recommendations']) + : []; + + $values = []; + foreach ($recommendations as $recommendation) { + if (! is_array($recommendation)) { + continue; + } + $targetUrl = (string) ($recommendation['target_url'] ?? ''); + if ($targetUrl !== '') { + $values[] = $targetUrl; + } + } + + return $values; + } + + /** + * @param list $targets + * @return list + */ + private function fixedV85V5Bilingual64Issues(array $targets): array + { + if (! $this->v85V5Bilingual64Requested()) { + return []; + } + + $issues = []; + $packagePath = trim((string) $this->option('package')); + $path = $this->safePath($packagePath); + if ($packagePath === '' || $path === null) { + $issues[] = 'v8_5_v5_bilingual_64_package_required'; + + return $issues; + } + + if (hash_file('sha256', $path) !== self::V8_5_V5_BILINGUAL_64_PACKAGE_FILE_SHA256) { + $issues[] = 'unsupported_v8_5_v5_bilingual_64_package_file_sha256'; + } + + $decoded = json_decode((string) file_get_contents($path), true); + if (! is_array($decoded)) { + $issues[] = 'v8_5_v5_bilingual_64_package_json_invalid'; + + return $issues; + } + + if ((string) ($decoded['artifact'] ?? '') !== self::V8_5_V5_BILINGUAL_64_ARTIFACT) { + $issues[] = 'unsupported_v8_5_v5_bilingual_64_artifact'; + } + if ((string) ($decoded['package_version'] ?? '') !== self::V8_5_V5_BILINGUAL_64_PACKAGE_VERSION) { + $issues[] = 'unsupported_v8_5_v5_bilingual_64_package_version'; + } + if ((string) ($decoded['package_sha256'] ?? '') !== self::V8_5_V5_BILINGUAL_64_EMBEDDED_PACKAGE_SHA256) { + $issues[] = 'unsupported_v8_5_v5_bilingual_64_embedded_package_sha256'; + } + if ((int) ($decoded['target_count'] ?? -1) !== 64) { + $issues[] = 'v8_5_v5_bilingual_64_target_count_mismatch'; + } + + $summary = is_array($decoded['summary'] ?? null) ? $decoded['summary'] : []; + if ((int) ($summary['target_count'] ?? -1) !== 64 + || (int) ($summary['variant_pages'] ?? $summary['variant_count'] ?? -1) !== 64 + || (int) ($summary['comparison_pages'] ?? $summary['comparison_count'] ?? -1) !== 0 + || (int) ($summary['zh_pages'] ?? $summary['zh_count'] ?? -1) !== 32 + || (int) ($summary['en_pages'] ?? $summary['en_count'] ?? -1) !== 32 + || (int) ($summary['qa_blocked_count'] ?? $summary['blocked_count'] ?? -1) !== 0) { + $issues[] = 'v8_5_v5_bilingual_64_summary_mismatch'; + } + + $actualUrls = array_values(array_unique(array_column($targets, 'canonical_url'))); + sort($actualUrls); + if ($actualUrls !== $this->v85V5Bilingual64Urls()) { + $issues[] = 'v8_5_v5_bilingual_64_url_set_mismatch'; + } + + return array_values(array_unique($issues)); + } + + /** + * @return array + */ + private function contractSummary(array $targets): array + { + if (! $this->v85V5Bilingual64Requested()) { + return [ + 'mode' => 'ad_hoc_targets', + ]; + } + + return [ + 'mode' => 'v8_5_v5_bilingual_64', + 'package_file_sha256' => self::V8_5_V5_BILINGUAL_64_PACKAGE_FILE_SHA256, + 'embedded_package_sha256' => self::V8_5_V5_BILINGUAL_64_EMBEDDED_PACKAGE_SHA256, + 'artifact' => self::V8_5_V5_BILINGUAL_64_ARTIFACT, + 'package_version' => self::V8_5_V5_BILINGUAL_64_PACKAGE_VERSION, + 'target_count' => count($targets), + ]; + } + + /** + * @return list + */ + private function v85V5Bilingual64Urls(): array + { + $urls = []; + foreach (['en', 'zh'] as $prefix) { + foreach (PersonalityProfile::BASE_TYPE_CODES as $typeCode) { + foreach (['a', 't'] as $variant) { + $urls[] = 'https://fermatmind.com/'.$prefix.'/personality/'.strtolower($typeCode).'-'.$variant; + } + } + } + sort($urls); + + return $urls; + } + /** * @return list */ diff --git a/backend/tests/Feature/SeoIntel/PersonalityAgentPostPromotionSearchGateCommandTest.php b/backend/tests/Feature/SeoIntel/PersonalityAgentPostPromotionSearchGateCommandTest.php index 1c6052718..777f723d1 100644 --- a/backend/tests/Feature/SeoIntel/PersonalityAgentPostPromotionSearchGateCommandTest.php +++ b/backend/tests/Feature/SeoIntel/PersonalityAgentPostPromotionSearchGateCommandTest.php @@ -5,9 +5,11 @@ namespace Tests\Feature\SeoIntel; use App\Console\Commands\PersonalityAgentPostPromotionSearchGateCommand; +use App\Models\PersonalityProfile; use Illuminate\Contracts\Console\Kernel as ConsoleKernel; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Schema; use PHPUnit\Framework\Attributes\Test; @@ -15,6 +17,8 @@ final class PersonalityAgentPostPromotionSearchGateCommandTest extends TestCase { + private const V8_5_V5_BILINGUAL_64_PACKAGE_PATH = 'docs/seo/personality/mbti64-zh32-en32-v8-5-v5-bilingual-package-2026-07-01.json'; + protected function setUp(): void { parent::setUp(); @@ -199,6 +203,75 @@ public function command_requires_explicit_dry_run_and_never_calls_external_searc Http::assertNothingSent(); } + #[Test] + public function dry_run_supports_fixed_v8_5_v5_bilingual_sixty_four_package_without_search_mutation(): void + { + $canonicalUrls = $this->v85V5Bilingual64Urls(); + foreach ($canonicalUrls as $canonicalUrl) { + $this->seedSeoUrl($canonicalUrl, [ + 'locale' => str_contains($canonicalUrl, '/zh/') ? 'zh-CN' : 'en', + 'entity_id_or_slug' => $this->targetKeyForUrl($canonicalUrl), + ]); + } + $this->fakeHttp($canonicalUrls); + + $countsBefore = $this->rowCounts(); + $output = $this->runGate([ + '--dry-run' => true, + '--json' => true, + '--package' => self::V8_5_V5_BILINGUAL_64_PACKAGE_PATH, + '--v8-5-v5-bilingual-64' => true, + ]); + + $this->assertSame('GO_FOR_INDEXNOW_DRY_RUN', $output['final_decision'] ?? null); + $this->assertTrue((bool) ($output['ok'] ?? false)); + $this->assertSame(64, $output['target_count'] ?? null); + $this->assertSame('v8_5_v5_bilingual_64', data_get($output, 'contract.mode')); + $this->assertSame(64, data_get($output, 'contract.target_count')); + $this->assertSame(64, data_get($output, 'counts.surface_ok')); + $this->assertSame(64, data_get($output, 'counts.sitemap_llms_ok')); + $this->assertSame(64, data_get($output, 'counts.url_truth_ready')); + $this->assertSame(64, data_get($output, 'counts.planned_or_duplicate_safe')); + $this->assertSame([], $output['issues'] ?? []); + $this->assertFalse((bool) data_get($output, 'safety_flags.enqueue_attempted', true)); + $this->assertFalse((bool) data_get($output, 'safety_flags.search_submission_attempted', true)); + $this->assertFalse((bool) data_get($output, 'safety_flags.url_truth_write_attempted', true)); + $this->assertSame($countsBefore, $this->rowCounts()); + } + + #[Test] + public function fixed_v8_5_v5_bilingual_package_fails_closed_when_url_set_is_not_exact(): void + { + Http::fake(); + $package = json_decode( + (string) File::get(base_path(self::V8_5_V5_BILINGUAL_64_PACKAGE_PATH)), + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertIsArray($package); + array_pop($package['recommendations']); + $package['target_count'] = 63; + $tempPath = sys_get_temp_dir().'/mbti64-v85-v5-bilingual-63-for-search-gate.json'; + File::put($tempPath, json_encode($package, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + + $exitCode = Artisan::call('personality:agent-post-promotion-search-gate', [ + '--dry-run' => true, + '--json' => true, + '--package' => $tempPath, + '--v8-5-v5-bilingual-64' => true, + ]); + $output = json_decode(trim(Artisan::output()), true); + + $this->assertSame(1, $exitCode); + $this->assertSame('NO_GO_SAFETY_VIOLATION', $output['final_decision'] ?? null); + $this->assertContains('unsupported_v8_5_v5_bilingual_64_package_file_sha256', $output['issues'] ?? []); + $this->assertContains('v8_5_v5_bilingual_64_target_count_mismatch', $output['issues'] ?? []); + $this->assertContains('v8_5_v5_bilingual_64_url_set_mismatch', $output['issues'] ?? []); + $this->assertSame(0, DB::connection('seo_intel')->table('seo_search_channel_queue_items')->count()); + Http::assertNothingSent(); + } + /** * @param array $arguments * @return array @@ -363,4 +436,33 @@ private function rowCounts(): array 'seo_urls' => DB::connection('seo_intel')->table('seo_urls')->count(), ]; } + + /** + * @return list + */ + private function v85V5Bilingual64Urls(): array + { + $urls = []; + foreach (['en', 'zh'] as $prefix) { + foreach (PersonalityProfile::BASE_TYPE_CODES as $typeCode) { + foreach (['a', 't'] as $variant) { + $urls[] = 'https://fermatmind.com/'.$prefix.'/personality/'.strtolower($typeCode).'-'.$variant; + } + } + } + sort($urls); + + return $urls; + } + + private function targetKeyForUrl(string $url): string + { + $path = (string) (parse_url($url, PHP_URL_PATH) ?: ''); + $this->assertMatchesRegularExpression('#^/(?en|zh)/personality/(?[a-z]{4})-(?a|t)$#i', $path); + preg_match('#^/(?en|zh)/personality/(?[a-z]{4})-(?a|t)$#i', $path, $matches); + $locale = $matches['prefix'] === 'zh' ? 'zh-CN' : 'en'; + $runtimeTypeCode = strtoupper((string) $matches['type']).'-'.strtoupper((string) $matches['variant']); + + return $locale.'|'.$runtimeTypeCode; + } }