"""Tolerant value coercion for tool arguments (models send '185 000 $', 'Oui', '12 %', …).""" from __future__ import annotations import re from typing import Any _NUM_RE = re.compile(r"^[+-]?\d+(?:[.,]\d+)?$") TRUE_WORDS = {"oui", "yes", "vrai", "true", "o", "y", "x", "✓"} FALSE_WORDS = {"non", "no", "faux", "false", "n", "-", "—", ""} def num(value: Any, default: float | None = None) -> float | None: """Parse a number from int/float/str (French formats, currency, percent). None if impossible. '185 000 $' → 185000 ; '12,5 %' → 0.125 ; '+3 %' → 0.03 ; '-5 000' → -5000 ; 'Oui' → None. """ if value is None or isinstance(value, bool): return default if isinstance(value, (int, float)): return float(value) if not isinstance(value, str): return default s = value.strip().replace(" ", "").replace(" ", "").replace(" ", "") if not s or s.startswith("="): return default pct = s.endswith("%") s = re.sub(r"/?(?:[$€£%]|CAD|USD|pi²|pi2|m²|m2|mois|ans?\b)", "", s, flags=re.I).strip() s = s.replace("'", "") if s.count(",") == 1 and s.count(".") == 0: s = s.replace(",", ".") elif s.count(",") >= 1 and s.count(".") == 1: s = s.replace(",", "") if not _NUM_RE.match(s): return default v = float(s) return v / 100.0 if pct else v def pct(value: Any, default: float | None = None) -> float | None: """Percent as a ratio. 3 → 0.03 (if |v| > 1), '3 %' → 0.03, 0.03 → 0.03.""" v = num(value, None) if v is None: return default if isinstance(value, str) and value.strip().endswith("%"): return v return v / 100.0 if abs(v) > 1 else v def boolish(value: Any) -> bool | None: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): s = value.strip().lower() if s in TRUE_WORDS: return True if s in FALSE_WORDS: return False return None def cell(value: Any) -> Any: """Value safe for openpyxl: numbers parsed, formulas kept, everything else → text.""" if value is None or isinstance(value, (int, float, bool)): return value if isinstance(value, str): s = value.strip() if s.startswith("="): return s n = num(s, None) stripped = re.sub(r"(pi²|pi2|m²|m2|CAD|USD|ans?\b|mois)", "", s, flags=re.I) if n is not None and not re.search(r"[A-Za-z]{2,}", stripped): return n return s if isinstance(value, dict): return ", ".join(f"{k}: {v}" for k, v in value.items())[:250] if isinstance(value, (list, tuple)): return ", ".join(str(v) for v in value)[:250] return str(value)