#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Serveur local pour l'app budget : sert index.html et proxifie /api/chat vers l'API Anthropic.
# La cle API est lue depuis apikey.txt (a cote de ce fichier), jamais dans le navigateur.
import http.server, socketserver, json, os, sys, urllib.request, webbrowser, threading

HERE = os.path.dirname(os.path.abspath(__file__))
PORT = 8737
import time, collections, threading as _th
# --- Serveur PUBLIC (VM): CORS pour l'app Netlify, jeton obligatoire, plafond anti-abus ---
ALLOW_ORIGIN = "https://budget-timothee.netlify.app"
TOKEN = "PangolinC4bien"          # meme jeton que la synchro (present cote app)
RL_MAX = 120                       # requetes max par fenetre
RL_WINDOW = 3600                   # fenetre en secondes (1h)
_rl_lock = _th.Lock(); _rl_hits = collections.deque()
def _rate_ok():
    now = time.time()
    with _rl_lock:
        while _rl_hits and now - _rl_hits[0] > RL_WINDOW: _rl_hits.popleft()
        if len(_rl_hits) >= RL_MAX: return False
        _rl_hits.append(now); return True

# Sonnet 5 (meilleure memoire/raisonnement) MAIS avec effort reduit (output_config.effort) pour qu'il
# reflechisse moins longtemps => rapide, ne se bloque plus. Effort "low": plan complet en ~24s sans blocage.
# Repli sur 4-6 (qui n'accepte pas output_config, retire dans anthropic_call) si la cle n'a pas Sonnet 5.
MODEL = "claude-sonnet-5"
FALLBACK_MODEL = "claude-sonnet-4-6"
EFFORT_CHAT = "low"
EFFORT_JSON = "low"

def read_key():
    p = os.path.join(HERE, "apikey.txt")
    try:
        return open(p, encoding="utf-8").read().strip()
    except Exception:
        return ""

OP_ITEMS = {
    "type": "object",
    "properties": {
        "libelle": {"type": "string"},
        "montant": {"type": "number"},
        "categorie": {"type": "string"},
        "nature": {"type": "string", "enum": ["depense", "versement"]},
        "date": {"type": "string", "description": "date de l'operation si visible (AAAA-MM-JJ ou JJ/MM)"},
    },
    "required": ["libelle", "montant"],
}
PARSE_TOOL = {
    "name": "extract_ops",
    "description": "Renvoie les operations lues sur une capture bancaire.",
    "input_schema": {
        "type": "object",
        "properties": {
            "compte": {"type": "string"},
            "depenses": {"type": "array", "items": OP_ITEMS},
            "rejets": {"type": "array", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}}, "required": ["libelle", "montant"]}},
            "note": {"type": "string"},
        },
        "required": ["depenses", "rejets"],
    },
}
ASSIST_TOOL = {
    "name": "update_budget",
    "description": "Propose des mises a jour du budget a partir d'une capture et d'une consigne.",
    "input_schema": {
        "type": "object",
        "properties": {
            "message": {"type": "string"},
            "actions": {
                "type": "object",
                "properties": {
                    "setPockets": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "remaining": {"type": "number"}}, "required": ["name", "remaining"]}},
                    "setDispo": {"type": "number"},
                    "addDepenses": {"type": "array", "items": OP_ITEMS},
                    "addRejets": {"type": "array", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}}, "required": ["libelle", "montant"]}},
                    "setAmex": {"type": "number", "description": "solde BRUT du cycle Amex lu sur l'appli Amex (depenses + tiers 3x compris), SANS l'abonnement: l'app ajoute l'abonnement par-dessus"},
                    "setAmexAbo": {"type": "number", "description": "abonnement mensuel Amex (defaut 53), toujours anticipe dans le solde courant"},
                    "setSavings": {"type": "number"},
                    "newPeriod": {"type": "boolean"},
                    "periodStartDate": {"type": "string", "description": "date de debut du nouveau mois (AAAA-MM-JJ), = date du versement du salaire CE"},
                    "amexEchelonne": {"type": "array", "description": "depense Amex payee en plusieurs fois", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "total": {"type": "number"}, "mois": {"type": "number"}}, "required": ["total", "mois"]}},
                    "regularise": {"type": "array", "description": "prelevement/rejet regularise (paye CB, capture, arriere solde)", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}}, "required": ["libelle"]}},
                    "addIncome": {"type": "array", "description": "rentree d'argent; mois=AAAA-MM si future (ex prime)", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}, "mois": {"type": "string"}}, "required": ["montant"]}},
                    "addOblig": {"type": "array", "description": "AJOUTER une echeance au Plan (mensuelle par defaut, ou ponctuelle datee via moisPonctuel)", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}, "jour": {"type": "number"}, "prio": {"type": "number", "description": "0 sanctuarise,1 critique,2 important,3 compressible"}, "rejetable": {"type": "boolean", "description": "true=rattrapable CB, false=a honorer"}, "compte": {"type": "string", "description": "lcl|sg|bourso|revolut"}, "arriere": {"type": "number"}, "maxRejets": {"type": "number", "description": "nb rejets successifs toleres avant regul IMPERATIVE (1=un seul retard possible)"}, "moisPonctuel": {"type": "string", "description": "AAAA-MM: echeance ponctuelle ce mois-la seulement"}, "note": {"type": "string"}}, "required": ["libelle", "montant"]}},
                    "removeOblig": {"type": "array", "description": "SUPPRIMER une echeance (par libelle). Uniquement apres confirmation explicite.", "items": {"type": "object", "properties": {"libelle": {"type": "string"}}, "required": ["libelle"]}},
                    "setOblig": {"type": "array", "description": "MODIFIER une echeance (par libelle), seulement les champs a changer. compte pour changer de compte (ex Floa sur revolut), maxRejets pour la tolerance (1=un seul retard).", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "prio": {"type": "number"}, "rejetable": {"type": "boolean"}, "montant": {"type": "number"}, "jour": {"type": "number"}, "arriere": {"type": "number"}, "compte": {"type": "string"}, "maxRejets": {"type": "number"}, "moisPonctuel": {"type": "string"}, "nouveauLibelle": {"type": "string"}}, "required": ["libelle"]}},
                    "addMemo": {"type": "array", "description": "MEMOIRE DURABLE: fait/correction/montant recurrent/priorite/preference a retenir POUR TOUJOURS. A utiliser des que Timothee enonce une info durable, MEME sans dire 'retiens que'. Phrase concise et autoportante. Pas si deja dans REGLES PERMANENTES du contexte. En cas de conflit avec une regle existante, demande d'abord confirmation, puis forgetMemo l'ancienne + addMemo la nouvelle.", "items": {"type": "object", "properties": {"texte": {"type": "string"}}, "required": ["texte"]}},
                    "forgetMemo": {"type": "array", "description": "Retire de la memoire durable toute regle contenant ce bout de texte. Pour corriger/remplacer une regle perimee ou contredite (apres confirmation de Timothee): forgetMemo{contient} l'ancienne puis addMemo la nouvelle.", "items": {"type": "object", "properties": {"contient": {"type": "string"}}, "required": ["contient"]}},
                    "addJournal": {"type": "array", "description": "JOURNAL DUREE des EVENEMENTS et DECISIONS (rejet, regularisation, paiement, choix de plan, date cle). Des que Timothee annonce un evenement, ajoute une ligne datee ICI (en plus de l'action structuree comme addRejets/regularise) pour en garder une trace durable relue a chaque session. texte concis; date=AAAA-MM-JJ si connue.", "items": {"type": "object", "properties": {"texte": {"type": "string"}, "date": {"type": "string"}}, "required": ["texte"]}},
                    "setMonthPlan": {"type": "object", "description": "VALIDER LE PLAN D'UN MOIS DONNE, propre a ce mois (sans figer les autres). Utilise ceci, PAS setOblig. mois=AAAA-MM. reset=true efface le mois. TOUJOURS remplir 'plan' quand tu valides: il ECRIT DIRECTEMENT les rubriques visuelles de l'onglet Plan (a honorer, laisse en rejet, repartition) exactement comme tu les as chiffrees, au lieu du calcul auto. 'arbitrages' pour les garde-fous (laisser filer).", "properties": {"mois": {"type": "string"}, "arbitrages": {"type": "array", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "prio": {"type": "number"}, "rejetable": {"type": "boolean"}, "laisserFiler": {"type": "boolean"}}, "required": ["libelle"]}}, "plan": {"type": "object", "description": "plan valide complet ecrit dans les rubriques visuelles", "properties": {"aHonorer": {"type": "array", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}, "compte": {"type": "string"}}, "required": ["libelle", "montant"]}}, "rejet": {"type": "array", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}, "rattrapable": {"type": "boolean"}}, "required": ["libelle", "montant"]}}, "virements": {"type": "array", "items": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}, "montant": {"type": "number"}}, "required": ["from", "to", "montant"]}}, "garder": {"type": "array", "items": {"type": "object", "properties": {"compte": {"type": "string"}, "montant": {"type": "number"}, "detail": {"type": "string"}}, "required": ["compte", "montant"]}}, "pockets": {"type": "array", "items": {"type": "object", "properties": {"nom": {"type": "string"}, "montant": {"type": "number"}}, "required": ["nom", "montant"]}}, "reste": {"type": "number"}, "resume": {"type": "string"}}}, "note": {"type": "string"}, "reset": {"type": "boolean"}}, "required": ["mois"]},
                    "removeDepense": {"type": "array", "description": "ANNULER une depense deja enregistree ce mois (ex DOUBLON). Reconnue par libelle + montant dans DEPENSES DEJA ENREGISTREES du contexte. Reprend le montant sur l'enveloppe ou l'Amex.", "items": {"type": "object", "properties": {"libelle": {"type": "string"}, "montant": {"type": "number"}}, "required": ["libelle"]}},
                    "addPocket": {"type": "array", "description": "CRÉER/configurer une enveloppe (pocket). name, budget=provisionnement mensuel, reserved=true si dédiée à des prélèvements, emoji optionnel. Regarde la liste Enveloppes du contexte avant, ne demande pas si elle existe.", "items": {"type": "object", "properties": {"name": {"type": "string"}, "budget": {"type": "number"}, "reserved": {"type": "boolean"}, "emoji": {"type": "string"}}, "required": ["name"]}},
                    "setPocketBudget": {"type": "array", "description": "Changer le provisionnement mensuel (budget) d'une enveloppe existante, sans toucher au solde réel.", "items": {"type": "object", "properties": {"name": {"type": "string"}, "budget": {"type": "number"}}, "required": ["name", "budget"]}},
                    "removePocket": {"type": "array", "description": "Supprimer une enveloppe (par nom). Jamais sans confirmation.", "items": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
                    "transferPocket": {"type": "array", "description": "VIREMENT INTERNE (NI depense NI revenu, net nul): entre deux emplacements d'argent. from/to = un COMPTE ('LCL','SG','Boursorama','Revolut'/'Personnel') OU un nom d'enveloppe. Sert aussi aux virements entre tes propres banques (un 'virement recu de toi-meme' = interne, PAS un revenu). Baisse from, augmente to. date=AAAA-MM-JJ: si un solde a ete resynchronise apres cette date (voir 'a jour au' du contexte), l'app ignore ce cote (anti double-comptage). Jamais dans addDepenses NI addIncome.", "items": {"type": "object", "properties": {"from": {"type": "string"}, "to": {"type": "string"}, "montant": {"type": "number"}, "date": {"type": "string"}}, "required": ["montant"]}},
                },
            },
        },
        "required": ["message", "actions"],
    },
}

SYS_CHAT = ("Tu es Coach Pango, un pangolin bienveillant et un peu farceur, le coach budgetaire personnel de Timothee, haut fonctionnaire francais tres endette et en cours de divorce, "
            "qui gagne bien sa vie mais subit des rejets de prelevements en cascade. Tu es chaleureux, direct, un peu drole, jamais "
            "moralisateur. Tu parles francais, JAMAIS de tirets cadratins. Tu chiffres. Tu connais son budget via le contexte. "
            "Reponses courtes, humaines, actionnables. MISE EN FORME (le chat n'affiche PAS le markdown): jamais d'asterisques (**) ni de dièses (#); pour toute presentation chiffree, un VRAI retour a la ligne apres chaque information (une info par ligne), une ligne vide entre sections.\n"
            "TU PEUX AGIR DIRECTEMENT SUR L'APP (toute l'app, Plan compris), pas seulement conseiller. Quand Timothee te demande d'enregistrer, modifier ou organiser quelque chose, APPELLE l'outil update_budget avec les bonnes actions et mets dans message une phrase qui confirme. Tu peux: soldes de comptes (setLcl/setSg/setBourso/setRevolut), enveloppes/pockets (tu VOIS la liste Enveloppes du contexte, ne demande jamais si une existe: setPockets solde, addPocket créer/configurer, setPocketBudget provisionnement mensuel, removePocket avec confirmation), Amex (setAmex), epargne (setSavings), depenses (addDepenses), annuler un doublon de depense (removeDepense: tu vois les DEPENSES DEJA ENREGISTREES dans le contexte), virement interne Personnel<->enveloppe ou enveloppe<->enveloppe qui n'est PAS une depense (transferPocket: from,to,montant), rejets (addRejets), etalement Amex (amexEchelonne), regularisation (regularise), rentree (addIncome), regle a retenir (addMemo), et le PLAN: addOblig (ajouter une echeance), setOblig (modifier montant/jour/priorite/rejetable=rattrapable CB/arriere/renommer), removeOblig (supprimer, JAMAIS sans confirmation explicite: demande d'abord). rejetable:true = rattrapable par CB en fin de mois; false = a honorer absolument. Pour 'faire le plan' ou saisir plusieurs echeances, emets plusieurs addOblig/setOblig d'un coup. NE DIS JAMAIS que tu n'as pas acces a l'app: tu appliques les changements toi-meme via l'outil. Pour une simple question ou un conseil, reponds en texte SANS appeler l'outil. Ne redemande jamais une info deja dans le PLAN ou les REGLES PERMANENTES. PLAN SUR 3 MOIS: le contexte contient 'PLAN D'AMELIORATION SUR 3 MOIS' = la cascade du salaire deja calculee pour les 3 prochains mois (CE MOIS + les deux suivants nommes). Quand on te dit 'fais/refais le plan', 'le plan de septembre' (ou un autre mois) ou une repartition: presente EN TEXTE le mois demande (defaut = mois en cours) en DEUX blocs (A HONORER + reste; LAISSE EN REJET avec retard/a venir et rattrapable ou non), PUIS sa cloture (reste/manque, progres vs mois precedent, dette reportee a faire baisser pour apurer) et sa repartition detaillee (garder sur LCL/SG/Bourso pour leurs credits, virer vers Revolut pour remplir les pockets). Utilise les chiffres du contexte, ne recalcule pas. Pas une action outil pour la simple presentation. TU AS DEJA TOUT DANS LE CONTEXTE POUR LE PLAN: soldes de TOUS les comptes (ligne 'Comptes: Revolut/LCL/SG/Boursobank'), DATE de prochaine paie, salaire, pockets, echeances/credits, cascade 3 mois. NE REDEMANDE JAMAIS un solde de compte ni la date de paie, sers-t'en directement et sors le plan. Ne pose une question QUE si une donnee est vraiment absente, jamais 'pour confirmer'. Puis propose des arbitrages selon priorites et plafonds de rejet. VALIDATION D'UN MOIS (propre a ce mois, sans figer les autres): quand Timothee valide le plan d'un mois precis, utilise setMonthPlan et PAS setOblig. TRES IMPORTANT: remplis le champ 'plan' avec ton plan validé complet (aHonorer avec montants+compte, rejet, virements LCL/SG->Revolut, garder par compte, pockets, reste, resume): il ECRIT DIRECTEMENT les rubriques visuelles de l'onglet Plan pour ce mois, exactement comme tu les as chiffrees. Remplis aussi 'arbitrages' (laisserFiler) pour les garde-fous. Pas juste un 'note'. Les arbitrages ne valent QUE pour ce mois: le mois suivant repart AUTOMATIQUEMENT des priorites par defaut, les choix du mois precedent deviennent caducs sauf si Timothee valide separement ce mois. N'utilise setOblig QUE pour un changement PERMANENT (tous les mois). Reset d'un mois: setMonthPlan{mois, reset:true}. PLANIFIER PLUSIEURS MOIS D'UN COUP (tu SAIS faire, ne dis jamais 'je n'ai pas de reponse'): quand Timothee demande un scenario sur 2-3 mois avec une strategie d'arbitrage (ex 'regularise completement l'immo en octobre = regul de septembre + echeance normale d'octobre, et decale au maximum les credits conso rejetables vers novembre'), tu prends les chiffres de la cascade 3 mois du contexte, tu presentes octobre PUIS novembre l'un apres l'autre (chacun: a honorer + reste, laisse en rejet, cloture, repartition), tu appliques la strategie demandee (immo double en octobre, conso repousses en novembre dans la limite de 2 rejets d'affilee), tu DIS clairement si c'est tenable ou tendu (reste pour vivre, plafonds de rejet respectes ou non), et tu proposes de valider chaque mois via setMonthPlan{mois} separement. Un scenario multi-mois est TOUJOURS une reponse en texte chiffree, pas un refus. REPARTITION DE DEBUT DE MOIS: elle ALIMENTE les pockets (paye/'nouveau mois' => newPeriod=true remet chaque pocket a son budget). Donne la ventilation: combien virer de LCL et SG vers Revolut, puis combien alimenter dans chaque pocket (jusqu'a son budget, une par ligne); ajuste au besoin via setPockets. Un retour a la ligne par information. Quand on te demande le detail d'un credit/prelevement, presente ce que le PLAN contient (libelle, montant, arriere, jour, compte, priorite, note); ne dis jamais que tu n'as pas les details. Distingue les 3 Boursorama: Pret 80404 (a jour), Pret 80438 (recouvrement, impaye 174,73), Apurement decouvert (pas un pret). Le montant MENSUEL de chaque credit du Plan est DEJA connu (affiche apres le libelle, ex '114'): ne dis JAMAIS qu'il te manque le montant mensuel d'un credit deja liste, meme si son libelle porte un statut entre parentheses comme '(a jour)' ou '(recouvrement)' (c'est une note, pas une absence de montant). Utilise directement ce montant. La SEULE chose que tu peux demander cote credits est le solde Amex BRUT du cycle a VENIR (valeur future pas encore connue).\n"
            "TOLERANCE AUX REJETS (Cofidis Accessio, Cofidis renouvelable, Pret conso Boursorama 80404, Floa, Cetelem, et par defaut les credits rattrapables): 1 rejet => rattrapage du mois suivant PRIORITAIRE mais pas impératif (peut encore glisser); 2 rejets mensuels successifs => au debut du mois suivant le 2e rejet, remboursement des 2 echeances IMPERATIF (l'app le rend non rejetable et le remonte en priorite). La cascade 3 mois applique deja cette regle; ne propose pas un 3e rejet d'affilee d'un meme credit sauf demande explicite.\n"
            "POCKETS JAMAIS NEGATIVES: une enveloppe ne peut pas passer sous zero. Vide (0 restant), toute depense de plus sort du compte EUR principal Revolut (Personnel); l'app le fait toute seule. Ne parle jamais d'une pocket 'a -50', dis 0 et surplus passe sur le compte principal.\n"
            "CYCLE AMEX AUTO: des le 7 (arrete du 6 passe), l'app cloture le cycle: ancien solde (brut + abonnement) => prelevement LCL date du 11, solde courant repart a zero (abonnement reconduit). Reflete juste les chiffres du contexte.\n"
            "AMEX (carte a DEBIT DIFFERE), SOLDE ANTICIPE: le contexte donne le solde BRUT du cycle (depenses + tiers des 3x compris, comme sur l'appli Amex) ET le SOLDE COURANT ANTICIPE = brut + abonnement mensuel. Le prelevement tombe sur LCL le 10-12, le MEME mois que l'arrete du 6 (quelques jours apres, PAS le mois suivant). Quand tu parles de ce qui sera preleve, cite TOUJOURS le solde courant anticipe (abonnement et tiers des 3x compris), pas le brut. Le solde lu sur l'appli Amex par Timothee => setAmex avec ce chiffre BRUT (n'y ajoute pas l'abonnement, l'app le fait). Changer l'abonnement => setAmexAbo.\n"
            "ACTUALISATION DE SOLDES = ACTION IMMEDIATE (REGLE ABSOLUE, cause de frustration recurrente de Timothee): des qu'il te donne un nouveau solde ou une resynchro (solde Amex, une ou plusieurs pockets/enveloppes, un compte LCL/SG/Boursorama/Revolut, l'epargne), tu DOIS immediatement l'ecrire via l'ACTION structuree correspondante (setAmex pour le solde brut Amex, setPockets pour les enveloppes, setLcl/setSg/setBourso/setRevolut pour les comptes, setSavings pour l'epargne). JAMAIS via addMemo: un solde n'est PAS une regle memoire. INTERDIT de repondre 'c'est note / je retiens / je garde ca' pour un solde sans avoir appele l'action: sinon RIEN n'est enregistre et tout est perdu a la prochaine conversation (c'est exactement le bug a eliminer). Apres avoir applique, CONFIRME en citant le nouveau chiffre enregistre ET redonne l'etat a jour de l'ensemble concerne (ex: nouveau solde Amex courant anticipe + ce qui sera preleve; ou la liste des pockets resynchronisees). Ces soldes sont sauvegardes et synchronises tout seuls, donc ils restent d'une conversation a l'autre. Si un chiffre manque ou est ambigu, demande-le, mais des que tu l'as, applique-le sans dire seulement que tu l'as note.\n"
            "MEMOIRE DURABLE, TU LA TIENS TOI-MEME (priorite numero 1 pour ne PLUS JAMAIS oublier). Le contexte contient REGLES PERMANENTES = ta memoire durable, un fichier que TU ecris au fil de l'eau. Reflexes obligatoires:\n"
            "1) MEMORISER: des que Timothee enonce un FAIT durable, une CORRECTION d'un chiffre, un MONTANT recurrent, une PRIORITE, une PREFERENCE ou une facon de faire (workflow), tu appelles addMemo{texte} (une phrase concise et autoportante). N'attends PAS qu'il dise 'retiens que'. Dans le doute, memorise.\n"
            "2) CONFIRMER: quand tu memorises, dis-le en une ligne claire ('Noté et gardé pour de bon: ...'), pour qu'il sache que c'est acquis.\n"
            "3) DETECTER LES CONFLITS ET L'AMBIGUITE: AVANT de memoriser, compare la nouvelle consigne aux REGLES PERMANENTES existantes. Si elle en CONTREDIT une (ex un montant ou une priorite different de ce qui est deja retenu), ou si elle est AMBIGUE / pas claire, tu NE memorises PAS en silence: tu poses UNE question courte qui pointe le conflit ('Tu m'avais dit X, la tu dis Y: je remplace X par Y ?') ou qui leve l'ambiguite, et tu attends sa reponse. Une fois qu'il tranche, tu mets a jour: forgetMemo{contient:<bout de l'ancienne regle>} pour retirer la regle perimee, puis addMemo{texte} pour la nouvelle. Ne laisse jamais deux regles contradictoires cohabiter.\n"
            "4) NE PAS INVENTER: n'inscris jamais comme durable un chiffre que TU as deduit ou suppose; seulement ce que Timothee a dit ou un document montre. Un chiffre incertain => tu le lui demandes avant de le graver.\n"
            "5) NE REDEMANDE JAMAIS une info deja dans REGLES PERMANENTES ou le PLAN: relis-les avant toute question. Si tu corriges un chiffre important (echeance, solde, priorite) applique AUSSI l'action correspondante (setOblig, setLcl...) en plus de addMemo.\n"
            "RAISONNEMENT (prends ton temps, raisonne par etapes avant de repondre): sur un sujet emmele (plusieurs credits qui se ressemblent, cascade, rejets, deux lignes immo), 1) identifie l'ENTITE exacte concernee (libelle/alias precis), ne fusionne JAMAIS deux credits distincts; 2) verifie le CONTEXTE, le PLAN et le JOURNAL avant d'affirmer quoi que ce soit; 3) si c'est ambigu, pose une question avant d'agir. EVENEMENTS: quand Timothee annonce un rejet, une regularisation, un paiement ou une decision de plan, fais l'action structuree (addRejets/regularise/setMonthPlan/setOblig) ET addJournal{texte,date} pour la trace, puis confirme. Avant de dire 'je n'ai pas de trace', RELIS le JOURNAL du contexte.\n\nEtat actuel du budget:\n{ctx}")
SYS_PARSE = ("Tu lis une capture d'ecran d'un compte bancaire (Revolut, LCL, SG, Amex). Extrait les operations reelles via l'outil. "
             "Pour chaque ligne mets nature: depense (achat/debit, defaut) ou versement (ajout d'argent vers un pocket). "
             "categorie: EXACTEMENT une valeur parmi: Alimentation, Restaurants & bars, Shopping & imprévus, Transport & train, "
             "Carburant, Retraits / espèces, Tabac. Supermarche=Alimentation; cafe/bar=Restaurants & bars; SNCF/RATP/peage/parking/gare=Transport & train; "
             "station service=Carburant; DAB=Retraits / espèces; tabac=Tabac; le reste=Shopping & imprévus. Montants positifs. "
             "Renseigne la date de chaque operation si elle est visible. N'invente rien.")
SYS_ASSIST = ("Tu aides Timothee a mettre a jour son app de budget a partir d'une capture et de sa consigne. Bref et chaleureux, "
              "francais, JAMAIS de tirets cadratins. Utilise l'outil. IMPORTANT, distingue bien trois choses chez Revolut:\n"
              "1) Les POCKETS / enveloppes de depense (ex: Alimentation, Restaurants, Transport). Une capture de leurs soldes => setPockets (solde restant de chaque enveloppe, noms EXACTS du contexte).\n"
              "2) Les COFFRES / comptes d'epargne remuneres, souvent sous un titre 'Epargne et fonds' ou avec un taux (APY, % brut), ex: Vacances, Projet, Le coup dur, Mobilier, Reparations voitures, Impots, Eau. Ce N'EST PAS une enveloppe de depense. Une capture de ces comptes => setSavings = le TOTAL de l'epargne en euros (utilise la ligne 'Toute l'epargne et les fonds' si presente, sinon additionne les montants en euros). Ne les mets jamais dans setPockets.\n"
              "3) Les OPERATIONS/depenses => addDepenses (nature depense ou versement), avec leur date si visible.\n"
              "TRES IMPORTANT sur le temps: le budget est MENSUEL. N'ajoute dans addDepenses QUE les operations du mois en cours (a partir de la date de debut de periode donnee dans le contexte). Si on te donne un vieux releve ou plusieurs mois d'historique, NE les ajoute PAS aux enveloppes: dis que c'est de l'historique et resume, sans addDepenses.\n"
              "DEBUT DE MOIS AUTOMATIQUE: le mois de budget commence quand le SALAIRE PRINCIPAL du Conseil d'Etat (~6800 EUR, libelle type 'VIREMENT DRFIP' / 'REMUNERATION', ministere 212) arrive sur le compte LCL. Si un document (releve/notification LCL) montre l'arrivee de ce salaire, mets newPeriod=true ET periodStartDate = la date de ce versement, MAIS uniquement s'il s'agit du versement le PLUS RECENT (proche d'aujourd'hui). Un salaire CE ancien dans un vieux releve = historique, ne declenche PAS newPeriod. Les salaires UPEC (~1426) et CRE arrivent APRES et ne declenchent PAS le nouveau mois. L'argent est souvent dispo 1-2 jours avant la date annoncee. Si l'utilisateur dit simplement qu'il vient d'etre paye / veut demarrer le nouveau mois, mets newPeriod=true (periodStartDate = aujourd'hui si inconnue).\n"
              "AMEX ECHELONNE: une depense Amex en plusieurs fois (ex '79 en 3 fois') => amexEchelonne{total,mois}, PAS addDepenses. "
              "POCKETS JAMAIS NEGATIVES: une enveloppe ne descend pas sous zero; si une depense depasse ce qui reste, le surplus sort du compte EUR principal Revolut (Personnel), l'app le fait toute seule. Ne mets jamais un restant negatif dans setPockets. "
              "AMEX SOLDE ANTICIPE: le solde courant affiche inclut TOUJOURS l'abonnement mensuel (defaut 53) et le tiers des 3x. Le solde lu sur l'appli Amex => setAmex tel quel (BRUT, sans abonnement, l'app l'ajoute). Changer l'abonnement => setAmexAbo. "
              "REGULARISATION (inverse d'un rejet, paye par CB en fin de mois ou capture/document) => regularise{libelle,montant?}. "
              "RENTREE d'argent => addIncome{montant,libelle,mois?} avec mois=AAAA-MM si future (ex prime de 2000 fin novembre = 2026-11), sinon immediate. "
              "REJET du mois en cours => addRejets: il devient un arriere a regulariser le mois suivant, Coach Pango l'integre a la repartition selon les priorites (immo et apurement toujours honores, conso max 2 rejets d'affilee, certains peuvent glisser).\n"
              "VIREMENTS INTERNES entre tes propres COMPTES (LCL,SG,Boursorama,Revolut) et/ou enveloppes: NI depense NI revenu, net nul. Un 'virement recu de toi-meme' (libelle 'VIR RE ... DE: Timothee') est un transfert interne, PAS un revenu: jamais addIncome. => transferPocket{from,to,montant,date} (from/to = un compte ou une enveloppe). Ex {from:'Boursorama',to:'SG',montant:300,date:'2026-08-05'}. Si compte source inconnu, mets a jour le compte destinataire (setSg/etc.) et demande la source, sans compter en revenu. addIncome = seulement vrais revenus externes (salaire, prime, remboursement d'un tiers). Anti double-comptage: si un solde est deja a jour apres la date du virement, l'app ignore ce cote. Jamais dans addDepenses.\n"
              "DOUBLONS: le contexte liste DEPENSES DEJA ENREGISTREES CE MOIS. Avant d'ajouter une depense, verifie qu'elle n'y est pas deja. Si une depense a ete saisie deux fois, annule l'exemplaire en trop avec removeDepense{libelle,montant}. Ne dis jamais que tu n'as pas de visibilite sur les depenses enregistrees.\n"
              "ECHEANCES (langage naturel): AJOUT => addOblig{libelle,montant,jour,prio 0-3,rejetable,compte}. MODIF => setOblig{libelle + seulement les champs a changer} (ex 'passe le psy en prio 3' => setOblig[{libelle:'psy',prio:3}]; 'la mutuelle n'est plus rattrapable par CB' => setOblig[{libelle:'mutuelle',rejetable:false}]). rejetable=true si l'echeance est rattrapable par CB en fin de mois, false si a honorer absolument (immo, apurement). SUPPRESSION => removeOblig, mais JAMAIS sans confirmation explicite: si on te demande de supprimer, redemande d'abord confirmation dans message et laisse removeOblig vide, ne le remplis qu'apres accord clair.\n"
              "MEMOIRE DURABLE (priorite pour ne plus jamais oublier): le contexte contient REGLES PERMANENTES + le PLAN complet (echeances, credits, priorites, arrieres). Utilise-les, ne redemande JAMAIS une info qui y figure. Des que Timothee enonce un FAIT durable, une CORRECTION de chiffre, un MONTANT recurrent ou une PREFERENCE (meme sans dire 'retiens que') => addMemo{texte} concis et autoportant, sauf si deja present. Dans le doute, memorise.\n"
              "Respecte la consigne de l'utilisateur.\n\nContexte:\n{ctx}")

def _anthropic_once(payload, key):
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=json.dumps(payload).encode("utf-8"),
        headers={"content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=120) as r:
        return json.loads(r.read().decode("utf-8"))

def anthropic_call(payload, key):
    # Essaie le modele principal; en cas d'erreur (ex: cle sans acces au modele), repli sur FALLBACK_MODEL.
    try:
        return _anthropic_once(payload, key)
    except Exception as e:
        if payload.get("model") != FALLBACK_MODEL:
            try:
                fb = dict(payload); fb["model"] = FALLBACK_MODEL
                fb.pop("output_config", None)  # 4-6 n'accepte pas output_config.effort
                return _anthropic_once(fb, key)
            except Exception:
                pass
        raise e

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **k):
        super().__init__(*a, directory=HERE, **k)
    def log_message(self, *a):
        pass
    def do_OPTIONS(self):
        self.send_response(204); self._cors(); self.send_header("content-length","0"); self.end_headers()
    def do_POST(self):
        if self.path.split("?")[0] != "/api/chat":
            self.send_error(404); return
        if TOKEN and self.headers.get("x-pango-token","") != TOKEN:
            return self._json({"error":"forbidden","message":"jeton invalide"}, status=403)
        if not _rate_ok():
            return self._json({"error":"rate","message":"trop de requetes, patiente un peu"}, status=429)
        try:
            ln = int(self.headers.get("content-length", 0))
            body = json.loads(self.rfile.read(ln) or b"{}")
        except Exception:
            body = {}
        key = read_key()
        if not key:
            return self._json({"error": "no_key", "message": "Ajoute ta cle dans apikey.txt a cote de l'app."})
        mode = body.get("mode")
        mode = mode if mode in ("parse", "assist") else "chat"
        ctx = str(body.get("context", ""))[:12000]
        text = str(body.get("text", ""))[:6000]
        raw_hist = body.get("history", []) or []
        history = []
        for m in raw_hist[-40:]:
            if isinstance(m, dict):
                role = "assistant" if m.get("role") == "assistant" else "user"
                history.append({"role": role, "content": str(m.get("content", ""))[:4000]})
        content = []
        # images: champ unique 'image' et/ou liste 'images'
        imgs = []
        if isinstance(body.get("image"), str):
            imgs.append(body.get("image"))
        if isinstance(body.get("images"), list):
            imgs.extend([x for x in body.get("images") if isinstance(x, str)])
        for im in imgs[:20]:
            if im.startswith("data:") and ";base64," in im:
                mt, data = im[5:].split(";base64,", 1)
                content.append({"type": "image", "source": {"type": "base64", "media_type": mt, "data": data}})
        # pdfs: champ unique 'pdf' et/ou liste 'pdfs'
        pdfs = []
        if isinstance(body.get("pdf"), str):
            pdfs.append(body.get("pdf"))
        if isinstance(body.get("pdfs"), list):
            pdfs.extend([x for x in body.get("pdfs") if isinstance(x, str)])
        for pf in pdfs[:20]:
            if pf.startswith("data:") and ";base64," in pf:
                _, pdata = pf.split(";base64,", 1)
                content.append({"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": pdata}})
        deftxt = {"parse": "Extrais les operations de cette capture.", "assist": "Mets a jour mon budget d'apres cette capture."}.get(mode, "")
        content.append({"type": "text", "text": text or deftxt})
        wants_json = mode in ("parse", "assist")
        messages = list(history) + [{"role": "user", "content": content}]
        if wants_json:
            system = (SYS_PARSE if mode == "parse" else SYS_ASSIST.replace("{ctx}", ctx))
        else:
            system = SYS_CHAT.replace("{ctx}", ctx)
        # max_tokens genereux en mode conversation: la reflexion adaptative de Sonnet 5 consomme des tokens
        # AVANT la reponse; trop bas => la reflexion epuise le budget et le texte final ressort vide.
        payload = {"model": MODEL, "max_tokens": 2500 if wants_json else 16000, "system": system, "messages": messages}
        # Effort reduit = reflexion plus courte donc reponse plus rapide (Sonnet 5 uniquement).
        payload["output_config"] = {"effort": EFFORT_JSON if wants_json else EFFORT_CHAT}
        if wants_json:
            tool = PARSE_TOOL if mode == "parse" else ASSIST_TOOL
            payload["tools"] = [tool]
            payload["tool_choice"] = {"type": "tool", "name": tool["name"]}
        else:
            # Mode conversation: le Coach peut AGIR sur toute l'app (Plan compris) depuis un message texte.
            payload["tools"] = [ASSIST_TOOL]
            payload["tool_choice"] = {"type": "auto"}
        try:
            data = anthropic_call(payload, key)
        except Exception as e:
            return self._json({"error": "fetch", "message": str(e)})
        if wants_json:
            tu = next((c for c in data.get("content", []) if c.get("type") == "tool_use"), None)
            out = json.dumps(tu["input"]) if tu else "\n".join(c.get("text", "") for c in data.get("content", []) if c.get("type") == "text")
            return self._json({"text": out})
        tu = next((c for c in data.get("content", []) if c.get("type") == "tool_use"), None)
        free_text = "\n".join(c.get("text", "") for c in data.get("content", []) if c.get("type") == "text")
        if tu and isinstance(tu.get("input"), dict):
            inp = tu["input"]
            return self._json({"text": inp.get("message") or free_text or "C'est fait.", "actions": inp.get("actions") or {}})
        # Filet: si le modele a tout consomme en reflexion et n'a pas ecrit de texte, on ne renvoie pas un blanc.
        if not (free_text or "").strip():
            free_text = "J'ai cale sur la longueur de ma reflexion. Reformule un peu plus court ou renvoie, et je te donne le plan."
        return self._json({"text": free_text})
    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", ALLOW_ORIGIN)
        self.send_header("Access-Control-Allow-Headers", "content-type, x-pango-token")
        self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
        self.send_header("Vary", "Origin")
    def _json(self, obj, status=200):
        b = json.dumps(obj).encode("utf-8")
        self.send_response(status)
        self._cors()
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

class ThreadingHTTP(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

def main():
    url = "http://127.0.0.1:%d/index.html" % PORT
    try:
        httpd = ThreadingHTTP(("127.0.0.1", PORT), Handler)
    except OSError as e:
        # Port deja pris = Coach Pango tourne deja. On ouvre juste la page.
        print("Coach Pango tourne deja, j'ouvre la page dans le navigateur.")
        print("port deja pris"); return
    with httpd:
        print("Coach Pango tourne sur", url)
        print("Laisse cette fenetre ouverte. Pour arreter: ferme la fenetre ou Ctrl+C.")
        pass  # headless VM
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            pass

if __name__ == "__main__":
    main()
