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
8 changes: 4 additions & 4 deletions app/Actions/StoreLlmUsageAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}
}
17 changes: 16 additions & 1 deletion app/Console/Commands/FetchLlmAnalyticsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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()}.");

Expand Down
2 changes: 2 additions & 0 deletions app/Jobs/FetchLlmUsageJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions app/Support/NewsImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 3 additions & 5 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
10 changes: 10 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/news/placeholders/docuware-7-13.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/news/placeholders/docuware-7-14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions resources/views/layouts/_partials/_seo.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
29 changes: 29 additions & 0 deletions tests/Feature/Jobs/FetchLlmUsageJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');