Météo en temps réel et swap Claude API

Météo en temps réel et swap Claude API sur Raspberry Pi

Météo en temps réel et swap Claude API

Cet article fait partie de la série Assistant vocal sur Raspberry Pi.

L’assistant répond bien, mais il ne sait pas quel temps il fait dehors. On branche Open-Meteo, une API météo gratuite et sans clé. Tant qu’à y être, on swap aussi Ollama pour Claude API : une seule ligne dans appsettings.json.

Le code complet de cet article est disponible sur GitHub.

Partie 1 : Météo en temps réel avec Open-Meteo

Open-Meteo est une API météo open-source, gratuite et sans inscription. On appelle l’API au démarrage de l’assistant pour obtenir les conditions actuelles de Blainville, puis on les injecte dans le system prompt.

Open-Meteo → température, conditions → injection dans SystemPrompt → LLM répond avec contexte météo

Étape 1.1 : Créer WeatherService

Crée Services/WeatherService.cs :

using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

namespace AudioAssistant.Services;

public interface IWeatherService
{
    Task<string> GetCurrentWeatherAsync(CancellationToken cancellationToken);
}

public class WeatherService : IWeatherService
{
    private readonly HttpClient _http;
    private readonly AssistantOptions _options;
    private readonly ILogger<WeatherService> _logger;

    public WeatherService(HttpClient http, IOptions<AssistantOptions> options, ILogger<WeatherService> logger)
    {
        _http = http;
        _options = options.Value;
        _logger = logger;
    }

    public async Task<string> GetCurrentWeatherAsync(CancellationToken cancellationToken)
    {
        try
        {
            var url = $"https://api.open-meteo.com/v1/forecast" +
                $"?latitude={_options.WeatherLatitude}" +
                $"&longitude={_options.WeatherLongitude}" +
                $"&current=temperature_2m,apparent_temperature,precipitation,weathercode,windspeed_10m" +
                $"&timezone=America%2FToronto" +
                $"&forecast_days=1";

            var response = await _http.GetFromJsonAsync<OpenMeteoResponse>(url, cancellationToken);

            if (response?.Current == null)
                return "Météo non disponible.";

            var description = GetWeatherDescription(response.Current.Weathercode);
            var summary = $"{description}, {response.Current.Temperature2m:F1}°C " +
                         $"(ressenti {response.Current.ApparentTemperature:F1}°C), " +
                         $"vent {response.Current.Windspeed10m:F0} km/h";

            if (response.Current.Precipitation > 0)
                summary += $", précipitations {response.Current.Precipitation:F1} mm";

            _logger.LogInformation("Météo Blainville : {Summary}", summary);
            return summary;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Impossible d'obtenir la météo.");
            return "Météo non disponible.";
        }
    }

    private static string GetWeatherDescription(int code) => code switch
    {
        0 => "Ciel dégagé",
        1 => "Principalement dégagé",
        2 => "Partiellement nuageux",
        3 => "Couvert",
        45 or 48 => "Brouillard",
        51 or 53 or 55 => "Bruine",
        61 or 63 or 65 => "Pluie",
        71 or 73 or 75 => "Neige",
        77 => "Grains de neige",
        80 or 81 or 82 => "Averses de pluie",
        85 or 86 => "Averses de neige",
        95 => "Orage",
        96 or 99 => "Orage avec grêle",
        _ => "Conditions variables"
    };
}

// JsonPropertyName requis : Open-Meteo retourne du snake_case que .NET ne mappe pas automatiquement
internal record OpenMeteoResponse(
    [property: JsonPropertyName("current")] OpenMeteoCurrent Current);

internal record OpenMeteoCurrent(
    [property: JsonPropertyName("temperature_2m")] double Temperature2m,
    [property: JsonPropertyName("apparent_temperature")] double ApparentTemperature,
    [property: JsonPropertyName("precipitation")] double Precipitation,
    [property: JsonPropertyName("weathercode")] int Weathercode,
    [property: JsonPropertyName("windspeed_10m")] double Windspeed10m);

Si vous voyez des valeurs à 0.0°C partout, c’est un problème de désérialisation : vérifiez que les attributs [JsonPropertyName] sont bien présents sur les DTOs. C’est le piège classique avec Open-Meteo.

Étape 1.2 : Mettre à jour AssistantOptions

Ajoutez les coordonnées GPS et les nouvelles options :

namespace AudioAssistant;

public class AssistantOptions
{
    public int GpioButtonPin { get; set; } = 17;
    public string AudioDevice { get; set; } = "hw:3,0";
    public int RecordingDurationSeconds { get; set; } = 15;
    public int SilenceDurationMs { get; set; } = 1500;
    public string SilenceThreshold { get; set; } = "-40dB";
    public string WhisperModel { get; set; } = "ggml-base.bin";
    public string PiperBinary { get; set; } = "/home/gabriel/piper/piper/piper";
    public string PiperVoice { get; set; } = "/home/gabriel/piper-voices/fr_FR-siwis-low.onnx";
    public string AudioOutputDevice { get; set; } = "hw:3,0";
    public string OllamaBaseUrl { get; set; } = "http://pi-cerveau.local:11434";
    public string OllamaModel { get; set; } = "llama3.2:1b";
    public string SystemPrompt { get; set; } = "";
    public int MaxConversationTurns { get; set; } = 10;

    // Météo
    public double WeatherLatitude { get; set; } = 45.67;
    public double WeatherLongitude { get; set; } = -73.88;

    // LLM Provider
    public string LlmProvider { get; set; } = "ollama"; // "ollama" ou "claude"
    public string ClaudeApiKey { get; set; } = "";
    public string ClaudeModel { get; set; } = "claude-sonnet-4-6";
}

Les coordonnées 45.67, -73.88 correspondent à Blainville, QC. Ajustez selon votre emplacement.

Étape 1.3 : Mettre à jour ContextService

Le ContextService reçoit maintenant le IWeatherService et injecte la météo dans le system prompt via InitializeAsync :

using Microsoft.Extensions.Options;

namespace AudioAssistant.Services;

public interface IContextService
{
    Task InitializeAsync(CancellationToken cancellationToken);
    List<ConversationMessage> AddUserMessage(string userInput);
    void AddAssistantMessage(string response);
    void Reset();
}

public class ContextService : IContextService
{
    private readonly AssistantOptions _options;
    private readonly IWeatherService _weather;
    private readonly List<ConversationMessage> _history = new();
    private readonly ILogger<ContextService> _logger;

    public ContextService(
        IOptions<AssistantOptions> options,
        IWeatherService weather,
        ILogger<ContextService> logger)
    {
        _options = options.Value;
        _weather = weather;
        _logger = logger;
    }

    public async Task InitializeAsync(CancellationToken cancellationToken)
    {
        var meteo = await _weather.GetCurrentWeatherAsync(cancellationToken);
        var systemPromptAvecMeteo = _options.SystemPrompt +
            $"\n\nMétéo actuelle à Blainville : {meteo}";

        _history.Clear();
        _history.Add(new ConversationMessage("system", systemPromptAvecMeteo));
        _logger.LogInformation("Contexte initialisé avec météo : {Meteo}", meteo);
    }

    public List<ConversationMessage> AddUserMessage(string userInput)
    {
        _history.Add(new ConversationMessage("user", userInput));
        _logger.LogInformation("Historique : {Count} messages", _history.Count);
        return _history;
    }

    public void AddAssistantMessage(string response)
    {
        _history.Add(new ConversationMessage("assistant", response));
    }

    public void Reset()
    {
        _history.Clear();
        _logger.LogInformation("Historique réinitialisé.");
    }
}

Étape 1.4 : Mettre à jour Worker.cs

Appelez InitializeAsync avant la boucle principale :

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    _logger.LogInformation("Assistant démarré. Initialisation du contexte...");

    await _context.InitializeAsync(stoppingToken);

    _logger.LogInformation("Appuie sur le bouton pour parler.");

    while (!stoppingToken.IsCancellationRequested)
    {
        _gpio.WaitForButtonPress(stoppingToken);
        if (stoppingToken.IsCancellationRequested) break;

        try
        {
            var audioFile = await _recorder.RecordAsync(stoppingToken);
            var texte = await _transcription.TranscribeAsync(audioFile, stoppingToken);

            if (string.IsNullOrWhiteSpace(texte))
            {
                await _speech.SpeakAsync("Je n'ai pas bien entendu. Peux-tu répéter?", stoppingToken);
            }
            else
            {
                var history = _context.AddUserMessage(texte);
                var reponse = await _llm.ChatAsync(history, stoppingToken);
                _context.AddAssistantMessage(reponse);
                await _speech.SpeakAsync(reponse, stoppingToken);
            }

            if (File.Exists(audioFile))
                File.Delete(audioFile);
        }
        catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogError(ex, "Erreur dans le pipeline");
            await _speech.SpeakAsync("Une erreur s'est produite.", stoppingToken);
        }
    }
}

Test météo

Arrêtez le service systemd avant de tester. Sinon les deux instances entrent en conflit sur le périphérique audio :

sudo systemctl stop assistant
dotnet run

Vous devriez voir dans les logs :

info: Météo Blainville : Principalement dégagé, 8.3°C (ressenti 6.1°C), vent 14 km/h
info: Contexte initialisé avec météo : Principalement dégagé, 8.3°C...

Demandez à l’assistant : “Comment je devrais m’habiller ce matin?” La réponse devrait tenir compte de la météo réelle.

Partie 2 : Swapper entre Ollama et Claude API

Pourquoi swapper ?

Ollama est parfait pour un usage quotidien : local, gratuit, privé. Mais llama3.2:1b a des lacunes notables en français québécois. Un exemple réel obtenu durant le développement, quand on lui a demandé comment s’habiller par temps frais :

“La crinière devra s’abuffer et les baskets te serviront bien pour les balades à pied.”

Voilà les limites du modèle 1B. Le modèle 3B est meilleur, mais trop lent sur Pi. Claude s’en sort nettement mieux en français québécois. L’interface ILlmService qu’on a mise en place à l’article #4 rend le swap trivial.

appsettings.json: "LlmProvider": "claude"
Program.cs injecte ClaudeService au lieu de OllamaService
Worker.cs ne change pas — il parle toujours à ILlmService

Étape 2.1 : Ajouter le package Anthropic

dotnet add package Anthropic.SDK

Étape 2.2 : Créer ClaudeService

Crée Services/ClaudeService.cs :

using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Options;

namespace AudioAssistant.Services;

public class ClaudeService : ILlmService
{
    private readonly AssistantOptions _options;
    private readonly ILogger<ClaudeService> _logger;

    public ClaudeService(IOptions<AssistantOptions> options, ILogger<ClaudeService> logger)
    {
        _options = options.Value;
        _logger = logger;
    }

    public async Task<string> ChatAsync(List<ConversationMessage> history, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Envoi à Claude ({Count} messages)...", history.Count);

        var client = new AnthropicClient(_options.ClaudeApiKey);

        var systemPrompt = history
            .FirstOrDefault(m => m.Role == "system")?.Content ?? "";

        var messages = history
            .Where(m => m.Role != "system")
            .Select(m => new Message
            {
                Role = m.Role == "user" ? RoleType.User : RoleType.Assistant,
                Content = new List<ContentBase> { new TextContent { Text = m.Content } }
            })
            .ToList();

        var request = new MessageParameters
        {
            Model = _options.ClaudeModel,
            MaxTokens = 512,
            System = new List<SystemMessage> { new SystemMessage(systemPrompt) },
            Messages = messages
        };

        var response = await client.Messages.GetClaudeMessageAsync(request, cancellationToken);
        var text = response.Content.OfType<TextContent>().FirstOrDefault()?.Text?.Trim()
            ?? "Je n'ai pas de réponse.";

        _logger.LogInformation("Réponse de Claude : \"{Text}\"", text);
        return text;
    }
}

Étape 2.3 : Mettre à jour Program.cs

using AudioAssistant;
using AudioAssistant.Services;
using Microsoft.Extensions.Options;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.Configure<AssistantOptions>(
    builder.Configuration.GetSection("Assistant"));

builder.Services.AddSingleton<IGpioService, GpioService>();
builder.Services.AddSingleton<IAudioRecorderService, AudioRecorderService>();
builder.Services.AddSingleton<ITranscriptionService, WhisperTranscriptionService>();
builder.Services.AddSingleton<ISpeechService, PiperSpeechService>();
builder.Services.AddSingleton<IContextService, ContextService>();

builder.Services.AddHttpClient<IWeatherService, WeatherService>();

// Enregistrer les deux services LLM
builder.Services.AddHttpClient<OllamaService>(client =>
{
    client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddTransient<ClaudeService>();

// Factory qui choisit selon LlmProvider dans appsettings
builder.Services.AddTransient<ILlmService>(sp =>
{
    var options = sp.GetRequiredService<IOptions<AssistantOptions>>().Value;
    return options.LlmProvider.ToLower() switch
    {
        "claude" => (ILlmService)sp.GetRequiredService<ClaudeService>(),
        _ => sp.GetRequiredService<OllamaService>()
    };
});

builder.Services.AddHostedService<Worker>();

var host = builder.Build();
host.Run();

Étape 2.4 : Mettre à jour appsettings.json

{
  "Assistant": {
    "GpioButtonPin": 17,
    "AudioDevice": "hw:3,0",
    "RecordingDurationSeconds": 15,
    "SilenceDurationMs": 1500,
    "SilenceThreshold": "-40dB",
    "WhisperModel": "ggml-base.bin",
    "PiperBinary": "/home/gabriel/piper/piper/piper",
    "PiperVoice": "/home/gabriel/piper-voices/fr_FR-siwis-low.onnx",
    "AudioOutputDevice": "hw:3,0",
    "OllamaBaseUrl": "http://pi-cerveau.local:11434",
    "OllamaModel": "llama3.2:1b",
    "MaxConversationTurns": 10,
    "WeatherLatitude": 45.67,
    "WeatherLongitude": -73.88,
    "LlmProvider": "ollama",
    "ClaudeApiKey": "sk-ant-...",
    "ClaudeModel": "claude-sonnet-4-6",
    "SystemPrompt": "Tu es un assistant vocal personnel qui s'appelle Alex. Tu aides la famille Mongeon qui habite à Blainville, Québec, Canada. Tu réponds toujours en français québécois, de façon naturelle et chaleureuse. Tu es concis : tes réponses font 1 à 3 phrases maximum, car elles seront lues à voix haute."
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

Ne jamais committer ClaudeApiKey dans Git. Utilisez plutôt une variable d’environnement dans le service systemd :

sudo systemctl edit assistant
[Service]
Environment="Assistant__ClaudeApiKey=sk-ant-..."

Tester le swap

Pour passer à Claude, changez "LlmProvider": "ollama" pour "LlmProvider": "claude" dans appsettings.json, puis republier et redémarrer :

cd ~/projects/AudioAssistant
dotnet publish -c Release -r linux-arm64 --self-contained false -o ~/assistant-publish
sudo systemctl restart assistant
journalctl -u assistant -f

Dans les logs, vous devriez voir Envoi à Claude au lieu de Envoi au LLM.

La différence de qualité avec Claude se voit dès la première réponse en français. Open-Meteo : gratuit, sans inscription, sans clé. Ce genre d’API, ça ne court pas les rues.

Le code complet de cet article est disponible sur GitHub.

Articles de la série

  1. Setup des deux Raspberry Pi
  2. Worker Service .NET 10 et pipeline audio
  3. Intégration Ollama et contexte maison
  4. Mémoire, détection de silence et systemd
  5. Météo en temps réel et swap Claude API (cet article)
  6. Function Calling : enseigner des outils à l’assistant
  7. Bilan, leçons apprises et perspectives v2

Dans l’article #6, on pousse plus loin l’idée de la factory LLM en ajoutant du function calling : l’assistant peut appeler des outils définis en code, pas juste répondre à des questions.


Cet article a été rédigé avec l’aide de l’IA et révisé par moi.


Suggestions de lecture :