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:
parent
5fd448bf7f
commit
886fca3152
7 changed files with 350 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
49
app/Ai/Agents/BrandAnalyzer.php
Normal file
49
app/Ai/Agents/BrandAnalyzer.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
91
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
22
resources/views/prompts/brand_analyzer.blade.php
Normal file
22
resources/views/prompts/brand_analyzer.blade.php
Normal 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.
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue