From b7b2a141e3a07238b1989987f8f512596c152213 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 18 Jul 2026 11:00:28 +0200 Subject: [PATCH] Supervisor S-3/S-4: MCP-Light-Server, Profile, gespeicherte Berichte, Designer-UI S-4 MCP-Light (Daten-Tuer fuer externe KI-Clients, KEIN Modell-Zugang - Modelle laufen weiter ueber OpenRouter): - McpJsonRpc (pur): JSON-RPC 2.0 fuer initialize/ping/tools/list/tools/call ueber die read-only Tool-Registry; Notifications/Fehlerfaelle spezifikationskonform. - McpLightServer (HostedService): lokaler Streamable-HTTP-Endpoint. OPT-IN via POLYTRADER_MCP_PORT, bindet NUR 127.0.0.1. Claude Code: claude mcp add --transport http polytrader http://127.0.0.1:PORT/mcp - End-to-End-Test ueber echtes HTTP (initialize, tools/call, Notification=202, GET=405). S-3 Profile + Berichte: - SupervisorProfiles: Allgemein/Technik/CopyTrading/ResolutionFarming als System-Prompt- Zusatz + Tool-Subset ueber EINER Agent-Infrastruktur (Technik z.B. ohne Strategie-Tools). Agent filtert Tools je Profil. - sup_reports (SupervisorDbContext, Migration generiert UND angewendet): jede Analyse wird mit Profil/Modell/Frage/Antwort/Tool-Aufrufen/Token gespeichert -> Supervisor auditierbar. UI (Richards Vorgabe: designerfaehig): - SupervisorMainForm auf partial + .Designer.cs umgestellt - alle Controls im Designer (3 Tabs: Analyse mit Profil-Combo+Modellfeld, Dossiers, Berichte mit Split/Grid/Detail). Tests: +16 (MCP-JSON-RPC 6, MCP-HTTP-E2E 1, Profile 2, bestehende erweitert). Build 0 Fehler, 360 Tests gruen, --smoke-ui alle 5 Views gruen. Co-Authored-By: Claude Opus 4.8 --- .../Agent/SupervisorAgent.cs | 33 +- .../Agent/SupervisorProfiles.cs | 53 +++ .../Mcp/McpJsonRpc.cs | 149 +++++++ .../Mcp/McpLightServer.cs | 109 +++++ ...260718085811_InitialSupervisor.Designer.cs | 80 ++++ .../20260718085811_InitialSupervisor.cs | 58 +++ .../SupervisorDbContextModelSnapshot.cs | 77 ++++ .../Persistence/SupervisorDbContext.cs | 82 ++++ .../Persistence/SupervisorReport.cs | 27 ++ .../PolyTrader.Modules.Supervisor.csproj | 8 + .../SupervisorModule.cs | 11 +- .../Ui/SupervisorMainForm.Designer.cs | 412 ++++++++++++++++++ .../Ui/SupervisorMainForm.cs | 194 ++++----- tests/PolyTrader.Tests/McpJsonRpcTests.cs | 89 ++++ tests/PolyTrader.Tests/McpLightServerTests.cs | 69 +++ .../PolyTrader.Tests/SupervisorAgentTests.cs | 29 ++ 16 files changed, 1369 insertions(+), 111 deletions(-) create mode 100644 src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Mcp/McpLightServer.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.Designer.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Persistence/SupervisorReport.cs create mode 100644 src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs create mode 100644 tests/PolyTrader.Tests/McpJsonRpcTests.cs create mode 100644 tests/PolyTrader.Tests/McpLightServerTests.cs diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs index 6d9c12b..44d984a 100644 --- a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -34,22 +35,36 @@ namespace PolyTrader.Modules.Supervisor.Agent _tools = tools; } - private static string SystemPrompt() => - "Du bist der Supervisor von PolyTrader: ein Analyse-Agent für automatisierten Polymarket-Handel. " + - "Du bist strikt read-only – du kannst und darfst nicht handeln. Nutze die Tools, um Entscheidungsjournal, " + - "Order-Events, Trades und Logs abzufragen, BEVOR du Schlüsse ziehst. Zitiere konkrete Daten " + - "(SignalIds, Zeiten, Preise, ReasonCodes). Antworte auf Deutsch, präzise und mit klarer Schlussfolgerung.\n\n" + - "=== ARCHITEKTUR-KONTEXT ===\n" + ArchitectureContext.Load(); + private static string SystemPrompt(SupervisorProfile profile) + { + string basePrompt = + "Du bist der Supervisor von PolyTrader: ein Analyse-Agent für automatisierten Polymarket-Handel. " + + "Du bist strikt read-only – du kannst und darfst nicht handeln. Nutze die Tools, um Entscheidungsjournal, " + + "Order-Events, Trades und Logs abzufragen, BEVOR du Schlüsse ziehst. Zitiere konkrete Daten " + + "(SignalIds, Zeiten, Preise, ReasonCodes). Antworte auf Deutsch, präzise und mit klarer Schlussfolgerung."; + if (!string.IsNullOrEmpty(profile.PromptAddendum)) + basePrompt += "\n\n" + profile.PromptAddendum; + return basePrompt + "\n\n=== ARCHITEKTUR-KONTEXT ===\n" + ArchitectureContext.Load(); + } + + /// Tools des Profils (Subset oder alle). + private IReadOnlyList ToolsFor(SupervisorProfile profile) => + profile.ToolFilter == null + ? _tools.Tools + : _tools.Tools.Where(t => Array.Exists(profile.ToolFilter, n => + string.Equals(n, t.Name, StringComparison.OrdinalIgnoreCase))).ToList(); /// /// Beantwortet eine Analyse-Frage. meldet Tool-Aufrufe live an die UI. /// public async Task AskAsync(string question, string? model = null, - IProgress? progress = null, CancellationToken ct = default) + IProgress? progress = null, SupervisorProfile? profile = null, CancellationToken ct = default) { + var activeProfile = profile ?? SupervisorProfiles.Allgemein; + var activeTools = ToolsFor(activeProfile); var messages = new List { - ChatMessage.System(SystemPrompt()), + ChatMessage.System(SystemPrompt(activeProfile)), ChatMessage.User(question) }; var invocations = new List<(string, string, string)>(); @@ -59,7 +74,7 @@ namespace PolyTrader.Modules.Supervisor.Agent for (int i = 0; i < MaxIterations; i++) { ct.ThrowIfCancellationRequested(); - var response = await _chat.CompleteAsync(usedModel, messages, _tools.Tools, ct); + var response = await _chat.CompleteAsync(usedModel, messages, activeTools, ct); promptTokens += response.PromptTokens; completionTokens += response.CompletionTokens; diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs new file mode 100644 index 0000000..caae4e7 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorProfiles.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PolyTrader.Modules.Supervisor.Agent +{ + /// + /// Ein Supervisor-Profil (S-3): Fokus-Anweisung + optionales Tool-Subset über EINER gemeinsamen + /// Agent-Infrastruktur (Konzept §4a) — bewusst KEINE Agent-zu-Agent-Orchestrierung. + /// + public sealed record SupervisorProfile(string Name, string PromptAddendum, string[]? ToolFilter) + { + public override string ToString() => Name; + } + + /// Die eingebauten Profile. Modul-Wissen liegt im Architektur-Kontext; hier nur der Fokus. + public static class SupervisorProfiles + { + public static readonly SupervisorProfile Allgemein = new( + "Allgemein", + "", + null); + + public static readonly SupervisorProfile Technik = new( + "Technik", + "FOKUS TECHNIK-SUPERVISOR: Du prüfst ausschließlich die technische Gesundheit — Fehler-/Warning-" + + "Muster in den Logs, fehlgeschlagene/stornierte Orders, CLOB-Fehlerantworten, auffällige " + + "Latenzen und Lücken in den Datenketten. KEINE Strategie-Bewertung (ob ein Trade klug war, " + + "ist nicht dein Thema — nur ob die Software korrekt funktioniert hat).", + new[] { "read_logs", "query_order_events", "query_decisions", "get_dossier", "get_architecture_context" }); + + public static readonly SupervisorProfile CopyTrading = new( + "CopyTrading", + "FOKUS COPYTRADING-SUPERVISOR: Du bewertest die CopyTrading-Strategie — Master-Qualität vs. " + + "Ausführungsqualität (Latenz/Preisdifferenz Signal→Fill), Verhalten der SELL-Eskalationsleiter, " + + "Reject-Muster (waren die Risk-Limits klug oder haben sie profitable Trades verhindert?). " + + "Filtere Daten auf ModuleName='CopyTrading'.", + null); + + public static readonly SupervisorProfile ResolutionFarming = new( + "ResolutionFarming", + "FOKUS RF-SUPERVISOR: Du bewertest die ResolutionFarming-Strategie — Kalibrierung (realisierte " + + "Winrate je Einstiegspreisband vs. implizite Wahrscheinlichkeit), Cluster-Risiken, Scanner-" + + "Qualität. Filtere Daten auf ModuleName='ResolutionFarming'.", + null); + + public static IReadOnlyList All { get; } = + new[] { Allgemein, Technik, CopyTrading, ResolutionFarming }; + + public static SupervisorProfile ByName(string? name) => + All.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) ?? Allgemein; + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs b/src/PolyTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs new file mode 100644 index 0000000..8f12b5f --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Mcp/McpJsonRpc.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using PolyTrader.Modules.Supervisor.Agent; + +namespace PolyTrader.Modules.Supervisor.Mcp +{ + /// + /// MCP-Light (S-4): purer JSON-RPC-2.0-Handler für das Model Context Protocol über die + /// read-only . Externe KI-Clients (z. B. Claude Code) + /// erhalten damit dieselben Analyse-Tools wie der In-App-Agent — **kein** Modell-Zugang, + /// nur die Daten-Tür. Unterstützt: initialize, ping, tools/list, tools/call. + /// Pur und seiteneffektfrei → unit-getestet; der HTTP-Host ist nur eine dünne Hülle. + /// + public static class McpJsonRpc + { + public const string ProtocolVersion = "2025-03-26"; + public const string ServerName = "polytrader-supervisor"; + public const string ServerVersion = "1.0"; + + /// + /// Verarbeitet eine JSON-RPC-Nachricht. Liefert die Antwort als JSON-String — + /// oder null für Notifications (kein id) und unparsbare Eingaben ohne id. + /// + public static string? Handle(string requestJson, SupervisorToolRegistry registry) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(requestJson); } + catch (JsonException) { return Error(null, -32700, "Parse error"); } + + using (doc) + { + var root = doc.RootElement; + JsonElement? id = root.TryGetProperty("id", out var idProp) ? idProp.Clone() : (JsonElement?)null; + string method = root.TryGetProperty("method", out var m) ? m.GetString() ?? "" : ""; + + // Notifications (kein id) werden nicht beantwortet. + if (id == null) return null; + + try + { + return method switch + { + "initialize" => Result(id.Value, w => + { + w.WriteString("protocolVersion", ProtocolVersion); + w.WriteStartObject("capabilities"); + w.WriteStartObject("tools"); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteStartObject("serverInfo"); + w.WriteString("name", ServerName); + w.WriteString("version", ServerVersion); + w.WriteEndObject(); + }), + + "ping" => Result(id.Value, _ => { }), + + "tools/list" => Result(id.Value, w => + { + w.WriteStartArray("tools"); + foreach (var tool in registry.Tools) + { + w.WriteStartObject(); + w.WriteString("name", tool.Name); + w.WriteString("description", tool.Description); + w.WritePropertyName("inputSchema"); + using (var schema = JsonDocument.Parse(tool.ParametersJsonSchema)) + schema.RootElement.WriteTo(w); + w.WriteEndObject(); + } + w.WriteEndArray(); + }), + + "tools/call" => HandleToolCall(id.Value, root, registry), + + _ => Error(id, -32601, $"Method not found: {method}") + }; + } + catch (Exception ex) + { + return Error(id, -32603, $"Internal error: {ex.Message}"); + } + } + } + + private static string HandleToolCall(JsonElement id, JsonElement root, SupervisorToolRegistry registry) + { + if (!root.TryGetProperty("params", out var p) || p.ValueKind != JsonValueKind.Object) + return Error(id, -32602, "Invalid params"); + + string name = p.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + string argsJson = p.TryGetProperty("arguments", out var a) && a.ValueKind == JsonValueKind.Object + ? a.GetRawText() : "{}"; + + string toolResult = registry.Execute(name, argsJson); + bool isError = toolResult.StartsWith("FEHLER", StringComparison.OrdinalIgnoreCase); + + return Result(id, w => + { + w.WriteStartArray("content"); + w.WriteStartObject(); + w.WriteString("type", "text"); + w.WriteString("text", toolResult); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteBoolean("isError", isError); + }); + } + + // ----- JSON-RPC-Hüllen ----- + + private static string Result(JsonElement id, Action writeResult) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("jsonrpc", "2.0"); + w.WritePropertyName("id"); + id.WriteTo(w); + w.WriteStartObject("result"); + writeResult(w); + w.WriteEndObject(); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + + private static string Error(JsonElement? id, int code, string message) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms)) + { + w.WriteStartObject(); + w.WriteString("jsonrpc", "2.0"); + w.WritePropertyName("id"); + if (id.HasValue) id.Value.WriteTo(w); else w.WriteNullValue(); + w.WriteStartObject("error"); + w.WriteNumber("code", code); + w.WriteString("message", message); + w.WriteEndObject(); + w.WriteEndObject(); + } + return Encoding.UTF8.GetString(ms.ToArray()); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Mcp/McpLightServer.cs b/src/PolyTrader.Modules.Supervisor/Mcp/McpLightServer.cs new file mode 100644 index 0000000..e4d6a73 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Mcp/McpLightServer.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Modules.Supervisor.Agent; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.Supervisor.Mcp +{ + /// + /// MCP-Light-Host (S-4): lokaler HTTP-Endpoint (Streamable-HTTP-Transport, nur POST-JSON), der die + /// read-only Tool-Registry per Model Context Protocol exponiert. Externe Clients wie Claude Code + /// verbinden sich mit: claude mcp add --transport http polytrader http://127.0.0.1:PORT/mcp. + /// + /// SICHERHEIT: bewusst OPT-IN (startet nur, wenn die Umgebungsvariable POLYTRADER_MCP_PORT gesetzt + /// ist) und bindet ausschließlich an 127.0.0.1 (kein Netzwerkzugriff). Die Tools sind read-only — + /// es existiert kein Mechanismus zum Handeln/Schreiben. Kein Modell-Zugang: MCP ist nur die + /// Daten-Tür; Modelle laufen weiterhin über OpenRouter (In-App-Agent) bzw. den externen Client. + /// + public sealed class McpLightServer : BackgroundService + { + private readonly SupervisorToolRegistry _registry; + private readonly TerminalLogger _logger; + + public McpLightServer(SupervisorToolRegistry registry, TerminalLogger logger) + { + _registry = registry; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + string? portRaw = Environment.GetEnvironmentVariable("POLYTRADER_MCP_PORT"); + if (string.IsNullOrWhiteSpace(portRaw)) + { + _logger.Info("MCP-Light: deaktiviert (POLYTRADER_MCP_PORT nicht gesetzt)."); + return; + } + if (!int.TryParse(portRaw, out int port) || port is < 1024 or > 65535) + { + _logger.Warning($"MCP-Light: ungültiger Port '{portRaw}' – Server startet nicht."); + return; + } + + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/mcp/"); + try { listener.Start(); } + catch (Exception ex) + { + _logger.Error($"MCP-Light: Start auf Port {port} fehlgeschlagen: {ex.Message}"); + return; + } + + _logger.Info($"🔌 MCP-Light aktiv: http://127.0.0.1:{port}/mcp (read-only, {_registry.Tools.Count} Tools). " + + $"Claude Code: claude mcp add --transport http polytrader http://127.0.0.1:{port}/mcp"); + + using var reg = stoppingToken.Register(() => { try { listener.Stop(); } catch { } }); + while (!stoppingToken.IsCancellationRequested) + { + HttpListenerContext ctx; + try { ctx = await listener.GetContextAsync(); } + catch when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) { _logger.Warning($"MCP-Light: Listener-Fehler: {ex.Message}"); continue; } + + _ = Task.Run(() => HandleRequestAsync(ctx), stoppingToken); + } + } + + private async Task HandleRequestAsync(HttpListenerContext ctx) + { + try + { + if (ctx.Request.HttpMethod != "POST") + { + ctx.Response.StatusCode = 405; // GET/SSE-Stream bieten wir bewusst nicht an + ctx.Response.Close(); + return; + } + + string body; + using (var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding)) + body = await reader.ReadToEndAsync(); + + string? response = McpJsonRpc.Handle(body, _registry); + if (response == null) + { + ctx.Response.StatusCode = 202; // Notification: angenommen, keine Antwort + ctx.Response.Close(); + return; + } + + byte[] bytes = Encoding.UTF8.GetBytes(response); + ctx.Response.StatusCode = 200; + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + ctx.Response.Close(); + } + catch (Exception ex) + { + _logger.Warning($"MCP-Light: Request-Fehler: {ex.Message}"); + try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { } + } + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.Designer.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.Designer.cs new file mode 100644 index 0000000..498d8e6 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.Designer.cs @@ -0,0 +1,80 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PolyTrader.Modules.Supervisor.Persistence; + +#nullable disable + +namespace PolyTrader.Modules.Supervisor.Persistence.Migrations +{ + [DbContext(typeof(SupervisorDbContext))] + [Migration("20260718085811_InitialSupervisor")] + partial class InitialSupervisor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("PolyTrader.Modules.Supervisor.Persistence.SupervisorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ToolCallCount") + .HasColumnType("int"); + + b.Property("ToolCallsJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("sup_reports", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.cs new file mode 100644 index 0000000..70d0754 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/20260718085811_InitialSupervisor.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.Supervisor.Persistence.Migrations +{ + /// + public partial class InitialSupervisor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "sup_reports", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + Profile = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Model = table.Column(type: "varchar(120)", maxLength: 120, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Question = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Answer = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ToolCallsJson = table.Column(type: "text", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ToolCallCount = table.Column(type: "int", nullable: false), + PromptTokens = table.Column(type: "int", nullable: false), + CompletionTokens = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sup_reports", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_sup_reports_CreatedAt", + table: "sup_reports", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "sup_reports"); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs new file mode 100644 index 0000000..47bbb57 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/Migrations/SupervisorDbContextModelSnapshot.cs @@ -0,0 +1,77 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PolyTrader.Modules.Supervisor.Persistence; + +#nullable disable + +namespace PolyTrader.Modules.Supervisor.Persistence.Migrations +{ + [DbContext(typeof(SupervisorDbContext))] + partial class SupervisorDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("PolyTrader.Modules.Supervisor.Persistence.SupervisorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ToolCallCount") + .HasColumnType("int"); + + b.Property("ToolCallsJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("sup_reports", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs new file mode 100644 index 0000000..e002f9f --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorDbContext.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using PolyTrader.Core.Configuration; + +namespace PolyTrader.Modules.Supervisor.Persistence +{ + /// EF-Kontext des Supervisor-Moduls (gleiche MySQL-DB, Tabellen mit Präfix sup_). + public class SupervisorDbContext : DbContext + { + public SupervisorDbContext(DbContextOptions options) : base(options) { } + + public DbSet Reports => Set(); + + protected override void OnModelCreating(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("sup_reports"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.Profile).HasMaxLength(50); + e.Property(x => x.Model).HasMaxLength(120); + e.Property(x => x.Question).HasMaxLength(4000); + e.Property(x => x.Answer).HasColumnType("text"); + e.Property(x => x.ToolCallsJson).HasColumnType("text"); + e.HasIndex(x => x.CreatedAt); + }); + } + } + + /// + /// Design-Time-Factory (fixe Server-Version → Migrations-Scaffolding OHNE DB-Verbindung). + /// + public class SupervisorDbContextFactory : IDesignTimeDbContextFactory + { + public SupervisorDbContext CreateDbContext(string[] args) + { + var conn = Environment.GetEnvironmentVariable("POLYTRADER_MYSQL") + ?? "Server=localhost;Port=3306;Database=polytrader;User ID=root;Password=;"; + var options = new DbContextOptionsBuilder() + .UseMySql(conn, DatabaseServerVersion.Value) + .Options; + return new SupervisorDbContext(options); + } + } + + /// Bericht-Ablage. Write fehlertolerant (Analyse darf nie an der Persistenz scheitern). + public interface ISupervisorReportRepository + { + void Insert(SupervisorReport report); + List GetRecent(int limit); + } + + public class EfSupervisorReportRepository : ISupervisorReportRepository + { + private readonly IDbContextFactory _factory; + public EfSupervisorReportRepository(IDbContextFactory factory) => _factory = factory; + + public void Insert(SupervisorReport report) + { + try + { + using var ctx = _factory.CreateDbContext(); + ctx.Reports.Add(report); + ctx.SaveChanges(); + } + catch (Exception ex) + { + Console.WriteLine($"[SupervisorReports] Write fehlgeschlagen (ignoriert): {ex.Message}"); + } + } + + public List GetRecent(int limit) + { + using var ctx = _factory.CreateDbContext(); + return ctx.Reports.AsNoTracking().OrderByDescending(r => r.CreatedAt).Take(limit).ToList(); + } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorReport.cs b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorReport.cs new file mode 100644 index 0000000..f91357f --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Persistence/SupervisorReport.cs @@ -0,0 +1,27 @@ +using System; + +namespace PolyTrader.Modules.Supervisor.Persistence +{ + /// + /// Gespeicherte Analyse (Tabelle sup_reports): Frage, Antwort, Profil/Modell und die + /// Tool-Aufruf-Historie — macht den Supervisor selbst auditierbar (Konzept §6) und + /// füttert später den „Chef"-Supervisor sowie Tagesberichte. + /// + public class SupervisorReport + { + public long Id { get; set; } // DB-Autoincrement + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public string Profile { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string Question { get; set; } = string.Empty; + public string Answer { get; set; } = string.Empty; + + /// Tool-Aufrufe als JSON [{tool,args}] (Ergebnisse sind reproduzierbar, daher nicht gespeichert). + public string ToolCallsJson { get; set; } = string.Empty; + + public int ToolCallCount { get; set; } + public int PromptTokens { get; set; } + public int CompletionTokens { get; set; } + } +} diff --git a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj index 790239d..c08ebcf 100644 --- a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj +++ b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj @@ -4,6 +4,14 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs index 13688a6..50ed606 100644 --- a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs +++ b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Core.Modularity; @@ -34,7 +35,15 @@ namespace PolyTrader.Modules.Supervisor new Agent.OpenRouterClient(new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMinutes(3) })); services.AddSingleton(); - // sup_-Persistenz (Berichte/Konversationen) + Profile folgen mit S-3. + // S-3: sup_-Persistenz (gespeicherte Analysen/Berichte). + var conn = configuration["Database:MySqlConnectionString"] ?? string.Empty; + services.AddDbContextFactory(o => + o.UseMySql(conn, PolyTrader.Core.Configuration.DatabaseServerVersion.Value)); + services.AddSingleton(); + + // S-4: MCP-Light – exponiert die read-only Tool-Registry für externe KI-Clients + // (z. B. Claude Code). OPT-IN via POLYTRADER_MCP_PORT, bindet nur 127.0.0.1. + services.AddHostedService(); } public void RegisterUi(IModuleUiHost host, System.IServiceProvider services) diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs new file mode 100644 index 0000000..7d85ca8 --- /dev/null +++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.Designer.cs @@ -0,0 +1,412 @@ +namespace PolyTrader.Modules.Supervisor.Ui +{ + partial class SupervisorMainForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Vom Komponenten-Designer generierter Code + + private void InitializeComponent() + { + this.tabControlSup = new System.Windows.Forms.TabControl(); + this.tabAnalyse = new System.Windows.Forms.TabPage(); + this.rtbChat = new System.Windows.Forms.RichTextBox(); + this.pnlChatInput = new System.Windows.Forms.Panel(); + this.tbChatInput = new System.Windows.Forms.TextBox(); + this.btnSend = new System.Windows.Forms.Button(); + this.toolStripChat = new System.Windows.Forms.ToolStrip(); + this.lblProfil = new System.Windows.Forms.ToolStripLabel(); + this.cbProfile = new System.Windows.Forms.ToolStripComboBox(); + this.lblModel = new System.Windows.Forms.ToolStripLabel(); + this.tbModel = new System.Windows.Forms.ToolStripTextBox(); + this.sepChat = new System.Windows.Forms.ToolStripSeparator(); + this.btnClearChat = new System.Windows.Forms.ToolStripButton(); + this.tabDossiers = new System.Windows.Forms.TabPage(); + this.splitDossiers = new System.Windows.Forms.SplitContainer(); + this.dgvSignals = new System.Windows.Forms.DataGridView(); + this.tbDossier = new System.Windows.Forms.TextBox(); + this.toolStripDossier = new System.Windows.Forms.ToolStrip(); + this.btnDossierRefresh = new System.Windows.Forms.ToolStripButton(); + this.sepDossier = new System.Windows.Forms.ToolStripSeparator(); + this.lblSignal = new System.Windows.Forms.ToolStripLabel(); + this.tbSignalId = new System.Windows.Forms.ToolStripTextBox(); + this.btnDossierOpen = new System.Windows.Forms.ToolStripButton(); + this.tabBerichte = new System.Windows.Forms.TabPage(); + this.splitReports = new System.Windows.Forms.SplitContainer(); + this.dgvReports = new System.Windows.Forms.DataGridView(); + this.tbReport = new System.Windows.Forms.TextBox(); + this.toolStripReports = new System.Windows.Forms.ToolStrip(); + this.btnReportsRefresh = new System.Windows.Forms.ToolStripButton(); + this.lblStatus = new System.Windows.Forms.Label(); + this.tabControlSup.SuspendLayout(); + this.tabAnalyse.SuspendLayout(); + this.pnlChatInput.SuspendLayout(); + this.toolStripChat.SuspendLayout(); + this.tabDossiers.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitDossiers)).BeginInit(); + this.splitDossiers.Panel1.SuspendLayout(); + this.splitDossiers.Panel2.SuspendLayout(); + this.splitDossiers.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvSignals)).BeginInit(); + this.toolStripDossier.SuspendLayout(); + this.tabBerichte.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitReports)).BeginInit(); + this.splitReports.Panel1.SuspendLayout(); + this.splitReports.Panel2.SuspendLayout(); + this.splitReports.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dgvReports)).BeginInit(); + this.toolStripReports.SuspendLayout(); + this.SuspendLayout(); + // + // tabControlSup + // + this.tabControlSup.Controls.Add(this.tabAnalyse); + this.tabControlSup.Controls.Add(this.tabDossiers); + this.tabControlSup.Controls.Add(this.tabBerichte); + this.tabControlSup.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabControlSup.Location = new System.Drawing.Point(0, 0); + this.tabControlSup.Name = "tabControlSup"; + this.tabControlSup.SelectedIndex = 0; + this.tabControlSup.Size = new System.Drawing.Size(1250, 676); + this.tabControlSup.TabIndex = 0; + // + // tabAnalyse + // + this.tabAnalyse.Controls.Add(this.rtbChat); + this.tabAnalyse.Controls.Add(this.pnlChatInput); + this.tabAnalyse.Controls.Add(this.toolStripChat); + this.tabAnalyse.Location = new System.Drawing.Point(4, 24); + this.tabAnalyse.Name = "tabAnalyse"; + this.tabAnalyse.Padding = new System.Windows.Forms.Padding(3); + this.tabAnalyse.Size = new System.Drawing.Size(1242, 648); + this.tabAnalyse.TabIndex = 0; + this.tabAnalyse.Text = "Analyse"; + this.tabAnalyse.UseVisualStyleBackColor = true; + // + // rtbChat + // + this.rtbChat.BackColor = System.Drawing.Color.White; + this.rtbChat.Dock = System.Windows.Forms.DockStyle.Fill; + this.rtbChat.Font = new System.Drawing.Font("Segoe UI", 9.5F); + this.rtbChat.Location = new System.Drawing.Point(3, 28); + this.rtbChat.Name = "rtbChat"; + this.rtbChat.ReadOnly = true; + this.rtbChat.Size = new System.Drawing.Size(1236, 557); + this.rtbChat.TabIndex = 1; + this.rtbChat.Text = ""; + // + // pnlChatInput + // + this.pnlChatInput.Controls.Add(this.tbChatInput); + this.pnlChatInput.Controls.Add(this.btnSend); + this.pnlChatInput.Dock = System.Windows.Forms.DockStyle.Bottom; + this.pnlChatInput.Location = new System.Drawing.Point(3, 585); + this.pnlChatInput.Name = "pnlChatInput"; + this.pnlChatInput.Padding = new System.Windows.Forms.Padding(4); + this.pnlChatInput.Size = new System.Drawing.Size(1236, 60); + this.pnlChatInput.TabIndex = 2; + // + // tbChatInput + // + this.tbChatInput.Dock = System.Windows.Forms.DockStyle.Fill; + this.tbChatInput.Location = new System.Drawing.Point(4, 4); + this.tbChatInput.Multiline = true; + this.tbChatInput.Name = "tbChatInput"; + this.tbChatInput.PlaceholderText = "Analyse-Frage stellen … (Strg+Enter zum Senden)"; + this.tbChatInput.Size = new System.Drawing.Size(1118, 52); + this.tbChatInput.TabIndex = 0; + // + // btnSend + // + this.btnSend.Dock = System.Windows.Forms.DockStyle.Right; + this.btnSend.Location = new System.Drawing.Point(1122, 4); + this.btnSend.Name = "btnSend"; + this.btnSend.Size = new System.Drawing.Size(110, 52); + this.btnSend.TabIndex = 1; + this.btnSend.Text = "Senden"; + this.btnSend.UseVisualStyleBackColor = true; + // + // toolStripChat + // + this.toolStripChat.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.lblProfil, this.cbProfile, this.lblModel, this.tbModel, this.sepChat, this.btnClearChat}); + this.toolStripChat.Location = new System.Drawing.Point(3, 3); + this.toolStripChat.Name = "toolStripChat"; + this.toolStripChat.Size = new System.Drawing.Size(1236, 25); + this.toolStripChat.TabIndex = 0; + // + // lblProfil + // + this.lblProfil.Name = "lblProfil"; + this.lblProfil.Text = "Profil:"; + // + // cbProfile + // + this.cbProfile.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.cbProfile.Name = "cbProfile"; + this.cbProfile.Size = new System.Drawing.Size(160, 25); + // + // lblModel + // + this.lblModel.Name = "lblModel"; + this.lblModel.Text = "Modell:"; + // + // tbModel + // + this.tbModel.AutoSize = false; + this.tbModel.Name = "tbModel"; + this.tbModel.Size = new System.Drawing.Size(220, 25); + this.tbModel.Text = "openrouter/auto"; + // + // sepChat + // + this.sepChat.Name = "sepChat"; + // + // btnClearChat + // + this.btnClearChat.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnClearChat.Name = "btnClearChat"; + this.btnClearChat.Text = "Verlauf leeren"; + // + // tabDossiers + // + this.tabDossiers.Controls.Add(this.splitDossiers); + this.tabDossiers.Controls.Add(this.toolStripDossier); + this.tabDossiers.Location = new System.Drawing.Point(4, 24); + this.tabDossiers.Name = "tabDossiers"; + this.tabDossiers.Padding = new System.Windows.Forms.Padding(3); + this.tabDossiers.Size = new System.Drawing.Size(1242, 648); + this.tabDossiers.TabIndex = 1; + this.tabDossiers.Text = "Dossiers"; + this.tabDossiers.UseVisualStyleBackColor = true; + // + // splitDossiers + // + this.splitDossiers.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitDossiers.Location = new System.Drawing.Point(3, 28); + this.splitDossiers.Name = "splitDossiers"; + this.splitDossiers.Panel1.Controls.Add(this.dgvSignals); + this.splitDossiers.Panel2.Controls.Add(this.tbDossier); + this.splitDossiers.Size = new System.Drawing.Size(1236, 617); + this.splitDossiers.SplitterDistance = 420; + this.splitDossiers.TabIndex = 1; + // + // dgvSignals + // + this.dgvSignals.AllowUserToAddRows = false; + this.dgvSignals.AllowUserToDeleteRows = false; + this.dgvSignals.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvSignals.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvSignals.Location = new System.Drawing.Point(0, 0); + this.dgvSignals.MultiSelect = false; + this.dgvSignals.Name = "dgvSignals"; + this.dgvSignals.ReadOnly = true; + this.dgvSignals.RowHeadersVisible = false; + this.dgvSignals.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvSignals.Size = new System.Drawing.Size(420, 617); + this.dgvSignals.TabIndex = 0; + // + // tbDossier + // + this.tbDossier.Dock = System.Windows.Forms.DockStyle.Fill; + this.tbDossier.Font = new System.Drawing.Font("Consolas", 9.5F); + this.tbDossier.Location = new System.Drawing.Point(0, 0); + this.tbDossier.Multiline = true; + this.tbDossier.Name = "tbDossier"; + this.tbDossier.ReadOnly = true; + this.tbDossier.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.tbDossier.Size = new System.Drawing.Size(812, 617); + this.tbDossier.TabIndex = 0; + this.tbDossier.WordWrap = false; + // + // toolStripDossier + // + this.toolStripDossier.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.btnDossierRefresh, this.sepDossier, this.lblSignal, this.tbSignalId, this.btnDossierOpen}); + this.toolStripDossier.Location = new System.Drawing.Point(3, 3); + this.toolStripDossier.Name = "toolStripDossier"; + this.toolStripDossier.Size = new System.Drawing.Size(1236, 25); + this.toolStripDossier.TabIndex = 0; + // + // btnDossierRefresh + // + this.btnDossierRefresh.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnDossierRefresh.Name = "btnDossierRefresh"; + this.btnDossierRefresh.Text = "Aktualisieren"; + // + // sepDossier + // + this.sepDossier.Name = "sepDossier"; + // + // lblSignal + // + this.lblSignal.Name = "lblSignal"; + this.lblSignal.Text = "SignalId:"; + // + // tbSignalId + // + this.tbSignalId.AutoSize = false; + this.tbSignalId.Name = "tbSignalId"; + this.tbSignalId.Size = new System.Drawing.Size(220, 25); + // + // btnDossierOpen + // + this.btnDossierOpen.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnDossierOpen.Name = "btnDossierOpen"; + this.btnDossierOpen.Text = "Dossier öffnen"; + // + // tabBerichte + // + this.tabBerichte.Controls.Add(this.splitReports); + this.tabBerichte.Controls.Add(this.toolStripReports); + this.tabBerichte.Location = new System.Drawing.Point(4, 24); + this.tabBerichte.Name = "tabBerichte"; + this.tabBerichte.Padding = new System.Windows.Forms.Padding(3); + this.tabBerichte.Size = new System.Drawing.Size(1242, 648); + this.tabBerichte.TabIndex = 2; + this.tabBerichte.Text = "Berichte"; + this.tabBerichte.UseVisualStyleBackColor = true; + // + // splitReports + // + this.splitReports.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitReports.Location = new System.Drawing.Point(3, 28); + this.splitReports.Name = "splitReports"; + this.splitReports.Panel1.Controls.Add(this.dgvReports); + this.splitReports.Panel2.Controls.Add(this.tbReport); + this.splitReports.Size = new System.Drawing.Size(1236, 617); + this.splitReports.SplitterDistance = 520; + this.splitReports.TabIndex = 1; + // + // dgvReports + // + this.dgvReports.AllowUserToAddRows = false; + this.dgvReports.AllowUserToDeleteRows = false; + this.dgvReports.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dgvReports.Dock = System.Windows.Forms.DockStyle.Fill; + this.dgvReports.Location = new System.Drawing.Point(0, 0); + this.dgvReports.MultiSelect = false; + this.dgvReports.Name = "dgvReports"; + this.dgvReports.ReadOnly = true; + this.dgvReports.RowHeadersVisible = false; + this.dgvReports.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + this.dgvReports.Size = new System.Drawing.Size(520, 617); + this.dgvReports.TabIndex = 0; + // + // tbReport + // + this.tbReport.Dock = System.Windows.Forms.DockStyle.Fill; + this.tbReport.Font = new System.Drawing.Font("Segoe UI", 9.5F); + this.tbReport.Location = new System.Drawing.Point(0, 0); + this.tbReport.Multiline = true; + this.tbReport.Name = "tbReport"; + this.tbReport.ReadOnly = true; + this.tbReport.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.tbReport.Size = new System.Drawing.Size(712, 617); + this.tbReport.TabIndex = 0; + // + // toolStripReports + // + this.toolStripReports.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.btnReportsRefresh}); + this.toolStripReports.Location = new System.Drawing.Point(3, 3); + this.toolStripReports.Name = "toolStripReports"; + this.toolStripReports.Size = new System.Drawing.Size(1236, 25); + this.toolStripReports.TabIndex = 0; + // + // btnReportsRefresh + // + this.btnReportsRefresh.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnReportsRefresh.Name = "btnReportsRefresh"; + this.btnReportsRefresh.Text = "Aktualisieren"; + // + // lblStatus + // + this.lblStatus.Dock = System.Windows.Forms.DockStyle.Bottom; + this.lblStatus.Location = new System.Drawing.Point(0, 676); + this.lblStatus.Name = "lblStatus"; + this.lblStatus.Padding = new System.Windows.Forms.Padding(6, 2, 6, 2); + this.lblStatus.Size = new System.Drawing.Size(1250, 22); + this.lblStatus.TabIndex = 1; + this.lblStatus.Text = ""; + // + // SupervisorMainForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(1250, 698); + this.Controls.Add(this.tabControlSup); + this.Controls.Add(this.lblStatus); + this.Name = "SupervisorMainForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Supervisor"; + this.tabControlSup.ResumeLayout(false); + this.tabAnalyse.ResumeLayout(false); + this.pnlChatInput.ResumeLayout(false); + this.pnlChatInput.PerformLayout(); + this.toolStripChat.ResumeLayout(false); + this.toolStripChat.PerformLayout(); + this.tabDossiers.ResumeLayout(false); + this.splitDossiers.Panel1.ResumeLayout(false); + this.splitDossiers.Panel2.ResumeLayout(false); + this.splitDossiers.Panel2.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitDossiers)).EndInit(); + this.splitDossiers.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dgvSignals)).EndInit(); + this.toolStripDossier.ResumeLayout(false); + this.toolStripDossier.PerformLayout(); + this.tabBerichte.ResumeLayout(false); + this.splitReports.Panel1.ResumeLayout(false); + this.splitReports.Panel2.ResumeLayout(false); + this.splitReports.Panel2.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitReports)).EndInit(); + this.splitReports.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dgvReports)).EndInit(); + this.toolStripReports.ResumeLayout(false); + this.toolStripReports.PerformLayout(); + this.ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TabControl tabControlSup; + private System.Windows.Forms.TabPage tabAnalyse; + private System.Windows.Forms.ToolStrip toolStripChat; + private System.Windows.Forms.ToolStripLabel lblProfil; + private System.Windows.Forms.ToolStripComboBox cbProfile; + private System.Windows.Forms.ToolStripLabel lblModel; + private System.Windows.Forms.ToolStripTextBox tbModel; + private System.Windows.Forms.ToolStripSeparator sepChat; + private System.Windows.Forms.ToolStripButton btnClearChat; + private System.Windows.Forms.RichTextBox rtbChat; + private System.Windows.Forms.Panel pnlChatInput; + private System.Windows.Forms.TextBox tbChatInput; + private System.Windows.Forms.Button btnSend; + private System.Windows.Forms.TabPage tabDossiers; + private System.Windows.Forms.ToolStrip toolStripDossier; + private System.Windows.Forms.ToolStripButton btnDossierRefresh; + private System.Windows.Forms.ToolStripSeparator sepDossier; + private System.Windows.Forms.ToolStripLabel lblSignal; + private System.Windows.Forms.ToolStripTextBox tbSignalId; + private System.Windows.Forms.ToolStripButton btnDossierOpen; + private System.Windows.Forms.SplitContainer splitDossiers; + private System.Windows.Forms.DataGridView dgvSignals; + private System.Windows.Forms.TextBox tbDossier; + private System.Windows.Forms.TabPage tabBerichte; + private System.Windows.Forms.ToolStrip toolStripReports; + private System.Windows.Forms.ToolStripButton btnReportsRefresh; + private System.Windows.Forms.SplitContainer splitReports; + private System.Windows.Forms.DataGridView dgvReports; + private System.Windows.Forms.TextBox tbReport; + private System.Windows.Forms.Label lblStatus; + } +} diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs index c2611d8..b8bf0d9 100644 --- a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs +++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs @@ -4,110 +4,52 @@ using System.Windows.Forms; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Core.Analytics; using PolyTrader.Modules.Supervisor.Agent; +using PolyTrader.Modules.Supervisor.Persistence; using PolyTrader.Modules.Supervisor.Services; namespace PolyTrader.Modules.Supervisor.Ui { /// - /// Hauptfenster des Supervisor-Moduls: Tab „Analyse" (Chat mit dem read-only-Agenten, - /// Tool-Aufrufe transparent im Verlauf) und Tab „Dossiers" (Signal-Browser mit Markdown-Dossier). - /// Code-only konstruiert (Muster RF-Modul); DB-/API-Zugriffe defensiv. + /// Hauptfenster des Supervisor-Moduls: Tab „Analyse" (Chat mit Profil-/Modellwahl, Tool-Aufrufe + /// transparent), Tab „Dossiers" (Signal-Browser), Tab „Berichte" (gespeicherte Analysen). + /// Layout im Designer (SupervisorMainForm.Designer.cs), Verhalten/Daten hier. /// - public sealed class SupervisorMainForm : Form + public partial class SupervisorMainForm : Form { private DossierService? _dossiers; private SupervisorAgent? _agent; - - // ----- Tab Analyse (Chat) ----- - private readonly ToolStrip _chatStrip = new(); - private readonly ToolStripLabel _lblModel = new() { Text = "Modell:" }; - private readonly ToolStripTextBox _tbModel = new() { AutoSize = false, Width = 220, Text = SupervisorAgent.DefaultModel }; - private readonly ToolStripButton _btnClearChat = new() { Text = "Verlauf leeren", DisplayStyle = ToolStripItemDisplayStyle.Text }; - private readonly RichTextBox _chatLog = new() - { - Dock = DockStyle.Fill, ReadOnly = true, BackColor = System.Drawing.Color.White, - Font = new System.Drawing.Font("Segoe UI", 9.5f) - }; - private readonly TextBox _chatInput = new() - { - Dock = DockStyle.Fill, Multiline = true, Height = 54, - PlaceholderText = "Analyse-Frage stellen … (Strg+Enter zum Senden)" - }; - private readonly Button _btnSend = new() { Text = "Senden", Dock = DockStyle.Right, Width = 110 }; - - // ----- Tab Dossiers (Browser) ----- - private readonly ToolStrip _dossierStrip = new(); - private readonly ToolStripButton _btnRefresh = new() { Text = "Aktualisieren", DisplayStyle = ToolStripItemDisplayStyle.Text }; - private readonly ToolStripLabel _lblSearch = new() { Text = "SignalId:" }; - private readonly ToolStripTextBox _tbSignalId = new() { AutoSize = false, Width = 220 }; - private readonly ToolStripButton _btnOpen = new() { Text = "Dossier öffnen", DisplayStyle = ToolStripItemDisplayStyle.Text }; - private readonly SplitContainer _split = new() { Dock = DockStyle.Fill, SplitterDistance = 420 }; - private readonly DataGridView _grid = new() - { - Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, - AutoGenerateColumns = true, SelectionMode = DataGridViewSelectionMode.FullRowSelect, - RowHeadersVisible = false, MultiSelect = false - }; - private readonly TextBox _dossierText = new() - { - Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, - Font = new System.Drawing.Font("Consolas", 9.5f), WordWrap = false - }; - - private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 22, Padding = new Padding(6, 2, 6, 2), Text = "" }; + private ISupervisorReportRepository? _reports; public SupervisorMainForm() { - Text = "Supervisor"; - Width = 1250; - Height = 720; - StartPosition = FormStartPosition.CenterScreen; + InitializeComponent(); - var tabs = new TabControl { Dock = DockStyle.Fill }; + foreach (var profile in SupervisorProfiles.All) + cbProfile.Items.Add(profile); + cbProfile.SelectedIndex = 0; - // --- Tab Analyse --- - var tabAnalyse = new TabPage("Analyse"); - _chatStrip.Items.AddRange(new ToolStripItem[] { _lblModel, _tbModel, new ToolStripSeparator(), _btnClearChat }); - var inputPanel = new Panel { Dock = DockStyle.Bottom, Height = 60, Padding = new Padding(4) }; - inputPanel.Controls.Add(_chatInput); - inputPanel.Controls.Add(_btnSend); - tabAnalyse.Controls.Add(_chatLog); - tabAnalyse.Controls.Add(inputPanel); - tabAnalyse.Controls.Add(_chatStrip); - _chatStrip.Dock = DockStyle.Top; - - // --- Tab Dossiers --- - var tabDossiers = new TabPage("Dossiers"); - _dossierStrip.Items.AddRange(new ToolStripItem[] { _btnRefresh, new ToolStripSeparator(), _lblSearch, _tbSignalId, _btnOpen }); - _split.Panel1.Controls.Add(_grid); - _split.Panel2.Controls.Add(_dossierText); - tabDossiers.Controls.Add(_split); - tabDossiers.Controls.Add(_dossierStrip); - _dossierStrip.Dock = DockStyle.Top; - - tabs.TabPages.AddRange(new[] { tabAnalyse, tabDossiers }); - Controls.Add(tabs); - Controls.Add(_status); - - // Verhalten - _btnSend.Click += async (_, _) => await SendQuestionAsync(); - _chatInput.KeyDown += async (_, e) => + btnSend.Click += async (_, _) => await SendQuestionAsync(); + tbChatInput.KeyDown += async (_, e) => { if (e.Control && e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await SendQuestionAsync(); } }; - _btnClearChat.Click += (_, _) => _chatLog.Clear(); - _btnRefresh.Click += (_, _) => LoadSignals(); - _btnOpen.Click += (_, _) => OpenDossier(_tbSignalId.Text.Trim()); - _grid.SelectionChanged += (_, _) => OpenSelected(); + btnClearChat.Click += (_, _) => rtbChat.Clear(); + btnDossierRefresh.Click += (_, _) => LoadSignals(); + btnDossierOpen.Click += (_, _) => OpenDossier(tbSignalId.Text.Trim()); + dgvSignals.SelectionChanged += (_, _) => OpenSelectedSignal(); + btnReportsRefresh.Click += (_, _) => LoadReports(); + dgvReports.SelectionChanged += (_, _) => ShowSelectedReport(); } public void Initialize(IServiceProvider services) { _dossiers = services.GetRequiredService(); _agent = services.GetRequiredService(); + _reports = services.GetRequiredService(); LoadSignals(); - AppendChat("System", "Supervisor bereit. Read-only-Analyse über Entscheidungsjournal, Order-Events, Trades und Logs. " + - "API-Key: POLYTRADER_OPENROUTER_KEY oder Datei openrouter.key.", System.Drawing.Color.Gray); + LoadReports(); + AppendChat("System", "Supervisor bereit (read-only). API-Key: POLYTRADER_OPENROUTER_KEY oder Datei openrouter.key. " + + "MCP-Light für externe Clients: POLYTRADER_MCP_PORT setzen.", System.Drawing.Color.Gray); } // ===== Analyse-Chat ===== @@ -115,40 +57,66 @@ namespace PolyTrader.Modules.Supervisor.Ui private async System.Threading.Tasks.Task SendQuestionAsync() { if (_agent == null) return; - string question = _chatInput.Text.Trim(); + string question = tbChatInput.Text.Trim(); if (question.Length == 0) return; + var profile = cbProfile.SelectedItem as SupervisorProfile ?? SupervisorProfiles.Allgemein; - _chatInput.Text = ""; - _btnSend.Enabled = false; - AppendChat("Du", question, System.Drawing.Color.DarkBlue); - _status.Text = "Analyse läuft …"; + tbChatInput.Text = ""; + btnSend.Enabled = false; + AppendChat($"Du ({profile.Name})", question, System.Drawing.Color.DarkBlue); + lblStatus.Text = "Analyse läuft …"; var progress = new Progress(msg => AppendChat("Tool", msg, System.Drawing.Color.DarkGoldenrod)); try { + string model = tbModel.Text; var result = await System.Threading.Tasks.Task.Run(() => - _agent.AskAsync(question, _tbModel.Text, progress)); + _agent.AskAsync(question, model, progress, profile)); AppendChat("Supervisor", result.Answer, System.Drawing.Color.Black); - _status.Text = $"Fertig. {result.ToolInvocations.Count} Tool-Aufruf(e), ~{result.PromptTokens + result.CompletionTokens} Tokens."; + lblStatus.Text = $"Fertig. {result.ToolInvocations.Count} Tool-Aufruf(e), ~{result.PromptTokens + result.CompletionTokens} Tokens."; + SaveReport(profile, model, question, result); } catch (Exception ex) { AppendChat("Fehler", ex.Message, System.Drawing.Color.Firebrick); - _status.Text = "Fehler bei der Analyse."; + lblStatus.Text = "Fehler bei der Analyse."; } finally { - _btnSend.Enabled = true; + btnSend.Enabled = true; } } + private void SaveReport(SupervisorProfile profile, string model, string question, AgentResult result) + { + try + { + var calls = new List(); + foreach (var (tool, args, _) in result.ToolInvocations) + calls.Add(new { tool, args }); + _reports?.Insert(new SupervisorReport + { + Profile = profile.Name, + Model = model, + Question = question, + Answer = result.Answer, + ToolCallsJson = System.Text.Json.JsonSerializer.Serialize(calls), + ToolCallCount = result.ToolInvocations.Count, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens + }); + LoadReports(); + } + catch { /* Bericht-Ablage ist Beiwerk – Analyse-Ergebnis steht im Chat */ } + } + private void AppendChat(string who, string text, System.Drawing.Color color) { if (InvokeRequired) { BeginInvoke(() => AppendChat(who, text, color)); return; } - _chatLog.SelectionStart = _chatLog.TextLength; - _chatLog.SelectionColor = color; - _chatLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {who}: {text}{Environment.NewLine}{Environment.NewLine}"); - _chatLog.ScrollToCaret(); + rtbChat.SelectionStart = rtbChat.TextLength; + rtbChat.SelectionColor = color; + rtbChat.AppendText($"[{DateTime.Now:HH:mm:ss}] {who}: {text}{Environment.NewLine}{Environment.NewLine}"); + rtbChat.ScrollToCaret(); } // ===== Dossier-Browser ===== @@ -159,20 +127,20 @@ namespace PolyTrader.Modules.Supervisor.Ui try { List signals = _dossiers.RecentSignals(200); - _grid.DataSource = signals; - _status.Text = signals.Count == 0 + dgvSignals.DataSource = signals; + lblStatus.Text = signals.Count == 0 ? "Noch keine Journal-Einträge (Entscheidungen entstehen, sobald Signale verarbeitet werden)." : $"{signals.Count} Signale."; } catch (Exception ex) { - _status.Text = $"Journal nicht lesbar: {ex.Message}"; + lblStatus.Text = $"Journal nicht lesbar: {ex.Message}"; } } - private void OpenSelected() + private void OpenSelectedSignal() { - if (_grid.CurrentRow?.DataBoundItem is SignalSummary s) + if (dgvSignals.CurrentRow?.DataBoundItem is SignalSummary s) OpenDossier(s.SignalId); } @@ -182,13 +150,37 @@ namespace PolyTrader.Modules.Supervisor.Ui try { var dossier = _dossiers.BuildForSignal(signalId); - _dossierText.Text = DossierBuilder.ToMarkdown(dossier).Replace("\n", Environment.NewLine); - _tbSignalId.Text = signalId; + tbDossier.Text = DossierBuilder.ToMarkdown(dossier).Replace("\n", Environment.NewLine); + tbSignalId.Text = signalId; } catch (Exception ex) { - _dossierText.Text = $"Dossier konnte nicht geladen werden: {ex.Message}"; + tbDossier.Text = $"Dossier konnte nicht geladen werden: {ex.Message}"; } } + + // ===== Berichte ===== + + private void LoadReports() + { + if (_reports == null) return; + try + { + dgvReports.DataSource = _reports.GetRecent(100); + } + catch (Exception ex) + { + lblStatus.Text = $"Berichte nicht lesbar (sup_-Migration angewendet?): {ex.Message}"; + } + } + + private void ShowSelectedReport() + { + if (dgvReports.CurrentRow?.DataBoundItem is SupervisorReport r) + tbReport.Text = + $"[{r.CreatedAt:dd.MM.yyyy HH:mm}] Profil {r.Profile} · Modell {r.Model} · {r.ToolCallCount} Tool-Aufrufe{Environment.NewLine}{Environment.NewLine}" + + $"FRAGE:{Environment.NewLine}{r.Question}{Environment.NewLine}{Environment.NewLine}" + + $"ANTWORT:{Environment.NewLine}{r.Answer.Replace("\n", Environment.NewLine)}"; + } } } diff --git a/tests/PolyTrader.Tests/McpJsonRpcTests.cs b/tests/PolyTrader.Tests/McpJsonRpcTests.cs new file mode 100644 index 0000000..1c4db81 --- /dev/null +++ b/tests/PolyTrader.Tests/McpJsonRpcTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using PolyTrader.Modules.Supervisor.Agent; +using PolyTrader.Modules.Supervisor.Mcp; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für MCP-Light (S-4): JSON-RPC-Handling (initialize, tools/list, tools/call, + /// Notifications, Fehlerfälle) über die read-only Tool-Registry — pur, ohne HTTP. + /// + public class McpJsonRpcTests + { + private static SupervisorToolRegistry Registry() + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("echo", "Echo-Tool", + """{"type":"object","properties":{"text":{"type":"string"}}}""", + args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? ""))); + return reg; + } + + [Fact] + public void Initialize_returns_protocol_and_serverinfo() + { + string? resp = McpJsonRpc.Handle( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}""", + Registry()); + + Assert.NotNull(resp); + using var doc = JsonDocument.Parse(resp!); + var result = doc.RootElement.GetProperty("result"); + Assert.Equal(McpJsonRpc.ProtocolVersion, result.GetProperty("protocolVersion").GetString()); + Assert.Equal(McpJsonRpc.ServerName, result.GetProperty("serverInfo").GetProperty("name").GetString()); + Assert.True(result.GetProperty("capabilities").TryGetProperty("tools", out _)); + } + + [Fact] + public void ToolsList_exposes_registry_tools_with_schema() + { + string? resp = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""", Registry()); + using var doc = JsonDocument.Parse(resp!); + var tools = doc.RootElement.GetProperty("result").GetProperty("tools"); + Assert.Equal(1, tools.GetArrayLength()); + Assert.Equal("echo", tools[0].GetProperty("name").GetString()); + Assert.Equal("object", tools[0].GetProperty("inputSchema").GetProperty("type").GetString()); + } + + [Fact] + public void ToolsCall_executes_and_wraps_result_as_text_content() + { + string? resp = McpJsonRpc.Handle( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hallo"}}}""", + Registry()); + using var doc = JsonDocument.Parse(resp!); + var result = doc.RootElement.GetProperty("result"); + Assert.Equal("ECHO:hallo", result.GetProperty("content")[0].GetProperty("text").GetString()); + Assert.False(result.GetProperty("isError").GetBoolean()); + } + + [Fact] + public void ToolsCall_unknown_tool_sets_isError() + { + string? resp = McpJsonRpc.Handle( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nix","arguments":{}}}""", + Registry()); + using var doc = JsonDocument.Parse(resp!); + Assert.True(doc.RootElement.GetProperty("result").GetProperty("isError").GetBoolean()); + } + + [Fact] + public void Notification_returns_null_and_unknown_method_returns_error() + { + Assert.Null(McpJsonRpc.Handle("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", Registry())); + + string? resp = McpJsonRpc.Handle("""{"jsonrpc":"2.0","id":5,"method":"gibtsnicht"}""", Registry()); + using var doc = JsonDocument.Parse(resp!); + Assert.Equal(-32601, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public void Parse_error_returns_minus32700() + { + string? resp = McpJsonRpc.Handle("{kaputt", Registry()); + using var doc = JsonDocument.Parse(resp!); + Assert.Equal(-32700, doc.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + } +} diff --git a/tests/PolyTrader.Tests/McpLightServerTests.cs b/tests/PolyTrader.Tests/McpLightServerTests.cs new file mode 100644 index 0000000..485d5b5 --- /dev/null +++ b/tests/PolyTrader.Tests/McpLightServerTests.cs @@ -0,0 +1,69 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.Supervisor.Agent; +using PolyTrader.Modules.Supervisor.Mcp; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// End-to-End-Test des MCP-Light-HTTP-Hosts (echter HttpListener auf 127.0.0.1). + public class McpLightServerTests + { + [Fact] + public async Task Server_answers_initialize_and_tools_call_over_http() + { + int port = 52000 + new Random().Next(1000, 9000); + Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", port.ToString()); + try + { + var reg = new SupervisorToolRegistry(); + reg.Register(new SupervisorTool("echo", "Echo", + """{"type":"object","properties":{"text":{"type":"string"}}}""", + args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? ""))); + + var server = new McpLightServer(reg, new TerminalLogger()); + await server.StartAsync(CancellationToken.None); + await Task.Delay(300); // Listener-Start abwarten + + using var http = new HttpClient(); + string url = $"http://127.0.0.1:{port}/mcp/"; + + // initialize + var initResp = await http.PostAsync(url, new StringContent( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""", Encoding.UTF8, "application/json")); + Assert.True(initResp.IsSuccessStatusCode); + using (var doc = JsonDocument.Parse(await initResp.Content.ReadAsStringAsync())) + Assert.Equal(McpJsonRpc.ServerName, + doc.RootElement.GetProperty("result").GetProperty("serverInfo").GetProperty("name").GetString()); + + // tools/call + var callResp = await http.PostAsync(url, new StringContent( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"text":"mcp"}}}""", + Encoding.UTF8, "application/json")); + using (var doc = JsonDocument.Parse(await callResp.Content.ReadAsStringAsync())) + Assert.Equal("ECHO:mcp", + doc.RootElement.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString()); + + // Notification -> 202 + var notifyResp = await http.PostAsync(url, new StringContent( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""", Encoding.UTF8, "application/json")); + Assert.Equal(202, (int)notifyResp.StatusCode); + + // GET -> 405 (kein SSE-Stream) + var getResp = await http.GetAsync(url); + Assert.Equal(405, (int)getResp.StatusCode); + + await server.StopAsync(CancellationToken.None); + } + finally + { + Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", null); + } + } + } +} diff --git a/tests/PolyTrader.Tests/SupervisorAgentTests.cs b/tests/PolyTrader.Tests/SupervisorAgentTests.cs index 7ac5339..af2e57c 100644 --- a/tests/PolyTrader.Tests/SupervisorAgentTests.cs +++ b/tests/PolyTrader.Tests/SupervisorAgentTests.cs @@ -51,6 +51,7 @@ namespace PolyTrader.Tests { private readonly Queue _script; public List> Requests { get; } = new(); + public List> OfferedTools { get; } = new(); public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue(script); @@ -58,6 +59,7 @@ namespace PolyTrader.Tests IReadOnlyList tools, CancellationToken ct) { Requests.Add(new List(messages)); + OfferedTools.Add(new List(tools)); return Task.FromResult(_script.Count > 0 ? _script.Dequeue() : new ChatResponse { Content = "leer" }); } } @@ -154,5 +156,32 @@ namespace PolyTrader.Tests Assert.Contains("PolyTrader", ctx); Assert.Contains("read-only", ctx); } + + // ----- Profile (S-3) ----- + + [Fact] + public async Task Technik_profile_filters_tools_and_extends_prompt() + { + var reg = RegistryWithEcho(); + reg.Register(new SupervisorTool("read_logs", "Logs", """{"type":"object","properties":{}}""", _ => "logs")); + var chat = new ScriptedChatClient(new ChatResponse { Content = "ok" }); + var agent = new SupervisorAgent(chat, reg); + + await agent.AskAsync("check", profile: SupervisorProfiles.Technik); + + // Prompt trägt den Profil-Fokus; dem Modell wurde NUR das Technik-Subset angeboten + // (Registry hat echo/boom/read_logs → im Technik-Filter ist davon nur read_logs). + Assert.Contains("FOKUS TECHNIK-SUPERVISOR", chat.Requests[0][0].Content); + Assert.Single(chat.OfferedTools[0]); + Assert.Equal("read_logs", chat.OfferedTools[0][0].Name); + } + + [Fact] + public void Profiles_lookup_is_case_insensitive_with_fallback() + { + Assert.Equal("Technik", SupervisorProfiles.ByName("technik").Name); + Assert.Equal("Allgemein", SupervisorProfiles.ByName("gibtsnicht").Name); + Assert.Equal(4, SupervisorProfiles.All.Count); + } } }