SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

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

Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 56031a8

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