spb/ka2 Public
ka2 — explorateur structuré du web québécois (édition légère, Groupe KA). Bot scraper+IA → graphe de connaissances. Claude Haiku + Firecrawl/Scrapfly.
Python 98.1%
Shell 1.9%
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