feat: replace SerpApi web search with Mistral native web_search tool

MistralClient gains generateWithWebSearch() which uses Mistral's built-in
web_search tool, handling the multi-turn tool-call loop server-side.
SerpApiWebSearch and WebSearchInterface are removed — no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 07:18:49 +00:00
parent f3b018e048
commit b9907d6c63
3 changed files with 53 additions and 58 deletions

View file

@ -66,6 +66,59 @@ final class MistralClient implements OllamaClientInterface
return $data['choices'][0]['message']['content']; return $data['choices'][0]['message']['content'];
} }
/**
* Chat completion with Mistral's built-in web_search tool enabled.
* Mistral executes searches server-side; we handle the multi-turn loop if needed.
*/
public function generateWithWebSearch(string $model, string $prompt): string
{
$messages = [['role' => 'user', 'content' => $prompt]];
$response = $this->httpClient->request('POST', $this->mistralBaseUrl.'/v1/chat/completions', [
'headers' => ['Authorization' => 'Bearer '.$this->mistralApiKey],
'json' => [
'model' => $model,
'tools' => [['type' => 'web_search']],
'messages' => $messages,
],
'timeout' => 120,
]);
/** @var array{choices: array{0: array{finish_reason: string, message: array{content: ?string, tool_calls?: list<array{id: string, function: array{name: string, arguments: string}}>}}}} $data */
$data = $response->toArray();
$choice = $data['choices'][0];
if ('tool_calls' !== $choice['finish_reason']) {
return (string) $choice['message']['content'];
}
// Append assistant's tool call turn, then return empty tool results so
// Mistral's servers can complete the search and produce the final answer.
$messages[] = $choice['message'];
foreach ($choice['message']['tool_calls'] ?? [] as $toolCall) {
$messages[] = [
'role' => 'tool',
'tool_call_id' => $toolCall['id'],
'content' => '',
];
}
$final = $this->httpClient->request('POST', $this->mistralBaseUrl.'/v1/chat/completions', [
'headers' => ['Authorization' => 'Bearer '.$this->mistralApiKey],
'json' => [
'model' => $model,
'tools' => [['type' => 'web_search']],
'messages' => $messages,
],
'timeout' => 120,
]);
/** @var array{choices: array{0: array{message: array{content: string}}}} $finalData */
$finalData = $final->toArray();
return $finalData['choices'][0]['message']['content'];
}
private function guessMimeType(string $path): string private function guessMimeType(string $path): string
{ {
return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) {

View file

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class SerpApiWebSearch implements WebSearchInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $serpApiKey,
) {
}
public function search(string $query): string
{
$response = $this->httpClient->request('GET', 'https://serpapi.com/search', [
'query' => [
'q' => $query,
'api_key' => $this->serpApiKey,
'num' => 5,
'hl' => 'de',
],
'timeout' => 15,
]);
/** @var array{organic_results?: list<array{title?: string, snippet?: string}>} $data */
$data = $response->toArray();
$results = $data['organic_results'] ?? [];
if ([] === $results) {
return '';
}
$texts = [];
foreach ($results as $result) {
$title = $result['title'] ?? '';
$snippet = $result['snippet'] ?? '';
if ('' !== $title || '' !== $snippet) {
$texts[] = $title."\n".$snippet;
}
}
return implode("\n\n", $texts);
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Search;
interface WebSearchInterface
{
public function search(string $query): string;
}