Files
gott/Services/CronjobService.cs
T
MelekiandClaude Opus 4.7 ecddbe5f36 fix(openai): ask_user_choice-Intercept, Interleaved Thinking in History, MiniMax-Debug-Log
OpenAI-kompatible Provider (MiniMax, DeepSeek-R1, QwQ, Kimi K2, etc.) verloren bei
jeder neuen Iteration ihre eigene Chain-of-Thought, weil thinking-Messages aus der
DB-History rausgefiltert wurden. Folge: Modell kündigt Action an, ruft Tool aber
nicht auf → Stream endet mit finish_reason:stop ohne Tool-Call. Zusätzlich fehlte
der ask_user_choice-Intercept (Tool wurde als unbekannt abgelehnt) und es gab keine
Diagnose-Möglichkeit für unerwartete Stop-Verhalten.

- ChatService.BuildHistoryFromDb(includeThinking): neuer Parameter, fügt
  thinking-Messages als {type:"thinking"}-Block in die Assistant-Message ein.
  Text/Tool-Use derselben Iteration werden via AppendToLastMessage angehängt
  → ein Assistant-Block enthält [thinking, text, tool_use…].
- OpenAiService.ConvertMessage + StreamChat + ChatNonStreaming: Thinking als
  <think>...</think>-Prefix dem Assistant-Content voranstellen (Per-Iteration und
  beim DB-History-Aufbau). Konvention von MiniMax-Docs, wird auch von DeepSeek-R1,
  QwQ, Kimi K2 verstanden.
- ChatEndpoint, SessionEndpoint, CronjobService, TelegramService, ToolService:
  includeThinking je nach Provider durchgereicht. Anthropic-Pfad bleibt aus
  (lehnt thinking-Blöcke ohne Signature ab).
- OpenAiService: ask_user_choice-Intercept im Streaming-Pfad (analog
  AnthropicService.Streaming) – Tool nicht ausführen, SSE user_choice rausschicken,
  mit stopReason awaiting_user_choice finalisieren und return.
- OpenAiService: MiniMax-SSE-Debug-Log in data/openai-debug/ (JSONL: Header +
  Roh-data-Zeilen + Summary mit promptTokens/completionTokens/finishReason/
  toolCalls/duration) – nur für provider=="minimax", nicht-blockierend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:19:06 +02:00

227 lines
8.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Cronos;
using Gott.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Gott.Services;
public class CronjobService : BackgroundService
{
private readonly DatabaseService _db;
private readonly ChatService _chatService;
private readonly AnthropicService _anthropicService;
private readonly OpenAiService _openAiService;
private readonly TelegramService _telegramService;
private readonly ILogger<CronjobService> _logger;
public CronjobService(
DatabaseService db,
ChatService chatService,
AnthropicService anthropicService,
OpenAiService openAiService,
TelegramService telegramService,
ILogger<CronjobService> logger)
{
_db = db;
_chatService = chatService;
_anthropicService = anthropicService;
_openAiService = openAiService;
_telegramService = telegramService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("CronjobService gestartet");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessDueCronjobs();
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Verarbeiten der Cronjobs");
}
// Jede Minute prüfen
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
private async Task ProcessDueCronjobs()
{
var dueJobs = await _db.GetDueCronjobs();
foreach (var job in dueJobs)
{
_logger.LogInformation("Cronjob '{Name}' ({Id}) wird ausgeführt", job.Name, job.CronjobId);
try
{
await ExecuteCronjob(job);
var nextRun = CalculateNextRun(job.CronExpression);
await _db.UpdateCronjobRun(job.CronjobId, "success", null, nextRun);
_logger.LogInformation("Cronjob '{Name}' erfolgreich. Nächste Ausführung: {Next}", job.Name, nextRun);
}
catch (Exception ex)
{
_logger.LogError(ex, "Cronjob '{Name}' fehlgeschlagen", job.Name);
var nextRun = CalculateNextRun(job.CronExpression);
await _db.UpdateCronjobRun(job.CronjobId, "error", ex.Message, nextRun);
}
}
}
public async Task ExecuteCronjob(CronjobRecord job)
{
// Chat-Context vorbereiten (wie bei normalem Chat)
var ctx = await _chatService.PrepareContext(job.SessionId, job.UserId);
if (ctx == null)
throw new Exception("Session oder Agent nicht gefunden / nicht konfiguriert");
// API-Key des Cronjob-Owners für diesen Agent auflösen
var userApiKey = await _db.GetAgentApiKeyRecord(job.UserId, ctx.AgentId);
if (userApiKey == null)
throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId})");
ctx.ApiToken = userApiKey.ApiKey;
// History aus DB laden. includeThinking=true für OpenAI-kompatible Provider →
// <think>...</think>-Prefix wird dem Assistant-Text vorangestellt.
var history = await _chatService.BuildHistoryFromDb(job.SessionId, promptBlocks: ctx.PromptBlocks, includeThinking: !string.IsNullOrEmpty(ctx.Provider));
// User-Nachricht hinzufügen
var userContent = new List<object> { new { type = "text", text = job.Prompt } };
history.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = userContent });
// User-Nachricht in DB speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = ctx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = job.Prompt
});
// Non-Streaming Chat ausführen – Provider-Weiche
string responseText;
if (!string.IsNullOrEmpty(ctx.Provider))
responseText = await _openAiService.ChatNonStreaming(ctx, history);
else
responseText = await _anthropicService.ChatNonStreaming(ctx, history);
// Session-Timestamp aktualisieren
await _db.UpdateSessionTimestamp(job.SessionId);
// Telegram-Benachrichtigung auf der Arbeits-Session senden, falls sie Telegram hat
if (!string.IsNullOrEmpty(responseText))
{
await _telegramService.SendNotification(job.SessionId,
$"⏰ Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{responseText}");
}
// Benachrichtigungs-Session: Zusammenfassung in eine andere Session schreiben
if (!string.IsNullOrEmpty(job.NotifySessionId) && job.NotifySessionId != job.SessionId)
{
await SendCronjobNotification(job, responseText);
}
}
private async Task SendCronjobNotification(CronjobRecord job, string responseText)
{
try
{
var notifyCtx = await _chatService.PrepareContext(job.NotifySessionId, job.UserId);
if (notifyCtx == null)
{
_logger.LogWarning("Notification-Session {SessionId} für Cronjob '{Name}' nicht gefunden", job.NotifySessionId, job.Name);
return;
}
// API-Key des Cronjob-Owners für Notify-Agent auflösen
var notifyApiKey = await _db.GetAgentApiKeyRecord(job.UserId, notifyCtx.AgentId);
if (notifyApiKey == null)
{
_logger.LogWarning("Kein API-Key für Agent {AgentId} (Cronjob '{Name}' Notification)", notifyCtx.AgentId, job.Name);
return;
}
notifyCtx.ApiToken = notifyApiKey.ApiKey;
// Kurze Zusammenfassung als User-Nachricht in die Notify-Session schreiben
var summary = $"[Cronjob \"{job.Name}\" wurde in Session \"{job.SessionName}\" ausgeführt]\n\nAntwort:\n{Truncate(responseText, 2000)}";
// In Notify-Session als Nachricht speichern
await _db.SaveMessage(new MessageRecord
{
ChainId = notifyCtx.ChainId,
Iteration = 0,
Role = "user",
Type = "text",
Content = summary
});
// Nur die Benachrichtigung als History senden (nicht volle Session-History,
// da diese mit einer assistant-Nachricht enden könnte → prefill-Fehler)
var notifyHistory = new List<object>
{
new Dictionary<string, object>
{
["role"] = "user",
["content"] = new List<object> { new { type = "text", text = summary } }
}
};
string notifyResponse;
if (!string.IsNullOrEmpty(notifyCtx.Provider))
notifyResponse = await _openAiService.ChatNonStreaming(notifyCtx, notifyHistory);
else
notifyResponse = await _anthropicService.ChatNonStreaming(notifyCtx, notifyHistory);
await _db.UpdateSessionTimestamp(job.NotifySessionId);
// Telegram auf Notify-Session senden
if (!string.IsNullOrEmpty(notifyResponse))
{
await _telegramService.SendNotification(job.NotifySessionId,
$"📋 Cronjob: {job.Name}\n📂 Session: {job.SessionName}\n\n{notifyResponse}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Senden der Cronjob-Benachrichtigung an Session {SessionId}", job.NotifySessionId);
}
}
private static string Truncate(string text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? "";
return text.Substring(0, maxLength) + "…";
}
public static string CalculateNextRun(string cronExpression)
{
try
{
var cron = CronExpression.Parse(cronExpression, CronFormat.Standard);
// Cron-Expression in Server-Lokalzeit interpretieren
var next = cron.GetNextOccurrence(DateTimeOffset.Now, TimeZoneInfo.Local);
return next?.UtcDateTime.ToString("o") ?? "";
}
catch
{
return "";
}
}
}