| 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 |
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 |
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 |
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 |
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}) |