Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -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}';

Expand All @@ -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));
Comment on lines +83 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate fixed-package input before empty-target return

When --v8-5-v5-bilingual-64 is used with a missing/unreadable/malformed package, or a package that contains no valid recommendations[*].target_url, targets() returns an empty list and the method has already returned target_urls_missing before this fixed-package contract check runs. finish() treats that non-safety NO_GO as exit code 0, so a CI/release script relying on the command status can proceed without ever validating the locked package hash or exact URL contract. Move this validation ahead of the empty-target return, or make empty targets in fixed mode a safety violation.

Useful? React with 👍 / 👎.

}

$surfaceTexts = $this->fetchSurfaceTexts($baseUrl);
$results = [];
$issues = [];
Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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));
}
}
}
Expand All @@ -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<mixed> $payload
* @return list<string>
*/
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<array{canonical_url:string, path:string}> $targets
* @return list<string>
*/
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<string, mixed>
*/
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<string>
*/
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<string>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
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;
use Tests\TestCase;

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();
Expand Down Expand Up @@ -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<string, mixed> $arguments
* @return array<string, mixed>
Expand Down Expand Up @@ -363,4 +436,33 @@ private function rowCounts(): array
'seo_urls' => DB::connection('seo_intel')->table('seo_urls')->count(),
];
}

/**
* @return list<string>
*/
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('#^/(?<prefix>en|zh)/personality/(?<type>[a-z]{4})-(?<variant>a|t)$#i', $path);
preg_match('#^/(?<prefix>en|zh)/personality/(?<type>[a-z]{4})-(?<variant>a|t)$#i', $path, $matches);
$locale = $matches['prefix'] === 'zh' ? 'zh-CN' : 'en';
$runtimeTypeCode = strtoupper((string) $matches['type']).'-'.strtoupper((string) $matches['variant']);

return $locale.'|'.$runtimeTypeCode;
}
}