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