feat: replace Mistral web_search with Tavily for specs research

- Add WebSearchInterface + TavilyWebSearch (POST /search, max 5 results)
- SpecsResearchAgent now fetches search results first, injects them as
  {{searchResults}} context into the prompt, then calls plain generate()
  — no dependency on model-specific web_search tool support
- Update specs_research prompt template (PHP default + DB migration) to
  use the new {{searchResults}} variable
- Wire TAVILY_API_KEY env var; register TavilyWebSearch in services.yaml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 08:35:52 +00:00
parent 00dc232426
commit cfb5cc4ad0
7 changed files with 133 additions and 5 deletions

2
.env
View file

@ -16,6 +16,8 @@ OLLAMA_BASE_URL=http://172.18.0.1:11434
OLLAMA_VISION_MODEL=llava OLLAMA_VISION_MODEL=llava
OLLAMA_TEXT_MODEL=llama3.2 OLLAMA_TEXT_MODEL=llama3.2
TAVILY_API_KEY=
MISTRAL_BASE_URL=https://api.mistral.ai MISTRAL_BASE_URL=https://api.mistral.ai
MISTRAL_API_KEY= MISTRAL_API_KEY=
# Vision requires a Pixtral model, e.g. pixtral-12b-2409 # Vision requires a Pixtral model, e.g. pixtral-12b-2409

View file

@ -82,6 +82,13 @@ services:
arguments: arguments:
$model: '%env(AI_TEXT_MODEL)%' $model: '%env(AI_TEXT_MODEL)%'
App\Infrastructure\Search\WebSearchInterface:
alias: App\Infrastructure\Search\TavilyWebSearch
App\Infrastructure\Search\TavilyWebSearch:
arguments:
$apiKey: '%env(TAVILY_API_KEY)%'
App\Infrastructure\AI\Agent\JsonCodingAgent: App\Infrastructure\AI\Agent\JsonCodingAgent:
arguments: arguments:
$model: '%env(AI_TEXT_MODEL)%' $model: '%env(AI_TEXT_MODEL)%'

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520020000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Update specs_research prompt to include {{searchResults}} from Tavily';
}
public function up(Schema $schema): void
{
$body = <<<'PROMPT'
You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
Web search results:
{{searchResults}}
Based on the search results above, list all technical specifications including:
processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
ports, connectivity, weight, dimensions, OS, and any other relevant specs.
Be specific and accurate. If a spec is not found in the search results, omit it rather than guessing.
PROMPT;
$this->addSql(
"UPDATE app.prompt_templates SET body = :body WHERE key = 'specs_research'",
['body' => $body],
);
}
public function down(Schema $schema): void
{
$body = <<<'PROMPT'
List all known technical specifications for the {{articleType}}: "{{subject}}".
Include: processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
ports, connectivity, weight, dimensions, OS, and any other relevant specs.
If you know this device, be specific and complete. If it is unknown, say so explicitly.
PROMPT;
$this->addSql(
"UPDATE app.prompt_templates SET body = :body WHERE key = 'specs_research'",
['body' => $body],
);
}
}

View file

@ -6,12 +6,14 @@ namespace App\Infrastructure\AI\Agent;
use App\Infrastructure\AI\OllamaClientInterface; use App\Infrastructure\AI\OllamaClientInterface;
use App\Infrastructure\AI\PromptTemplateService; use App\Infrastructure\AI\PromptTemplateService;
use App\Infrastructure\Search\WebSearchInterface;
final class SpecsResearchAgent final class SpecsResearchAgent
{ {
public function __construct( public function __construct(
private readonly OllamaClientInterface $client, private readonly OllamaClientInterface $client,
private readonly PromptTemplateService $prompts, private readonly PromptTemplateService $prompts,
private readonly WebSearchInterface $search,
private readonly string $model, private readonly string $model,
) { ) {
} }
@ -20,12 +22,15 @@ final class SpecsResearchAgent
{ {
$subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName); $subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName);
$searchResults = $this->search->search("{$subject} {$articleTypeName} specifications");
$prompt = $this->prompts->render('specs_research', [ $prompt = $this->prompts->render('specs_research', [
'articleType' => $articleTypeName, 'articleType' => $articleTypeName,
'subject' => $subject, 'subject' => $subject,
'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.',
]); ]);
$result = $this->client->generateWithWebSearch($this->model, $prompt); $result = $this->client->generate($this->model, $prompt);
if ('' === trim($result)) { if ('' === trim($result)) {
throw new \RuntimeException("No specifications found for model: {$modelName}"); throw new \RuntimeException("No specifications found for model: {$modelName}");

View file

@ -11,10 +11,15 @@ final class PromptTemplateService
/** @var array<string, string> */ /** @var array<string, string> */
private const array DEFAULTS = [ private const array DEFAULTS = [
'specs_research' => <<<'PROMPT' 'specs_research' => <<<'PROMPT'
List all known technical specifications for the {{articleType}}: "{{subject}}". You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
Include: processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
Web search results:
{{searchResults}}
Based on the search results above, list all technical specifications including:
processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
ports, connectivity, weight, dimensions, OS, and any other relevant specs. ports, connectivity, weight, dimensions, OS, and any other relevant specs.
If you know this device, be specific and complete. If it is unknown, say so explicitly. Be specific and accurate. If a spec is not found in the search results, omit it rather than guessing.
PROMPT, PROMPT,
'ebay_title' => <<<'PROMPT' 'ebay_title' => <<<'PROMPT'
@ -89,7 +94,7 @@ PROMPT,
public static function knownKeys(): array public static function knownKeys(): array
{ {
return [ return [
'specs_research' => ['articleType', 'subject'], 'specs_research' => ['articleType', 'subject', 'searchResults'],
'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'], 'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'],
'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'], 'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'],
'vision_analyze' => [], 'vision_analyze' => [],

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class TavilyWebSearch implements WebSearchInterface
{
private const string API_URL = 'https://api.tavily.com/search';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $apiKey,
) {
}
public function search(string $query): string
{
$response = $this->httpClient->request('POST', self::API_URL, [
'json' => [
'api_key' => $this->apiKey,
'query' => $query,
'max_results' => 5,
'include_raw_content' => false,
],
'timeout' => 30,
]);
/** @var array{results: list<array{title: string, url: string, content: string}>} $data */
$data = $response->toArray();
if (empty($data['results'])) {
return '';
}
$parts = [];
foreach ($data['results'] as $result) {
$parts[] = "### {$result['title']}\n{$result['content']}";
}
return implode("\n\n", $parts);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
interface WebSearchInterface
{
/**
* Run a web search and return the results as plain text suitable for use as AI context.
*/
public function search(string $query): string;
}