Skip to content

Commit 85a42b0

Browse files
committed
feat: adding basic Brave search tool and crawler
1 parent bdddee6 commit 85a42b0

File tree

7 files changed

+482
-0
lines changed

7 files changed

+482
-0
lines changed

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ SERP_API_KEY=
3737
# For using Tavily (tool)
3838
TAVILY_API_KEY=
3939

40+
# For using Brave (tool)
41+
BRAVE_API_KEY=
42+
4043
# For using MongoDB Atlas (store)
4144
MONGODB_URI=
4245

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,9 @@ $eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecu
340340

341341
#### Code Examples (with built-in tools)
342342

343+
1. **Brave Tool**: [toolbox-brave.php](examples/toolbox-brave.php)
343344
1. **Clock Tool**: [toolbox-clock.php](examples/toolbox-clock.php)
345+
1. **Crawler Tool**: [toolbox-brave.php](examples/toolbox-brave.php)
344346
1. **SerpAPI Tool**: [toolbox-serpapi.php](examples/toolbox-serpapi.php)
345347
1. **Tavily Tool**: [toolbox-tavily.php](examples/toolbox-tavily.php)
346348
1. **Weather Tool with Event Listener**: [toolbox-weather-event.php](examples/toolbox-weather-event.php)

examples/toolbox-brave.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
4+
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
5+
use PhpLlm\LlmChain\Chain;
6+
use PhpLlm\LlmChain\Chain\Toolbox\ChainProcessor;
7+
use PhpLlm\LlmChain\Chain\Toolbox\Tool\Brave;
8+
use PhpLlm\LlmChain\Chain\Toolbox\Tool\Crawler;
9+
use PhpLlm\LlmChain\Chain\Toolbox\Toolbox;
10+
use PhpLlm\LlmChain\Model\Message\Message;
11+
use PhpLlm\LlmChain\Model\Message\MessageBag;
12+
use Symfony\Component\Dotenv\Dotenv;
13+
use Symfony\Component\HttpClient\HttpClient;
14+
15+
require_once dirname(__DIR__).'/vendor/autoload.php';
16+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
17+
18+
if (empty($_ENV['OPENAI_API_KEY']) || empty($_ENV['BRAVE_API_KEY'])) {
19+
echo 'Please set the OPENAI_API_KEY and BRAVE_API_KEY environment variable.'.PHP_EOL;
20+
exit(1);
21+
}
22+
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
23+
$llm = new GPT(GPT::GPT_4O_MINI);
24+
25+
$httpClient = HttpClient::create();
26+
$brave = new Brave($httpClient, $_ENV['BRAVE_API_KEY']);
27+
$crawler = new Crawler($httpClient);
28+
$toolbox = Toolbox::create($brave, $crawler);
29+
$processor = new ChainProcessor($toolbox);
30+
$chain = new Chain($platform, $llm, [$processor], [$processor]);
31+
32+
$messages = new MessageBag(Message::ofUser('What was the latest game result of Dallas Cowboys?'));
33+
$response = $chain->call($messages);
34+
35+
echo $response->getContent().PHP_EOL;

src/Chain/Toolbox/Tool/Brave.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\Toolbox\Tool;
6+
7+
use PhpLlm\LlmChain\Chain\JsonSchema\Attribute\With;
8+
use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool;
9+
use Symfony\Contracts\HttpClient\HttpClientInterface;
10+
11+
#[AsTool('brave_search', 'Tool that searches the web using Brave Search')]
12+
final readonly class Brave
13+
{
14+
/**
15+
* @param array<string, mixed> $options See https://api-dashboard.search.brave.com/app/documentation/web-search/query#WebSearchAPIQueryParameters
16+
*/
17+
public function __construct(
18+
private HttpClientInterface $httpClient,
19+
#[\SensitiveParameter]
20+
private string $apiKey,
21+
private array $options = [],
22+
) {
23+
}
24+
25+
/**
26+
* @param string $query the search query term
27+
* @param int $count The number of search results returned in response.
28+
* Combine this parameter with offset to paginate search results.
29+
* @param int $offset The number of search results to skip before returning results.
30+
* In order to paginate results use this parameter together with count.
31+
*
32+
* @return array<int, array{
33+
* title: string,
34+
* description: string,
35+
* url: string,
36+
* }>
37+
*/
38+
public function __invoke(
39+
#[With(maximum: 500)]
40+
string $query,
41+
int $count = 20,
42+
#[With(minimum: 0, maximum: 9)]
43+
int $offset = 0,
44+
): array {
45+
$response = $this->httpClient->request('GET', 'https://api.search.brave.com/res/v1/web/search', [
46+
'headers' => ['X-Subscription-Token' => $this->apiKey],
47+
'query' => array_merge($this->options, [
48+
'q' => $query,
49+
'count' => $count,
50+
'offset' => $offset,
51+
]),
52+
]);
53+
54+
$data = $response->toArray();
55+
56+
return array_map(static function (array $result) {
57+
return ['title' => $result['title'], 'description' => $result['description'], 'url' => $result['url']];
58+
}, $data['web']['results'] ?? []);
59+
}
60+
}

src/Chain/Toolbox/Tool/Crawler.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\Toolbox\Tool;
6+
7+
use PhpLlm\LlmChain\Chain\Toolbox\Attribute\AsTool;
8+
use Symfony\Component\DomCrawler\Crawler as DomCrawler;
9+
use Symfony\Contracts\HttpClient\HttpClientInterface;
10+
11+
#[AsTool('crawler', 'A tool that crawls one page of a website and returns the visible text of it.')]
12+
final readonly class Crawler
13+
{
14+
public function __construct(
15+
private HttpClientInterface $httpClient,
16+
) {
17+
if (!class_exists(DomCrawler::class)) {
18+
throw new \RuntimeException('The DomCrawler component is not installed. Please install it using "composer require symfony/dom-crawler".');
19+
}
20+
}
21+
22+
/**
23+
* @param string $url the URL of the page to crawl
24+
*/
25+
public function __invoke(string $url): string
26+
{
27+
$response = $this->httpClient->request('GET', $url);
28+
29+
return (new DomCrawler($response->getContent()))->filter('body')->text();
30+
}
31+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Tests\Chain\Toolbox\Tool;
6+
7+
use PhpLlm\LlmChain\Chain\Toolbox\Tool\Brave;
8+
use PHPUnit\Framework\Attributes\CoversClass;
9+
use PHPUnit\Framework\Attributes\Test;
10+
use PHPUnit\Framework\TestCase;
11+
use Symfony\Component\HttpClient\MockHttpClient;
12+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
13+
use Symfony\Component\HttpClient\Response\MockResponse;
14+
15+
#[CoversClass(Brave::class)]
16+
final class BraveTest extends TestCase
17+
{
18+
#[Test]
19+
public function returnsSearchResults(): void
20+
{
21+
$response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json');
22+
$httpClient = new MockHttpClient($response);
23+
$brave = new Brave($httpClient, 'test-api-key');
24+
25+
$results = $brave('latest Dallas Cowboys game result');
26+
27+
self::assertCount(5, $results);
28+
self::assertArrayHasKey('title', $results[0]);
29+
self::assertSame('Dallas Cowboys Scores, Stats and Highlights - ESPN', $results[0]['title']);
30+
self::assertArrayHasKey('description', $results[0]);
31+
self::assertSame('Visit ESPN for <strong>Dallas</strong> <strong>Cowboys</strong> live scores, video highlights, and <strong>latest</strong> news. Find standings and the full 2024 season schedule.', $results[0]['description']);
32+
self::assertArrayHasKey('url', $results[0]);
33+
self::assertSame('https://www.espn.com/nfl/team/_/name/dal/dallas-cowboys', $results[0]['url']);
34+
}
35+
36+
#[Test]
37+
public function passesCorrectParametersToApi(): void
38+
{
39+
$response = $this->jsonMockResponseFromFile(__DIR__.'/fixtures/brave.json');
40+
$httpClient = new MockHttpClient($response);
41+
$brave = new Brave($httpClient, 'test-api-key', ['extra' => 'option']);
42+
43+
$brave('test query', 10, 5);
44+
45+
$request = $response->getRequestUrl();
46+
self::assertStringContainsString('q=test%20query', $request);
47+
self::assertStringContainsString('count=10', $request);
48+
self::assertStringContainsString('offset=5', $request);
49+
self::assertStringContainsString('extra=option', $request);
50+
51+
$requestOptions = $response->getRequestOptions();
52+
self::assertArrayHasKey('headers', $requestOptions);
53+
self::assertContains('X-Subscription-Token: test-api-key', $requestOptions['headers']);
54+
}
55+
56+
#[Test]
57+
public function handlesEmptyResults(): void
58+
{
59+
$response = new MockResponse(json_encode(['web' => ['results' => []]]));
60+
$httpClient = new MockHttpClient($response);
61+
$brave = new Brave($httpClient, 'test-api-key');
62+
63+
$results = $brave('this should return nothing');
64+
65+
self::assertEmpty($results);
66+
}
67+
68+
/**
69+
* This can be replaced by `JsonMockResponse::fromFile` when dropping Symfony 6.4.
70+
*/
71+
private function jsonMockResponseFromFile(string $file): JsonMockResponse
72+
{
73+
return new JsonMockResponse(json_decode(file_get_contents($file), true));
74+
}
75+
}

0 commit comments

Comments
 (0)