<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="https://ktherage.github.io/fr/xsl/atom.xsl" media="all"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr">
  <id>https://ktherage.github.io/fr/tags/ollama/</id>
  <title>Kévin THÉRAGE | Expert Symfony Developer - Ollama</title>
  <subtitle><![CDATA[Kévin THÉRAGE – Expert Symfony Developer. Technical blog on Symfony, PHP, web development with tutorials, best practices and expert advice for developers.]]></subtitle>
  <link href="https://ktherage.github.io/fr/tags/ollama/atom.xml" rel="self" type="application/atom+xml" />
  <link href="https://ktherage.github.io/fr/tags/ollama/" rel="alternate" type="text/html" />
  <updated>2026-08-10T17:48:13+00:00</updated>
  <author>
    <name>Kévin THÉRAGE</name>
    <uri>https://ktherage.github.io/</uri>
  </author>
  <entry xml:lang="fr">
    <id>https://ktherage.github.io/fr/blog/building-a-rag-powered-code-review-assistant/</id>
    <title>Construire un assistant de code review RAG avec PHP, Ollama et Qdrant</title>
    <published>2026-08-10T00:00:00+00:00</published>
    <link href="https://ktherage.github.io/fr/blog/building-a-rag-powered-code-review-assistant/" rel="alternate" type="text/html" />
    <content type="html">
      <![CDATA[<p>Les <a href="https://fr.wikipedia.org/wiki/Grand_mod%C3%A8le_de_langage" rel="noopener noreferrer">LLM</a> sont excellents pour produire des code reviews qui sonnent juste. Mais « sonner juste » n'est pas la même chose qu'être utiles. Une review qui vous dit de « corrige le Code Style » est correcte mais inutile — chaque projet applique Code Style qui peut différer.</p>
<p>Symfony a plus de <strong>20 ans de code reviews publiques</strong> sur <a href="https://github.com/" rel="noopener noreferrer">GitHub</a>. Chaque PR mergée contient des commentaires de <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, et des dizaines d'autres reviewers de la core team et de contributeurs. C'est une mine d'or de patterns de review spécifiques au domaine : quels arguments convainquent, quels patterns sont rejetés, ce que la communauté considère comme du bon code Symfony.</p>
<p>Le problème ? Personne n'avait construit de moteur de recherche pour l'exploiter. Alors je l'ai fais 🤣.</p>
<h2 id="le-lore-derriere-cette-idee-folle">Le lore derrière cette idée folle</h2>
<p>Cette histoire prends sa source au <a href="https://live.symfony.com/" rel="noopener noreferrer">Symfony Live de Paris</a>. Comme vous l'imaginez, celui de cette année était très orienté IA. J'ai vu bon nombre de Talks en parlé et j'ai voulu jouer un peu avec cette nouveautée mais jusque là, je n'avais pas de cas concrét.</p>
<p>J'y ai assisté au un Talk de <a href="https://github.com/lyrixx" rel="noopener noreferrer">Grégoire Pineau</a> ou il expliquais comment avec <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a>, <a href="https://clickhouse.com/fr" rel="noopener noreferrer">Clickhouse</a> et <a href="https://redirection.io/" rel="noopener noreferrer">redirection.io</a> il avait mené à bien la migration d'un site E-Commerce en réduissant la perte de trafic.</p>
<p>Plus tard est arrivé le <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Console Bundle</a>.</p>
<p>J'avais déjà demandé à un LLM de relire les modifications que j'avais faites <em>(sur des projets perso bien sûr)</em> et, comme vous vous en doutez, j'ai obtenu des conseils du genre <code translate="no">pensez à utiliser l'injection de dépendances</code>, <code translate="no">peut-être extraire cette logique dans un service</code> ou <code translate="no">pense a vérifier le code style</code>. Ces retours sont techniquements corrects, mais surtout universellements applicables et complètements génériques. Bref, rien qui ne puisse être corrigé avec de bons outils et un peu de rigeur.</p>
<p>Sortant du Symfony Live, m'est venu une idée un peu dingue.
Et si je pouvais demander au même LLM : </p>
<blockquote>
<p>« Review ce code comme le ferait stof »</p>
</blockquote>
<p>J'aurais des retours ultra pointu et un code qui en ressortira grandi.</p>
<p>Ou</p>
<blockquote>
<p>« Review ce code comme le ferait n'importe quel contributeur Symfony »</p>
</blockquote>
<p>J'aurais alors le point de vu de l'ensemble de la communauté sur le code que je viens de créé. </p>
<p>Ou bien même</p>
<blockquote>
<p>« Review ce code comme le ferait n'importe quel membre de la core team Symfony »</p>
</blockquote>
<p>J'aurais un panel d'experts à ma disposition pour m'expliquer ce qui ne vas pas dans ce que j'ai fait.</p>
<p>C'est ce que fait <strong>Symfony Reviewer MCP</strong> : un <a href="https://fr.wikipedia.org/wiki/G%C3%A9n%C3%A9ration_%C3%A0_enrichissement_contextuel" rel="noopener noreferrer">moteur de recherche sémantique (RAG)</a> sur l'ensemble des code reviews historiques de Symfony accessibles via l'API <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">GitHub</a>, exposé via le <a href="https://fr.wikipedia.org/wiki/Model_Context_Protocol" rel="noopener noreferrer">Model Context Protocol (MCP)</a> le tout avec <a href="https://ai.symfony.com/" rel="noopener noreferrer">Symfony AI</a> et dans une application <a href="https://symfony.com/blog/new-in-symfony-8-1-http-less-symfony-applications" rel="noopener noreferrer">Symfony HTTP-Less</a>.</p>
<p>Je l'ai construit en <a href="https://www.php.net/releases/8.5/" rel="noopener noreferrer">PHP 8.5</a> avec <a href="https://symfony.com/" rel="noopener noreferrer">Symfony 8.1</a>, utilisant <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> en local pour la vectorisation avec un model issu de <a href="https://huggingface.co/" rel="noopener noreferrer">huggingface.co</a> (embeddinggemma-300m, 768 dimensions) et <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> comme base de donnée vectorielle.</p>
<p>Aucun GPU requis si on accepte la contrepartie, l'indexation complète des reviews historiques s'est exécutée sur le CPU de ma machine <em>pendant plusieurs jours</em> 😅. Pour un projet ponctuel, ce compromis m'a semblé largement acceptable et "cost-efficient".</p>
<p>Comment ça fonctionne ?</p>
<h2 id="architecture-globale">Architecture globale</h2>
<p>L'architecture globale se découpe en deux gros blocs :</p>
<ol>
<li><strong>La génération du RAG :</strong> <ol>
<li>Avec la récupération des données et leurs mise en cache</li>
<li>Avec la génération du dataset et l'indexation dans <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a></li>
</ol>
</li>
<li><strong>Le serveur MCP</strong></li>
</ol>
<h3 id="la-generation-du-rag">La génération du RAG</h3>
<p>Voici le pipeline de génération RAG complet :</p>
<p>Ne vous inquiétez pas si ce schéma paraît dense, je vais parcourir chaque étape du pipeline dans le reste de l'article, depuis la récupération des reviews GitHub jusqu'à la recherche sémantique.</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["API GitHub (symfony/symfony)"] --&gt; B["PullsFetcher (PR mergées uniquement)"]
    B --&gt; C["ReviewsFetcher (commentaires + réponses)"]
    C --&gt; D["DatasetGenerator (var/dataset/pull-{id}.txt)"]
    D --&gt; E["Builder::build"]
    E --&gt; F["Ollama (embeddinggemma-300m)"]
    F --&gt; G["Qdrant (collection: reviews)"]
</pre>
<h4>Récupération des données</h4>
<p>Avant que toute cette mécanique de décorateurs HTTP ait de l'importance, il faut d'abord parcourir <a href="https://docs.github.com/en/rest" rel="noopener noreferrer">l'API GitHub</a> et décider ce qui mérite d'être gardé.</p>
<p><code translate="no">PullsFetcher</code> pagine <code translate="no">GET /repos/symfony/symfony/pulls?state=all&amp;per_page=100</code>, en ne gardant que les PR dont <code translate="no">merged_at</code> n'est pas nul — inutile d'entraîner le système sur des idées rejetées. Plutôt que de paginer aveuglément jusqu'à tomber sur une page vide, il envoie d'abord une unique requête <code translate="no">HEAD</code> et lit le nombre total de pages directement dans le <a href="https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api" rel="noopener noreferrer">header <code translate="no">Link</code></a>. Cet appel <code translate="no">HEAD</code> est d'ailleurs exactement la raison d'être de <code translate="no">BLACKLISTED_PATTERN</code> : c'est une requête de découverte, pas quelque chose qui mérite d'être caché 365 jours.</p>
<p><code translate="no">ReviewsFetcher</code> parcourt ensuite <a href="https://docs.github.com/en/rest/pulls/comments" rel="noopener noreferrer"><code translate="no">GET /repos/symfony/symfony/pulls/{id}/comments</code></a> pour chaque PR et reconstruit le véritable arbre de conversation — les commentaires parents avec leurs réponses attachées. Le piège : l'API GitHub ne garantit pas l'ordre des commentaires. Si une réponse arrive avant son parent, <code translate="no">ReviewsFetcher</code> la met de côté dans un pool temporaire (<code translate="no">$repliesTempPool</code>) au lieu de la perdre, et la rattache dès que le parent est trouvé. Un petit détail de tenue de registre, mais sans lui, n'importe quel thread où trois personnes se disputent sur tabs vs espaces dans le désordre perdrait silencieusement des réponses.</p>
<h5>La chaîne de décorateurs HTTP : Logging &amp; Caching</h5>
<h6>L'architecture</h6>
<p>Les deux fetchers passent par la même petite <a href="https://symfony.com/doc/current/http_client.html#decorating-the-client" rel="noopener noreferrer">chaîne de décorateurs HTTP</a> :</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    A["GithubHttpClient (scoping + auth Bearer)"] --&gt; B["CachedHttpClient (cache filesystem, TTL 365j)"]
    B --&gt; C["LoggedHttpClient (logging structuré)"]
    C --&gt; D["HttpClient::create() (Symfony natif)"]
</pre>
<p>Chaque décorateur ajoute une responsabilité :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedHttpClient</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">HttpClientInterface</span>, <span class="hljs-title">ResetInterface</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(
        private HttpClientInterface $client,
        private FilesystemAdapter $cache,
        private LoggerInterface $logger,
        private array $blacklistedPatterns = [],
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">request</span><span class="hljs-params">(string $method, string $url, array $options = [])</span>: <span class="hljs-title">ResponseInterface</span>
    </span>{
        $pattern = array_map(<span class="hljs-keyword">static</span> fn (string $pattern): string =&gt; preg_quote($pattern, <span class="hljs-string">'#'</span>), <span class="hljs-keyword">$this</span>-&gt;blacklistedPatterns)
            |&gt; (<span class="hljs-keyword">static</span> fn ($x): string =&gt; implode(<span class="hljs-string">'|'</span>, $x))
            |&gt; (<span class="hljs-keyword">static</span> fn (string $x): string =&gt; \sprintf(<span class="hljs-string">'#^%s$#'</span>, $x))
        ;
        $httpCall = $method.<span class="hljs-string">' '</span>.$url;
        <span class="hljs-keyword">if</span> (preg_match($pattern, $httpCall, $matches)) {
            <span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options);
        }

        $key = md5($method.$url);
        $cacheItem = <span class="hljs-keyword">$this</span>-&gt;cache-&gt;getItem($key);
        <span class="hljs-keyword">if</span> ($cacheItem-&gt;isHit()) {
            <span class="hljs-keyword">return</span> $cacheItem-&gt;get();
        }

        $response = <span class="hljs-keyword">new</span> CachedResponse(<span class="hljs-keyword">$this</span>-&gt;client-&gt;request($method, $url, $options));

        $cacheItem-&gt;set($response);
        <span class="hljs-keyword">$this</span>-&gt;cache-&gt;save($cacheItem);

        <span class="hljs-keyword">return</span> $response;
    }
}</code></pre>
<h6>Le problème de sérialisation en chemin</h6>
<p>Cette chaîne contient un piège que j'ai déjà documenté dans mon article précédent — <a href="https://symfony.com/doc/current/http_client.html#information-related-to-the-response" rel="noopener noreferrer"><code translate="no">HttpClient::getInfo()</code></a> de Symfony contient une clé <code translate="no">pause_handler</code> avec une <code translate="no">Closure</code>, impossible à sérialiser. La classe <code translate="no">CachedResponse</code> gère cela en filtrant les valeurs non sérialisables :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CachedResponse</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ResponseInterface</span>
</span>{
    <span class="hljs-keyword">private</span> int $statusCode;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string, list&lt;string&gt;&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $headers;

    <span class="hljs-keyword">private</span> string $content;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $toArray;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; */</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">array</span> $info;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(ResponseInterface $response)</span>
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;statusCode = $response-&gt;getStatusCode();
        <span class="hljs-keyword">$this</span>-&gt;headers = $response-&gt;getHeaders();
        <span class="hljs-keyword">$this</span>-&gt;content = $response-&gt;getContent();
        <span class="hljs-keyword">$this</span>-&gt;toArray = $response-&gt;toArray();

        <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> array&lt;string|int, mixed&gt; $info */</span>
        $info = $response-&gt;getInfo();
        <span class="hljs-keyword">$this</span>-&gt;info = array_filter($info, <span class="hljs-keyword">static</span> fn ($v): bool =&gt; !$v <span class="hljs-keyword">instanceof</span> \Closure);
    }
}</code></pre>
<p>Sans ce filtre, <a href="https://symfony.com/doc/current/components/cache.html" rel="noopener noreferrer"><code translate="no">FilesystemAdapter</code></a> échoue silencieusement — l'exception de sérialisation est attrapée par <code translate="no">DefaultMarshaller</code> avec <code translate="no">throwOnSerializationFailure</code> à <code translate="no">false</code>, et la clé de cache est discrètement ignorée.</p>
<h4>Génération du dataset</h4>
<p>La commande <code translate="no">BuildCommand</code> orchestre le pipeline de vectorisation :</p>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[AsCommand(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">"build a RAG over Symfony's official Github repository's code review"</span>,
    help: <span class="hljs-string">'This command is a pre-requisites for the MCP server'</span>,
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BuildCommand</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'mcp:build'</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(
        private LoggerInterface $logger,
        private DatasetGenerator $datasetGenerator,
        private Builder $builder,
    )</span> </span>{
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip dataset generation and uses dataset cache'</span>, name: <span class="hljs-string">'skip-generation'</span>, shortcut: <span class="hljs-string">'G'</span>)</span>]
        bool $skipGeneration = false,
        #[Option<span class="hljs-params">(description: <span class="hljs-string">'Skip build of dataset cache'</span>, name: <span class="hljs-string">'skip-build'</span>, shortcut: <span class="hljs-string">'B'</span>)</span>]
        bool $skipBuild = false,
    )</span>: <span class="hljs-title">int</span> </span>{
        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">if</span> (!$skipGeneration) {
                <span class="hljs-keyword">$this</span>-&gt;datasetGenerator-&gt;generate();
            }

            <span class="hljs-keyword">if</span> (!$skipBuild) {
                <span class="hljs-keyword">$this</span>-&gt;builder-&gt;build();
            }

            <span class="hljs-keyword">return</span> Command::SUCCESS;
        } <span class="hljs-keyword">catch</span> (\Throwable $throwable) {
            <span class="hljs-keyword">$this</span>-&gt;logger-&gt;error($throwable-&gt;getMessage());

            <span class="hljs-keyword">return</span> Command::FAILURE;
        }
    }
}</code></pre>
<h5>Le fichier de base</h5>
<p>Une fois les données récupérées, <code translate="no">DatasetGenerator</code> transforme chaque PR et ses reviews en fichier texte structuré :</p>
<pre><code translate="no">[PULL_REQUEST]
    id: 54321
    author: nicolas-grekas
    author_association: MEMBER
    description:
        [HttpKernel] Fix edge case in exception handling

[REVIEWS]
    [REVIEW_1234]
        replyTo:
        reviewer: stof
        reviewer_association: MEMBER
        file: src/Component/HttpKernel/Event/ExceptionEvent.php
        diff:
            @@ -88,7 +88,7 @@
             public function getThrowable(): ?\Throwable
             {
        comment:
            We should keep the original exception here,
            the wrapper is only for internal use.

        reactions:
            +1: 5
            -1: 0
            laugh: 0
            hooray: 0
            confused: 0
            heart: 0
            rocket: 0
            eyes: 0</code></pre>
<p>Ces fichiers vivent dans <code translate="no">var/dataset/pull-{id}.txt</code> et servent de vérité terrain pour la vectorisation.</p>
<h5>Intégration Qdrant</h5>
<p>La base vectorielle est câblée dans le conteneur comme <code translate="no">StoreInterface</code>, via le <code translate="no">StoreFactory</code> de <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a> :</p>
<pre><code class="language-php hljs php" translate="no">-&gt;set(StoreInterface::class, Store::class)
    -&gt;autowire()
    -&gt;factory(StoreFactory::create(...))
    -&gt;args([
        <span class="hljs-string">'$collectionName'</span> =&gt; <span class="hljs-string">'reviews'</span>,
        <span class="hljs-string">'$endpoint'</span> =&gt; env(<span class="hljs-string">'QDRANT_DSN'</span>),
        <span class="hljs-string">'$httpClient'</span> =&gt; service(LoggedHttpClient::class),
        <span class="hljs-string">'$embeddingsDimension'</span> =&gt; <span class="hljs-number">768</span>,
        <span class="hljs-string">'$embeddingsDistance'</span> =&gt; <span class="hljs-string">'Cosine'</span>,
    ])

-&gt;set(VectorizerInterface::class, Vectorizer::class)
    -&gt;autowire()
    -&gt;args([
        <span class="hljs-string">'$model'</span> =&gt; <span class="hljs-string">'hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0'</span>,
    ])</code></pre>
<p>Deux détails valent le détour. </p>
<ol>
<li>Le <code translate="no">Vectorizer</code> utilise exactement le même modèle <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> qu'au build — un modèle d'embedding <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code> produisant des vecteurs à 768 dimensions.
Cette partie est extrêment importante si vous ne voulez pas vous retrouver à comparer des pommes de terre avec des choux lors de la recherche via le MCP. En effet, un vecteur généré avec un modèle précis ne peux pas être comparé avec un vecteur généré avec un autre modèle.</li>
<li>La base réutilise le décorateur <code translate="no">LoggedHttpClient</code>, donc chaque aller-retour vers Qdrant bénéficie d'un logging structuré par-dessus le client HTTP natif de Symfony.</li>
</ol>
<h5>Vectorisation et stockage</h5>
<p>C'est là que les choses se gâtent, cette partie à elle seule m'a pris des jours.</p>
<h6>Qu'est qu'il se passe à la vectorisation ?</h6>
<p>Le fichier issu de <code translate="no">var/dataset/pull-{id}.txt</code> sont lus puis envoyés a <a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> pour demander à un modèle d'embedding, dans mon cas <code translate="no">hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0</code>, qui va en générer un vecteur de 768 dimensions <em>(chiffre qui dépends du modèle d'embedding)</em> avant d'être renvoyé à symfony-ai par Ollama pour enfin être sauvegarder dans un espace vectoriel dans <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a></p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant AI as Symfony AI (Builder)
    participant Ollama
    participant Model as embeddinggemma-300m
    participant Qdrant

    AI-&gt;&gt;Ollama: vectorize(contenu du fichier dataset)
    Ollama-&gt;&gt;Model: inférence du modèle
    Model--&gt;&gt;Ollama: 768 valeurs flottantes
    Ollama--&gt;&gt;AI: Vector (768 dimensions)
    AI-&gt;&gt;Qdrant: add(VectorDocument)
    Qdrant--&gt;&gt;AI: confirmation
</pre>
<p>Exemple de vecteur :</p>
<pre><code class="language-bash hljs bash" translate="no">❯ ollama run hf.co/ggml-org/embeddinggemma-300m-qat-q8_0-GGUF:Q8_0 <span class="hljs-string">'Hello world !'</span>
[0.058340553,0.017256556,-0.0023928124,0.062416226,-0.019779362,-0.069838926,0.003351966,0.029903421,0.01617497,0.009088458,-0.024585545,-0.07013172,0.0077750348,0.03643439,-0.02245716,0.02035499,0.005985676,0.008291158,0.013118213,-0.074038,0.014411658,0.011837681,0.027627029,-0.008276582,0.059961967,0.018847544,0.040701613,0.020481525,0.004880007,-0.026033,0.028065553,-0.015691148,-0.06859542,-0.04025022,-0.0046045044,-0.033632968,0.01929922,0.01854895,-0.000545488,-0.37636346,0.05929959,0.0069747632,-0.026434837,0.018491298,-0.00945082,0.0009028575,0.024641853,-0.053274404,-0.043102805,0.0016308841,-0.04315744,-0.0024387056,-0.007468653,-0.03491168,-0.00315068,-0.023274362,-0.0016503683,-0.027008653,-0.0016653507,0.029124975,0.015810343,-0.020706663,0.03261976,-0.015973076,-0.0104695875,0.0027727042,0.03187403,0.25363848,0.016416604,0.027735965,0.009674886,-0.031506274,-0.01176536,-0.060261074,-0.0031871377,-0.01684568,0.029581062,-0.05600014,-0.02623269,0.04637347,-0.04044786,0.0036846441,0.008154481,0.0190515,-0.023374602,-0.011516434,0.01098124,0.008980432,-0.02016589,-0.020812696,0.046533223,0.009594602,-0.033848874,-0.046110246,0.027529772,0.029260637,-0.04092383,0.00048074988,0.01185442,0.0059498977,0.02239185,-0.0014466406,-0.00841961,-0.011190161,0.05701471,-0.015211558,-0.052781906,0.014623615,-0.0012096912,0.029300302,0.0044483966,0.028927691,-0.032093737,0.048145276,0.034030333,0.03062545,0.015509856,-0.008765529,-0.027545273,0.010858431,0.021899927,-0.008150199,0.0034826857,-0.03487983,-0.039671477,0.009751623,-0.019133179,-0.0030401512,-0.010503486,-0.017615957,0.0365356,0.001852526,-0.013896455,0.04652015,-0.049408674,0.0120107075,0.0065035336,0.0004583914,0.0074103116,-0.028435387,-0.0110742645,-0.0012466233,0.0014801751,-0.015979966,-0.028333941,0.0053472416,0.014286039,0.00054261123,0.049599133,-0.026553018,-0.021315206,0.043478087,0.032634746,0.0031313808,-0.0007238099,-0.0033655402,0.02283678,0.012112167,-0.019739753,-0.0080446005,0.018196804,0.0334649,-0.04168719,-0.0046553654,-0.007058912,-0.0104055125,-0.033779215,0.0015363622,0.051331602,0.009603074,0.0061764405,-0.0047951997,0.0055726245,-0.013255206,-0.0184209,0.025034897,0.057538535,-0.022678796,0.029314326,-0.028992262,-0.024944754,0.005440558,-0.03586656,0.00470333,0.0039566993,-0.03774347,0.007882632,-0.019097507,0.019113457,-0.024320446,-0.012655296,-0.04030833,0.0036063064,0.017062565,-0.047738973,0.03617525,0.009754858,0.01101775,-0.03881582,-0.05497514,0.018249176,-0.02227283,-0.064608485,-0.009161445,-0.010031034,-0.0095641855,0.01916363,0.01516687,0.016545013,-0.002466866,0.05094877,-0.012659827,-0.014525608,-0.023728082,0.043452837,-0.0182468,0.0171756,-0.027970072,-0.07089399,0.030868053,-0.017713076,-0.012257217,-0.010346674,0.026055504,0.0060443725,0.017072264,0.008725935,0.013845085,-0.016069857,-0.015575777,-0.024176376,0.011916211,0.023472574,0.020805132,0.023326341,-0.032767452,-0.054396667,0.013436974,0.004714595,-0.033193223,0.018227011,0.021449534,0.035102192,0.014087173,-0.012364751,-0.02923529,-0.014678346,0.020951372,-0.046659384,-0.0001321898,-0.04854372,-0.008653948,-0.02752997,-0.039902873,0.059013035,-0.023333075,-0.002939849,-0.02412675,-0.004704963,0.005708739,0.0078358585,0.015467018,-0.017394196,-0.024916349,0.0033860407,-0.005256748,-0.019880958,0.02133062,-0.01909998,-0.03368627,-0.030686421,-0.046723425,-0.009353089,0.00718895,0.03141207,-0.005678604,0.010026497,0.017099433,0.09632587,-0.049855407,0.040112875,0.03521583,0.029755611,0.016151456,-0.011640585,-0.012128548,-0.028238969,0.015068383,-0.033082347,0.01045796,0.02537935,0.035929427,-0.066318564,0.0031127778,0.0012862016,-0.0036205864,0.025082638,-0.053492177,-0.0033758928,0.011705148,-0.0033429412,0.04687452,-0.008285868,0.0009990487,-0.032646406,0.009088849,-0.0041933167,-0.046134073,0.0067272885,0.009922917,0.01473402,-0.008017513,-0.042351346,-0.026370844,-0.013365593,-0.05842642,0.0053622974,0.07431096,-0.0013185574,-0.009227675,-0.023330051,-0.027747931,-0.009491263,0.021478329,-0.0059168683,-0.021412537,0.020145044,-0.039623715,-0.0428058,0.025570398,0.03506635,-0.037845273,0.05345302,-0.0574125,-0.00028089757,0.009052639,-0.019611377,0.04223033,0.014607936,0.04443048,0.0076912907,0.007895783,-0.0042047133,-0.0071978727,-0.005284037,0.019756729,0.006555163,0.0008758057,-0.017007992,0.050635543,0.0092563275,0.02716696,0.021407066,0.14517316,-0.02369811,0.0027539528,0.03910237,0.008360229,-0.021910692,0.011394674,0.011142392,-0.0015445971,0.0025348184,0.010536188,0.020002112,-0.025976151,0.02049012,-0.02106826,-0.032985732,0.019664701,0.021935249,0.0066386443,0.017932259,0.01572093,-0.010654519,0.023060553,-0.014906989,0.006019814,0.010056607,0.058398962,-0.033474576,0.0011755938,0.009262606,-0.01099747,-0.0015662273,-0.009874551,0.008189235,0.025591813,-0.018472403,0.04004355,-0.011285104,-0.014111953,-0.0063381894,0.0005300517,-0.023887232,-0.04470202,-0.0028616937,0.014985186,-0.03294004,-0.008383296,-0.043611214,-0.008217752,0.040103037,0.014903076,-0.0021273512,0.042627018,0.0010886613,0.41363978,0.03303489,-0.026918324,-0.051881365,-0.009240305,-0.017186431,-0.064830735,0.019849097,0.033434503,-0.0071105417,0.008766244,-0.017940182,-0.03069124,0.025611496,-0.0054379674,-0.018304668,0.035152443,0.017566781,-0.03807328,0.016257798,0.023560232,0.0043043825,0.08201138,-0.012402507,0.019028682,-0.018637981,0.0073655653,0.0015441499,-0.013527856,-0.0059385803,-0.022364056,-0.02425886,0.016911663,-0.0011785801,0.0053356686,0.016516488,-0.01820115,0.0032703253,-0.012067703,0.020428859,-0.004661816,0.0019089471,0.035107043,-0.04653369,0.032357465,0.037315182,-0.018799154,0.022463702,-0.020647656,0.021579335,-0.0511682,-0.016783282,-0.03466541,-0.0018399828,0.0013000914,-0.010348332,0.0012786519,-0.04518444,0.03560613,0.002523491,-0.005807527,0.010765285,-0.023149451,0.00044260465,0.0029642652,0.0010614877,-0.008083944,-0.022398435,-0.020968962,-0.014627337,0.008090492,0.005585011,-0.03377691,-0.011848554,0.0072662574,-0.03521151,-0.032203663,0.008255807,-0.040369254,0.030274471,-0.011215091,-0.008181156,0.053159524,-0.020998636,-0.002394821,0.0028064605,0.0074562496,0.007893967,-0.007300692,0.0015053308,-0.010529569,-0.0060634767,-0.024756493,-0.03676517,0.011349936,-0.015407753,-0.009043473,0.034528915,0.017980041,-0.021671167,-0.033637524,-0.049074188,-0.010759755,-0.016900545,0.054496538,0.09080891,-0.012992101,0.02599233,-0.0011818404,0.038375963,-0.0099124005,0.010196206,0.013038416,0.0007229094,0.058817368,-0.0010059974,0.031990774,0.05380181,0.024521016,0.002847628,0.07304043,0.0017232046,-0.031101514,-0.0050771832,0.024083985,0.005650839,0.013745816,0.037950784,-0.013184445,-0.030096699,0.0072532697,0.0069977636,0.012731625,-0.03473301,0.020486254,-0.028183239,-0.043638907,0.05255927,0.040101644,0.020626077,-0.00009298259,0.03147282,0.005550194,-0.0030457666,-0.015949357,-0.019966332,0.004474357,0.0073816148,-0.0966872,-0.0014123727,0.015428014,-0.00072764594,0.02582003,0.023952637,-0.013374169,-0.024338745,0.021395741,0.012303337,0.024213506,0.013632532,-0.016449485,-0.03322197,0.0039774035,0.00541838,0.0003197519,-0.031282444,0.021476608,0.006979306,0.024495661,0.008296023,-0.036932785,-0.031137321,-0.00706068,0.024338977,0.0073112054,0.06343152,0.010950803,-0.04011533,0.0023558561,0.005737587,-0.013831569,0.025473805,-0.017996674,0.030670065,-0.021311458,-0.014061837,-0.028316947,-0.016967898,0.05437623,-0.05517407,-0.011666794,-0.064273596,0.00039994833,-0.0016417564,0.00369619,-0.004408155,-0.033399895,0.010705014,0.022728024,-0.006053165,0.0031930788,-0.010684994,-0.05090471,-0.03378601,-0.016370287,0.00020012762,-0.022603909,0.036075003,0.030441662,0.03643664,0.01663764,0.010343481,-0.00867144,-0.015162774,-0.0014251935,0.03770172,-0.013012902,0.035615146,0.00044963427,0.012939211,-0.008898151,0.04329554,0.006962741,0.047645073,-0.058727764,0.0069460804,0.027805299,-0.0022572207,0.03155984,0.007940954,0.025537886,0.026445614,-0.01529072,0.01621024,0.0069643836,-0.013095124,-0.0015153071,-0.013846497,-0.0054590567,0.10567172,0.024595099,-0.021427441,-0.017892607,0.029084895,-0.044227537,-0.020952923,0.0037034317,-0.053684484,-0.026559578,0.0031811807,-0.0022820174,-0.07499727,-0.06748456,-0.031104647,-0.037120227,-0.0070385304,0.03623152,-0.06010997,-0.0040761833,-0.023788461,0.007862985,-0.0080264,0.0294231,-0.06763409,-0.027284352,0.02720548,0.012118604,-0.044641722,0.025212545,-0.0050499276,0.010612783,-0.0048592645,0.011480939,-0.038084067,0.050459232,-0.021653391,-0.016860519,-0.022343304,0.011578441,0.029444221,0.005036281,0.052007847,-0.00015478901,0.010757338,-0.008266853,-0.06438106,0.0038179888,0.010810128,0.014887702,0.041086577,0.09645378,-0.03697137,0.018508574,0.021505969,0.043335702,-0.015699612,0.006846198,0.059496786,0.042588223,-0.020572873,0.0042599905,0.0061774435,-0.0048375144,0.023622885,-0.013585687,0.012436588,-0.0031023244,-0.009659066,0.007284619,0.004994468,-0.03997744,0.025999822,-0.004322784,0.0021907475,0.027662035,0.013451684,0.0061220867,-0.053517137,0.011526667,-0.016668048,0.03389963,0.0024166985,-0.0087329345,0.0005349021,0.09292805,-0.024875147,0.01887172,-0.015814196,0.01548722,-0.0023607062,0.00095323403,0.040603366,-0.018233076,0.00050581084,-0.028851299,-0.05746257,-0.022262363,0.06411007,-0.015622854,0.02921695,0.032788914,-0.0042617223,-0.0026337528,0.029876161,0.026273958,-0.048925456,-0.014354729,0.0069950293,-0.046119038,-0.0015572214,-0.01648194,-0.018696893,-0.036625963,-0.0049027205,-0.002610518,-0.046027448,0.02675758,-0.033888668,0.0046032025,-0.017866787,-0.016139796]</code></pre>
<h6>Pourquoi une approche séquentielle ?</h6>
<p>Comme expliqué en introduction, sans GPU dédié, c'est mon CPU — plus précisément l'iGPU intégré à mon CPU — qui doit faire le travail de vectorisation.
Même si l'iGPU partage la RAM avec le CPU et bien que 32 Go soient disponibles <em>(modulo l'utilisation de mon système, de programme en cours, ...)</em>, la vitesse de la RAM n'a rien à voir avec la mémoire des cartes graphiques, bien plus rapide et dédiée.
De plus, le CPU ne peut traiter que quelques opérations en parallèle, là où le GPU en exécute des milliers simultanément ce qui fait que les calculs matriciels deviennent très lents et mobilisent ma machine à 100%.</p>
<p>Résultat, une seul vectorisation n'est possible à la fois.
Et donc, oui, un batch upsert serait plus rapide, mais le but était de construire un pipeline fonctionnel avec uniquement des ressources locales.</p>
<p>Ce n'est pas une limite de PHP ou de Qdrant, seulement un compromis pragmatique lié au matériel disponible.</p>
<h6>Atomicité par renommage de fichiers</h6>
<p>C'est la décision de design la plus intéressante. Au lieu d'une table en base de données pour suivre les fichiers traités, le <code translate="no">Builder</code> utilise des <code translate="no">rename()</code> atomiques :</p>
<pre><code translate="no">pull-{id}.txt               → prêt à traiter
processing_pull-{id}.txt    → en cours de vectorisation
ragged_pull-{id}.txt        → vectorisé avec succès</code></pre>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">build</span><span class="hljs-params">()</span>: <span class="hljs-title">void</span>
</span>{
    <span class="hljs-keyword">$this</span>-&gt;store-&gt;setup(); <span class="hljs-comment">// ManagedStoreInterface</span>

    <span class="hljs-keyword">$this</span>-&gt;recoverOrphanedProcessingFiles();

    <span class="hljs-keyword">foreach</span> (scandir(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory) <span class="hljs-keyword">as</span> $file) {
        <span class="hljs-keyword">if</span> (<span class="hljs-string">'.'</span> === $file || <span class="hljs-string">'..'</span> === $file
            || str_starts_with($file, <span class="hljs-keyword">self</span>::RAGGED_PREFIX)
            || str_starts_with($file, <span class="hljs-keyword">self</span>::PROCESSING_PREFIX)) {
            <span class="hljs-keyword">continue</span>;
        }
        <span class="hljs-keyword">if</span> (!rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.(<span class="hljs-string">'processing_'</span>.$file))) {
            <span class="hljs-keyword">continue</span>; <span class="hljs-comment">// un autre processus l'a pris</span>
        }

        <span class="hljs-keyword">try</span> {
            $content = file_get_contents(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file);
            $vector = <span class="hljs-keyword">$this</span>-&gt;vectorizer-&gt;vectorize($content);
            <span class="hljs-keyword">if</span> (<span class="hljs-number">768</span> !== \count($vector-&gt;getData())) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> \RuntimeException(<span class="hljs-string">'Wrong dimensions'</span>);
            }

            <span class="hljs-keyword">$this</span>-&gt;store-&gt;add(<span class="hljs-keyword">new</span> VectorDocument(
                id: (int) preg_replace(<span class="hljs-string">'/[^0-9]/'</span>, <span class="hljs-string">''</span>, $file),
                vector: $vector,
                metadata: <span class="hljs-keyword">new</span> Metadata([<span class="hljs-string">'content'</span> =&gt; $content]),
            ));

            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/ragged_'</span>.$file);
        } <span class="hljs-keyword">catch</span> (\Throwable $e) {
            rename(<span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/processing_'</span>.$file, <span class="hljs-keyword">$this</span>-&gt;datasetDirectory.<span class="hljs-string">'/'</span>.$file); <span class="hljs-comment">// rollback</span>
        }
    }
}</code></pre>
<p>Crash-safe par conception : si le script meurt en plein milieu, <code translate="no">recoverOrphanedProcessingFiles()</code> remet en file les orphelins <code translate="no">processing_*</code> au prochain lancement. Pas de locks, pas de base de données, pas de race conditions.</p>
<p>Oui, le code montre que, malgrés ce que j'ai dis plus haut :</p>
<blockquote>
<p>Résultat, une seul vectorisation n'est possible à la fois.</p>
</blockquote>
<p>Oui, j'ai quand même essayé 🤣.</p>
<h4>Utilisation</h4>
<p>La CLI expose deux commandes :</p>
<pre><code class="language-bash hljs bash" translate="no"><span class="hljs-comment"># Pipeline complet : récupération → dataset → vectorisation</span>
php bin/console mcp:build

<span class="hljs-comment"># Re-vectoriser sans re-récupérer</span>
php bin/console mcp:build --skip-generation

<span class="hljs-comment"># Re-récupérer sans re-vectoriser</span>
php bin/console mcp:build --skip-build</code></pre>
<h3 id="le-serveur-mcp">Le serveur MCP</h3>
<p>Pour que le LLM puisse avoir accès a ces review fraichement indéxés et effectuer ses recherches lui même, il faut lui donner les accès.
Tout ça s'effectue via le protocole MCP suivant ce pipeline d'appel simplifié :</p>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
flowchart TD
    H["Appel Tool MCP (review_as_group/person)"] --&gt; I["Retriever (recherche sémantique)"]
    I --&gt; G["Qdrant (collection: reviews)"]
    I --&gt; J["Client LLM (Claude Desktop)"]
</pre>
<p>Le pipeline de génération (récupération → dataset → vectorisation) et le pipeline de service (recherche → réponse) partagent un seul point commun : la collection <a href="https://qdrant.tech/" rel="noopener noreferrer">Qdrant</a>. </p>
<p>Le serveur MCP expose deux <strong>tools</strong> et quatre <strong>prompts</strong> :</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Recherche par groupe d'affiliation (MEMBER, CONTRIBUTOR, NONE)</td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Recherche par reviewer spécifique (nicolas-grekas, stof, etc.)</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th>Prompt</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">review_as_group</code></td>
<td>Message formaté utilisant <code translate="no">review_as_group</code></td>
</tr>
<tr>
<td><code translate="no">review_as_person</code></td>
<td>Message formaté utilisant <code translate="no">review_as_person</code></td>
</tr>
<tr>
<td><code translate="no">get_stofed</code></td>
<td>Force le reviewer à « stof » — le plus prolifique reviewer du Core Symfony</td>
</tr>
<tr>
<td><code translate="no">hq_review</code></td>
<td>Multi-review : interroge 14 reviewers et synthétise un rapport markdown</td>
</tr>
</tbody>
</table>
<h4>Recherche : les Tools MCP</h4>
<p>Quand un utilisateur envoie une requête via un tool MCP, voici ce qui se passe :</p>
<ol>
<li>Le tool construit une requête combinant reviewer, chemin de fichier et diff</li>
<li><code translate="no">RetrieverInterface::retrieve()</code> vectorise la requête via Ollama</li>
<li>Une recherche par similarité cosinus s'exécute sur Qdrant</li>
<li>Les <code translate="no">VectorDocument</code> correspondants sont retournés</li>
<li>Leur <code translate="no">metadata['content']</code> est extrait et assemblé en contexte</li>
</ol>
<pre class="mermaid d-flex flex-column m-2 justify-content-center align-items-center">
sequenceDiagram
    participant Client as Claude Desktop
    participant MCP as Serveur MCP (stdio)
    participant Tool as review_as_person
    participant Retriever
    participant Ollama
    participant Qdrant

    Client-&gt;&gt;MCP: call_tool(review_as_person)
    MCP-&gt;&gt;Tool: __invoke(pseudonym, file, diff, limit)
    Tool-&gt;&gt;Retriever: retrieve(query, ['limit' =&gt; limit])
    Retriever-&gt;&gt;Ollama: vectorize(query)
    Ollama--&gt;&gt;Retriever: vecteur de la requête
    Retriever-&gt;&gt;Qdrant: recherche par similarité cosinus
    Qdrant--&gt;&gt;Retriever: documents les plus proches
    Retriever--&gt;&gt;Tool: VectorDocument[]
    Tool--&gt;&gt;MCP: reviews trouvées (texte)
    MCP--&gt;&gt;Client: résultat du tool
</pre>
<pre><code class="language-php hljs php" translate="no"><span class="hljs-comment">#[McpTool(</span>
    name: <span class="hljs-keyword">self</span>::NAME,
    description: <span class="hljs-string">'Tool retrieving a `limit` amount of reviews from `pseudonym` github user based on a given git a complete `file` path and `diff`. Results are separated by `\n\n---\n\n`.'</span>,
    annotations: <span class="hljs-keyword">new</span> ToolAnnotations(<span class="hljs-string">'Review matching file diff as github user'</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>, <span class="hljs-keyword">true</span>, <span class="hljs-keyword">false</span>)
)]
<span class="hljs-keyword">final</span> readonly <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ReviewAsPersonMatchingFileDiffTool</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> string NAME = <span class="hljs-string">'review_as_person'</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__invoke</span><span class="hljs-params">(string $pseudonym, string $file, string $diff, int $limit)</span>: <span class="hljs-title">string</span>
    </span>{
        $query = <span class="hljs-string">&lt;&lt;&lt;TXT
        reviewer: <span class="hljs-subst">$pseudonym</span>
        file: <span class="hljs-subst">$file</span>
        diff:
        <span class="hljs-subst">{$diff}</span>
        TXT;

        try {
            <span class="hljs-subst">$retrieved</span> = <span class="hljs-subst">$this</span>-&gt;retriever-&gt;retrieve(<span class="hljs-subst">$query</span>, ['limit' =&gt; <span class="hljs-subst">$limit</span>]);

            <span class="hljs-subst">$return</span> = [];
            foreach (<span class="hljs-subst">$retrieved</span> as <span class="hljs-subst">$document</span>) {
                <span class="hljs-subst">$content</span> = <span class="hljs-subst">$document</span>-&gt;getMetadata()['content'] ?? null;
                if (null === <span class="hljs-subst">$content</span> || !\is_string(<span class="hljs-subst">$content</span>)) {
                    continue;
                }
                <span class="hljs-subst">$return</span>[] = <span class="hljs-subst">$content</span>;
            }
        } catch (\Throwable <span class="hljs-subst">$exception</span>) {
            return 'Error retrieving reviews: '.<span class="hljs-subst">$exception</span>-&gt;getMessage();
        }

        if (0 === \count(<span class="hljs-subst">$return</span>)) {
            return 'No reviews found.';
        }

        return implode("\n\n---\n\n", <span class="hljs-subst">$return</span>);
    }
}</span></code></pre>
<h4>Le Prompt HQ Review</h4>
<p>Le prompt <code translate="no">hq_review</code> est la fonctionnalité vedette. Il interroge 14 reviewers Symfony de premier plan (<a href="https://github.com/GromNaN" rel="noopener noreferrer">GromNaN</a>, <a href="https://github.com/dunglas" rel="noopener noreferrer">dunglas</a>, <a href="https://github.com/welcoMattic" rel="noopener noreferrer">welcoMattic</a>, <a href="https://github.com/nicolas-grekas" rel="noopener noreferrer">nicolas-grekas</a>, <a href="https://github.com/chalasr" rel="noopener noreferrer">chalasr</a>, <a href="https://github.com/stof" rel="noopener noreferrer">stof</a>, <a href="https://github.com/yceruto" rel="noopener noreferrer">yceruto</a>, <a href="https://github.com/mtarld" rel="noopener noreferrer">mtarld</a>, <a href="https://github.com/OskarStark" rel="noopener noreferrer">OskarStark</a>, <a href="https://github.com/xabbuh" rel="noopener noreferrer">xabbuh</a>, <a href="https://github.com/lyrixx" rel="noopener noreferrer">lyrixx</a>, <a href="https://github.com/kbond" rel="noopener noreferrer">kbond</a>, <a href="https://github.com/jderusse" rel="noopener noreferrer">jderusse</a>, <a href="https://github.com/alexandre-daubois" rel="noopener noreferrer">alexandre-daubois</a>), collecte leurs feedbacks historiques sur le même fichier/diff, et demande au LLM de synthétiser un rapport markdown avec des retours pondérés par reviewer.</p>
<p>Le résultat est une code review qui ressemble à un mini-symposium des mainteneurs du Core Symfony — sans nécessiter leur temps.</p>
<h4>Utilisation</h4>
<pre><code class="language-bash hljs bash" translate="no">docker build -t symfony-reviewer-mcp-cli /path/to/Dockerfile
docker run -i --rm --add-host=host.docker.internal:host-gateway -e QDRANT_DSN=http://host.docker.internal:6333 -e OLLAMA_DSN=http://host.docker.internal:11434 symfony-reviewer-mcp-cli</code></pre>
<p>Puis configurer Claude Desktop (ou tout client MCP) en ajoutant le serveur à <code translate="no">claude_desktop_config.json</code> :</p>
<pre><code class="language-json hljs json" translate="no">{
  <span class="hljs-attr">"mcpServers"</span>: {
    <span class="hljs-attr">"symfony-reviewer"</span>: {
      <span class="hljs-attr">"command"</span>: <span class="hljs-string">"docker"</span>,
      <span class="hljs-attr">"args"</span>: [
            <span class="hljs-string">"run"</span>, <span class="hljs-string">"-i"</span>, <span class="hljs-string">"--rm"</span>,
            <span class="hljs-string">"--add-host=host.docker.internal:host-gateway"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"QDRANT_DSN=http://host.docker.internal:6333"</span>,
            <span class="hljs-string">"-e"</span>, <span class="hljs-string">"OLLAMA_DSN=http://host.docker.internal:11434"</span>,
            <span class="hljs-string">"symfony-reviewer-mcp-cli"</span>
        ]
    }
  }
}</code></pre>
<h2 id="les-variables-d-env">Les variables d'env</h2>
<p>Toute la configuration passe par les variables <code translate="no">.env</code> :</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Rôle</th>
</tr>
</thead>
<tbody>
<tr>
<td><code translate="no">GITHUB_TOKEN</code></td>
<td><a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens" rel="noopener noreferrer">Token d'accès personnel GitHub</a></td>
</tr>
<tr>
<td><code translate="no">QDRANT_DSN</code></td>
<td>URL du service Qdrant</td>
</tr>
<tr>
<td><code translate="no">OLLAMA_DSN</code></td>
<td>URL du service Ollama</td>
</tr>
<tr>
<td><code translate="no">BLACKLISTED_PATTERN</code></td>
<td>Tableau JSON de patterns URL à exclure du cache</td>
</tr>
<tr>
<td><code translate="no">APP_VERSION</code></td>
<td>Version affichée dans les métadonnées MCP</td>
</tr>
</tbody>
</table>
<h2 id="lecons-apprises">Leçons apprises</h2>
<p>Ce projet m'a permis de comprendre comment fonctionne le protocole MCP, ce qu'est un base de données vectoriel et comment l'utiliser. L'architecture présentée ici est relativement classique pour un pipeline RAG mais il a été réalisé avec Symfony et peu servir de socle de base pour d'autres expérimentations ou usages.</p>
<h3 id="ce-qui-a-bien-fonctionne">Ce qui a bien fonctionné</h3>
<ul>
<li><strong>Atomicité par renommage de fichiers</strong> : Ce pattern est élégant, crash-safe et ne nécessite aucune infrastructure. Chaque développeur PHP comprend <code translate="no">rename()</code>. Pas de locks Redis, pas de migrations de base de données.</li>
<li><strong>Pipeline incrémental</strong> : Relancer <code translate="no">mcp:build</code> avec des fichiers existants est un no-op. L'itération est rapide — on peut ajuster la vectorisation et ne traiter que les nouveaux fichiers.</li>
<li><strong>Fonctionnalités PHP 8.x</strong> : La promotion de constructeur, les propriétés readonly, l'opérateur pipe (<code translate="no">|&gt;</code>), et les commandes invokables rendent le code bien plus propre.</li>
<li><strong><a href="https://ollama.com/" rel="noopener noreferrer">Ollama</a> en local</strong> : embeddinggemma-300m tourne sur CPU sans problème. 768 dimensions, c'est assez modeste pour des requêtes rapides mais assez riche pour la recherche sémantique sur des code reviews.</li>
</ul>
<h3 id="ce-qui-doit-etre-ameliore">Ce qui doit être amélioré</h3>
<p>Si j'avais eu une machine plus efficace avec un GPU dédié ou une RAM unifiée (👋 les propriétaires de Mac), j'aurai peut-être pu changer ce qui suit :</p>
<ul>
<li><strong>Interaction Qdrant naïve</strong> : Les documents sont ajoutés un par un. Un batch upsert serait nettement plus rapide pour les gros volumes.</li>
<li><strong>Pas de mise à jour incrémentale du RAG</strong> : Le pipeline est add-only. Il n'y a pas de mécanisme "builtin" <em>(c'est possible via le dashboard de qdrant)</em> pour purger ou mettre à jour les vecteurs existants quand des commentaires de PR sont édités sur GitHub. Ce qui en soit est un vrai/faux problème puisqu'on retrouve rarement de nouveaux commentaire sur des Pull-Requests déjà mergées.</li>
<li><strong>Ajoutez vos propres conventions</strong> : J'ai ajouté les reviews de Symfony, mais vous pouvez vous aussi modifier et adapter le code pour qu'il s'appuie sur un corpus de données supplémentaire comme les revues de vos collaborateurs.</li>
</ul>
<h3 id="ce-que-je-ferais-differemment">Ce que je ferais différemment</h3>
<ol>
<li><strong>Vectorisation par lots</strong> : Grouper les documents et vectoriser en lots pour un meilleur débit</li>
<li><strong>Récupération asynchrone</strong> : La phase de récupération des données est séquentielle par PR. Des requêtes concurrentes réduiraient significativement le temps de construction initial</li>
<li><strong>Webhook GitHub</strong> : Au lieu de reconstruire périodiquement, écouter les événements de PR mergées et mettre à jour le dataset de manière incrémentale</li>
<li><strong>Évaluation des modèles d'embeddings</strong> : 768 dimensions fonctionne bien, mais je devrais comparer différents modèles : des modèles plus petits (comme <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2" rel="noopener noreferrer">all-MiniLM-L6-v2</a>, 384 dimensions) pour améliorer les performances, ou des modèles beaucoup plus gros afin d'évaluer le gain potentiel en qualité de recherche.</li>
<li><strong>Contexte élargi : commentaires de PR et diff complet</strong> : le dataset actuel ne retient que les review comments attachés à un diff précis. Il ignore les commentaires généraux de la PR (issue comments, description) et surtout le diff complet de la PR — un reviewer ne juge jamais une ligne isolée, il la juge dans le contexte du changement entier. Injecter les deux donnerait au modèle bien plus de matière pour comprendre pourquoi une review a été formulée ainsi.</li>
<li><strong>Exploiter le champ <code translate="no">metadata</code> pour le JSON brut de GitHub</strong> : <a href="https://github.com/symfony/ai-store" rel="noopener noreferrer"><code translate="no">symfony/ai-store</code></a> attache à chaque <code translate="no">VectorDocument</code> un objet <code translate="no">Metadata</code> (<code translate="no">Symfony\AI\Store\Document\Metadata</code>) qui voyage jusqu'au store choisi. Chez Qdrant, ce <code translate="no">Metadata</code> correspond exactement à la notion de <em>payload</em> : un objet JSON arbitraire attaché à chaque point, indexable et filtrable nativement — par exemple filtrer par <code translate="no">reviewer_association</code>, par date, ou par nombre de réactions, sans re-parser le texte du dataset. Aujourd'hui, seul <code translate="no">content</code> (le texte assemblé du fichier dataset) y est stocké ; j'y aurais ajouté la réponse JSON brute de l'API GitHub (PR + review + reactions), pour garder une trace exploitable indépendante du format texte généré.</li>
<li><strong>Exploiter les réactions du JSON GitHub</strong>: Les réactions se prêteraient d'ailleurs à mieux qu'un simple filtre : elles pourraient pondérer le score de recherche lui-même, pas juste être renvoyées dans le <code translate="no">content</code>. Depuis la version 1.14, Qdrant propose une <a href="https://qdrant.tech/documentation/search/hybrid-queries/" rel="noopener noreferrer"><em>Formula Query</em></a> qui permet de composer un score final à partir du score de similarité initial et de champs du payload, dans une même formule de reclassement. Une review avec dix <code translate="no">+1</code> remonterait alors devant une review isolée à zéro réaction, à similarité vectorielle égale — une façon de faire remonter les avis que la communauté a elle-même validés, Une fonctionnalitée qui meriterai d'être explorée.</li>
</ol>
<h2 id="est-ce-que-ca-fonctionne">Est-ce que ça fonctionne ?</h2>
<p>La grande question : « est-ce que les reviews produites sont réellement meilleures ? ».</p>
<p>Répondre Oui serait en parti faux. En effet, aujourd'hui, mon évaluation reste essentiellement basée sur un sentiment générale plutôt que sur de vraies données tangibles et quantifiables.
Les réponses générées <strong>me</strong> paraissent beaucoup plus dans le ton d'une review qui aurait été effectuée sur le dépôt Github de Symfony, et donc plus proches des ses conventions, que les réponses obtenues avec un LLM sans contexte.
Enfin, comme un LLM et par nature "probabiliste", je ne crois pas qu'il soit pertinant de dire que sur la base de mes propres observations et mon sentiment personnel seul le fonctionnement d'un outil est établi.</p>
<p>Voici un exemple de revue effectuée sur ce projet pour vous donner un idée :</p>
<pre><code class="language-markdown hljs markdown" translate="no">---

<span class="hljs-section">### 1. fabpot</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; Thanks for the PR! A few things before we can merge:</span>
<span class="hljs-quote">&gt;
&gt; * Why is the cache key based on the URL only? For `POST` requests the body matters, otherwise all `/api/embed` calls will return the same result. This is a real bug, not an optimization issue.</span>
<span class="hljs-quote">&gt; * Please add a test covering the "non-buffered" response case — `getContent()` then `toArray()` will fail on `EventSourceHttpClient` responses.</span>
<span class="hljs-quote">&gt; * Do we need a `CHANGELOG` entry for the `CachedHttpClient`? I think we can keep it internal.</span>
<span class="hljs-quote">&gt;
&gt; Otherwise the approach is clean. Once the cache key is fixed, we can squash the commits.</span>

---

<span class="hljs-section">### 2. nicolas-grekas</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>, <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Reading the response body twice is going to blow up the moment the response is not buffered. `toArray()` internally calls `getContent()` — so snapshot the content once and `json_decode` it, don't call both.</span>
<span class="hljs-quote">&gt;
&gt; Also, the decorated chain is wrong: `service(LoggedHttpClient::class)` no longer resolves to the logger once you decorate it with `CachedHttpClient`. Qdrant and Ollama are being routed through the GitHub cache without intent. Decorate a dedicated alias, e.g. `cached.github.http_client`, and keep `HttpClientInterface` as the plain chain.</span>
<span class="hljs-quote">&gt;
&gt; One more: `getContent(false)` is being snapshotted eagerly in the constructor — that defeats lazy streaming for large GitHub responses. Buffer lazily.</span>

---

<span class="hljs-section">### 3. stof</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; Service wiring nit: decoration replaces the decorated id, so `$httpClient =&gt; service(LoggedHttpClient::class)` gives you the `CachedHttpClient`, not the logging client. That's a scope leak — the Qdrant store is now coupled to a cache tuned for the GitHub API (see `BLACKLISTED_PATTERN`).</span>
<span class="hljs-quote">&gt;
&gt; I'd define a dedicated `logged.http_client` service for the store/platform and only decorate `cached.github.http_client` for the GitHub fetchers. Also check the `-&gt;decorate(..., priority: 1)` priorities — with equal priorities the order of application is by declaration order, which is fragile to read.</span>
<span class="hljs-quote">&gt;
&gt; And the `json:BLACKLISTED_PATTERN` env var — document its format in the README.</span>

---

<span class="hljs-section">### 4. weaverryan</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>

<span class="hljs-quote">&gt; Hey! Love the ergonomics of this tool — the `review_as_person` name makes the intent super clear. Great job composing the query with the pseudonym, file, and diff.</span>
<span class="hljs-quote">&gt;
&gt; One DX thought: when no reviews are found we return `No reviews found.` — that's good. But maybe give the caller a hint that they can reduce `limit` or widen the diff? Small thing, ignore if you want.</span>
<span class="hljs-quote">&gt;
&gt; Also, the metadata `content` check with the warning log is nice defensive coding. Keep it up! 🎉</span>

---

<span class="hljs-section">### 5. derrabus</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; Two things:</span>
<span class="hljs-quote">&gt;
&gt; 1. `$key = md5($method.$url)` — please include the serialized options/body. Hash collisions here are silent correctness bugs, not just perf issues.</span>
<span class="hljs-quote">&gt; 2. The `preg_match` on a compiled regex built via pipe chains is clever but hard to read. Since the blacklist is a list of exact strings, why not use `in_array` or a simple `str_starts_with` on the pattern list? Keep it simple.</span>
<span class="hljs-quote">&gt;
&gt; Also, the code is `final readonly` — good. But it implements `ResetInterface`; make sure the decorated inner `reset()` is reachable in `withOptions()` clones (it is, since you forward to the scoped client — just double-check the cache/lifecycle after cloning).</span>

---

<span class="hljs-section">### 6. xabbuh</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedResponse.php`</span>

<span class="hljs-quote">&gt; I have concerns about the snapshot in the constructor:</span>
<span class="hljs-quote">&gt;
&gt; * Calling `$response-&gt;getContent()` eagerly downloads and stores the whole payload. For the GitHub fetchers this is fine, but a general-purpose cache should stream lazily.</span>
<span class="hljs-quote">&gt; * More importantly, `getContent()` followed by `toArray()` breaks for responses that disabled buffering (the Ollama `EventSourceHttpClient` forces `buffer =&gt; false`). This is an exploitable/observable crash — at minimum it should throw a clear `TransportException` or read once.</span>
<span class="hljs-quote">&gt; * `getInfo()` filtering out closures is a nice touch, but the returned array is shallow — nested closures could still leak. Use a recursive filter or `json_encode/decode` the info array.</span>

---

<span class="hljs-section">### 7. Tobion</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The pipeline operator chains in `request()` are over-engineered for building a regex. `implode('|', array_map(preg_quote(...), $this-&gt;blacklistedPatterns))` is enough. As written, an empty blacklist produces the pattern `#^$#` which would match an empty call string — harmless, but misleading.</span>
<span class="hljs-quote">&gt;
&gt; More importantly: cache invalidation. There is none — responses are cached for a year (`defaultLifetime`). GitHub data changes; the fetchers need a way to bust the cache (e.g. include a version/tag in the key or a TTL per-URL). Otherwise reviews fetched once are served stale forever.</span>

---

<span class="hljs-section">### 8. mpdude</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span> (via <span class="hljs-code">`Store`</span>)

<span class="hljs-quote">&gt; The Qdrant indexing loop swallows exceptions and logs `Index failed` — but the build then *continues* (I saw `Indexing document continues`). If a document fails vectorization, subsequent documents are still sent. That means the collection is only partially populated, and `review_as_person` will silently return "No reviews found" or partial results.</span>
<span class="hljs-quote">&gt;
&gt; Please make the build fail-fast or at least surface a summary count at the end ("indexed X / failed Y") so operators know the dataset is incomplete. Right now nothing tells us that only ~1106 of 8691 documents made it in.</span>

---

<span class="hljs-section">### 9. WouterJ</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>

<span class="hljs-quote">&gt; The container config reads really well — the decoration chain is easy to follow. Nice use of `env('json:BLACKLISTED_PATTERN')` and `StoreFactory::create(...)`.</span>
<span class="hljs-quote">&gt;
&gt; Minor: the `logged.http_client` vs `cached.github.http_client` distinction is muddied because both decorators use `priority: 1` and decorate each other's ids. I'd give them explicit service aliases (`github.logged.http_client`, etc.) so the intent is obvious. Also the unused `'stream_handler'` monolog handler and the commented-out `http` transport block could be cleaned up before merge.</span>

---

<span class="hljs-section">### 10. alexislefebvre</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`tests/HTTP/CachedHttpClientTest.php`</span>

<span class="hljs-quote">&gt; Nice test coverage — you test blacklist skipping, persistence across instances, `withOptions` cloning, and `reset`. 👍</span>
<span class="hljs-quote">&gt;
&gt; Missing cases I'd love to see:</span>
<span class="hljs-quote">&gt;
&gt; 1. A `POST` request with a body — assert that different bodies don't collide in the cache (this would catch the `md5(method.url)` bug).</span>
<span class="hljs-quote">&gt; 2. A non-buffered/streaming response (`MockResponse` with `buffer =&gt; false` is hard to fake; but at least an SSE-like response) going through `CachedResponse` without throwing.</span>
<span class="hljs-quote">&gt; 3. The cache should not be hit for `POST`/`PUT` (or should include the body in the key) — please encode that expectation in a test.</span>

---

<span class="hljs-section">### 11. Nyholm</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/HTTP/LoggedHttpClient.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; As the http-client component maintainer: don't re-implement caching. Symfony's `HttpClient` supports a `cache` option natively via the `http_cache` from the contracts, and it handles cache keys, headers, `Vary`, and ETags properly. Rolling your own `md5(method.url)` cache key is a regression waiting to happen (it already broke on POST bodies).</span>
<span class="hljs-quote">&gt;
&gt; If you keep the custom decorator, at least delegate to `CacheItemPoolInterface` semantics and include the request payload + relevant headers in the key. And please make `stream()` forward correctly — it does, but note that cached responses can never stream, which may surprise callers.</span>
<span class="hljs-quote">&gt;
&gt; Also: `FileSystemAdapter` on a single Docker container is fine, but for multi-instance deploys you'll want a shared pool (Redis). Worth a comment.</span>

---

<span class="hljs-section">### 12. jderusse</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/RAG/Builder.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The elephant in the room: the per-document vectorization loop is serial. 8691 documents, ~10s each — that's ~24h to build the RAG, and with the cache bug most embeddings were identical (same URL → same key). That's why retrieval feels broken.</span>
<span class="hljs-quote">&gt;
&gt; Fixes I'd push for:</span>
<span class="hljs-quote">&gt; * Parallelize vectorization with Symfony's `AsyncResponse` / `stream()` over batches.</span>
<span class="hljs-quote">&gt; * Include the body in the cache key (obviously).</span>
<span class="hljs-quote">&gt; * Index the docs that failed (`Index failed` ×636) with retry/backoff.</span>
<span class="hljs-quote">&gt;
&gt; Also `Builder` should checkpoint progress so a crash doesn't restart from zero.</span>

---

<span class="hljs-section">### 13. chalasr</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/MCP/Tools/ReviewAsPersonMatchingFileDiffTool.php`</span>, <span class="hljs-code">`src/Command/ServeCommand.php`</span>

<span class="hljs-quote">&gt; Tool ergonomics are good — `limit` is explicit, errors are caught and surfaced. But: the error path returns `Error retrieving reviews: {message}` as a *successful* tool result. For an MCP server, real failures should be proper exceptions/tool errors, not strings, otherwise the client can't distinguish "no data" from "server broken".</span>
<span class="hljs-quote">&gt;
&gt; Also, the query string embeds the raw diff with no size guard — a huge diff will blow the embedding context window. Truncate or chunk the diff.</span>
<span class="hljs-quote">&gt;
&gt; And the serve command: make sure `APP_DEBUG` is off in prod and there's a graceful shutdown on SIGTERM.</span>

---

<span class="hljs-section">### 14. yceruto</span>
<span class="hljs-strong">**Files:**</span> <span class="hljs-code">`src/Kernel.php`</span>, <span class="hljs-code">`src/HTTP/CachedHttpClient.php`</span>

<span class="hljs-quote">&gt; The routing of the HTTP decorators deserves attention: `LoggedHttpClient::class` is decorated by `CachedHttpClient`, so every consumer referencing it — including the Qdrant store — ends up behind the GitHub cache. That coupling is accidental.</span>
<span class="hljs-quote">&gt;
&gt; I'd restructure like this:</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; HttpClientInterface          # plain</span>
<span class="hljs-quote">&gt;  └─ logged.http_client       # logging only (for Qdrant/Ollama)</span>
<span class="hljs-quote">&gt;  └─ cached.github.http_client # cache + github token (for GitHub fetchers)</span>
<span class="hljs-quote">&gt; ```</span>
<span class="hljs-quote">&gt; Two separate chains, no cross-decorating. Then the cache key issue (URL-only, no body) also only affects GitHub GETs, which is safe.</span>
<span class="hljs-quote">&gt;
&gt; After that, the 14 `review_as_person` calls will stop returning the `buffering is disabled` error and start returning real reviews.</span>

---</code></pre>
<h2 id="pour-conclure">Pour conclure</h2>
<div style="width:100%;height:0;padding-bottom:56%;position:relative;">
    <iframe src="https://giphy.com/embed/NRiRXQTwbijNba2l2l" width="100%" height="100%" style="position:absolute" frameborder="0" class="giphy-embed" allowfullscreen></iframe>
</div>
<p><a href="https://giphy.com/gifs/The-Animal-Crackers-Movie-baking-try-it-NRiRXQTwbijNba2l2l">via GIPHY</a></p>
<p>Essayez-le vous même, le <a href="https://github.com/ktherage/symfony-review-mcp" rel="noopener noreferrer">projet</a> est conçu pour être autonome et indépendant. Les données sont publiquements accessibles, vous pouvez créé une instance Qdrant et Ollama facilement avec <a href="https://www.docker.com/" rel="noopener noreferrer">Docker</a>.</p>
<p>Pour l'installer :</p>
<pre><code class="language-bash hljs bash" translate="no">git <span class="hljs-built_in">clone</span> https://github.com/ktherage/symfony-review-mcp
<span class="hljs-built_in">cd</span> symfony-review-mcp
docker compose run --rm cli composer install
docker compose run --rm cli bin/console mcp:build
docker compose up -d</code></pre>
<p>La chose la plus surprenante que j'ai apprise en construisant ce projet : PHP est un langage parfaitement viable pour les pipelines RAG. Les composants HttpClient, Cache et Console de Symfony, combinés avec les packages <a href="https://github.com/symfony/ai" rel="noopener noreferrer"><code translate="no">symfony/ai-*</code></a>, gèrent tout, de la décoration HTTP aux opérations sur bases vectorielles. </p>
<p>Vous n'avez pas besoin de Python pour faire de la recherche sémantique.</p>
<p>Parfois, le meilleur outil pour le travail est celui que vous maîtrisez déjà.</p>]]>
    </content>
  </entry>
</feed>
