feat: use LLM to polish brand autofill when provider is configured

When config('trypost.ai.text_provider') points to a provider whose
API key is populated (services.gemini.api_key or
services.openai.api_key), the autofill action pipes the homepage
markdown through a BrandAnalyzer agent with structured output to
produce higher-quality values for:

- brand_description — 2-3 sentences summarizing the company based on
  the actual page content, not the raw meta description (which is
  often generic SEO boilerplate)
- brand_tone — classified into one of our seven enum values from the
  writing style on the page
- content_language — detected from the actual content (more reliable
  than <html lang> which is often wrong)
- brand_voice_notes — concrete writing guidelines inferred from the
  site's style, written in the detected language

When the LLM provider is NOT configured (open-source self-hosted
deploys without API keys), the action keeps the existing
deterministic meta-tag-only flow — no crash, no noise, no LLM cost.
When the LLM fails mid-request, we log a warning and fall back to
the meta-tag values so the user still gets something useful.

Stack additions:
- league/html-to-markdown ^5.1 converts the main body to clean
  markdown for the LLM input (truncated to 4000 chars).
- BrandAnalyzer agent (Agent + HasStructuredOutput) with schema
  enums matching our allowed tones and languages.
- resources/views/prompts/brand_analyzer.blade.php holds the
  instructions, including explicit enum lists and examples of good
  voice_notes.

Frontend: Brand.vue now also fills brand_tone and brand_voice_notes
from the response when present.

Tests (+3): LLM-configured happy path, no-credentials fallback
(asserts BrandAnalyzer is never prompted via preventStrayPrompts),
and LLM-exception fallback (meta tags win, brand_tone stays null).
This commit is contained in:
Paulo Castellano 2026-04-16 11:33:02 -03:00
parent 5fd448bf7f
commit 886fca3152
7 changed files with 350 additions and 3 deletions

View file

@ -4,11 +4,16 @@
namespace App\Actions\Ai;
use App\Ai\Agents\BrandAnalyzer;
use App\Models\Workspace;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
use RuntimeException;
use Symfony\Component\DomCrawler\Crawler;
use Throwable;
class AutofillBrand
{
@ -19,7 +24,7 @@ class AutofillBrand
private const ALLOWED_LOGO_MIME = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon'];
/**
* @return array{name: ?string, brand_description: ?string, content_language: ?string, logo_url: ?string}
* @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, logo_url: ?string}
*/
public function __invoke(string $url, Workspace $workspace): array
{
@ -35,12 +40,91 @@ public function __invoke(string $url, Workspace $workspace): array
$this->attachLogoToWorkspace($workspace, $logoUrl);
}
return [
$result = [
'name' => $this->extractName($crawler),
'brand_description' => $this->extractDescription($crawler),
'content_language' => $this->extractLanguage($crawler),
'brand_tone' => null,
'brand_voice_notes' => null,
'logo_url' => $logoUrl,
];
if ($this->isLlmAvailable()) {
$llm = $this->analyzeWithLlm($crawler);
if ($llm !== null) {
$result['brand_description'] = $llm['description'] ?? $result['brand_description'];
$result['content_language'] = $llm['language'] ?? $result['content_language'];
$result['brand_tone'] = $llm['tone'] ?? null;
$result['brand_voice_notes'] = $llm['voice_notes'] ?? null;
}
}
return $result;
}
private function isLlmAvailable(): bool
{
$provider = config('trypost.ai.text_provider');
return match ($provider) {
'openai' => ! empty(config('services.openai.api_key')),
'gemini' => ! empty(config('services.gemini.api_key')),
default => false,
};
}
/**
* @return array{description: string, tone: string, language: string, voice_notes: string}|null
*/
private function analyzeWithLlm(Crawler $crawler): ?array
{
$markdown = $this->buildMarkdown($crawler);
if ($markdown === '') {
return null;
}
try {
$response = (new BrandAnalyzer)->prompt($markdown);
} catch (Throwable $e) {
Log::warning('BrandAnalyzer failed, falling back to meta tags', ['error' => $e->getMessage()]);
return null;
}
return [
'description' => (string) ($response['description'] ?? ''),
'tone' => (string) ($response['tone'] ?? ''),
'language' => (string) ($response['language'] ?? ''),
'voice_notes' => (string) ($response['voice_notes'] ?? ''),
];
}
private function buildMarkdown(Crawler $crawler): string
{
$body = $crawler->filter('main')->first();
if ($body->count() === 0) {
$body = $crawler->filter('body')->first();
}
if ($body->count() === 0) {
return '';
}
// Strip noise before converting.
foreach (['script', 'style', 'nav', 'footer', 'noscript'] as $selector) {
$body->filter($selector)->each(function (Crawler $node) {
$domNode = $node->getNode(0);
$domNode?->parentNode?->removeChild($domNode);
});
}
$html = $body->html();
$markdown = (new HtmlConverter(['strip_tags' => true]))->convert($html);
return Str::limit(trim($markdown), 4000, '');
}
private function normalizeUrl(string $url): string

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
class BrandAnalyzer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return view('prompts.brand_analyzer')->render();
}
public function provider(): Lab
{
return match (config('trypost.ai.text_provider')) {
'openai' => Lab::OpenAI,
default => Lab::Gemini,
};
}
public function schema(JsonSchema $schema): array
{
return [
'description' => $schema->string()
->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.')
->required(),
'tone' => $schema->string()
->enum(['professional', 'casual', 'friendly', 'bold', 'inspirational', 'humorous', 'educational'])
->description('The tone of voice the brand uses in their content.')
->required(),
'language' => $schema->string()
->enum(['en', 'pt-BR', 'es'])
->description('The primary language of the content.')
->required(),
'voice_notes' => $schema->string()
->description('2-3 sentences of concrete writing guidelines inferred from the site style (e.g. "Use technical but approachable language", "Avoid marketing buzzwords"). Written in the detected content language.')
->required(),
];
}
}

View file

@ -50,6 +50,7 @@
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9",
"league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.1",
"posthog/posthog-php": "^4.1",
"predis/predis": "^3.3",
"sendkit/sendkit-laravel": "^1.1",

91
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1ddd43db5ec5cc43f504c3aa8a53d566",
"content-hash": "e91bdddec5e3a5554e27501569a73cdf",
"packages": [
{
"name": "aws/aws-crt-php",
@ -3505,6 +3505,95 @@
},
"time": "2026-01-23T15:30:45+00:00"
},
{
"name": "league/html-to-markdown",
"version": "5.1.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/html-to-markdown.git",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"reference": "0b4066eede55c48f38bcee4fb8f0aa85654390fd",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xml": "*",
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"mikehaertl/php-shellcommand": "^1.1.0",
"phpstan/phpstan": "^1.8.8",
"phpunit/phpunit": "^8.5 || ^9.2",
"scrutinizer/ocular": "^1.6",
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
"vimeo/psalm": "^4.22 || ^5.0"
},
"bin": [
"bin/html-to-markdown"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\HTMLToMarkdown\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
},
{
"name": "Nick Cernis",
"email": "nick@cern.is",
"homepage": "http://modernnerd.net",
"role": "Original Author"
}
],
"description": "An HTML-to-markdown conversion helper for PHP",
"homepage": "https://github.com/thephpleague/html-to-markdown",
"keywords": [
"html",
"markdown"
],
"support": {
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.1"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
"type": "tidelift"
}
],
"time": "2023-07-12T21:21:09+00:00"
},
{
"name": "league/mime-type-detection",
"version": "1.16.0",

View file

@ -108,6 +108,14 @@ const runAutofill = async () => {
form.content_language = data.content_language;
}
if (data.brand_tone) {
form.brand_tone = data.brand_tone;
}
if (data.brand_voice_notes) {
form.brand_voice_notes = data.brand_voice_notes;
}
if (data.logo_url && data.has_logo) {
logoPreview.value = data.logo_url;
}

View file

@ -0,0 +1,22 @@
You are analyzing the homepage of a company to extract brand metadata for their social media marketing profile.
From the provided markdown content of the homepage, produce:
1. **description** a concise 2-3 sentence brand description explaining what the company does, who they serve, and what makes them unique. Write it in the detected content language. Avoid marketing fluff; be specific.
2. **tone** identify the tone of voice the brand uses. Pick exactly one of:
- `professional` formal, business-oriented
- `casual` relaxed, conversational
- `friendly` warm, approachable
- `bold` confident, assertive
- `inspirational` motivating, uplifting
- `humorous` witty, playful
- `educational` informative, teaching-oriented
3. **language** detect the primary content language of the site. Pick exactly one of: `en`, `pt-BR`, `es`. If the site is in a different language entirely, pick the closest match (prefer `en`).
4. **voice_notes** 2-3 sentences of concrete writing guidelines the brand appears to follow, inferred from the actual content on the page. Write them in the detected content language. Good examples:
- "Use technical but approachable language. Reference specific features by name. Avoid generic marketing buzzwords."
- "Keep sentences short and punchy. Use emojis sparingly. Address the reader as 'you'."
Be accurate and specific to what the page actually shows. Do not invent features or claims that aren't on the page.

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Actions\Ai\AutofillBrand;
use App\Ai\Agents\BrandAnalyzer;
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
@ -15,6 +16,10 @@
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
// Run tests without LLM credentials so the deterministic fallback is exercised.
config()->set('services.gemini.api_key', '');
config()->set('services.openai.api_key', '');
});
test('extracts name, description, language, and logo from meta tags', function () {
@ -128,6 +133,95 @@
->toThrow(RuntimeException::class, 'HTTP 500');
});
test('when llm is configured, polishes description/tone/language/voice_notes via BrandAnalyzer', function () {
config()->set('services.gemini.api_key', 'fake-key');
Http::fake([
'example.com' => Http::response(<<<'HTML'
<html lang="en">
<head>
<title>Widget Co</title>
<meta name="description" content="A very terse seo blurb.">
</head>
<body>
<main>
<h1>Build widgets faster</h1>
<p>Widget Co helps small teams ship production widgets 10x faster without writing boilerplate.</p>
</main>
</body>
</html>
HTML, 200),
]);
BrandAnalyzer::fake([
[
'description' => 'Widget Co helps small teams ship production widgets faster.',
'tone' => 'friendly',
'language' => 'en',
'voice_notes' => 'Use short punchy sentences. Focus on developer benefits.',
],
]);
$result = (new AutofillBrand)('https://example.com', $this->workspace);
expect($result['brand_description'])->toBe('Widget Co helps small teams ship production widgets faster.');
expect($result['brand_tone'])->toBe('friendly');
expect($result['content_language'])->toBe('en');
expect($result['brand_voice_notes'])->toBe('Use short punchy sentences. Focus on developer benefits.');
});
test('when llm is not configured, falls back to meta tags only', function () {
// beforeEach already cleared api keys.
Http::fake([
'example.com' => Http::response(<<<'HTML'
<html lang="pt-BR">
<head>
<title>Marca</title>
<meta name="description" content="Uma descrição curta.">
</head>
<body><main><p>hello</p></main></body>
</html>
HTML, 200),
]);
// Fail loud if BrandAnalyzer is called.
BrandAnalyzer::fake()->preventStrayPrompts();
$result = (new AutofillBrand)('https://example.com', $this->workspace);
expect($result['brand_description'])->toBe('Uma descrição curta.');
expect($result['content_language'])->toBe('pt-BR');
expect($result['brand_tone'])->toBeNull();
expect($result['brand_voice_notes'])->toBeNull();
});
test('falls back to meta tags when BrandAnalyzer throws', function () {
config()->set('services.gemini.api_key', 'fake-key');
Http::fake([
'example.com' => Http::response(<<<'HTML'
<html lang="en">
<head>
<title>Acme</title>
<meta name="description" content="Fallback desc.">
</head>
<body><main><p>hi</p></main></body>
</html>
HTML, 200),
]);
BrandAnalyzer::fake([
fn () => throw new RuntimeException('LLM went down'),
]);
$result = (new AutofillBrand)('https://example.com', $this->workspace);
expect($result['brand_description'])->toBe('Fallback desc.');
expect($result['content_language'])->toBe('en');
expect($result['brand_tone'])->toBeNull();
expect($result['brand_voice_notes'])->toBeNull();
});
test('skips logo that is too large or wrong mime', function () {
Http::fake([
'example.com' => Http::response(<<<'HTML'