"""
Corso Machine Learning Engineer - Modulo 00, Lezione 2
"Dal modello all'agente: sistemi che percepiscono, decidono, agiscono"

OBIETTIVO DI QUESTO FILE
------------------------
Due esercizi di ragionamento quantitativo sulla scelta fra workflow e agente.
Sono gli stessi due esercizi carta-e-penna dell'articolo: qui il programma
ricalcola ogni numero, così puoi confrontare il tuo conto con il suo.

  1. AFFIDABILITÀ A CATENA. Un agente che fa k chiamate di strumento riesce
     solo se riescono TUTTE. La probabilità complessiva non cala un po':
     crolla, e crolla in modo esponenziale nel numero di passi.
  2. COSTO PER RICHIESTA. Un agente costa più chiamate del workflow ma manda
     meno pratiche all'operatore umano. Chi vince dipende da quanto costa
     l'operatore, e il punto di pareggio sorprende.

DOMINIO (simulato)
------------------
Lo sportello prenotazioni di un poliambulatorio. Tutti i numeri sono
inventati per l'esercizio e dichiarati tali: servono a far tornare i conti
in frazioni esatte, non a descrivere un poliambulatorio vero.

COME SI USA
-----------
    python3 esercizi.py

Il file gira anche così com'è: stampa quali esercizi mancano ancora.
Completa i TODO uno alla volta, in qualunque ordine, e rilancia. Le
soluzioni di riferimento sono commentate in fondo: guardale solo dopo aver
provato, perché qui la difficoltà è tutta nel ragionamento.

AMBIENTE DICHIARATO
-------------------
Eseguito con Python 3.14.4. Nessuna dipendenza esterna: si usa solo
`fractions` della libreria standard, perché tutti i risultati di questa
lezione sono frazioni esatte e vogliamo vederle come tali, non come
0.40960000000000013.
"""

from fractions import Fraction


# ----------------------------------------------------------------------
# ESERCIZIO 1 - L'affidabilità di una catena di strumenti
#
# Lo sportello usa un agente che, per rispondere, concatena chiamate a
# strumenti diversi (anagrafica, agenda, disponibilità del medico, invio
# della conferma). Ogni chiamata, presa da sola, riesce 4 volte su 5: il
# resto sono timeout, dati mancanti, servizi momentaneamente giù.
#
# L'agente produce una risposta corretta solo se riescono TUTTI i passi.
#
# Domande a cui rispondere a parole dopo aver visto i numeri:
#   - quanti passi bastano perché l'agente fallisca più spesso di quanto
#     riesca?
#   - un fornitore ti promette di portare l'affidabilità del singolo passo
#     da 4/5 a 9/10: su 4 passi quanto guadagni davvero?
# ----------------------------------------------------------------------

def successo_catena(p_passo: Fraction, passi: int) -> Fraction:
    """Probabilità che una catena di `passi` chiamate indipendenti riesca
    tutta quanta, se ogni singola chiamata riesce con probabilità `p_passo`.

    Parametri
    ---------
    p_passo : Fraction fra 0 e 1, affidabilità di UNA chiamata.
    passi   : int >= 0, quante chiamate in sequenza.

    Ritorna
    -------
    Fraction fra 0 e 1, oppure None se l'esercizio non è ancora svolto.

    Suggerimento: gli eventi sono indipendenti e servono tutti. Con passi = 0
    la catena è vuota e riesce con certezza (ritorna Fraction(1)).
    """
    # TODO (esercizio 1): sostituisci `return None` con il calcolo.
    return None


# ----------------------------------------------------------------------
# ESERCIZIO 2 - Agente o workflow: il conto in euro
#
# Lo stesso sportello, due architetture a confronto. Ogni chiamata al
# modello costa 0.02 euro. Una richiesta che il sistema non risolve finisce
# a un operatore umano, e quella gestione costa `costo_operatore` euro.
#
#   WORKFLOW: percorso fisso, 1 chiamata al modello, risolve il 60% delle
#             richieste. Il 40% passa all'operatore.
#   AGENTE  : percorso scelto a runtime, in media 4 chiamate al modello,
#             risolve il 90% delle richieste. Il 10% passa all'operatore.
#
# Domande:
#   - con un operatore da 3.00 euro a pratica, chi costa meno?
#   - esiste un costo dell'operatore sotto il quale vince il workflow?
#     Trovalo in forma chiusa, non per tentativi.
# ----------------------------------------------------------------------

COSTO_CHIAMATA = Fraction(2, 100)        # INVENTATO: 0.02 euro per chiamata

# (nome, chiamate medie al modello, quota di richieste che va all'operatore)
ARCHITETTURE = (
    ("workflow", 1, Fraction(40, 100)),
    ("agente", 4, Fraction(10, 100)),
)


def costo_per_richiesta(chiamate: int, quota_escalation: Fraction,
                        costo_operatore: Fraction) -> Fraction:
    """Costo atteso di UNA richiesta, in euro.

    Parametri
    ---------
    chiamate         : int, chiamate medie al modello per richiesta.
    quota_escalation : Fraction, quota di richieste che finisce all'operatore.
    costo_operatore  : Fraction, euro per pratica gestita dall'operatore.

    Ritorna
    -------
    Fraction (euro), oppure None se l'esercizio non è ancora svolto.

    Suggerimento: due addendi. Le chiamate si pagano sempre; l'operatore solo
    sulla quota che gli arriva, quindi in media costa quota x costo.
    """
    # TODO (esercizio 2): sostituisci `return None` con il calcolo.
    return None


def costo_operatore_di_pareggio() -> Fraction:
    """Il costo dell'operatore che rende le due architetture equivalenti.

    Ritorna
    -------
    Fraction (euro), oppure None se l'esercizio non è ancora svolto.

    Suggerimento: imponi costo_workflow(H) = costo_agente(H) e isola H. NON
    cercarlo provando valori a caso: la risposta è un rapporto fra la
    differenza dei costi di chiamata e la differenza delle quote.
    Serve `costo_per_richiesta`: se non l'hai ancora scritta, il runner se ne
    accorge da solo e te lo dice invece di sollevare un'eccezione.
    """
    # TODO (esercizio 2, seconda parte): sostituisci `return None`.
    return None


# ----------------------------------------------------------------------
# Runner: stampa i risultati degli esercizi svolti e segnala quelli mancanti
# ----------------------------------------------------------------------

def _sezione(titolo: str) -> None:
    print()
    print(titolo)
    print("-" * len(titolo))


def _da_fare(numero: int, dettaglio: str = "") -> None:
    coda = f" ({dettaglio})" if dettaglio else ""
    print(f"  [ ] Esercizio {numero} non ancora svolto{coda}: completa il TODO.")


def _perc(frazione: Fraction) -> str:
    return f"{float(frazione) * 100:6.2f}%"


def esegui_esercizio_1() -> bool:
    _sezione("Esercizio 1 - affidabilità di una catena di strumenti")
    if successo_catena(Fraction(4, 5), 1) is None:
        _da_fare(1)
        return False

    print("  passi | p=4/5 esatta  | p=4/5    | p=9/10 esatta  | p=9/10   | guadagno")
    for passi in (1, 2, 3, 4, 6):
        a = successo_catena(Fraction(4, 5), passi)
        b = successo_catena(Fraction(9, 10), passi)
        print(f"  {passi:5d} | {str(a):>13} | {_perc(a)} | {str(b):>14} | {_perc(b)} |"
              f" {_perc(b - a)}")

    primo = next(k for k in range(1, 50)
                 if successo_catena(Fraction(4, 5), k) < Fraction(1, 2))
    print(f"  Primo numero di passi in cui l'agente fallisce più spesso di"
          f" quanto riesca: {primo}")
    print("  Tre passi stanno ancora sopra metà (64/125 = 51.20%), quattro no")
    print("  (256/625 = 40.96%). Il tetto ai passi non è burocrazia: è la")
    print("  variabile che decide se il sistema funziona.")
    return True


def esegui_esercizio_2() -> bool:
    _sezione("Esercizio 2 - agente o workflow: il conto in euro")
    sonda = costo_per_richiesta(1, Fraction(40, 100), Fraction(3))
    if sonda is None:
        _da_fare(2, "costo_per_richiesta")
        return False

    print("  operatore | " + " | ".join(f"{nome:>9}" for nome, _, _ in ARCHITETTURE)
          + " |  vincitore")
    for euro in (Fraction(0), Fraction(10, 100), Fraction(20, 100),
                 Fraction(50, 100), Fraction(3)):
        costi = [(nome, costo_per_richiesta(ch, q, euro))
                 for nome, ch, q in ARCHITETTURE]
        migliore = min(costi, key=lambda c: c[1])
        pari = len({c for _, c in costi}) == 1
        etichetta = "pareggio" if pari else migliore[0]
        print(f"  {float(euro):8.2f}  | "
              + " | ".join(f"{float(c):9.4f}" for _, c in costi)
              + f" |  {etichetta}")

    pareggio = costo_operatore_di_pareggio()
    if pareggio is None:
        _da_fare(2, "costo_operatore_di_pareggio")
        return False
    print(f"  Costo dell'operatore al pareggio: {pareggio} euro ="
          f" {float(pareggio):.2f} euro")
    verifica = {costo_per_richiesta(ch, q, pareggio) for _, ch, q in ARCHITETTURE}
    print(f"  Controllo: a quel prezzo le due architetture costano"
          f" {float(next(iter(verifica))):.4f} euro entrambe ->"
          f" {'OK' if len(verifica) == 1 else 'INCOERENTE'}")
    print("  Sotto i 0.20 euro a pratica vince il workflow: se l'operatore è")
    print("  economico, le tre chiamate in più dell'agente non si ripagano.")
    return True


def main() -> None:
    print("Corso MLE - Modulo 00, Lezione 2 - esercizi di ragionamento")
    print("Dominio simulato: sportello prenotazioni di un poliambulatorio.")
    fatti = sum(bool(esegui()) for esegui in (esegui_esercizio_1, esegui_esercizio_2))
    print()
    print(f"Esercizi completati: {fatti} su 2.")
    if fatti < 2:
        print("Completa i TODO e rilancia: python3 esercizi.py")


if __name__ == "__main__":
    main()


# ======================================================================
# SOLUZIONI DI RIFERIMENTO
# Decommenta una funzione alla volta, incollala al posto del TODO
# corrispondente e rilancia.
# ======================================================================
#
# --- Esercizio 1 -------------------------------------------------------
# def successo_catena(p_passo, passi):
#     return p_passo ** passi
#
# Atteso: 4/5, 16/25, 64/125, 256/625, 4096/15625 per 1, 2, 3, 4 e 6 passi,
# cioè 80.00%, 64.00%, 51.20%, 40.96%, 26.21%. Il salto da 9/10 a 4/5 sembra
# piccolo sul singolo passo (10 punti) ma su 4 passi vale 65.61% - 40.96% =
# 24.65 punti: l'esponente amplifica la differenza invece di diluirla.
# La morale di ingegneria è doppia: (a) accorciare la catena vale più che
# ottimizzare il modello; (b) un agente senza riprova automatica dei passi
# falliti e senza tetto esplicito non è un sistema, è una scommessa.
#
# --- Esercizio 2 -------------------------------------------------------
# def costo_per_richiesta(chiamate, quota_escalation, costo_operatore):
#     return COSTO_CHIAMATA * chiamate + quota_escalation * costo_operatore
#
# def costo_operatore_di_pareggio():
#     (_, ch_w, q_w), (_, ch_a, q_a) = ARCHITETTURE
#     # COSTO_CHIAMATA*ch_w + q_w*H = COSTO_CHIAMATA*ch_a + q_a*H
#     return COSTO_CHIAMATA * (ch_a - ch_w) / (q_w - q_a)
#
# Atteso: con l'operatore a 3.00 euro il workflow costa 0.02 + 0.40*3 = 1.22
# euro a richiesta e l'agente 0.08 + 0.10*3 = 0.38 euro: l'agente costa tre
# volte meno, e non per merito del modello ma perché disturba l'operatore
# quattro volte di meno. Il pareggio è a 1/5 = 0.20 euro: sotto quella cifra
# vince il workflow. È il risultato che conta, perché smonta sia "l'agente
# è sempre più caro" (il costo delle chiamate è briciole rispetto al
# lavoro umano evitato) sia "l'agente è sempre meglio" (se l'escalation è
# quasi gratis, non c'è niente da risparmiare).
# Nota metodologica: "4 chiamate in media" nasconde la coda. Con il conto
# dell'esercizio 1 in mano, la domanda successiva è quanto spesso l'agente
# arriva al tetto dei passi senza concludere - quelle richieste pagano sia le
# chiamate sia l'operatore, e peggiorano il conto di cui sopra.
