diff --git a/app/Actions/StoreLlmUsageAction.php b/app/Actions/StoreLlmUsageAction.php index a01d683..d94e5be 100644 --- a/app/Actions/StoreLlmUsageAction.php +++ b/app/Actions/StoreLlmUsageAction.php @@ -7,9 +7,7 @@ use App\Models\AiModel; use App\Models\AiModelDailyUsage; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; -use Spatie\ResponseCache\Commands\ClearCommand; class StoreLlmUsageAction { @@ -35,10 +33,12 @@ public function store(Collection $rows): int update: ['ai_model_id', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'requests', 'spend'], ); + // Bumping the version invalidates every cached stats query at once, which is + // all this action is responsible for. Clearing the rendered pages is the + // sync's job, not one day's — FetchLlmAnalyticsCommand does it once the whole + // batch has finished, rather than four times per hourly run. Cache::increment(LlmUsageStatsAction::VERSION_CACHE_KEY); - Artisan::call(ClearCommand::class); - return $count; } } diff --git a/app/Console/Commands/FetchLlmAnalyticsCommand.php b/app/Console/Commands/FetchLlmAnalyticsCommand.php index 85a5ca0..44f3ad2 100644 --- a/app/Console/Commands/FetchLlmAnalyticsCommand.php +++ b/app/Console/Commands/FetchLlmAnalyticsCommand.php @@ -8,7 +8,10 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Carbon\CarbonPeriod; +use Illuminate\Bus\Batch; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Bus; +use Spatie\ResponseCache\Facades\ResponseCache; class FetchLlmAnalyticsCommand extends Command { @@ -35,7 +38,19 @@ public function handle(): int $days = collect(CarbonPeriod::create($from, $to)->toArray()); - $days->each(fn (CarbonInterface $day) => FetchLlmUsageJob::dispatch(CarbonImmutable::instance($day))); + // Batched rather than dispatched one by one: the usage figures are rendered + // into the AI pages, so the cached HTML has to go once the numbers change. + // finally() fires after the last day is stored — clearing per job would wipe + // the whole site's response cache once per day in the window, and clearing + // here in the command would fire before the queue had done any work at all. + Bus::batch( + $days->map(fn (CarbonInterface $day): FetchLlmUsageJob => new FetchLlmUsageJob(CarbonImmutable::instance($day)))->all() + ) + ->name('llm-usage-sync') + ->finally(function (Batch $batch): void { + ResponseCache::clear(); + }) + ->dispatch(); $this->info("Dispatched {$days->count()} job(s) for {$from->toDateString()} to {$to->toDateString()}."); diff --git a/app/Jobs/FetchLlmUsageJob.php b/app/Jobs/FetchLlmUsageJob.php index c27bdd8..89c6269 100644 --- a/app/Jobs/FetchLlmUsageJob.php +++ b/app/Jobs/FetchLlmUsageJob.php @@ -7,11 +7,13 @@ use App\Actions\FetchLlmUsageAction; use App\Actions\StoreLlmUsageAction; use Carbon\CarbonImmutable; +use Illuminate\Bus\Batchable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; class FetchLlmUsageJob implements ShouldQueue { + use Batchable; use Queueable; public int $tries = 3; diff --git a/app/Support/NewsImage.php b/app/Support/NewsImage.php index 15c04b8..a58d8ff 100644 --- a/app/Support/NewsImage.php +++ b/app/Support/NewsImage.php @@ -43,6 +43,23 @@ public static function src(?string $reference, int $width): ?string return self::fromPublicId($reference, $width); } + /** + * The og:image counterpart of an SVG hero. Social crawlers cannot render + * SVG, so a same-named PNG rendered from it — see + * public/images/news/placeholders/ — is used when one exists; otherwise + * the caller falls back to the site default image. + */ + public static function ogImage(string $svgReference): ?string + { + if (! self::isLocalPath($svgReference) || ! str_ends_with(strtolower($svgReference), '.svg')) { + return null; + } + + $png = substr($svgReference, 0, -4).'.png'; + + return is_file(public_path(ltrim($png, '/'))) ? asset(ltrim($png, '/')) : null; + } + public static function srcset(?string $reference, int $maxWidth): ?string { if (self::src($reference, $maxWidth) === null) { diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4219729..4dab0c8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -48,11 +48,9 @@ public function run(): void $this->call(AiModelsTableSeeder::class); $this->call(NetworksTableSeeder::class); $this->call(NetworkUsersTableSeeder::class); + $this->call(AiModelDailyUsagesTableSeeder::class); - if (app()->isLocal()) { - $this->call(AiModelDailyUsagesTableSeeder::class); - - Artisan::call(ClearCommand::class); - } + Artisan::call(ClearCommand::class); + Artisan::call('responsecache:clear'); } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 9e57c74..5621ea3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,16 @@ parameters: paths: - app/Notifications/* + # Mockery's shouldReceive()/expects() are documented as returning a union in + # which only Expectation declares the count modifiers. The calls are correct — + # a mutation check confirms the expectation fails when the behaviour is removed — + # but the stubs cannot express which member of the union is returned. + - + identifier: method.notFound + message: '#Mockery\\(ExpectationInterface|HigherOrderMessage|ExpectsHigherOrderMessage)#' + paths: + - tests/* + excludePaths: # Vendor-published migration (spatie/laravel-permission) — not our code. - database/migrations/2024_06_06_100452_create_permission_tables.php diff --git a/public/images/news/placeholders/docuware-7-12.png b/public/images/news/placeholders/docuware-7-12.png new file mode 100644 index 0000000..1a9a1c4 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-12.png differ diff --git a/public/images/news/placeholders/docuware-7-13.png b/public/images/news/placeholders/docuware-7-13.png new file mode 100644 index 0000000..a68bcf0 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-13.png differ diff --git a/public/images/news/placeholders/docuware-7-14.png b/public/images/news/placeholders/docuware-7-14.png new file mode 100644 index 0000000..3f8f901 Binary files /dev/null and b/public/images/news/placeholders/docuware-7-14.png differ diff --git a/resources/views/layouts/_partials/_seo.blade.php b/resources/views/layouts/_partials/_seo.blade.php index ca69cae..6e5c4e4 100644 --- a/resources/views/layouts/_partials/_seo.blade.php +++ b/resources/views/layouts/_partials/_seo.blade.php @@ -9,10 +9,11 @@ @php // A page image can be a Cloudinary public ID, a remote URL or a path inside // public/ — only the second form is usable as-is, so NewsImage resolves it to - // an absolute URL. SVG heroes (the local release placeholders) are skipped: - // the social networks do not render SVG, and og:image:type below says PNG. + // an absolute URL. SVG heroes (the local release placeholders) cannot be used + // directly: social networks do not render SVG, and og:image:type below says + // PNG. A same-named PNG rendered from the SVG is used instead when one exists. $seoImage = str_ends_with(strtolower((string) $page->image), '.svg') - ? null + ? \App\Support\NewsImage::ogImage($page->image) : \App\Support\NewsImage::src($page->image, config()->integer('seo.image_width')); $seoImage ??= url(asset(config('seo.default_image'))); diff --git a/tests/Feature/Jobs/FetchLlmUsageJobTest.php b/tests/Feature/Jobs/FetchLlmUsageJobTest.php index bb1f643..dbc73c2 100644 --- a/tests/Feature/Jobs/FetchLlmUsageJobTest.php +++ b/tests/Feature/Jobs/FetchLlmUsageJobTest.php @@ -10,6 +10,7 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Spatie\ResponseCache\Facades\ResponseCache; use function Pest\Laravel\assertDatabaseHas; @@ -76,3 +77,31 @@ function spendLogsPayload(int $promptTokens): array expect(Cache::get(LlmUsageStatsAction::VERSION_CACHE_KEY))->toBeGreaterThan($versionBefore); })->group('llm-analytics'); + +it('does not clear the response cache when a single day is stored', function () { + // Clearing the whole site's rendered HTML is the sync's decision, not one day's — + // otherwise an hourly run wipes the response cache once per day in its window. + ResponseCache::partialMock()->shouldReceive('clear')->never(); + + (new StoreLlmUsageAction)->store(collect([[ + 'date' => '2026-07-20', + 'model' => 'qwen3.6:35b', + 'prompt_tokens' => 100, + 'completion_tokens' => 50, + 'total_tokens' => 150, + 'requests' => 1, + 'spend' => 0.5, + ]])); + +})->group('llm-analytics'); + +it('clears the response cache once after the whole sync batch finishes', function () { + Http::fake(['llm.codebar.net/spend/logs*' => Http::response(spendLogsPayload(promptTokens: 100))]); + + // Four days are synced below; the rendered pages are dropped exactly once. + ResponseCache::partialMock()->shouldReceive('clear')->once(); + + runArtisan('llm:fetch-analytics', ['--from' => '2026-07-20', '--to' => '2026-07-23']) + ->assertSuccessful(); + +})->group('llm-analytics');