SPB Git

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

agent v3.1 : MAX_TOKENS 6000 + reprise automatique apres coupure max_tokens (2 max, sans saut de paragraphe injecte) + consigne de coherence annonces=cartes, cartes 1-8 en JSON une-ligne + widget ka-agent v4.2 : extraction cartes/choix par equilibrage d accolades — sync ka-ui 39e968d

Simon-Pierre Boucher committed (Aug 25, 2026) parent f6b831c Browse files

2 changed files +57 −22

modified src/api/routes/agent.py +26 −7
@@ -16,6 +16,10 @@
16 # son lien_fiche public ; résultats amincis (_slim) pour en montrer plus. 16 # son lien_fiche public ; résultats amincis (_slim) pour en montrer plus.
17 # L'agent peut répondre avec des CARTES d'annonces (bloc ```ka-card```) et des 17 # L'agent peut répondre avec des CARTES d'annonces (bloc ```ka-card```) et des
18 # CHOIX cliquables (bloc ```ka-choix```) rendus par le widget ka-agent.js v4. 18 # CHOIX cliquables (bloc ```ka-choix```) rendus par le widget ka-agent.js v4.
19+# v3.1 (2026-08-25) : MAX_TOKENS 6000 et REPRISE AUTOMATIQUE si la réponse est
20+# coupée à max_tokens (jusqu'à 2 reprises, sans saut de paragraphe injecté) —
21+# corrige les blocs de cartes tronqués (« 8 logements » annoncés, 1 affiché) ;
22+# consigne de cohérence : n'annoncer que ce qui est réellement montré.
19 # La clé Anthropic vit dans le .env du nœud (jamais côté navigateur). 23 # La clé Anthropic vit dans le .env du nœud (jamais côté navigateur).
20 from __future__ import annotations 24 from __future__ import annotations
21 25
@@ -36,7 +40,7 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
36 40
37 MODEL = "claude-haiku-4-5" 41 MODEL = "claude-haiku-4-5"
38 MAX_TURNS = 12 42 MAX_TURNS = 12
39−MAX_TOKENS = 3000 43+MAX_TOKENS = 6000 # assez pour 8 cartes ka-card avec de longues URLs d'images
40 RESULT_CHAR_CAP = 7000 44 RESULT_CHAR_CAP = 7000
41 45
42 SITES: dict[str, dict[str, str]] = { 46 SITES: dict[str, dict[str, str]] = {
@@ -780,14 +784,16 @@ def _system(site_id: str) -> list[dict]:
780 "Markdown quand on compare des prix ou des options ; réponses courtes et structurées. " 784 "Markdown quand on compare des prix ou des options ; réponses courtes et structurées. "
781 "CARTES : quand tu recommandes une ou plusieurs fiches précises (annonce, propriété, véhicule, produit, " 785 "CARTES : quand tu recommandes une ou plusieurs fiches précises (annonce, propriété, véhicule, produit, "
782 "resto, sortie, emploi, créateur), termine ta réponse par un bloc de cartes cliquables — un fence " 786 "resto, sortie, emploi, créateur), termine ta réponse par un bloc de cartes cliquables — un fence "
783− "```ka-card avec UN objet JSON par ligne : " 787+ "```ka-card avec UN objet JSON COMPACT par ligne (jamais de JSON étalé sur plusieurs lignes) : "
784 '{"site":"lou-ka","titre":"4½ lumineux — Limoilou","sous_titre":"Québec · 2 ch. · animaux acceptés",' 788 '{"site":"lou-ka","titre":"4½ lumineux — Limoilou","sous_titre":"Québec · 2 ch. · animaux acceptés",'
785 '"prix":"1 250 $/mois","badge":"Aubaine","image":"https://…jpg","lien":"https://www.lou-ka.com/logement/…"} ' 789 '"prix":"1 250 $/mois","badge":"Aubaine","image":"https://…jpg","lien":"https://www.lou-ka.com/logement/…"} '
786− "— 1 à 4 cartes par bloc ; champs titre/lien/site obligatoires, sous_titre/prix/badge/image optionnels ; " 790+ "— 1 à 8 cartes par bloc ; champs titre/lien/site obligatoires, sous_titre/prix/badge/image optionnels ; "
787 "site = l'id de la plateforme de la fiche (lou-ka, immo-ka, auto-ka, food-ka, fabri-ka, resto-ka, " 791 "site = l'id de la plateforme de la fiche (lou-ka, immo-ka, auto-ka, food-ka, fabri-ka, resto-ka, "
788 "sorti-ka, crea-ka, job-ka…) pour que la carte prenne ses couleurs ; lien = le champ lien_fiche fourni " 792 "sorti-ka, crea-ka, job-ka…) pour que la carte prenne ses couleurs ; lien = le champ lien_fiche fourni "
789 "par les outils (ne l'invente jamais) ; image = le champ image du résultat s'il existe. Ne répète pas " 793 "par les outils (ne l'invente jamais) ; image = le champ image du résultat s'il existe. Ne répète pas "
790− "en texte ce que la carte montre déjà. " 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. "
791 "CHOIX : si la demande est ambiguë et que tu as besoin d'UNE clarification, pose ta question puis " 797 "CHOIX : si la demande est ambiguë et que tu as besoin d'UNE clarification, pose ta question puis "
792 "termine par un fence ```ka-choix avec un objet JSON : " 798 "termine par un fence ```ka-choix avec un objet JSON : "
793 '{"question":"Pour quelle ville ?","options":["Montréal","Québec","Gatineau","Peu importe"]} ' 799 '{"question":"Pour quelle ville ?","options":["Montréal","Québec","Gatineau","Peu importe"]} '
@@ -829,7 +835,9 @@ async def agent_chat(request: Request):
829 835
830 async def gen() -> AsyncIterator[str]: 836 async def gen() -> AsyncIterator[str]:
831 convo: list[dict] = list(messages) 837 convo: list[dict] = list(messages)
832− emitted = False # du texte a déjà été streamé (tours précédents) 838+ emitted = False # du texte a déjà été streamé (tours précédents)
839+ resuming = False # tour de REPRISE après une coupure max_tokens
840+ continuations = 0
833 try: 841 try:
834 for _ in range(MAX_TURNS): 842 for _ in range(MAX_TURNS):
835 first_delta = True 843 first_delta = True
@@ -842,14 +850,25 @@ async def agent_chat(request: Request):
842 ) as stream: 850 ) as stream:
843 async for event in stream: 851 async for event in stream:
844 if event.type == "content_block_delta" and event.delta.type == "text_delta": 852 if event.type == "content_block_delta" and event.delta.type == "text_delta":
845− if first_delta and emitted: 853+ if first_delta and emitted and not resuming:
846 # saut de paragraphe entre les segments séparés par des outils 854 # saut de paragraphe entre les segments séparés par des outils
855+ # (JAMAIS en reprise : on est peut-être au milieu d'une ligne JSON)
847 yield _sse("delta", {"text": "\n\n"}) 856 yield _sse("delta", {"text": "\n\n"})
848 first_delta = False 857 first_delta = False
849 emitted = True 858 emitted = True
850 yield _sse("delta", {"text": event.delta.text}) 859 yield _sse("delta", {"text": event.delta.text})
851 response = await stream.get_final_message() 860 response = await stream.get_final_message()
852− 861+ resuming = False
862+
863+ if response.stop_reason == "max_tokens" and continuations < 2:
864+ # réponse coupée en plein vol (ex. bloc de cartes) → on la fait REPRENDRE
865+ continuations += 1
866+ resuming = True
867+ convo.append({"role": "assistant", "content": response.content})
868+ convo.append({"role": "user", "content":
869+ "[coupé] Continue ta réponse EXACTEMENT où elle s'est arrêtée "
870+ "(même au milieu d'une ligne JSON), sans rien répéter ni recommencer."})
871+ continue
853 if response.stop_reason != "tool_use": 872 if response.stop_reason != "tool_use":
854 break 873 break
855 convo.append({"role": "assistant", "content": response.content}) 874 convo.append({"role": "assistant", "content": response.content})
modified src/api/web/ka-agent.js +31 −15
@@ -20,6 +20,10 @@
20 * de clarification (bloc ```ka-choix — {question, options[]}) rendus en 20 * de clarification (bloc ```ka-choix — {question, options[]}) rendus en
21 * boutons : un clic envoie l'option comme message ; seuls les boutons de la 21 * boutons : un clic envoie l'option comme message ; seuls les boutons de la
22 * dernière bulle assistante restent actifs. 22 * dernière bulle assistante restent actifs.
23+ * v4.2 (2026-08-25) : extraction des cartes/choix par ÉQUILIBRAGE D'ACCOLADES
24+ * (jsonObjects) — tous les objets JSON d'un bloc sont trouvés quelle que soit
25+ * la mise en forme (une ligne, aéré, tableau) et un bloc tronqué en plein
26+ * streaming rend toutes ses cartes complètes au lieu d'une seule.
23 */ 27 */
24 (function () { 28 (function () {
25 "use strict"; 29 "use strict";
@@ -158,21 +162,34 @@
158 "valoplex": ["ValoPlex", "#ff9f45", "#141814"] 162 "valoplex": ["ValoPlex", "#ff9f45", "#141814"]
159 }; 163 };
160 function httpUrl(u) { return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; } 164 function httpUrl(u) { return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; }
161− function cards(lines) { 165+ /* Extrait chaque objet JSON de premier niveau d'un bloc ka-card, quelle que
162− var objs = []; 166+ soit sa mise en forme (un par ligne, aéré, tableau, bloc TRONQUÉ par le
163− lines.forEach(function (ln) { // format demandé : un JSON par ligne 167+ streaming) : balayage à équilibrage d'accolades, hors chaînes. */
164− ln = ln.trim(); 168+ function jsonObjects(txt) {
165− if (!ln) return; 169+ var objs = [], depth = 0, start = -1, inStr = false, escp = false;
166− try { objs.push(JSON.parse(ln)); } catch (e) {} // ligne incomplète (streaming) → ignorée 170+ for (var k = 0; k < txt.length; k++) {
167− }); 171+ var c = txt[k];
168− if (!objs.length) { // filet : le modèle aère parfois son JSON 172+ if (inStr) {
169− try { 173+ if (escp) escp = false;
170− var w = JSON.parse(lines.join("\n")); 174+ else if (c === "\\") escp = true;
171− objs = Array.isArray(w) ? w : [w]; 175+ else if (c === '"') inStr = false;
172− } catch (e) {} 176+ continue;
177+ }
178+ if (c === '"') { inStr = true; continue; }
179+ if (c === "{") { if (!depth) start = k; depth++; }
180+ else if (c === "}" && depth) {
181+ depth--;
182+ if (!depth && start >= 0) {
183+ try { objs.push(JSON.parse(txt.slice(start, k + 1))); } catch (e) {}
184+ start = -1;
185+ }
186+ }
173 } 187 }
188+ return objs; // un objet incomplet en fin de bloc est simplement ignoré
189+ }
190+ function cards(lines) {
174 var html = ""; 191 var html = "";
175− objs.forEach(function (o) { 192+ jsonObjects(lines.join("\n")).forEach(function (o) {
176 if (!o || !o.titre || !httpUrl(o.lien)) return; 193 if (!o || !o.titre || !httpUrl(o.lien)) return;
177 var pal = ECO[o.site] || ["Groupe KA", "var(--accent,#d9f26b)", "var(--on-accent,#141814)"]; 194 var pal = ECO[o.site] || ["Groupe KA", "var(--accent,#d9f26b)", "var(--on-accent,#141814)"];
178 var img = httpUrl(o.image); 195 var img = httpUrl(o.image);
@@ -191,8 +208,7 @@
191 return html ? '<div class="kaa-cards">' + html + "</div>" : ""; 208 return html ? '<div class="kaa-cards">' + html + "</div>" : "";
192 } 209 }
193 function choices(lines) { 210 function choices(lines) {
194− var o = null; 211+ var o = jsonObjects(lines.join("\n"))[0] || null; // bloc incomplet (streaming) → rien
195− try { o = JSON.parse(lines.join("\n")); } catch (e) { return ""; } // bloc incomplet (streaming)
196 if (!o || !Array.isArray(o.options) || !o.options.length) return ""; 212 if (!o || !Array.isArray(o.options) || !o.options.length) return "";
197 var h = '<span class="kaa-choices">' + 213 var h = '<span class="kaa-choices">' +
198 (o.question ? '<span class="kaa-choices-q">' + esc(String(o.question)) + "</span>" : ""); 214 (o.question ? '<span class="kaa-choices-q">' + esc(String(o.question)) + "</span>" : "");
199 215
Menu

Theme