Spaces:
Runtime error
Runtime error
| import os | |
| import re | |
| import json | |
| import logging | |
| from typing import Any, List, Dict, Optional | |
| from openai import OpenAI, AzureOpenAI | |
| # ===================================================== | |
| # LOGGING SETUP | |
| # ===================================================== | |
| _LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() | |
| logging.basicConfig( | |
| level=getattr(logging, _LOG_LEVEL, logging.INFO), | |
| format="%(asctime)s | %(levelname)s | %(message)s" | |
| ) | |
| logger = logging.getLogger("TLC_AGENT_UTILS") | |
| def _log_info(message: str) -> None: | |
| logger.info(message) | |
| def _log_warning(message: str) -> None: | |
| logger.warning(message) | |
| def _log_error(message: str) -> None: | |
| logger.error(message) | |
| # ===================================================== | |
| # JSON HELPERS | |
| # ===================================================== | |
| def clean_json_text(text: str) -> str: | |
| """ | |
| Retire les fences markdown et nettoie le texte JSON. | |
| """ | |
| if not text: | |
| return "" | |
| text = str(text).strip() | |
| # retire fences markdown | |
| text = re.sub(r"^```json\s*", "", text, flags=re.IGNORECASE) | |
| text = re.sub(r"^```\s*", "", text) | |
| text = re.sub(r"\s*```$", "", text) | |
| return text.strip() | |
| def safe_json_loads(text: Any): | |
| """ | |
| Parse JSON robuste pour les réponses LLM. | |
| Retourne dict/list ou None. | |
| """ | |
| if text is None: | |
| return None | |
| if isinstance(text, (dict, list)): | |
| return text | |
| text = str(text).strip() | |
| if not text: | |
| return None | |
| _log_info(f"safe_json_loads -> raw preview: {text[:300]}") | |
| text = clean_json_text(text) | |
| # 1) tableau JSON | |
| array_match = re.search(r"(\[[\s\S]*\])", text) | |
| if array_match: | |
| candidate = array_match.group(1) | |
| try: | |
| parsed = json.loads(candidate) | |
| _log_info("safe_json_loads -> parsed as array") | |
| return parsed | |
| except Exception as e: | |
| _log_warning(f"safe_json_loads -> array parse failed: {e}") | |
| # 2) objet JSON | |
| object_match = re.search(r"(\{[\s\S]*\})", text) | |
| if object_match: | |
| candidate = object_match.group(1) | |
| try: | |
| parsed = json.loads(candidate) | |
| _log_info("safe_json_loads -> parsed as object") | |
| return parsed | |
| except Exception as e: | |
| _log_warning(f"safe_json_loads -> object parse failed: {e}") | |
| # 3) parse direct | |
| try: | |
| parsed = json.loads(text) | |
| _log_info("safe_json_loads -> parsed directly") | |
| return parsed | |
| except Exception as e: | |
| _log_error(f"safe_json_loads -> final parse failed: {e}") | |
| return None | |
| # ===================================================== | |
| # LLM CALLS | |
| # ===================================================== | |
| def call_deepseek( | |
| prompt: str, | |
| model: str = "deepseek-chat", | |
| max_tokens: int = 2000 | |
| ) -> str: | |
| """ | |
| DeepSeek via API compatible OpenAI. | |
| """ | |
| api_key = os.getenv("DEEPSEEK_API_KEY") | |
| if not api_key: | |
| _log_error("DEEPSEEK_API_KEY manquante") | |
| return "ERREUR: DEEPSEEK_API_KEY manquante" | |
| try: | |
| _log_info(f"Calling DeepSeek model={model}, max_tokens={max_tokens}") | |
| client = OpenAI( | |
| api_key=api_key, | |
| base_url="https://api.deepseek.com" | |
| ) | |
| response = client.chat.completions.create( | |
| model=model, | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=max_tokens, | |
| temperature=0.3 | |
| ) | |
| content = response.choices[0].message.content or "" | |
| _log_info(f"DeepSeek response length: {len(content)}") | |
| return content.strip() | |
| except Exception as e: | |
| _log_error(f"ERREUR DEEPSEEK: {e}") | |
| return f"ERREUR DEEPSEEK: {e}" | |
| def call_groq( | |
| prompt: str, | |
| model: str = "llama-3.3-70b-versatile", | |
| max_tokens: int = 2000 | |
| ) -> str: | |
| """ | |
| Groq via OpenAI-compatible endpoint. | |
| Évite les soucis du SDK Groq et les conflits de version. | |
| """ | |
| api_key = os.getenv("GROQ_API_KEY") | |
| if not api_key: | |
| _log_error("GROQ_API_KEY manquante") | |
| return "ERREUR: GROQ_API_KEY manquante" | |
| try: | |
| _log_info(f"Calling Groq model={model}, max_tokens={max_tokens}") | |
| client = OpenAI( | |
| api_key=api_key, | |
| base_url="https://api.groq.com/openai/v1" | |
| ) | |
| response = client.chat.completions.create( | |
| model=model, | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=max_tokens, | |
| temperature=0.3 | |
| ) | |
| content = response.choices[0].message.content or "" | |
| _log_info(f"Groq response length: {len(content)}") | |
| return content.strip() | |
| except Exception as e: | |
| _log_error(f"ERREUR GROQ: {e}") | |
| return f"ERREUR GROQ: {e}" | |
| def call_azure_openai( | |
| prompt: str, | |
| max_tokens: int = 2000 | |
| ) -> str: | |
| """ | |
| Azure OpenAI. | |
| OPENAI_MODEL doit correspondre au nom du déploiement Azure. | |
| """ | |
| api_key = os.getenv("AZUREOPENAI_API_KEY") | |
| endpoint = os.getenv("AZUREOPENAI_API_ENDPOINT") | |
| api_version = os.getenv("AZUREOPENAI_API_VERSION") | |
| deployment = os.getenv("OPENAI_MODEL", "gpt-4o-mini") | |
| if not api_key or not endpoint or not api_version: | |
| _log_error("Azure OpenAI credentials manquantes") | |
| return "ERREUR: Azure OpenAI credentials manquantes" | |
| try: | |
| _log_info( | |
| f"Calling Azure OpenAI deployment={deployment}, max_tokens={max_tokens}" | |
| ) | |
| client = AzureOpenAI( | |
| api_key=api_key, | |
| api_version=api_version, | |
| azure_endpoint=endpoint | |
| ) | |
| response = client.chat.completions.create( | |
| model=deployment, | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=max_tokens, | |
| temperature=0.3 | |
| ) | |
| content = response.choices[0].message.content or "" | |
| _log_info(f"Azure OpenAI response length: {len(content)}") | |
| return content.strip() | |
| except Exception as e: | |
| _log_error(f"ERREUR AZURE OPENAI: {e}") | |
| return f"ERREUR AZURE OPENAI: {e}" | |
| def call_llm( | |
| prompt: str, | |
| provider: str = "azure", | |
| max_tokens: int = 2000 | |
| ) -> str: | |
| """ | |
| Routeur principal. | |
| """ | |
| provider = (provider or "").lower().strip() | |
| _log_info(f"call_llm -> provider={provider}") | |
| if provider == "azure": | |
| return call_azure_openai(prompt, max_tokens=max_tokens) | |
| if provider == "groq": | |
| return call_groq(prompt, max_tokens=max_tokens) | |
| if provider == "deepseek": | |
| return call_deepseek(prompt, max_tokens=max_tokens) | |
| _log_error(f"Provider inconnu: {provider}") | |
| return "ERREUR: provider inconnu" | |
| def smart_call(prompt: str, max_tokens: int = 2000) -> str: | |
| """ | |
| Fallback automatique. | |
| Ordre conseillé: Azure -> Groq -> DeepSeek. | |
| """ | |
| providers = ["azure", "groq", "deepseek"] | |
| for provider in providers: | |
| _log_info(f"smart_call -> trying {provider}") | |
| result = call_llm(prompt, provider=provider, max_tokens=max_tokens) | |
| if isinstance(result, str) and not result.startswith("ERREUR"): | |
| _log_info(f"smart_call -> success with {provider}") | |
| return result | |
| _log_error("smart_call -> all providers failed") | |
| return "ERREUR: tous les providers ont échoué" | |
| # ===================================================== | |
| # LATEX EXTRACTION | |
| # ===================================================== | |
| def extract_latex_blocks(text: str) -> List[str]: | |
| """ | |
| Extraction rapide des blocs LaTeX: | |
| - $$...$$ | |
| - \[...\] | |
| - $...$ | |
| """ | |
| if not text: | |
| return [] | |
| pattern = r'\$\$([^\$]+)\$\$|\\\[(.*?)\\\]|\$([^\$]+)\$' | |
| matches = re.findall(pattern, text, re.DOTALL) | |
| equations = [] | |
| for m in matches: | |
| eq = m[0] or m[1] or m[2] | |
| if eq and eq.strip(): | |
| equations.append(eq.strip()) | |
| unique = [] | |
| seen = set() | |
| for eq in equations: | |
| if eq not in seen: | |
| seen.add(eq) | |
| unique.append(eq) | |
| _log_info(f"extract_latex_blocks -> found {len(unique)} equations") | |
| return unique | |
| def _normalize_llm_equation_item(item: Any) -> Optional[Dict[str, str]]: | |
| """ | |
| Normalise un item issu d'une réponse LLM en {"latex": "..."}. | |
| """ | |
| if item is None: | |
| return None | |
| if isinstance(item, str): | |
| s = item.strip() | |
| if s: | |
| return {"latex": s} | |
| return None | |
| if isinstance(item, dict): | |
| latex = item.get("latex") or item.get("equation") or item.get("expr") | |
| if latex and str(latex).strip(): | |
| return {"latex": str(latex).strip()} | |
| return None | |
| s = str(item).strip() | |
| if s: | |
| return {"latex": s} | |
| return None | |
| def extract_equations_with_llm( | |
| text: str, | |
| provider: str = "azure" | |
| ) -> List[Dict[str, str]]: | |
| """ | |
| Extrait des équations mathématiques via LLM. | |
| Retourne une liste de dicts: [{"latex": "..."}] | |
| """ | |
| prompt = f""" | |
| Tu es un assistant scientifique. | |
| Extrait uniquement les équations mathématiques présentes dans ce texte. | |
| Retourne STRICTEMENT un JSON valide sous cette forme: | |
| [ | |
| {{ | |
| "latex": "E = mc^2" | |
| }} | |
| ] | |
| Contraintes: | |
| - Aucun texte hors JSON | |
| - Pas de markdown | |
| - Pas d'explication | |
| Texte: | |
| {text} | |
| """ | |
| _log_info(f"extract_equations_with_llm -> sending to provider={provider}") | |
| response = call_llm( | |
| prompt, | |
| provider=provider, | |
| max_tokens=2000 | |
| ) | |
| data = safe_json_loads(response) | |
| if data is None: | |
| _log_warning("extract_equations_with_llm -> parsing failed") | |
| return [] | |
| normalized: List[Dict[str, str]] = [] | |
| if isinstance(data, list): | |
| for item in data: | |
| norm = _normalize_llm_equation_item(item) | |
| if norm: | |
| normalized.append(norm) | |
| else: | |
| norm = _normalize_llm_equation_item(data) | |
| if norm: | |
| normalized.append(norm) | |
| _log_info( | |
| f"extract_equations_with_llm -> parsed {len(normalized)} equations" | |
| ) | |
| return normalized | |
| # ===================================================== | |
| # IR HELPERS | |
| # ===================================================== | |
| def validate_ir(ir: Any) -> bool: | |
| if not isinstance(ir, dict): | |
| return False | |
| if "nodes" not in ir or "edges" not in ir: | |
| return False | |
| return True | |
| def normalize_ir(ir: Any) -> Dict[str, Any]: | |
| if not isinstance(ir, dict): | |
| _log_warning("normalize_ir -> invalid IR, building fallback shell") | |
| return { | |
| "name": "Invalid IR", | |
| "strategy": "fallback", | |
| "nodes": [], | |
| "edges": [] | |
| } | |
| ir.setdefault("name", "Unnamed IR") | |
| ir.setdefault("strategy", "fallback") | |
| ir.setdefault("nodes", []) | |
| ir.setdefault("edges", []) | |
| return ir | |
| def build_fallback_ir(equations: List[Any]) -> Dict[str, Any]: | |
| nodes = [] | |
| for i, eq in enumerate(equations): | |
| latex = eq.get("latex", "") if isinstance(eq, dict) else str(eq) | |
| nodes.append({ | |
| "id": f"eq_{i}", | |
| "type": "equation", | |
| "latex": latex | |
| }) | |
| _log_info(f"build_fallback_ir -> built IR with {len(nodes)} nodes") | |
| return { | |
| "name": "Fallback Variant 1", | |
| "strategy": "fallback", | |
| "nodes": nodes, | |
| "edges": [] | |
| } |