Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,351 @@
using Omika.Diagnostics.Models;
using Omika.Diagnostics.Prompt;
namespace Omika.Diagnostics.Tests;
/// <summary>
/// T24-A Partie A — génération LOCALE du prompt IA. Aucun appel IA externe : le builder
/// transforme un diagnostic en texte lisible. Tests : précision /dev/throw, corrélation
/// (TraceId/OperationId), données NULL, bornes, et sécurité (0 fuite de valeurs interdites,
/// réutilisation de <see cref="Omika.Core.Observability.Outbox.DiagnosticForbiddenData"/>).
/// </summary>
public class DiagnosticPromptBuilderTests
{
private static readonly DateTimeOffset Base = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static DiagnosticEventRecord Ev(
string eventCode = "TEST",
string level = "Error",
string priority = "P2",
string? errorCode = null,
string? message = null,
string? exceptionType = null,
string? stackTrace = null,
Guid? traceId = null,
Guid? operationId = null,
Guid? sessionId = null,
string? actorKey = null,
string? clientCode = null,
string? environment = null,
string? serverInstance = null,
string? source = null,
string? component = null,
string? platform = null,
string? operatingSystem = null,
string? operatingSystemVersion = null,
string? manufacturer = null,
string? model = null,
string? appVersion = null,
string? appBuild = null,
string? screenCode = null,
string? featureCode = null,
string? licenseNumber = null,
string? deviceKey = null,
long? durationMs = null,
string? metadataJson = null) =>
new()
{
Id = 1,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = Base,
Level = level,
Priority = priority,
ErrorCode = errorCode,
Message = message,
ExceptionType = exceptionType,
StackTrace = stackTrace,
TraceId = traceId,
OperationId = operationId,
SessionId = sessionId,
ActorKey = actorKey,
ClientCode = clientCode,
Environment = environment,
ServerInstance = serverInstance,
Source = source ?? DiagnosticSources.Mobile,
Component = component,
Platform = platform,
OperatingSystem = operatingSystem,
OperatingSystemVersion = operatingSystemVersion,
DeviceManufacturer = manufacturer,
DeviceModel = model,
AppVersion = appVersion,
AppBuild = appBuild,
ScreenCode = screenCode,
FeatureCode = featureCode,
LicenseNumber = licenseNumber,
DeviceKey = deviceKey,
DurationMs = durationMs,
MetadataJson = metadataJson,
IngestedAtUtc = DateTimeOffset.UtcNow
};
private static DiagnosticEventRecord Correlated(long id, DateTimeOffset ts, string eventCode, string level, string component, string message) =>
new()
{
Id = id,
EventId = Guid.NewGuid(),
EventCode = eventCode,
TimestampUtc = ts,
Level = level,
Priority = "P2",
Component = component,
Message = message,
Source = DiagnosticSources.Api,
IngestedAtUtc = DateTimeOffset.UtcNow
};
// ------------------------------------------- A : /dev/throw (volontaire/test) ----
[Fact]
public void DevThrow_PromptContainsEnoughContextToInferVoluntaryTest()
{
DiagnosticEventRecord ev = Ev(
eventCode: "API_HTTP_ERROR",
errorCode: "TELEMETRY_TEST_ERROR",
exceptionType: "InvalidOperationException",
environment: "Development",
serverInstance: "API-A-01",
source: DiagnosticSources.Api,
component: "Omika.Api",
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
// Aucune règle codée en dur « /dev/throw » : le résultat vient des données.
Assert.Contains("API_HTTP_ERROR", prompt, StringComparison.Ordinal);
Assert.Contains("InvalidOperationException", prompt, StringComparison.Ordinal);
Assert.Contains("/dev/throw", prompt, StringComparison.Ordinal);
Assert.Contains("TELEMETRY_TEST_ERROR", prompt, StringComparison.Ordinal);
Assert.Contains("Development", prompt, StringComparison.Ordinal);
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- B : TraceId + corrélés ----
[Fact]
public void TraceId_PromptIncludesCorrelatedSection()
{
Guid traceId = Guid.NewGuid();
DiagnosticEventRecord ev = Ev(traceId: traceId, eventCode: "ERR", exceptionType: "System.Exception");
var correlated = new[]
{
Correlated(1, Base.AddMinutes(-1), "OMK-API-HTTP-001", "Info", "Omika.Api", "api request"),
Correlated(2, Base, "ERR", "Error", "Omika.Api", "boom")
};
string prompt = DiagnosticPromptBuilder.Build(ev, correlated);
Assert.Contains(traceId.ToString("D"), prompt, StringComparison.Ordinal);
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
Assert.Contains("OMK-API-HTTP-001", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- C : OperationId ----
[Fact]
public void OperationId_PromptIncludesOperationId()
{
Guid operationId = Guid.NewGuid();
DiagnosticEventRecord ev = Ev(operationId: operationId);
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains(operationId.ToString("D"), prompt, StringComparison.Ordinal);
}
// ------------------------------------------- D : mobile ScreenCode/DeviceInfo ----
[Fact]
public void MobileEvent_PromptIncludesScreenAndDeviceInfo()
{
DiagnosticEventRecord ev = Ev(
screenCode: "AGENDA",
featureCode: "INTERVENTION_SEARCH_RUN",
platform: "Android",
manufacturer: "Samsung",
model: "SM-S928B",
operatingSystem: "Android",
operatingSystemVersion: "15",
appVersion: "1.1.0",
deviceKey: "devk-pseudonymized-abcdef");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("AGENDA", prompt, StringComparison.Ordinal);
Assert.Contains("INTERVENTION_SEARCH_RUN", prompt, StringComparison.Ordinal);
Assert.Contains("Samsung", prompt, StringComparison.Ordinal);
Assert.Contains("SM-S928B", prompt, StringComparison.Ordinal);
Assert.Contains("Android", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- E : beaucoup de NULL ----
[Fact]
public void AllNull_PromptStillBuildsWithoutException()
{
DiagnosticEventRecord ev = Ev(eventCode: "OLD", level: "Info");
ev.TraceId = null;
ev.OperationId = null;
ev.SessionId = null;
ev.StackTrace = null;
ev.Message = null;
ev.MetadataJson = null;
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.StartsWith("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
Assert.Contains("DEMANDE :", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- F : sécurité métadonnées (0 fuite) ----
[Fact]
public void ForbiddenMetadata_IsNeverLeakedInPrompt()
{
DiagnosticEventRecord ev = Ev(
eventCode: "ERR",
metadataJson: """{"password":"secret123","token":"tok-abc","apiKey":"key-xyz","connectionString":"Server=x;Password=y","deviceId":"dev-raw-1","method":"POST","statusCode":500,"routeTemplate":"/api/v1/x"}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.DoesNotContain("secret123", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("tok-abc", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("key-xyz", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=y", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dev-raw-1", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("password", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("apiKey", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("connectionString", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("deviceId", prompt, StringComparison.OrdinalIgnoreCase);
// Les clés whitelistées et saines restent présentes.
Assert.Contains("routeTemplate", prompt, StringComparison.Ordinal);
Assert.Contains("/api/v1/x", prompt, StringComparison.Ordinal);
}
[Fact]
public void ForbiddenValueUnderWhitelistedKey_IsDropped()
{
DiagnosticEventRecord ev = Ev(
eventCode: "ERR",
metadataJson: """{"method":"ConnectionString=Server=x;Password=hidden","statusCode":200}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.DoesNotContain("ConnectionString", prompt, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password=hidden", prompt, StringComparison.OrdinalIgnoreCase);
Assert.Contains("statusCode", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §48 : sans corrélation ----
[Fact]
public void NoTraceOperationSession_PromptStillBuilds()
{
DiagnosticEventRecord ev = Ev();
ev.TraceId = null;
ev.OperationId = null;
ev.SessionId = null;
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("Analyse cette erreur Omika", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("TraceId", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §49 : sans stack trace ----
[Fact]
public void NoStackTrace_PromptStillBuilds()
{
DiagnosticEventRecord ev = Ev(exceptionType: "System.Exception", stackTrace: null);
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.Contains("ExceptionType", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("StackTrace", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- §50 : bornes corrélés ----
[Fact]
public void CorrelatedEvents_AreCappedAtMax()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
var many = new List<DiagnosticEventRecord>();
for (int i = 0; i < 60; i++)
{
many.Add(Correlated(i + 1, Base.AddSeconds(i), "E", "Info", "C", "m" + i));
}
string prompt = DiagnosticPromptBuilder.Build(ev, many);
Assert.Contains("ÉVÉNEMENTS CORRÉLÉS", prompt, StringComparison.Ordinal);
// 60 événements fournis, mais la section est bornée à MaxCorrelatedEvents.
int count = prompt.Split("- ", StringSplitOptions.None).Length - 1;
Assert.Equal(DiagnosticPromptBuilder.MaxCorrelatedEvents, count);
Assert.DoesNotContain("m59", prompt, StringComparison.Ordinal); // au-delà de la borne.
}
[Fact]
public void CorrelatedEvents_SelectionneLesPlusProchesDeLErreur()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
var many = new List<DiagnosticEventRecord>();
// 30 événements ANTÉRIEURS (de 30 à 1 minutes avant l'erreur) : ce sont eux que
// la logique « les N premiers de la liste » reproduisait, sans jamais montrer
// ce qui s'est passé juste après l'incident.
for (int i = 30; i >= 1; i--)
{
many.Add(Correlated(100 + i, Base.AddMinutes(-i), "E", "Info", "C", $"avant{i:00}"));
}
// 5 événements POSTÉRIEURS : le contexte immédiat de l'erreur.
for (int i = 1; i <= 5; i++)
{
many.Add(Correlated(200 + i, Base.AddMinutes(i), "E", "Info", "C", $"apres{i:00}"));
}
string prompt = DiagnosticPromptBuilder.Build(ev, many);
// Retenus : les 5 postérieurs + les 20 antérieurs les plus proches.
Assert.Contains("apres05", prompt, StringComparison.Ordinal);
Assert.Contains("avant01", prompt, StringComparison.Ordinal);
Assert.Contains("avant20", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("avant21", prompt, StringComparison.Ordinal);
Assert.DoesNotContain("avant30", prompt, StringComparison.Ordinal);
// Restitution chronologique : le passé avant le futur.
Assert.True(
prompt.IndexOf("avant01", StringComparison.Ordinal)
< prompt.IndexOf("apres01", StringComparison.Ordinal));
}
[Fact]
public void CorrelatedEvents_PromptIndiqueQueLaListeNEstPasExhaustive()
{
DiagnosticEventRecord ev = Ev(traceId: Guid.NewGuid());
string prompt = DiagnosticPromptBuilder.Build(
ev,
[Correlated(1, Base.AddMinutes(-1), "E", "Info", "C", "m")]);
// Le lecteur (humain ou IA) ne doit pas conclure à une absence d'activité :
// les événements Info sont remontés après les erreurs (file prioritaire).
Assert.Contains("NON exhaustive", prompt, StringComparison.Ordinal);
Assert.Contains("remontés APRÈS les erreurs", prompt, StringComparison.Ordinal);
}
// ------------------------------------------- longueur raisonnable ----
[Fact]
public void Prompt_IsReasonablySized()
{
DiagnosticEventRecord ev = Ev(
eventCode: "API_HTTP_ERROR",
exceptionType: "InvalidOperationException",
stackTrace: new string('x', 10000),
message: new string('y', 5000),
metadataJson: """{"routeTemplate":"/dev/throw","method":"GET","statusCode":500}""");
string prompt = DiagnosticPromptBuilder.Build(ev);
Assert.True(prompt.Length < 9000, $"Prompt trop grand : {prompt.Length}");
// La stack trace est tronquée, pas reproduite intégralement.
Assert.DoesNotContain(new string('x', 10000), prompt, StringComparison.Ordinal);
}
}