Files
Mimante/Mimante/Controls/LearningControl.xaml.cs
T
Alby96andClaude Fable 5.1 9177b31bd5 Due database, interruttore dell'apprendimento, barre a icone; via kill-switch, esportazioni e voci deprecate
Tutto ciò che l'applicazione registra sta ora in due file SQLite in
%LocalAppData%\AutoBidder\Database: autobidder.sqlite per le osservazioni e
esercizio.sqlite per i documenti d'esercizio (aste nel monitor, prodotti, promozioni,
modelli appresi con prefisso ml/) e per i registri applicativo e del riscatto puntate.
Il motore comune è SqliteDatabase; i file JSON e la cartella Apprendimento delle
versioni precedenti vengono importati la prima volta e lasciati dove sono. La cartella
si cambia dalle Impostazioni: al salvataggio si chiede se spostare i file, e si riavvia.
Le cartelle Dati/Statistiche/Registri, i registri su file, le misure dell'anticipo e
le impostazioni morte (LogBids, AutoApplyProductDefaults, NewAuctionLimitsPriority…)
non esistono più.

Il kill-switch se ne va: al suo posto un solo interruttore in barra, accanto ad
Avvia/Osserva/Ferma, che spegne l'apprendimento. Spento, il motore punta solo entro i
limiti dell'utente (prezzo, puntate, budget, fascia oraria, rischio) con l'anticipo
fisso: niente valore atteso, regime, duello, bandit, anticipo adattivo. Le aste vengono
comunque registrate e studiate.

Tre difetti visti osservando il motore dal vivo per un'ora, in Osserva:
- la copertura «Compralo Ora» con V lasciato al listino azzerava la perdita coperta e
  il motore diceva BID a ogni scadenza con P dell'1%: ora vale solo per i prodotti con
  «Valore reale €» scritto, altrimenti la puntata si conta persa e P decide;
- la chiusura di un'asta arrivava due volte (fine, poi rimozione dal monitor) e di
  nuovo al riavvio: modello, profilo, bandit e statistiche per prodotto contavano ogni
  asta due volte. Il monitor la comunica una volta sola, l'apprendimento salta le aste
  già apprese, e le schede prodotto vengono ricostruite una volta dallo storico;
- la pagina delle ricompense veniva scambiata per la pagina di accesso perché porta un
  collegamento a /login.php: prima si guarda se è la pagina delle ricompense.

Interfaccia: la scheda Esporta e tutto il suo codice sono tolti; le esportazioni
chiedono sempre dove salvare. Prodotti, Storico e Apprendimento hanno barre a sole
icone con tooltip che dicono esattamente cosa succede, colorate come il monitor, con
i nuovi pulsanti di pulizia (spegni tutte le stelline, togli i non seguiti, pulizia
completa; seleziona tutte / elimina le selezionate / svuota lo storico). Impostazioni:
sezione Database, Manutenzione con «Elimina tutti i dati (tranne la login)» e
«Impostazioni di fabbrica», esportazione dei registri in testo. Modifiche.txt, il
prompt ormai realizzato, esce dal repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:58:08 +02:00

377 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using AutoBidder.Ml;
using AutoBidder.Utilities;
namespace AutoBidder.Controls
{
/// <summary>
/// La scheda dell'apprendimento: cosa sa il modello, cosa ha deciso di recente, cosa
/// aspettarsi per prodotto e ora, e l'ultima valutazione.
///
/// <para>Legge direttamente da <see cref="LearningService"/>: non ha bisogno della
/// finestra principale per niente, e si aggiorna da sola ogni pochi secondi mentre è
/// visibile. Tutto il resto dell'applicazione non sa che esiste.</para>
/// </summary>
public partial class LearningControl : UserControl
{
private readonly DispatcherTimer _refresh;
private CancellationTokenSource? _evaluation;
private sealed class PesoRow
{
public string Nome { get; init; } = "";
public double Peso { get; init; }
public string PesoDisplay => Peso.ToString("+0.000;-0.000");
public string Effetto => Math.Abs(Peso) < 0.05 ? "quasi nullo"
: Peso > 0 ? "più senza risposta" : "più risposte";
}
private sealed class ProfiloRow
{
public string Prodotto { get; init; } = "";
public string Fascia { get; init; } = "";
public string Giorno { get; init; } = "";
public long Aste { get; init; }
public string Puntate { get; init; } = "";
public string Chiusura { get; init; } = "";
}
private sealed class RegimeRow
{
public string Asta { get; init; } = "";
public string StatoAsta { get; init; } = "";
public string Regime { get; init; } = "";
public string Pazienza { get; init; } = "";
public string Sondaggi { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Duello { get; init; } = "";
}
/// <summary>
/// Da dove prendere le aste seguite adesso, per la tabella dei regimi. Lo imposta
/// la finestra principale: la scheda non conosce il monitor, e non deve.
/// </summary>
public Func<IReadOnlyList<Models.AuctionInfo>>? AuctionsProvider { get; set; }
private sealed class DecisioneRow
{
public string Ora { get; init; } = "";
public string Asta { get; init; } = "";
public string Prezzo { get; init; } = "";
public string Prob { get; init; } = "";
public string Ev { get; init; } = "";
public string Esito { get; init; } = "";
}
public LearningControl()
{
InitializeComponent();
_refresh = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_refresh.Tick += (_, _) => { if (IsVisible) Refresh(); };
IsVisibleChanged += (_, e) =>
{
if ((bool)e.NewValue) { Refresh(); _refresh.Start(); }
else _refresh.Stop();
};
}
/// <summary>Rilegge tutto dal servizio. Costa poco: sono numeri già in memoria.</summary>
public void Refresh()
{
try
{
var settings = SettingsManager.Load();
var apprese = LearningService.AuctionsLearned;
var soglia = Math.Max(1, settings.LearningMinAuctions);
var pronto = LearningService.IsReady(settings);
var calib = LearningService.CalibrationFactor;
PillAuctions.Text = $"{apprese:N0} aste";
PillUpdates.Text = $"{LearningService.ModelUpdates:N0} esempi";
PillCalibration.Text = $"calibrazione {calib:N2}";
// L'interruttore in barra: spento, il modello studia ma non decide.
PillReady.Text = !settings.LearningEnabled ? "SPENTO (interruttore in barra)"
: pronto ? "pronto" : $"in ascolto ({apprese}/{soglia})";
PillReady.Foreground = (System.Windows.Media.Brush)FindResource(
!settings.LearningEnabled ? "Brush.Danger" : pronto ? "Brush.Success" : "Brush.Warning");
StatAuctions.Text = apprese.ToString("N0");
StatUpdates.Text = LearningService.ModelUpdates.ToString("N0");
StatProfile.Text = LearningService.ProfileAuctions.ToString("N0");
StatCalibration.Text = calib.ToString("N2");
StatThreshold.Text = soglia.ToString("N0");
StatBootstrap.Text = LearningService.BootstrapRunning ? "in corso" : "fermo";
RefreshAutonomy(settings);
RefreshShadow(settings);
var decisioni = LearningService.RecentDecisions();
var fermate = decisioni.Count(d => d.Blocked);
StatDecisions.Text = decisioni.Count == 0
? "Nessuna decisione ancora: il modello risponde quando il motore è pronto a puntare."
: $"Ultime {decisioni.Count} decisioni: {fermate} fermate, {decisioni.Count - fermate} lasciate passare" +
(settings.LearningGateEnabled ? "." : ". Il cancello è spento nelle impostazioni: il modello parla ma non decide.");
WeightsGrid.ItemsSource = LearningService.Pesi()
.Take(30)
.Select(p => new PesoRow { Nome = p.Nome, Peso = p.Peso })
.ToList();
DecisionsGrid.ItemsSource = decisioni
.Select(d => new DecisioneRow
{
Ora = d.At.ToString("HH:mm:ss"),
Asta = d.Auction,
Prezzo = d.Price.ToString("F2") + " €",
Prob = d.Probability.ToString("P2"),
Ev = d.ExpectedValue.ToString("+0.000;-0.000") + " €",
Esito = !d.Ready ? "parere" : d.Blocked ? "fermata" : "passa"
})
.ToList();
ProfileGrid.ItemsSource = LearningService.ProfileRows(150)
.Select(r => new ProfiloRow
{
Prodotto = r.Product,
Fascia = FasciaLabel(r.HourBand),
Giorno = r.Weekend ? "festivo" : "feriale",
Aste = r.Auctions,
Puntate = double.IsNaN(r.WinnerBids) ? "—" : r.WinnerBids.ToString("N0"),
Chiusura = double.IsNaN(r.CloseRatio) ? "—" : r.CloseRatio.ToString("P1")
})
.ToList();
if (_evaluation == null)
{
var ultima = LearningService.LastEvaluation();
if (!string.IsNullOrWhiteSpace(ultima)) EvaluationBox.Text = ultima;
}
}
catch (Exception ex)
{
StatDecisions.Text = $"Aggiornamento non riuscito: {ex.Message}";
}
}
private static string FasciaLabel(int band) => band switch
{
0 => "0-8", 1 => "9", 2 => "10-12", 3 => "13-17", 4 => "18-20", _ => "21-23"
};
/// <summary>La sezione «Autonomia sul momento»: modello di latenza e regime per asta.</summary>
private void RefreshAutonomy(AppSettings settings)
{
var lat = LatencyModel.Snapshot();
var lead = LatencyModel.RecommendedLeadMs(settings);
StatLeadNow.Text = $"{lead} ms";
StatLatP99.Text = lat.Samples > 0 ? $"{lat.P99} ms" : "—";
StatLatP50.Text = lat.Samples > 0 ? $"{lat.P50} ms" : "—";
StatLatMargin.Text = $"{lat.MarginMs} ms";
StatLatSamples.Text = lat.Samples.ToString("N0");
StatLatLate.Text = $"{lat.LateBids} / {lat.Bids}";
StatLeadHint.Text = !settings.AdaptiveLeadEnabled
? $"Anticipo adattivo spento nelle impostazioni: vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: lat.Samples < 10
? $"Ancora pochi campioni: finché non ce ne sono dieci vale l'anticipo fisso di {settings.DefaultBidBeforeDeadlineMs} ms."
: $"Margine {lat.MarginMs} + coda {lat.P99} = {lat.MarginMs + lat.P99} ms, tenuto fra {settings.LeadMinMs} e {settings.LeadMaxMs}.";
var aste = AuctionsProvider?.Invoke() ?? Array.Empty<Models.AuctionInfo>();
var righe = aste
.Where(a => a.State != Models.RunState.Stopped)
.Select(a => new RegimeRow
{
Asta = a.Name,
StatoAsta = a.State == Models.RunState.Active ? "Attiva" : "Osserva",
Regime = a.Regime.StatoAttuale switch
{
CompetitionRegime.Stato.Sfogo => $"Sfogo ({a.Regime.CicliBuoni}/{a.Regime.Pazienza})",
CompetitionRegime.Stato.Sondaggio => "Sondaggio",
_ => "Calmo"
},
Pazienza = a.Regime.Pazienza.ToString(),
Sondaggi = a.Regime.SondaggiFalliti.ToString(),
Prob = a.LearnedUnansweredProbability is { } p ? p.ToString("P1") : "—",
Ev = a.LearnedExpectedValue is { } ev ? ev.ToString("+0.000;-0.000") + " €" : "—",
Duello = a.AutoBidDuelDetected ? "sì" : a.AutoResponsesInARow > 0 ? $"{a.AutoResponsesInARow}/5" : ""
})
.ToList();
RegimeGrid.ItemsSource = righe;
var sfogo = righe.Count(r => r.Regime.StartsWith("Sfogo"));
RegimeHint.Text = righe.Count == 0
? "Nessuna asta seguita in questo momento."
: $"{righe.Count} aste seguite: {sfogo} in Sfogo, {righe.Count(r => r.Regime == "Sondaggio")} in Sondaggio, {righe.Count - sfogo - righe.Count(r => r.Regime == "Sondaggio")} Calme.";
}
private DateTime _shadowRefreshedAt = DateTime.MinValue;
/// <summary>Campione, Brier, bandit e il rapporto shadow (ogni mezzo minuto: interroga il database).</summary>
private void RefreshShadow(AppSettings settings)
{
var (bl, bn, samples) = LearningService.BrierScores;
StatChampion.Text = LearningService.ChallengerLeads ? "rete neurale" : "logistico";
StatBrier.Text = samples == 0 ? "—" : $"{bl:F5} / {bn:F5}";
StatBandit.Text = $"{LearningService.Bandit.Arms:N0} / {LearningService.Bandit.Observations:N0}";
if ((DateTime.Now - _shadowRefreshedAt).TotalSeconds < 30) return;
_shadowRefreshedAt = DateTime.Now;
try
{
var report = ShadowReport.Compute(Data.AuctionDatabase.Instance, settings, 30);
ShadowBox.Text = report.WithOutcome == 0
? $"Decisioni registrate negli ultimi 30 giorni: {report.TotalDecisions:N0}, nessuna con esito ancora (arriva alla chiusura dell'asta)."
: report.Text;
}
catch (Exception ex)
{
ShadowBox.Text = $"Rapporto non disponibile: {ex.Message}";
}
}
private void ExportDecisionsButton_Click(object sender, RoutedEventArgs e)
{
string? path;
try
{
var dialog = new Microsoft.Win32.SaveFileDialog
{
Title = "Esporta le decisioni in CSV",
FileName = $"decisioni-{DateTime.Now:yyyyMMdd-HHmm}.csv",
Filter = "CSV (foglio di calcolo)|*.csv",
OverwritePrompt = true,
AddExtension = true
};
path = dialog.ShowDialog() == true ? dialog.FileName : null;
}
catch { path = null; }
if (path == null) return;
var result = DecisionExporter.ExportCsv(path);
if (result.Success)
{
try { Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(result.Path)!, UseShellExecute = true }); } catch { }
MessageBox.Show($"{result.Message}\n{result.Path}", "Decisioni", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show(result.Message, "Decisioni", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private CancellationTokenSource? _simulation;
private async void SimulateButton_Click(object sender, RoutedEventArgs e)
{
if (_simulation != null) { _simulation.Cancel(); return; }
_simulation = new CancellationTokenSource();
var ct = _simulation.Token;
SimulateButton.Content = "Annulla";
SimulationBox.Text = "Simulazione in corso…";
var settings = SettingsManager.Load();
try
{
var text = await Task.Run(() => SimulationLab.Run(Data.AuctionDatabase.Instance, settings, 500,
msg => Dispatcher.BeginInvoke(() => SimulateHint.Text = msg), ct), ct);
SimulationBox.Text = text;
SimulateHint.Text = $"fatto alle {DateTime.Now:HH:mm}";
}
catch (OperationCanceledException)
{
SimulationBox.Text = "Simulazione annullata.";
SimulateHint.Text = "";
}
catch (Exception ex)
{
SimulationBox.Text = $"Simulazione non riuscita: {ex.Message}";
SimulateHint.Text = "";
}
finally
{
_simulation = null;
SimulateButton.Content = "Simula 500 aste per policy";
}
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Refresh();
private async void EvaluateButton_Click(object sender, RoutedEventArgs e)
{
if (_evaluation != null)
{
_evaluation.Cancel();
return;
}
_evaluation = new CancellationTokenSource();
// Il pulsante è solo icona: mentre gira diventa una X, e il tooltip lo dice.
EvaluateGlyph.Text = "\uE711";
EvaluateButton.ToolTip = "Annulla la valutazione in corso.";
EvaluationBox.Text = "Valutazione in corso: lettura delle aste…";
try
{
// Al massimo tremila aste, le più recenti: bastano a giudicare, e la
// lettura di tutto l'archivio in sottofondo mentre le aste corrono non serve.
var report = await LearningService.EvaluateAsync(3000,
msg => Dispatcher.BeginInvoke(() => EvaluationBox.Text = "Valutazione in corso: " + msg),
_evaluation.Token);
EvaluationBox.Text = report.Text;
}
catch (OperationCanceledException)
{
EvaluationBox.Text = "Valutazione annullata.";
}
catch (Exception ex)
{
EvaluationBox.Text = $"Valutazione non riuscita: {ex.Message}";
}
finally
{
_evaluation = null;
EvaluateGlyph.Text = "\uE9F5";
EvaluateButton.ToolTip = "Valuta ora.\nAddestra sulle aste più vecchie del database e giudica sulle più recenti, mai viste. Poi la prova prequenziale: ogni asta prima prevista e poi appresa, come fa il motore. Qualche minuto in sottofondo; il rapporto compare nella scheda «Valutazione».";
}
}
private async void RetrainButton_Click(object sender, RoutedEventArgs e)
{
var answer = MessageBox.Show(
"Butto modello, profilo, sfidante, bandit e contatore delle aste studiate, e ricomincio a studiare tutte le aste chiuse del database.\n\n" +
"Lo storico e i prodotti non vengono toccati. Ci vorranno un paio di minuti in sottofondo.\n\nProcedo?",
"Ricomincia da capo", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) return;
RetrainButton.IsEnabled = false;
try
{
await LearningService.RetrainFromScratchAsync(SettingsManager.Load());
}
finally
{
RetrainButton.IsEnabled = true;
Refresh();
}
}
}
}