From 886fca3152dac3f0d0de2dc2d2ad65638ac3eb98 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 16 Apr 2026 11:33:02 -0300 Subject: [PATCH] feat: use LLM to polish brand autofill when provider is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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). --- app/Actions/Ai/AutofillBrand.php | 88 ++++++++++++++++- app/Ai/Agents/BrandAnalyzer.php | 49 ++++++++++ composer.json | 1 + composer.lock | 91 +++++++++++++++++- resources/js/pages/onboarding/Brand.vue | 8 ++ .../views/prompts/brand_analyzer.blade.php | 22 +++++ tests/Feature/Ai/AutofillBrandTest.php | 94 +++++++++++++++++++ 7 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 app/Ai/Agents/BrandAnalyzer.php create mode 100644 resources/views/prompts/brand_analyzer.blade.php diff --git a/app/Actions/Ai/AutofillBrand.php b/app/Actions/Ai/AutofillBrand.php index a5703b21..f7128144 100644 --- a/app/Actions/Ai/AutofillBrand.php +++ b/app/Actions/Ai/AutofillBrand.php @@ -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 diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php new file mode 100644 index 00000000..4f75ab9b --- /dev/null +++ b/app/Ai/Agents/BrandAnalyzer.php @@ -0,0 +1,49 @@ +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(), + ]; + } +} diff --git a/composer.json b/composer.json index 0d05220b..720308bd 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 62a35932..59fb561c 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/js/pages/onboarding/Brand.vue b/resources/js/pages/onboarding/Brand.vue index 1d43711a..a0f1626f 100644 --- a/resources/js/pages/onboarding/Brand.vue +++ b/resources/js/pages/onboarding/Brand.vue @@ -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; } diff --git a/resources/views/prompts/brand_analyzer.blade.php b/resources/views/prompts/brand_analyzer.blade.php new file mode 100644 index 00000000..dc12142f --- /dev/null +++ b/resources/views/prompts/brand_analyzer.blade.php @@ -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. diff --git a/tests/Feature/Ai/AutofillBrandTest.php b/tests/Feature/Ai/AutofillBrandTest.php index e1f46716..3bf40ede 100644 --- a/tests/Feature/Ai/AutofillBrandTest.php +++ b/tests/Feature/Ai/AutofillBrandTest.php @@ -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' + + + Widget Co + + + +
+

Build widgets faster

+

Widget Co helps small teams ship production widgets 10x faster without writing boilerplate.

+
+ + + 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' + + + Marca + + +

hello

+ + 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' + + + Acme + + +

hi

+ + 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'