agent v3.2 : outil afficher_cartes — cartes construites et injectees COTE SERVEUR (harvest uid→fiche, lien+image automatiques, nombre exact garanti) ; _sans_images retire les URLs geantes des resultats vus par le modele (le 8e resultat ne saute plus au plafond RESULT_CHAR_CAP) ; reprise auto max_tokens (v3.1) conservee
1 changed file +89 −13
modified
src/api/routes/agent.py
+89 −13
@@ -20,6 +20,10 @@ | ||
| 20 | 20 | # coupée à max_tokens (jusqu'à 2 reprises, sans saut de paragraphe injecté) — |
| 21 | 21 | # corrige les blocs de cartes tronqués (« 8 logements » annoncés, 1 affiché) ; |
| 22 | 22 | # consigne de cohérence : n'annoncer que ce qui est réellement montré. |
| 23 | +# v3.2 (2026-08-25) : outil afficher_cartes — le modèle ne passe que uid+site+ | |
| 24 | +# titre court, le SERVEUR construit le bloc ```ka-card (lien + image récoltés | |
| 25 | +# par _harvest sur les résultats d'outils du même échange) et l'injecte dans | |
| 26 | +# le flux SSE : nombre de cartes exact garanti, plus de retranscription JSON. | |
| 23 | 27 | # La clé Anthropic vit dans le .env du nœud (jamais côté navigateur). |
| 24 | 28 | from __future__ import annotations |
| 25 | 29 | |
@@ -236,6 +240,34 @@ def _slim(item: Any, site: str | None = None) -> Any: | ||
| 236 | 240 | return out |
| 237 | 241 | |
| 238 | 242 | |
| 243 | +def _sans_images(obj: Any) -> Any: | |
| 244 | + """Copie pour le MODÈLE : remplace les longues URLs d'images par un | |
| 245 | + marqueur — le serveur garde l'original (via _harvest) pour les cartes. | |
| 246 | + Évite que les URLs géantes (Facebook CDN ~450 car.) fassent déborder le | |
| 247 | + plafond RESULT_CHAR_CAP et cachent les derniers résultats au modèle.""" | |
| 248 | + if isinstance(obj, dict): | |
| 249 | + return {k: ("(image disponible — reprise automatiquement par afficher_cartes)" | |
| 250 | + if k == "image" and isinstance(v, str) and len(v) > 100 else _sans_images(v)) | |
| 251 | + for k, v in obj.items()} | |
| 252 | + if isinstance(obj, list): | |
| 253 | + return [_sans_images(v) for v in obj] | |
| 254 | + return obj | |
| 255 | + | |
| 256 | + | |
| 257 | +def _harvest(seen: dict[str, dict], obj: Any) -> None: | |
| 258 | + """Indexe (uid → fiche amincie) tout résultat d'outil portant un lien_fiche, | |
| 259 | + pour que l'outil afficher_cartes retrouve lien et image sans re-requête.""" | |
| 260 | + if isinstance(obj, dict): | |
| 261 | + uid = obj.get("uid") or obj.get("id") | |
| 262 | + if uid and obj.get("lien_fiche"): | |
| 263 | + seen[str(uid)] = obj | |
| 264 | + for v in obj.values(): | |
| 265 | + _harvest(seen, v) | |
| 266 | + elif isinstance(obj, list): | |
| 267 | + for v in obj: | |
| 268 | + _harvest(seen, v) | |
| 269 | + | |
| 270 | + | |
| 239 | 271 | # ---------------------------------------------------------------- outils |
| 240 | 272 | def _obj(props: dict, required: list[str] | None = None) -> dict: |
| 241 | 273 | return {"type": "object", "properties": props, |
@@ -491,6 +523,23 @@ TOOLS: list[dict] = [ | ||
| 491 | 523 | "city": _S, |
| 492 | 524 | }, ["address"]), |
| 493 | 525 | }, |
| 526 | + { | |
| 527 | + "name": "afficher_cartes", | |
| 528 | + "description": "AFFICHE des cartes cliquables (titre, prix, image, lien) directement dans le chat pour des fiches déjà retournées par tes recherches. Donne le uid EXACT de chaque fiche, sa plateforme et un titre court — le lien et l'image sont ajoutés automatiquement par le système. UTILISE-LE chaque fois que tu recommandes des fiches précises, avec AUTANT de cartes que d'éléments que tu annonces (jusqu'à 8). N'écris JAMAIS de bloc ```ka-card toi-même.", | |
| 529 | + "input_schema": _obj({ | |
| 530 | + "cartes": { | |
| 531 | + "type": "array", "minItems": 1, "maxItems": 8, | |
| 532 | + "items": _obj({ | |
| 533 | + "uid": {"type": "string", "description": "uid/id exact du résultat de recherche"}, | |
| 534 | + "site": {"type": "string", "enum": list(FICHE_URLS.keys())}, | |
| 535 | + "titre": {"type": "string", "description": "titre court de la carte"}, | |
| 536 | + "sous_titre": {"type": "string", "description": "ex. « Québec · 2 ch. · animaux acceptés »"}, | |
| 537 | + "prix": {"type": "string", "description": "ex. « 1 250 $/mois »"}, | |
| 538 | + "badge": {"type": "string", "description": "ex. « Aubaine », « En solde »"}, | |
| 539 | + }, ["uid", "site", "titre"]), | |
| 540 | + }, | |
| 541 | + }, ["cartes"]), | |
| 542 | + }, | |
| 494 | 543 | { |
| 495 | 544 | "name": "ka_scores_logements", |
| 496 | 545 | "description": "Statistiques des KA Scores de Lou·Ka (marchabilité, transport, services… sur les ~47 000 annonces) : moyennes et couverture.", |
@@ -782,18 +831,14 @@ def _system(site_id: str) -> list[dict]: | ||
| 782 | 831 | "FORMAT : le widget rend le Markdown — utilise-le bien : petits titres (###), listes à puces, " |
| 783 | 832 | "**gras** pour les chiffres clés, liens nommés [texte](url) vers les fiches et les sites, tableaux " |
| 784 | 833 | "Markdown quand on compare des prix ou des options ; réponses courtes et structurées. " |
| 785 | − "CARTES : quand tu recommandes une ou plusieurs fiches précises (annonce, propriété, véhicule, produit, " | |
| 786 | − "resto, sortie, emploi, créateur), termine ta réponse par un bloc de cartes cliquables — un fence " | |
| 787 | − "```ka-card avec UN objet JSON COMPACT par ligne (jamais de JSON étalé sur plusieurs lignes) : " | |
| 788 | − '{"site":"lou-ka","titre":"4½ lumineux — Limoilou","sous_titre":"Québec · 2 ch. · animaux acceptés",' | |
| 789 | − '"prix":"1 250 $/mois","badge":"Aubaine","image":"https://…jpg","lien":"https://www.lou-ka.com/logement/…"} ' | |
| 790 | − "— 1 à 8 cartes par bloc ; champs titre/lien/site obligatoires, sous_titre/prix/badge/image optionnels ; " | |
| 791 | − "site = l'id de la plateforme de la fiche (lou-ka, immo-ka, auto-ka, food-ka, fabri-ka, resto-ka, " | |
| 792 | − "sorti-ka, crea-ka, job-ka…) pour que la carte prenne ses couleurs ; lien = le champ lien_fiche fourni " | |
| 793 | − "par les outils (ne l'invente jamais) ; image = le champ image du résultat s'il existe. Ne répète pas " | |
| 794 | − "en texte ce que la carte montre déjà ; garde le texte d'accompagnement très court. " | |
| 795 | − "COHÉRENCE : n'annonce JAMAIS plus de résultats que tu n'en montres réellement — si tu écris « voici 8 " | |
| 796 | − "logements », il doit y avoir 8 cartes ; si tu en montres 4, dis 4. Compte tes cartes avant d'annoncer. " | |
| 834 | + "CARTES : quand tu recommandes des fiches précises (annonces, propriétés, véhicules, produits, restos, " | |
| 835 | + "sorties, emplois, créateurs), appelle l'outil afficher_cartes avec le uid EXACT, le site et un titre " | |
| 836 | + "court (+ prix/sous_titre/badge utiles) de CHAQUE fiche — le système construit et affiche lui-même les " | |
| 837 | + "cartes cliquables (lien et image ajoutés automatiquement). Montre autant de cartes que de fiches " | |
| 838 | + "trouvées et pertinentes (jusqu'à 8) ; si on t'en demande N, mets les N dans le même appel. Avant les " | |
| 839 | + "cartes, une phrase d'intro suffit ; après, l'outil te dit combien de cartes sont affichées — si tu " | |
| 840 | + "annonces un nombre, annonce CELUI-LÀ, et ne redécris pas les fiches en texte (pas de liste, pas de " | |
| 841 | + "liens répétés). N'écris jamais de bloc ```ka-card toi-même. " | |
| 797 | 842 | "CHOIX : si la demande est ambiguë et que tu as besoin d'UNE clarification, pose ta question puis " |
| 798 | 843 | "termine par un fence ```ka-choix avec un objet JSON : " |
| 799 | 844 | '{"question":"Pour quelle ville ?","options":["Montréal","Québec","Gatineau","Peu importe"]} ' |
@@ -838,6 +883,7 @@ async def agent_chat(request: Request): | ||
| 838 | 883 | emitted = False # du texte a déjà été streamé (tours précédents) |
| 839 | 884 | resuming = False # tour de REPRISE après une coupure max_tokens |
| 840 | 885 | continuations = 0 |
| 886 | + seen: dict[str, dict] = {} # uid → fiche amincie (pour afficher_cartes) | |
| 841 | 887 | try: |
| 842 | 888 | for _ in range(MAX_TURNS): |
| 843 | 889 | first_delta = True |
@@ -876,10 +922,40 @@ async def agent_chat(request: Request): | ||
| 876 | 922 | for block in response.content: |
| 877 | 923 | if block.type != "tool_use": |
| 878 | 924 | continue |
| 925 | + if block.name == "afficher_cartes": | |
| 926 | + # cartes construites CÔTÉ SERVEUR (lien + image récoltés des | |
| 927 | + # recherches de cet échange) et injectées dans le flux : le | |
| 928 | + # nombre affiché = le nombre demandé, sans retranscription. | |
| 929 | + lignes: list[str] = [] | |
| 930 | + for c in (dict(block.input or {}).get("cartes") or [])[:8]: | |
| 931 | + uid = str(c.get("uid") or "").strip() | |
| 932 | + hit = seen.get(uid) or {} | |
| 933 | + lien = hit.get("lien_fiche") or _fiche_url(str(c.get("site") or ""), uid) | |
| 934 | + if not uid or not lien: | |
| 935 | + continue | |
| 936 | + card: dict[str, Any] = {"site": c.get("site"), "titre": c.get("titre") or uid, "lien": lien} | |
| 937 | + for k in ("sous_titre", "prix", "badge"): | |
| 938 | + if c.get(k): | |
| 939 | + card[k] = c[k] | |
| 940 | + if hit.get("image"): | |
| 941 | + card["image"] = hit["image"] | |
| 942 | + lignes.append(json.dumps(card, ensure_ascii=False)) | |
| 943 | + if lignes: | |
| 944 | + bloc = ("\n\n" if emitted else "") + "```ka-card\n" + "\n".join(lignes) + "\n```" | |
| 945 | + yield _sse("delta", {"text": bloc}) | |
| 946 | + emitted = True | |
| 947 | + results.append({"type": "tool_result", "tool_use_id": block.id, | |
| 948 | + "content": json.dumps({ | |
| 949 | + "cartes_affichees": len(lignes), | |
| 950 | + "note": "les cartes sont déjà visibles dans le chat — si tu annonces un " | |
| 951 | + "nombre, c'est CELUI-CI ; conclus en une phrase au plus, sans " | |
| 952 | + "redécrire les fiches ni redonner les liens"}, ensure_ascii=False)}) | |
| 953 | + continue | |
| 879 | 954 | yield _sse("tool", {"name": block.name}) |
| 880 | 955 | try: |
| 881 | 956 | out = await _run_tool(block.name, dict(block.input or {})) |
| 882 | − payload = json.dumps(out, ensure_ascii=False, default=str)[:RESULT_CHAR_CAP] | |
| 957 | + _harvest(seen, out) # garde les images/liens complets côté serveur | |
| 958 | + payload = json.dumps(_sans_images(out), ensure_ascii=False, default=str)[:RESULT_CHAR_CAP] | |
| 883 | 959 | results.append({"type": "tool_result", "tool_use_id": block.id, "content": payload}) |
| 884 | 960 | except Exception as exc: # outil en échec → l'agent le sait |
| 885 | 961 | results.append({"type": "tool_result", "tool_use_id": block.id, |
| 886 | 962 | |