SPB Git

spb/ka4 Public

ka4 — explorateur structuré du web québécois (édition agressive, Groupe KA). Crawling concurrent, Claude Sonnet+Haiku, Firecrawl/Scrapfly, graphe de connaissances.

Python 97.8% Shell 2.2%
12.4 KB · 299 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3"""Contrôleur agentique rigoureux : Claude Haiku 4.5 pilote la cartographie.45Points clés :6- Méthodologie imposée (plan -> découverte -> collecte -> vérification).7- Garde anti-boucle : on rejette les actions déjà faites (mêmes URL/recherches).8- Journal de progression persistant (outil note_progress).9- AUTO-COMPACTION du contexte : quand l'historique dépasse un seuil de tokens, les10  anciens échanges sont résumés par le modèle et remplacés par un condensé, ce qui11  permet des missions très longues sans dépasser la fenêtre de contexte.12"""1314from __future__ import annotations1516import json17from typing import Any, Callable, Optional1819from anthropic import Anthropic2021TOOLS = [22    {23        "name": "update_plan",24        "description": "Établis ou révise ton plan de mission : étapes, secteurs/régions à couvrir, "25        "et prochaines actions. À appeler au début, puis quand la stratégie change.",26        "input_schema": {27            "type": "object",28            "properties": {29                "plan": {"type": "array", "items": {"type": "string"}},30                "rationale": {"type": "string"},31            },32            "required": ["plan"],33        },34    },35    {36        "name": "search_web",37        "description": "Recherche web (Firecrawl). Retourne titres, URLs et extraits.",38        "input_schema": {39            "type": "object",40            "properties": {41                "query": {"type": "string"},42                "limit": {"type": "integer", "default": 8},43            },44            "required": ["query"],45        },46    },47    {48        "name": "map_site",49        "description": "Liste rapidement les URLs d'un site (découverte). 'search' filtre par mot-clé.",50        "input_schema": {51            "type": "object",52            "properties": {"url": {"type": "string"}, "search": {"type": "string"}},53            "required": ["url"],54        },55    },56    {57        "name": "scrape_page",58        "description": "Récupère le contenu d'une page ET en extrait/enregistre le graphe "59        "(entités + relations typées avec rôles). Retourne un résumé.",60        "input_schema": {61            "type": "object",62            "properties": {"url": {"type": "string"}},63            "required": ["url"],64        },65    },66    {67        "name": "deep_dive",68        "description": "APPROFONDIT une organisation : mappe son site et scrape automatiquement ses "69        "pages clés (à propos, équipe, direction, contact) pour extraire un profil complet ET les "70        "relations (personnes ↔ organisation avec rôles). À privilégier sur toute org prometteuse "71        "pour bâtir un vrai graphe plutôt que des entités isolées.",72        "input_schema": {73            "type": "object",74            "properties": {75                "url": {"type": "string", "description": "URL ou domaine de l'organisation"},76                "max_pages": {"type": "integer", "default": 5},77            },78            "required": ["url"],79        },80    },81    {82        "name": "crawl_queue",83        "description": "Traite EN PARALLÈLE un lot d'URLs déjà en file (crawling concurrent). "84        "La frontière se remplit automatiquement à chaque page ; sers-t'en pour avancer vite.",85        "input_schema": {86            "type": "object",87            "properties": {"count": {"type": "integer", "default": 6}},88        },89    },90    {91        "name": "enqueue_urls",92        "description": "Ajoute des URLs prometteuses à la file d'exploration long terme.",93        "input_schema": {94            "type": "object",95            "properties": {"urls": {"type": "array", "items": {"type": "string"}}},96            "required": ["urls"],97        },98    },99    {100        "name": "note_progress",101        "description": "Consigne une note de progression (couverture, angles morts, pistes). "102        "Sers-t'en pour rester rigoureux et éviter les redites.",103        "input_schema": {104            "type": "object",105            "properties": {"note": {"type": "string"}},106            "required": ["note"],107        },108    },109    {110        "name": "finish",111        "description": "Termine la mission avec un bilan (couverture atteinte, entités clés, reste à faire).",112        "input_schema": {113            "type": "object",114            "properties": {"summary": {"type": "string"}},115            "required": ["summary"],116        },117    },118]119120SYSTEM = (121    "Tu es ka6, l'agent FLAGSHIP du Groupe KA. Objectif initial : cartographier à grande échelle "122    "l'écosystème des CRÉATEURS DE CONTENU et INFLUENCEURS québécois (et l'industrie autour : agences "123    "de talents/influence, marques, plateformes, collectifs).\n\n"124    "Pour chaque créateur : nom, handle, plateformes + nombre d'abonnés, niche, langues, région, et "125    "les RELATIONS (représenté par une agence, collabore avec / sponsorisé par des marques, membre d'un "126    "collectif, publie sur telles plateformes).\n\n"127    "MÉTHODE (agressive, flagship) :\n"128    "1. PLANIFIE : update_plan (niches × plateformes, agences, palmarès, marques).\n"129    "2. DÉCOUVRE en volume : search_web (ex. « top influenceurs québécois mode TikTok », "130    "« agences d'influence Montréal », « créateurs YouTube Québec gaming », palmarès, répertoires).\n"131    "3. APPROFONDIS : deep_dive sur les annuaires, rosters d'agences et pages de créateurs (scrape "132    "parallèle) — c'est là que se trouvent handles, abonnés et relations. Outil PRINCIPAL.\n"133    "4. AVANCE VITE : crawl_queue traite en parallèle la frontière qui se remplit automatiquement.\n"134    "5. COMPLÈTE : scrape_page pour une page précise; enqueue_urls pour de nouvelles pistes.\n"135    "6. VÉRIFIE : note_progress (couverture par niche/plateforme, qualité des relations).\n"136    "7. finish quand la limite est atteinte.\n\n"137    "RÈGLES : données PUBLIQUES uniquement; priorise le Québec et les LIENS créateur↔agence↔marque; "138    "ne répète pas une action; une action à la fois; raisonne brièvement. robots.txt/débit gérés par les outils."139)140141ToolFn = Callable[[dict[str, Any]], dict[str, Any]]142EventFn = Callable[[str, str], None]143144145class Controller:146    def __init__(147        self,148        client: Anthropic,149        model: str,150        tool_impls: dict[str, ToolFn],151        on_event: Optional[EventFn] = None,152        max_context_tokens: int = 120_000,153        keep_last_messages: int = 6,154    ):155        self.client = client156        self.model = model157        self.tools = tool_impls158        self.on_event = on_event or (lambda kind, msg: None)159        self.max_context_tokens = max_context_tokens160        self.keep_last_messages = keep_last_messages161        # garde anti-boucle162        self._done_actions: set[str] = set()163164    # -- boucle principale -------------------------------------------------165    def run(self, goal: str, seed: str | None = None, max_steps: int = 40,166            should_abort: Optional[Callable[[], bool]] = None) -> str:167        user = f"Objectif de mission : {goal}"168        if seed:169            user += f"\nPoint de départ suggéré : {seed}"170        messages: list[dict[str, Any]] = [{"role": "user", "content": user}]171172        for step in range(max_steps):173            if should_abort and should_abort():174                self.on_event("agent", "Mission interrompue par l'utilisateur.")175                return "Interrompu par l'utilisateur."176            messages = self._maybe_compact(messages)177            resp = self.client.messages.create(178                model=self.model,179                max_tokens=1600,180                system=SYSTEM,181                tools=TOOLS,182                messages=messages,183            )184            messages.append({"role": "assistant", "content": resp.content})185186            tool_uses = [b for b in resp.content if b.type == "tool_use"]187            for b in resp.content:188                if b.type == "text" and b.text.strip():189                    self.on_event("agent", b.text.strip()[:500])190191            if not tool_uses:192                return self._final(resp)193194            results = []195            for tu in tool_uses:196                if tu.name == "finish":197                    summary = tu.input.get("summary", "Mission terminée.")198                    self.on_event("agent", f"FIN : {summary[:300]}")199                    return summary200201                out = self._dispatch(tu.name, tu.input)202                results.append(203                    {"type": "tool_result", "tool_use_id": tu.id, "content": _stringify(out)}204                )205            messages.append({"role": "user", "content": results})206207        return "Budget d'étapes épuisé (mission non conclue explicitement)."208209    # -- exécution d'un outil (avec garde anti-boucle) --------------------210    def _dispatch(self, name: str, inp: dict[str, Any]) -> Any:211        key = name + ":" + json.dumps(inp, ensure_ascii=False, sort_keys=True)212        if name in ("search_web", "map_site", "scrape_page", "deep_dive") and key in self._done_actions:213            return {"skipped": "action déjà effectuée — change d'angle ou d'URL."}214        self._done_actions.add(key)215216        self.on_event("agent", f"→ {name}({_short(inp)})")217        fn = self.tools.get(name)218        if not fn:219            return {"error": f"outil inconnu: {name}"}220        try:221            return fn(inp)222        except Exception as e:  # noqa: BLE001223            self.on_event("error", f"{name}: {e}")224            return {"error": str(e)}225226    # -- auto-compaction ---------------------------------------------------227    def _maybe_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:228        if self._estimate_tokens(messages) < self.max_context_tokens:229            return messages230        if len(messages) <= self.keep_last_messages + 2:231            return messages232233        cut = self._safe_cut(messages)234        if cut <= 1:235            return messages236237        head, tail = messages[:cut], messages[cut:]238        summary = self._summarize(head)239        self.on_event("info", f"Contexte compacté : {len(head)} messages résumés.")240        return [{"role": "user", "content": f"[MÉMOIRE COMPACTÉE — progrès à ce jour]\n{summary}"}] + tail241242    def _safe_cut(self, messages: list[dict[str, Any]]) -> int:243        """Coupe à une frontière valide : juste après un message user (tool_result),244        pour que le premier message conservé soit un assistant et que toute paire245        tool_use/tool_result reste intacte."""246        target = len(messages) - self.keep_last_messages247        cut = 0248        for i in range(min(target, len(messages) - 1)):249            if messages[i]["role"] == "user":250                cut = i + 1251        return cut252253    def _summarize(self, head: list[dict[str, Any]]) -> str:254        transcript = _stringify(head, limit=40000)255        try:256            resp = self.client.messages.create(257                model=self.model,258                max_tokens=1200,259                system=(260                    "Résume l'avancement d'une mission de cartographie web pour poursuivre sans perdre "261                    "le contexte. Conserve : plan courant, secteurs/régions couverts, URLs et domaines "262                    "déjà traités, entités clés trouvées, pistes en attente, angles morts. Sois dense et factuel."263                ),264                messages=[{"role": "user", "content": f"Historique à résumer :\n{transcript}"}],265            )266            return "".join(b.text for b in resp.content if b.type == "text").strip() or "(résumé vide)"267        except Exception as e:  # noqa: BLE001268            return f"(échec du résumé: {e})"269270    def _estimate_tokens(self, messages: list[dict[str, Any]]) -> int:271        return len(_stringify(messages, limit=10**9)) // 3272273    def _final(self, resp) -> str:274        return "".join(b.text for b in resp.content if b.type == "text").strip() or "(fin sans appel d'outil)"275276277def _short(d: dict[str, Any]) -> str:278    s = json.dumps(d, ensure_ascii=False)279    return s if len(s) <= 120 else s[:117] + "..."280281282def _stringify(obj: Any, limit: int = 6000) -> str:283    try:284        s = json.dumps(obj, ensure_ascii=False, default=_json_default)285    except Exception:286        s = str(obj)287    return s[:limit]288289290def _json_default(o: Any) -> Any:291    # Les blocs de contenu Anthropic ne sont pas JSON-sérialisables nativement.292    for attr in ("model_dump", "dict", "to_dict"):293        if hasattr(o, attr):294            try:295                return getattr(o, attr)()296            except Exception:297                pass298    return str(o)299