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.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 1 mo ago (Aug 25, 2026) parent f6b831c

2 changed files +57 −22

modified src/api/routes/agent.py +26 −7
@@ -16,6 +16,10 @@
16 16 # son lien_fiche public ; résultats amincis (_slim) pour en montrer plus.
17 17 # L'agent peut répondre avec des CARTES d'annonces (bloc ```ka-card```) et des
18 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 23 # La clé Anthropic vit dans le .env du nœud (jamais côté navigateur).
20 24 from __future__ import annotations
21 25
@@ -36,7 +40,7 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
36 40
37 41 MODEL = "claude-haiku-4-5"
38 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 44 RESULT_CHAR_CAP = 7000
41 45
42 46 SITES: dict[str, dict[str, str]] = {
@@ -780,14 +784,16 @@ def _system(site_id: str) -> list[dict]:
780 784 "Markdown quand on compare des prix ou des options ; réponses courtes et structurées. "
781 785 "CARTES : quand tu recommandes une ou plusieurs fiches précises (annonce, propriété, véhicule, produit, "
782 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 788 '{"site":"lou-ka","titre":"4½ lumineux — Limoilou","sous_titre":"Québec · 2 ch. · animaux acceptés",'
785 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 791 "site = l'id de la plateforme de la fiche (lou-ka, immo-ka, auto-ka, food-ka, fabri-ka, resto-ka, "
788 792 "sorti-ka, crea-ka, job-ka…) pour que la carte prenne ses couleurs ; lien = le champ lien_fiche fourni "
789 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 797 "CHOIX : si la demande est ambiguë et que tu as besoin d'UNE clarification, pose ta question puis "
792 798 "termine par un fence ```ka-choix avec un objet JSON : "
793 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 836 async def gen() -> AsyncIterator[str]:
831 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 841 try:
834 842 for _ in range(MAX_TURNS):
835 843 first_delta = True
@@ -842,14 +850,25 @@ async def agent_chat(request: Request):
842 850 ) as stream:
843 851 async for event in stream:
844 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 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 856 yield _sse("delta", {"text": "\n\n"})
848 857 first_delta = False
849 858 emitted = True
850 859 yield _sse("delta", {"text": event.delta.text})
851 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 872 if response.stop_reason != "tool_use":
854 873 break
855 874 convo.append({"role": "assistant", "content": response.content})
modified src/api/web/ka-agent.js +31 −15
@@ -20,6 +20,10 @@
20 20 * de clarification (bloc ```ka-choix — {question, options[]}) rendus en
21 21 * boutons : un clic envoie l'option comme message ; seuls les boutons de la
22 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 28 (function () {
25 29 "use strict";
@@ -158,21 +162,34 @@
158 162 "valoplex": ["ValoPlex", "#ff9f45", "#141814"]
159 163 };
160 164 function httpUrl(u) { return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; }
161 − function cards(lines) {
162 − var objs = [];
163 − lines.forEach(function (ln) { // format demandé : un JSON par ligne
164 − ln = ln.trim();
165 − if (!ln) return;
166 − try { objs.push(JSON.parse(ln)); } catch (e) {} // ligne incomplète (streaming) → ignorée
167 − });
168 − if (!objs.length) { // filet : le modèle aère parfois son JSON
169 − try {
170 − var w = JSON.parse(lines.join("\n"));
171 − objs = Array.isArray(w) ? w : [w];
172 − } catch (e) {}
165 + /* Extrait chaque objet JSON de premier niveau d'un bloc ka-card, quelle que
166 + soit sa mise en forme (un par ligne, aéré, tableau, bloc TRONQUÉ par le
167 + streaming) : balayage à équilibrage d'accolades, hors chaînes. */
168 + function jsonObjects(txt) {
169 + var objs = [], depth = 0, start = -1, inStr = false, escp = false;
170 + for (var k = 0; k < txt.length; k++) {
171 + var c = txt[k];
172 + if (inStr) {
173 + if (escp) escp = false;
174 + else if (c === "\\") escp = true;
175 + else if (c === '"') inStr = false;
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 191 var html = "";
175 − objs.forEach(function (o) {
192 + jsonObjects(lines.join("\n")).forEach(function (o) {
176 193 if (!o || !o.titre || !httpUrl(o.lien)) return;
177 194 var pal = ECO[o.site] || ["Groupe KA", "var(--accent,#d9f26b)", "var(--on-accent,#141814)"];
178 195 var img = httpUrl(o.image);
@@ -191,8 +208,7 @@
191 208 return html ? '<div class="kaa-cards">' + html + "</div>" : "";
192 209 }
193 210 function choices(lines) {
194 − var o = null;
195 − try { o = JSON.parse(lines.join("\n")); } catch (e) { return ""; } // bloc incomplet (streaming)
211 + var o = jsonObjects(lines.join("\n"))[0] || null; // bloc incomplet (streaming) → rien
196 212 if (!o || !Array.isArray(o.options) || !o.options.length) return "";
197 213 var h = '<span class="kaa-choices">' +
198 214 (o.question ? '<span class="kaa-choices-q">' + esc(String(o.question)) + "</span>" : "");
199 215