Switch AI diagnostics from OpenAI to Mistral and use .env for API key
- Changed API endpoint to api.mistral.ai, model to mistral-small-latest - Replaced openAiConfig.json with .env file for secure API key storage - Added .env loader in Program.cs, added .env to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7f8aacc34
commit
68f4006f55
|
|
@ -3,4 +3,4 @@
|
||||||
**/obj
|
**/obj
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
**/.idea/
|
**/.idea/
|
||||||
|
**/.env
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@
|
||||||
<None Update="Resources/s3cmd.py">
|
<None Update="Resources/s3cmd.py">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update=".env">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
<None Remove="DbBackups\db-1698326303.sqlite" />
|
<None Remove="DbBackups\db-1698326303.sqlite" />
|
||||||
<None Remove="DbBackups\db-1698327045.sqlite" />
|
<None Remove="DbBackups\db-1698327045.sqlite" />
|
||||||
<None Remove="DbBackups\db-1699453468.sqlite" />
|
<None Remove="DbBackups\db-1699453468.sqlite" />
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ public class Controller : ControllerBase
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns an AI-generated diagnosis for a single error/alarm description.
|
/// Returns an AI-generated diagnosis for a single error/alarm description.
|
||||||
/// Responses are cached in memory — repeated calls for the same error code
|
/// Responses are cached in memory — repeated calls for the same error code
|
||||||
/// do not hit OpenAI again.
|
/// do not hit Mistral again.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet(nameof(DiagnoseError))]
|
[HttpGet(nameof(DiagnoseError))]
|
||||||
public async Task<ActionResult<DiagnosticResponse>> DiagnoseError(Int64 installationId, string errorDescription, Token authToken)
|
public async Task<ActionResult<DiagnosticResponse>> DiagnoseError(Int64 installationId, string errorDescription, Token authToken)
|
||||||
|
|
@ -791,7 +791,7 @@ public class Controller : ControllerBase
|
||||||
"Warning 500",
|
"Warning 500",
|
||||||
"Error 408",
|
"Error 408",
|
||||||
"AFCI Fault",
|
"AFCI Fault",
|
||||||
// Unknown alarm (should return null - would call OpenAI)
|
// Unknown alarm (should return null - would call Mistral)
|
||||||
"Some unknown alarm XYZ123"
|
"Some unknown alarm XYZ123"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -803,7 +803,7 @@ public class Controller : ControllerBase
|
||||||
{
|
{
|
||||||
Alarm = alarm,
|
Alarm = alarm,
|
||||||
FoundInKnowledgeBase = diagnosis != null,
|
FoundInKnowledgeBase = diagnosis != null,
|
||||||
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call OpenAI API",
|
Explanation = diagnosis?.Explanation ?? "NOT FOUND - Would call Mistral API",
|
||||||
CausesCount = diagnosis?.Causes.Count ?? 0,
|
CausesCount = diagnosis?.Causes.Count ?? 0,
|
||||||
NextStepsCount = diagnosis?.NextSteps.Count ?? 0
|
NextStepsCount = diagnosis?.NextSteps.Count ?? 0
|
||||||
});
|
});
|
||||||
|
|
@ -814,7 +814,7 @@ public class Controller : ControllerBase
|
||||||
TestTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
TestTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
TotalTests = testCases.Length,
|
TotalTests = testCases.Length,
|
||||||
FoundInKnowledgeBase = results.Count(r => ((dynamic)r).FoundInKnowledgeBase),
|
FoundInKnowledgeBase = results.Count(r => ((dynamic)r).FoundInKnowledgeBase),
|
||||||
WouldCallOpenAI = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
|
WouldCallMistral = results.Count(r => !((dynamic)r).FoundInKnowledgeBase),
|
||||||
Results = results
|
Results = results
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ public static class Program
|
||||||
|
|
||||||
Watchdog.NotifyReady();
|
Watchdog.NotifyReady();
|
||||||
Db.Init();
|
Db.Init();
|
||||||
|
LoadEnvFile();
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
@ -89,6 +90,33 @@ public static class Program
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void LoadEnvFile()
|
||||||
|
{
|
||||||
|
var envPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".env");
|
||||||
|
|
||||||
|
if (!File.Exists(envPath))
|
||||||
|
envPath = ".env"; // fallback for dev
|
||||||
|
|
||||||
|
if (!File.Exists(envPath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var line in File.ReadAllLines(envPath))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.Length == 0 || trimmed.StartsWith('#'))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var idx = trimmed.IndexOf('=');
|
||||||
|
if (idx <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var key = trimmed[..idx].Trim();
|
||||||
|
var value = trimmed[(idx + 1)..].Trim();
|
||||||
|
|
||||||
|
Environment.SetEnvironmentVariable(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
|
private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo
|
||||||
{
|
{
|
||||||
Title = "Inesco Backend API",
|
Title = "Inesco Backend API",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"ApiKey": "sk-your-openai-api-key-here"
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Static knowledge base for Sinexcel and Growatt alarms.
|
/// Static knowledge base for Sinexcel and Growatt alarms.
|
||||||
/// Provides pre-defined diagnostics without requiring OpenAI API calls.
|
/// Provides pre-defined diagnostics without requiring Mistral API calls.
|
||||||
/// Data sourced from vendor alarm documentation.
|
/// Data sourced from vendor alarm documentation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AlarmKnowledgeBase
|
public static class AlarmKnowledgeBase
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ using Newtonsoft.Json;
|
||||||
namespace InnovEnergy.App.Backend.Services;
|
namespace InnovEnergy.App.Backend.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calls OpenAI to generate plain-English diagnostics for errors/warnings.
|
/// Calls Mistral AI to generate plain-English diagnostics for errors/warnings.
|
||||||
/// Caches responses in-memory keyed by error description so the same
|
/// Caches responses in-memory keyed by error description so the same
|
||||||
/// error code is only sent to the API once.
|
/// error code is only sent to the API once.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -22,30 +22,15 @@ public static class DiagnosticService
|
||||||
|
|
||||||
public static void Initialize()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "openAiConfig.json");
|
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||||||
|
|
||||||
if (!File.Exists(configPath))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
{
|
{
|
||||||
// Fallback: look relative to the working directory (useful in dev)
|
Console.Error.WriteLine("[DiagnosticService] MISTRAL_API_KEY not set – AI diagnostics disabled.");
|
||||||
configPath = Path.Combine("Resources", "openAiConfig.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(configPath))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[DiagnosticService] openAiConfig.json not found – AI diagnostics disabled.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = File.ReadAllText(configPath);
|
_apiKey = apiKey;
|
||||||
var config = JsonConvert.DeserializeObject<OpenAiConfig>(json);
|
|
||||||
|
|
||||||
if (config is null || string.IsNullOrWhiteSpace(config.ApiKey))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[DiagnosticService] ApiKey is empty – AI diagnostics disabled.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_apiKey = config.ApiKey;
|
|
||||||
Console.WriteLine("[DiagnosticService] initialised.");
|
Console.WriteLine("[DiagnosticService] initialised.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +41,7 @@ public static class DiagnosticService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a diagnosis for <paramref name="errorDescription"/>.
|
/// Returns a diagnosis for <paramref name="errorDescription"/>.
|
||||||
/// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms.
|
/// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms.
|
||||||
/// Falls back to in-memory cache, then calls OpenAI only for unknown alarms.
|
/// Falls back to in-memory cache, then calls Mistral AI only for unknown alarms.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<DiagnosticResponse?> DiagnoseAsync(Int64 installationId, string errorDescription)
|
public static async Task<DiagnosticResponse?> DiagnoseAsync(Int64 installationId, string errorDescription)
|
||||||
{
|
{
|
||||||
|
|
@ -89,10 +74,10 @@ public static class DiagnosticService
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// 5. Build prompt and call OpenAI API (only for unknown alarms)
|
// 5. Build prompt and call Mistral API (only for unknown alarms)
|
||||||
Console.WriteLine($"[DiagnosticService] Calling OpenAI for unknown alarm: {errorDescription}");
|
Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription}");
|
||||||
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions);
|
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions);
|
||||||
var response = await CallOpenAiAsync(prompt);
|
var response = await CallMistralAsync(prompt);
|
||||||
|
|
||||||
if (response is null) return null;
|
if (response is null) return null;
|
||||||
|
|
||||||
|
|
@ -121,17 +106,17 @@ Reply with ONLY valid JSON, no markdown:
|
||||||
";
|
";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OpenAI HTTP call ────────────────────────────────────────────
|
// ── Mistral HTTP call ────────────────────────────────────────────
|
||||||
|
|
||||||
private static readonly string OpenAiUrl = "https://api.openai.com/v1/chat/completions";
|
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||||||
|
|
||||||
private static async Task<DiagnosticResponse?> CallOpenAiAsync(string userPrompt)
|
private static async Task<DiagnosticResponse?> CallMistralAsync(string userPrompt)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var requestBody = new
|
var requestBody = new
|
||||||
{
|
{
|
||||||
model = "gpt-4o-mini", // cost-efficient, fast; swap to "gpt-4" if quality needs tuning
|
model = "mistral-small-latest", // cost-efficient, fast; swap to "mistral-large-latest" if quality needs tuning
|
||||||
messages = new[]
|
messages = new[]
|
||||||
{
|
{
|
||||||
new { role = "user", content = userPrompt }
|
new { role = "user", content = userPrompt }
|
||||||
|
|
@ -140,19 +125,19 @@ Reply with ONLY valid JSON, no markdown:
|
||||||
temperature = 0.2 // low temperature for factual consistency
|
temperature = 0.2 // low temperature for factual consistency
|
||||||
};
|
};
|
||||||
|
|
||||||
var responseText = await OpenAiUrl
|
var responseText = await MistralUrl
|
||||||
.SetHeader("Authorization", $"Bearer {_apiKey}")
|
.SetHeader("Authorization", $"Bearer {_apiKey}")
|
||||||
.SetHeader("Content-Type", "application/json")
|
.SetHeader("Content-Type", "application/json")
|
||||||
.PostJsonAsync(requestBody)
|
.PostJsonAsync(requestBody)
|
||||||
.ReceiveString();
|
.ReceiveString();
|
||||||
|
|
||||||
// parse OpenAI envelope
|
// parse Mistral envelope (same structure as OpenAI)
|
||||||
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
var envelope = JsonConvert.DeserializeObject<dynamic>(responseText);
|
||||||
var content = (string?) envelope?.choices?[0]?.message?.content;
|
var content = (string?) envelope?.choices?[0]?.message?.content;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine("[DiagnosticService] OpenAI returned empty content.");
|
Console.Error.WriteLine("[DiagnosticService] Mistral returned empty content.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,11 +160,6 @@ Reply with ONLY valid JSON, no markdown:
|
||||||
|
|
||||||
// ── config / response models ────────────────────────────────────────────────
|
// ── config / response models ────────────────────────────────────────────────
|
||||||
|
|
||||||
public class OpenAiConfig
|
|
||||||
{
|
|
||||||
public string ApiKey { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class DiagnosticResponse
|
public class DiagnosticResponse
|
||||||
{
|
{
|
||||||
public string Explanation { get; set; } = "";
|
public string Explanation { get; set; } = "";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue