"""Phase 2.5 operator: ``mutate_prompt_llm``. Quinto operatore di mutazione che riscrive il ``system_prompt`` di un genoma usando un LLM tier B come "mutator". Genera diversità prompt-level dove gli altri quattro operatori toccano solo i quattro parametri scalari. Fallback sicuro: se la mutazione LLM produce output invalido o troppo simile al parent, l'operatore degrada silenziosamente a ``random_mutate``. """ from __future__ import annotations import random import re from dataclasses import replace from difflib import SequenceMatcher from typing import Any, Protocol from .hypothesis import HypothesisAgentGenome, ModelTier from .mutation import _clone_with, random_mutate # Sei tipi di mutazione "atomiche", scelti uniformemente. MUTATION_INSTRUCTIONS: dict[str, str] = { "tighten_threshold": ( "Rendi UNA soglia numerica nella strategia più restrittiva del 10-20%. " "Esempio: se RSI > 70 diventa RSI > 78. Lascia tutto il resto identico." ), "swap_comparator": ( "Inverti UN comparator (gt -> lt, gte -> lte o viceversa) in una sola " "condizione. Mantieni lo stesso intent generale della strategia." ), "add_condition": ( "Aggiungi UNA condizione AND a una rule esistente per renderla più " "selettiva. La condizione deve usare una feature/indicator coerente con " "il resto della strategia." ), "remove_condition": ( "Rimuovi UNA condizione ridondante o ovvia da una rule, semplificando la " "logica senza alterarne l'intent principale." ), "change_timeframe": ( "Modifica UNA finestra rolling/lookback di +/- 20-40% (es. SMA(50) -> " "SMA(70)). Solo un parametro temporale." ), "add_temporal_gate": ( "Aggiungi UN gate temporale alla strategia usando una delle feature " "'hour', 'dow', 'is_weekend', 'minute_of_hour' per filtrare il " "trading a specifici momenti." ), } # Keyword tecniche minime per validare che il prompt sia ancora "una strategia". _VALID_KEYWORDS = ( "rsi", "sma", "ema", "atr", "momentum", "breakout", "mean", "reversion", "macd", "vwap", "bb", "bollinger", "stoch", "trend", "signal", "buy", "sell", "long", "short", "entry", "exit", "stop", "rule", "condition", "if", "when", "and", "or", "gt", "lt", ">", "<", "ge", "le", "hour", "dow", "weekend", "indicator", "feature", ) _MIN_PROMPT_LENGTH = 50 _MIN_DIFF_RATIO = 0.05 # Levenshtein-like: prompt deve essere almeno 5% diverso _MUTATOR_SYSTEM_PROMPT = ( "Sei un mutator evolutivo per prompt di strategie di trading algoritmico. " "Ricevi un PROMPT originale e una ISTRUZIONE di mutazione atomica. " "Produci una versione modificata del prompt che applica SOLO quella " "mutazione, preservando intent e struttura generale. " "Output: solo il nuovo prompt fra tag .... " "Nessun preambolo, nessuna spiegazione." ) _PROMPT_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL | re.IGNORECASE) class _LLMClientLike(Protocol): """Subset minimo dell'API LLMClient che usa l'operatore. Permette di mockare l'LLM nei test senza importare la classe concreta. """ def complete( self, genome: HypothesisAgentGenome, system: str, user: str, max_tokens: int = ..., ) -> Any: ... def _extract_prompt(text: str) -> str: """Estrae il prompt mutato dal completion text. Cerca tag ``...``. Se assenti, ritorna il testo strip. """ m = _PROMPT_RE.search(text) if m: return m.group(1).strip() return text.strip() def _string_diff_ratio(a: str, b: str) -> float: """Ritorna ``1 - similarity`` (0.0 = identici, 1.0 = completamente diversi).""" if not a and not b: return 0.0 return 1.0 - SequenceMatcher(None, a, b).ratio() def is_valid_prompt(new_prompt: str, parent_prompt: str) -> bool: """Validation gate per il prompt LLM-mutato. Tre check: 1. Lunghezza minima 50 caratteri. 2. Contiene almeno una keyword tecnica (rsi, sma, signal, ecc). 3. Diversità Levenshtein-like > 5% rispetto al parent. """ if len(new_prompt) < _MIN_PROMPT_LENGTH: return False lowered = new_prompt.lower() if not any(kw in lowered for kw in _VALID_KEYWORDS): return False if _string_diff_ratio(new_prompt, parent_prompt) < _MIN_DIFF_RATIO: return False return True def mutate_prompt_llm( g: HypothesisAgentGenome, llm: _LLMClientLike, rng: random.Random, mutator_tier: ModelTier = ModelTier.B, max_tokens: int = 2000, cost_tracker: Any | None = None, repo: Any | None = None, run_id: str | None = None, ) -> HypothesisAgentGenome: """Operatore di mutazione prompt-level via LLM mutator. Sceglie una mutation-instruction casuale fra sei tipi, fa una chiamata LLM tier B per ottenere il prompt mutato, valida l'output. Su validation fail (output troppo corto, non-strategia, troppo simile al parent), fallback silenzioso a ``random_mutate``. Se ``cost_tracker``, ``repo`` e ``run_id`` sono forniti, la chiamata mutator viene registrata con ``call_kind="mutation"`` per audit budget. """ instruction_key = rng.choice(list(MUTATION_INSTRUCTIONS)) instruction = MUTATION_INSTRUCTIONS[instruction_key] user_prompt = ( f"PROMPT ORIGINALE:\n{g.system_prompt}\n\n" f"ISTRUZIONE DI MUTAZIONE ({instruction_key}):\n{instruction}\n\n" f"Genera la versione modificata fra tag ...." ) # Mutator usa un tier diverso (B) — clone temporaneo del genoma con tier override. mutator_genome = replace(g, model_tier=mutator_tier) try: result = llm.complete( mutator_genome, system=_MUTATOR_SYSTEM_PROMPT, user=user_prompt, max_tokens=max_tokens, ) except Exception: return random_mutate(g, rng) # Cost tracking call_kind="mutation" se sink fornito. if cost_tracker is not None and repo is not None and run_id is not None: in_tok = getattr(result, "input_tokens", 0) out_tok = getattr(result, "output_tokens", 0) cr = cost_tracker.record( input_tokens=in_tok, output_tokens=out_tok, tier=mutator_tier, run_id=run_id, agent_id=g.id, call_kind="mutation", ) repo.save_cost_record( run_id=run_id, agent_id=g.id, tier=mutator_tier.value, input_tokens=in_tok, output_tokens=out_tok, cost_usd=cr.cost_usd, call_kind="mutation", ) new_prompt = _extract_prompt(getattr(result, "text", "")) if not is_valid_prompt(new_prompt, g.system_prompt): return random_mutate(g, rng) return _clone_with(g, system_prompt=new_prompt)