danylokhodus's picture
Refine strategist prompts for conversational tone, adaptive sizes, and correct chronological chaining
ab6ad9a
Raw
History Blame Contribute Delete
46 kB
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Net.Http;
using FlowAPI.Application.DTOs.AI;
using FlowAPI.Application.DTOs.Graph;
using FlowAPI.Application.DTOs.TaskNode;
using FlowAPI.Application.DTOs.Edge;
using FlowAPI.Application.Interfaces;
using Microsoft.Extensions.Configuration;
namespace FlowAPI.Application.Services
{
public class AIService : IAIService
{
private readonly IUserRepository _userRepo;
private readonly IGraphRepository _graphRepo;
private readonly IConfiguration _config;
private readonly IGraphService _graphService;
private readonly IGeminiBridgeManager _bridgeManager;
private const string GoalResponseSchema = @"
{
""type"": ""object"",
""properties"": {
""title"": { ""type"": ""string"" },
""description"": { ""type"": ""string"" },
""explanation"": { ""type"": ""string"" },
""nodes"": {
""type"": ""array"",
""items"": {
""type"": ""object"",
""properties"": {
""id"": { ""type"": ""string"" },
""label"": { ""type"": ""string"" },
""description"": { ""type"": ""string"" },
""type"": { ""type"": ""string"", ""enum"": [""sketch"", ""condition"", ""habit""] },
""decision"": { ""type"": ""string"" },
""dependsOn"": { ""type"": ""string"" },
""color"": { ""type"": ""string"", ""enum"": [""default"", ""yellow"", ""blue"", ""green"", ""pink"", ""purple"", ""orange"", ""white""] }
},
""required"": [""id"", ""label"", ""description"", ""type""]
}
},
""edges"": {
""type"": ""array"",
""items"": {
""type"": ""object"",
""properties"": {
""from"": { ""type"": ""string"" },
""to"": { ""type"": ""string"" },
""relationship"": { ""type"": ""string"", ""enum"": [""sequential"", ""subgoal""] },
""condition"": { ""type"": ""string"" }
},
""required"": [""from"", ""to"", ""relationship""]
}
}
},
""required"": [""title"", ""description"", ""explanation"", ""nodes"", ""edges""]
}";
private const string AssistResponseSchema = @"
{
""type"": ""object"",
""properties"": {
""explanation"": { ""type"": ""string"" },
""newNodes"": {
""type"": ""array"",
""items"": {
""type"": ""object"",
""properties"": {
""id"": { ""type"": ""string"" },
""label"": { ""type"": ""string"" },
""description"": { ""type"": ""string"" },
""type"": { ""type"": ""string"", ""enum"": [""sketch"", ""condition"", ""habit""] },
""decision"": { ""type"": ""string"" },
""color"": { ""type"": ""string"", ""enum"": [""default"", ""yellow"", ""blue"", ""green"", ""pink"", ""purple"", ""orange"", ""white""] }
},
""required"": [""id"", ""label"", ""description"", ""type""]
}
},
""newEdges"": {
""type"": ""array"",
""items"": {
""type"": ""object"",
""properties"": {
""from"": { ""type"": ""string"" },
""to"": { ""type"": ""string"" },
""relationship"": { ""type"": ""string"", ""enum"": [""sequential"", ""subgoal""] },
""condition"": { ""type"": ""string"" }
},
""required"": [""from"", ""to"", ""relationship""]
}
}
},
""required"": [""explanation"", ""newNodes"", ""newEdges""]
}";
public AIService(IUserRepository userRepo, IGraphRepository graphRepo, IConfiguration config, IGraphService graphService, IGeminiBridgeManager bridgeManager)
{
_userRepo = userRepo;
_graphRepo = graphRepo;
_config = config;
_graphService = graphService;
_bridgeManager = bridgeManager;
}
private async Task<string> CallGeminiApiAsync(string prompt, string? systemInstruction = null, string? responseSchemaJson = null)
{
var parts = new[] { new { text = prompt } };
var contents = new[] { new { parts = parts } };
return await CallGeminiApiAsync(contents, systemInstruction, responseSchemaJson);
}
private async Task<string> CallGeminiApiAsync(object[] contents, string? systemInstruction = null, string? responseSchemaJson = null)
{
var deepseekToken = Environment.GetEnvironmentVariable("DEEPSEEK_TOKEN");
var deepseekCookie = Environment.GetEnvironmentVariable("DEEPSEEK_COOKIE");
if (!string.IsNullOrEmpty(deepseekToken) && !string.IsNullOrEmpty(deepseekCookie))
{
return await CallDeepSeekProxyApiAsync(contents, systemInstruction, responseSchemaJson);
}
var apiKey = _config["Gemini:ApiKey"] ?? Environment.GetEnvironmentVariable("GEMINI_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
throw new Exception("Gemini API key is not configured. Please add Gemini:ApiKey to appsettings.json or set GEMINI_API_KEY environment variable.");
}
var model = _config["Gemini:Model"] ?? "gemini-2.5-flash";
if (string.IsNullOrWhiteSpace(model) ||
model.StartsWith("AIzaSy", StringComparison.OrdinalIgnoreCase) ||
model.StartsWith("AQ.Ab", StringComparison.OrdinalIgnoreCase) ||
model.Length > 30 ||
!model.Contains("gemini", StringComparison.OrdinalIgnoreCase))
{
model = "gemini-2.5-flash";
}
if (model.StartsWith("models/"))
{
model = model.Substring("models/".Length);
}
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={apiKey}";
object? generationConfig = null;
if (responseSchemaJson != null)
{
generationConfig = new
{
responseMimeType = "application/json",
responseSchema = JsonSerializer.Deserialize<object>(responseSchemaJson)
};
}
else
{
generationConfig = new
{
responseMimeType = "application/json"
};
}
object requestBody;
if (!string.IsNullOrEmpty(systemInstruction))
{
requestBody = new
{
contents = contents,
systemInstruction = new { parts = new[] { new { text = systemInstruction } } },
generationConfig = generationConfig
};
}
else
{
requestBody = new
{
contents = contents,
generationConfig = generationConfig
};
}
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(5);
var jsonPayload = JsonSerializer.Serialize(requestBody);
HttpResponseMessage? response = null;
string responseString = string.Empty;
int maxRetries = 3;
int delayMs = 1500; // 1.5s base delay
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
response = await httpClient.PostAsync(url, content);
responseString = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
break;
}
int statusCode = (int)response.StatusCode;
if (attempt < maxRetries && (statusCode == 503 || statusCode == 429 || statusCode == 500))
{
await Task.Delay(delayMs * attempt);
continue;
}
throw new Exception($"Gemini API error (Status {response.StatusCode}): {responseString}");
}
using var doc = JsonDocument.Parse(responseString);
var textResponse = doc.RootElement
.GetProperty("candidates")[0]
.GetProperty("content")
.GetProperty("parts")[0]
.GetProperty("text")
.GetString();
if (string.IsNullOrEmpty(textResponse))
{
throw new Exception("Empty response received from Gemini API.");
}
return textResponse;
}
public async Task<AIPathResponseDto> GenerateAlternativePathAsync(GenerateAlternativeRequestDto request)
{
var graph = await _graphRepo.GetByIdWithDetailsAsync(request.GraphId);
if (graph == null)
{
throw new Exception("Graph not found.");
}
await CheckAndIncrementAiGenerationsLimitAsync(graph.UserId);
var failedNode = graph.Nodes.FirstOrDefault(n => n.Id == request.FailedNodeId);
if (failedNode == null)
{
throw new Exception("Target node not found.");
}
var prompt = $"В нашей дорожной карте произошел сбой на шаге: \"{failedNode.Label}\" (Описание: \"{failedNode.Description}\").\n" +
$"Предложи альтернативный обходной путь из 2-3 шагов, чтобы пользователь мог продолжить движение к цели.\n" +
$"Предоставь только новые шаги (NewNodes) и новые связи (NewEdges) для их интеграции.";
var systemInstruction = "You are a Roadmap Strategist AI Agent. You design failover alternative paths for failed tasks in a roadmap.";
var responseJson = await CallGeminiApiAsync(prompt, systemInstruction, AssistResponseSchema);
responseJson = CleanJson(responseJson);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var rawAssist = JsonSerializer.Deserialize<GeminiAssistResponse>(responseJson, options)
?? throw new Exception("Failed to deserialize AI alternative path response from Gemini.");
var idMap = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
idMap[failedNode.Id.ToString()] = failedNode.Id;
foreach (var node in rawAssist.NewNodes)
{
if (!idMap.ContainsKey(node.Id))
{
idMap[node.Id] = Guid.NewGuid();
}
}
var mappedNodes = rawAssist.NewNodes.Select(node => new TaskNodeResponseDto
{
Id = idMap[node.Id],
Label = node.Label,
PosX = failedNode.PosX + 300,
PosY = failedNode.PosY + 150,
State = FlowAPI.Domain.Enums.NodeState.Pending,
CreatedAt = DateTime.UtcNow
}).ToList();
var mappedEdges = new List<EdgeResponseDto>();
foreach (var edge in rawAssist.NewEdges)
{
if (idMap.TryGetValue(edge.From, out var fromGuid) && idMap.TryGetValue(edge.To, out var toGuid))
{
mappedEdges.Add(new EdgeResponseDto
{
Id = Guid.NewGuid(),
FromNodeId = fromGuid,
ToNodeId = toGuid,
Condition = edge.Relationship == "subgoal" ? "subgoals-source-right" : "True"
});
}
}
return new AIPathResponseDto
{
SuggestedNodes = mappedNodes,
SuggestedEdges = mappedEdges
};
}
public async Task<FullGraphResponseDto> GenerateGoalGraphAsync(GenerateGoalGraphRequestDto request)
{
await CheckAndIncrementAiGenerationsLimitAsync(request.UserId);
if (string.IsNullOrWhiteSpace(request.Goal))
{
throw new ArgumentException("Goal description cannot be empty.");
}
// 1. Build prompt for Gemini
var prompt = $"������������ ����� ������� ��������� ����: \"{request.Goal}\". " +
"������� ��������� ������������� �������� �����.\n" +
"������� ���������� ������ � ��������� (�������������, ����������� � ������������������):\n" +
"- � ��� ���� ��� ���� ������:\n" +
" 1. 'relationship': 'sequential' � ���������������� ���� / ����. ������������ ��� �������� ���������, ��������� ����� ������� ������ ������ ����������, � ����� ��� ���������� ����� ������ ������� ������������ ���� � ������. ��������� ���� ��� ��� �����, ������� ������ �������������� ������� ���� �� ����� (��������: ����������� ���� -> �������� ����� -> ������ ����).\n" +
" 2. 'relationship': 'subgoal' � ������������ � �������. ������������ ��� ���������� ������� ���� �� ������ ������ �����.\n" +
"- ������� ����������� ������������:\n" +
" * ����� ������� ���� �� ����� ������ ����� ���� ���������������. ���� ������� �������, ������ � �� � ����������� ������� (subgoals) � ���������������� ����.\n" +
" * �� ������������� ����� ������� ��������. ������� �������� ������ (��������: ������� ���� -> ������� A -> ������� A.1 -> ������� A.1.1) � ��� ��������� � ��������������.\n" +
"- ��� ��������� ������� ������������ � ��������-������������ ����� (����������):\n" +
" * �� ����� ��� ��������� �������������! ���� ���� ������ ������� ������ ����������� ������ ���� �� ������ (���� ��������������� �����������), �� ������ ������� ������������ ���� ������ 'subgoal' ������ � ����� ������ ����� �������. ��� ����������� ���� ���� ������� �������� ��������������� ���� � ������ ����� 'sequential'.\n" +
" * ��������� ��������� ����� �������� � �������� ����� 'subgoal' (�����������) ������ ���� ��� ������ ���������� ���� �� ����� � �� ����� ������ ������������ ��� � ����� ������� (��������: ���� ingredients � ���������� ������).\n" +
" * �� ��������� ������������ �������� ��� ���������������� �����. ������� �� ����������������� ������ / ������. ������� ��������� / ����������� ������ �������� ������������.\n" +
"- ���������� ������ �������� �����:\n" +
" * ���� ���� ������� (��������, �������� ���, ������ ������), ������� ���������� ����� �� 3-5 �����.\n" +
" * ���� ���� ������� (������� �����, �������� ����������������), ���������� ����������� ����� �� 8-15+ �����, ������������ ��� ������ ���� � �� ���������.\n" +
"- ������� ��� ����� ����� (����������):\n" +
" * 'sketch' � ����������� ���/���� (��������� � 99% �������). ����� ������� �������� ���, ���� ���� �� �������� ��������, ����� �������� (��������, '�������� 30 �����', '��������� 1 ���') ��� �������� ��������, ������ ����� ��� 'sketch'!\n" +
" * 'habit' (��������) � ��������� ������ ������ ���� ������������ ����� ������ ���������� ����������, ���������/����������� ������������� �������� ��� ������������ �������� (��������, '������ ���� ���� ����'). ������� �� ��������� ��� ��� ������� ����� ��������!\n" +
" * 'condition' (�������) � ��������� ������ ������ ��� ����� ���������� ���������, �������� � ������ (��������, '���� X, �� Y, ����� Z'). ������� �� ��������� ��� ��� ������� ����� � ���������� ����������, ���� ��� ��������� ����!\n" +
"������� ���� ��� �������� �������� �������� (label) � ��������� �������� (description).\n" +
"� ���� 'explanation' ������ �����, ������, ����������� � ������������� ����� �� ���� ����������� ������-��������. ������� � ������������� �� ������� ������������ �����, ��� ���������� ���������. ������� ����� ������ ������������ �����, ������ ������������ ����� ����������� �����������, � ������ ���������������, � ��� ������ ������������ ������ �� ���������� ����.";
// 2. Call Gemini API
var systemInstruction = "You are a warm, friendly, and highly professional Roadmap Strategist AI Agent. You design roadmaps represented as graphs (nodes and edges). You break down complex goals into hierarchical, deeply nested subgoals (recursive subgoals, where subgoals can have their own subgoals). You connect a parent to multiple parallel subgoals using 'subgoal' relationship ONLY if they can be done in parallel or in any order. If subgoals have chronological dependencies, connect the parent to the first subgoal in the sequence using 'subgoal' and chain the remaining subgoals using 'sequential' relationship. Node types MUST follow these rules: 'sketch' for 99% of regular tasks (including tasks with durations/wait times); 'habit' ONLY for repeating daily/weekly actions meant to form a routine, if explicitly requested; 'condition' ONLY for logical forks/branches with conditions. You adapt the graph size: 3-5 nodes for simple goals, 8-15+ nodes for complex goals. In the 'explanation' field, you write in a warm, direct, and conversational human tone, explaining the plan's logic and giving practical advice.";
var responseJson = await CallGeminiApiAsync(prompt, systemInstruction, GoalResponseSchema);
responseJson = CleanJson(responseJson);
// 3. Deserialize response
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var rawGraph = JsonSerializer.Deserialize<GeminiGoalResponse>(responseJson, options)
?? throw new Exception("Failed to deserialize generated roadmap graph from Gemini.");
// 4. Create the Graph record in the database first
var createDto = new CreateGraphDto
{
Title = string.IsNullOrWhiteSpace(rawGraph.Title) ? request.Goal : rawGraph.Title,
Description = string.IsNullOrWhiteSpace(rawGraph.Description) ? "Дорожная карта, сгенерированная AI Агентом" : rawGraph.Description
};
var graphResponse = await _graphService.CreateAsync(createDto, request.UserId);
var graphId = graphResponse.Id;
// 5. Map Gemini string IDs to GUIDs
var idMap = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var node in rawGraph.Nodes)
{
if (!idMap.ContainsKey(node.Id))
{
idMap[node.Id] = Guid.NewGuid();
}
}
// 6. Construct DTO lists
var mappedNodes = rawGraph.Nodes.Select(node => new NodeDto
{
Id = idMap[node.Id],
Label = node.Label,
PosX = 0,
PosY = 0,
Type = node.Type ?? "sketch",
State = 0, // Pending
Decision = node.Decision,
Description = node.Description,
Color = node.Color ?? "default",
IsPinned = false
}).ToList();
var mappedEdges = new List<EdgeDto>();
foreach (var edge in rawGraph.Edges)
{
if (idMap.TryGetValue(edge.From, out var fromGuid) && idMap.TryGetValue(edge.To, out var toGuid))
{
mappedEdges.Add(new EdgeDto
{
Id = Guid.NewGuid(),
FromNodeId = fromGuid,
ToNodeId = toGuid,
Condition = edge.Relationship == "subgoal"
? "subgoals-source-right"
: (string.IsNullOrWhiteSpace(edge.Condition) ? "True" : edge.Condition)
});
}
}
// 7. Calculate layouts
ApplyBeautifulLayout(mappedNodes, mappedEdges);
// 8. Save fully generated graph to DB
var fullGraphDto = new FullGraphResponseDto
{
Id = graphId,
UserId = request.UserId,
Title = createDto.Title,
Description = createDto.Description,
CreatedAt = DateTime.UtcNow,
Nodes = mappedNodes,
Edges = mappedEdges
};
await _graphService.SaveFullGraphAsync(graphId, fullGraphDto, request.UserId);
// 9. Update roadmap description with explanation from strategist
var graphEntity = await _graphRepo.GetByIdAsync(graphId);
if (graphEntity != null)
{
graphEntity.Description = rawGraph.Explanation;
await _graphRepo.UpdateAsync(graphEntity);
fullGraphDto.Description = rawGraph.Explanation;
}
return fullGraphDto;
}
public async Task<AICanvasAssistResponseDto> CanvasAssistAsync(AICanvasAssistRequestDto request)
{
var graph = await _graphRepo.GetByIdWithDetailsAsync(request.GraphId);
if (graph == null)
{
throw new Exception("Graph not found.");
}
await CheckAndIncrementAiGenerationsLimitAsync(graph.UserId);
// 1. Serialize existing nodes and edges for Gemini context
var existingNodesText = new StringBuilder();
existingNodesText.AppendLine("Текущие узлы на доске:");
foreach (var node in graph.Nodes)
{
existingNodesText.AppendLine($"- ID: \"{node.Id}\", Название: \"{node.Label}\", Тип: \"{node.Type}\", Состояние: \"{node.State}\", Описание: \"{node.Description}\"");
}
var existingEdgesText = new StringBuilder();
existingEdgesText.AppendLine("Текущие связи на доске:");
foreach (var edge in graph.Edges)
{
existingEdgesText.AppendLine($"- От: \"{edge.FromNodeId}\", К: \"{edge.ToNodeId}\", Условие: \"{edge.Condition}\"");
}
// 2. Build the prompt
var prompt = $"������������ ���������� � ���� (������-��������) �� ��������� ���������� ��� ��������:\n" +
$"������ ������������: \"{request.UserPrompt}\"\n\n" +
$"{existingNodesText}\n" +
$"{existingEdgesText}\n\n" +
"���� ������:\n" +
"1. ���� ������ ������������ ������� ���������� ����� ���������, ����������� ��� ���������� �����: ���������� ����� ���� (NewNodes) � ����� ����� (NewEdges). ��� ���� ������ ������ �������� ���������� ������:\n" +
" - � ��� ���� ��� ���� ������:\n" +
" * 'relationship': 'sequential' � ���������������� ���� / ����. ������������ ��� �������� ���������, ��������� ����� ������� ������ ������ ����������, � ����� ��� ���������� ����� ������ ������� ������������ ���� � ������. ��������� ���� ��� ��� �����, ������� ������ �������������� ������� ���� �� ����� (��������: ����������� ���� -> �������� ����� -> ������ ����).\n" +
" * 'relationship': 'subgoal' � ������������ � �������. ������������ ��� ���������� ������� ���� �� ������ ������ �����.\n" +
" - ������� ����������� ������������:\n" +
" * ����� ���� �� ����� ������ ����� ���� ���������������! ���� ������� ���� �� ���� �������, �� ������ ������� � �� � ����������� ������� (subgoals) � ���������������� ����.\n" +
" * �� ������������� ����� ������� ��������. ������� �������� ������ (��������: ������� ���� -> ������� A -> ������� A.1 -> ������� A.1.1) � ��� ��������� � ��������������.\n" +
" - ��� ��������� ������� ������������ � ��������-������������ ����� (����������):\n" +
" * �� ����� ��� ��������� �������������! ���� ���� ������ ������� ������ ����������� ������ ���� �� ������ (���� ��������������� �����������), �� ������ ������� ������������ ���� ������ 'subgoal' ������ � ����� ������ ����� �������. ��� ����������� ���� ���� ������� �������� ��������������� ���� � ������ ����� 'sequential'.\n" +
" * ��������� ��������� ����� �������� � �������� ����� 'subgoal' (�����������) ������ ���� ��� ������ ���������� ���� �� ����� � �� ����� ������ ������������ ��� � ����� �������.\n" +
" * �� ��������� ������������ �������� ��� ���������������� �����. ������� �� ����������������� ������ / ������. ������� ��������� / ����������� ������ �������� ������������.\n" +
" - ���������� ������ ����������:\n" +
" * ���� ������������ ������ �������� ������� �������� ��� ������ ���������, ������ 1-3 ����.\n" +
" * ���� ������������ ������ ��������� ��� ��������� ������� ����, ������ �����������, ������� � ��������� ���� �� 5-12 �����.\n" +
" - ������� ��� ����� ����� (����������):\n" +
" * 'sketch' � ����������� ���/���� (��������� � 99% �������). ����� ������� �������� ���, ���� ���� �� �������� ��������, ����� �������� (��������, '�������� 30 �����', '��������� 1 ���') ��� �������� ��������, ������ ����� ��� 'sketch'!\n" +
" * 'habit' (��������) � ��������� ������ ������ ���� ������������ ����� ������ ���������� ����������, ���������/����������� ������������� �������� ��� ������������ �������� (��������, '������ ���� ���� ����'). ������� �� ��������� ��� ��� ������� ����� ��������!\n" +
" * 'condition' (�������) � ��������� ������ ������ ��� ����� ���������� ���������, �������� � ������ (��������, '���� X, �� Y, ����� Z'). ������� �� ��������� ��� ��� ������� ����� � ���������� ����������, ���� ��� ��������� ����!\n" +
" - �������� ����� ���� � ������������� ��� ���� � ������ � ������� ���������� ��������� ID (��������, new_step_1).\n" +
"2. ���� ������ ������������ �� ������� ���������� ���������: ������ ������ 'NewNodes' � 'NewEdges' ������� ( [] ).\n" +
"3. � ���� 'explanation' ������ ������������, ������, ����� � ����������� ����� �� ���� ��������-����������. ������ �� ������� ������������, ����� ������ ��������� ��������� ��� ������� ������ ������������ ����� �� ������� ������������ �����.";
var systemInstruction = "You are a warm, friendly, and highly professional Roadmap Strategist AI Agent. You interact with the user, answer their questions, and help them design, extend, or analyze their roadmaps. If changes are needed, you return the new nodes and edges, breaking down complex tasks into hierarchical, deeply nested subgoals (recursive subgoals, where subgoals can have their own subgoals). You connect a parent to multiple parallel subgoals using 'subgoal' relationship ONLY if they can be done in parallel or in any order. If subgoals have chronological dependencies, connect the parent to the first subgoal in the sequence using 'subgoal' and chain the remaining subgoals using 'sequential' relationship. Node types MUST follow these rules: 'sketch' for 99% of regular tasks (including tasks with durations/wait times); 'habit' ONLY for repeating daily/weekly actions meant to form a routine, if explicitly requested; 'condition' ONLY for logical forks/branches with conditions. You never connect subgoals directly to unrelated nodes without a parent node. In the 'explanation' field, you write in a warm, direct, and conversational human tone, explaining your changes or answering user questions.";
var contentsList = new List<object>();
if (request.History != null)
{
foreach (var msg in request.History)
{
var role = msg.Role == "user" ? "user" : "model";
if (!string.IsNullOrWhiteSpace(msg.Text))
{
contentsList.Add(new
{
role = role,
parts = new[] { new { text = msg.Text } }
});
}
}
}
// Add the current user prompt (contains graph context + query)
contentsList.Add(new
{
role = "user",
parts = new[] { new { text = prompt } }
});
var responseJson = await CallGeminiApiAsync(contentsList.ToArray(), systemInstruction, AssistResponseSchema);
responseJson = CleanJson(responseJson);
// 3. Deserialize Gemini response
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var rawAssist = JsonSerializer.Deserialize<GeminiAssistResponse>(responseJson, options)
?? throw new Exception("Failed to deserialize AI Assist response from Gemini.");
// 4. Map string IDs to Guids
var idMap = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
// Map existing node Guids to themselves so they remain stable
foreach (var node in graph.Nodes)
{
idMap[node.Id.ToString()] = node.Id;
}
// Generate new Guids for new node IDs
foreach (var node in rawAssist.NewNodes)
{
if (!idMap.ContainsKey(node.Id))
{
idMap[node.Id] = Guid.NewGuid();
}
}
// 5. Construct DTO nodes list
var mappedNewNodes = rawAssist.NewNodes.Select(node => new NodeDto
{
Id = idMap[node.Id],
Label = node.Label,
PosX = 0,
PosY = 0,
Type = node.Type ?? "sketch",
State = 0, // Pending
Decision = node.Decision,
Description = node.Description,
Color = node.Color ?? "default",
IsPinned = false
}).ToList();
// 6. Construct DTO edges list
var mappedNewEdges = new List<EdgeDto>();
foreach (var edge in rawAssist.NewEdges)
{
if (idMap.TryGetValue(edge.From, out var fromGuid) && idMap.TryGetValue(edge.To, out var toGuid))
{
mappedNewEdges.Add(new EdgeDto
{
Id = Guid.NewGuid(),
FromNodeId = fromGuid,
ToNodeId = toGuid,
Condition = edge.Relationship == "subgoal"
? "subgoals-source-right"
: (string.IsNullOrWhiteSpace(edge.Condition) ? "True" : edge.Condition)
});
}
}
// 7. Positioning/Layout:
// Find max PosY of existing nodes to place the new ones below
double maxPosY = 100;
if (graph.Nodes.Count > 0)
{
maxPosY = graph.Nodes.Max(n => n.PosY);
}
// Run layout engine on the new nodes/edges
ApplyBeautifulLayout(mappedNewNodes, mappedNewEdges);
// Shift all new nodes below the existing nodes
double shiftY = maxPosY + 250;
foreach (var node in mappedNewNodes)
{
node.PosY += shiftY;
}
return new AICanvasAssistResponseDto
{
Explanation = rawAssist.Explanation,
NewNodes = mappedNewNodes,
NewEdges = mappedNewEdges
};
}
private string CleanJson(string text)
{
text = text.Trim();
if (text.StartsWith("```"))
{
var lines = text.Split('\n');
var cleanLines = lines.Where(line => !line.Trim().StartsWith("```")).ToArray();
text = string.Join("\n", cleanLines).Trim();
}
return text;
}
private void ApplyBeautifulLayout(List<NodeDto> nodes, List<EdgeDto> edges)
{
if (nodes == null || nodes.Count == 0) return;
var inDegree = new Dictionary<Guid, int>();
var adj = new Dictionary<Guid, List<Guid>>();
foreach (var node in nodes)
{
inDegree[node.Id] = 0;
adj[node.Id] = new List<Guid>();
}
foreach (var edge in edges)
{
if (inDegree.ContainsKey(edge.ToNodeId) && inDegree.ContainsKey(edge.FromNodeId))
{
inDegree[edge.ToNodeId]++;
adj[edge.FromNodeId].Add(edge.ToNodeId);
}
}
var layers = new Dictionary<Guid, int>();
foreach (var node in nodes) { layers[node.Id] = 0; }
var queue = new Queue<Guid>();
foreach (var node in nodes)
{
if (inDegree[node.Id] == 0) { queue.Enqueue(node.Id); }
}
if (queue.Count == 0 && nodes.Count > 0)
{
queue.Enqueue(nodes[0].Id);
}
var processed = new HashSet<Guid>();
while (queue.Count > 0)
{
var curr = queue.Dequeue();
processed.Add(curr);
int currentLayer = layers[curr];
foreach (var neighbor in adj[curr])
{
layers[neighbor] = Math.Max(layers[neighbor], currentLayer + 1);
if (!processed.Contains(neighbor) && !queue.Contains(neighbor))
{
queue.Enqueue(neighbor);
}
}
}
var nodesByLayer = new Dictionary<int, List<NodeDto>>();
foreach (var node in nodes)
{
int layer = layers.ContainsKey(node.Id) ? layers[node.Id] : 0;
if (!nodesByLayer.ContainsKey(layer)) { nodesByLayer[layer] = new List<NodeDto>(); }
nodesByLayer[layer].Add(node);
}
double marginY = 100;
double spacingY = 220;
double spacingX = 350;
double centerX = 450;
var sortedLayers = nodesByLayer.Keys.OrderBy(l => l).ToList();
foreach (var layer in sortedLayers)
{
var layerNodes = nodesByLayer[layer];
int count = layerNodes.Count;
for (int i = 0; i < count; i++)
{
var node = layerNodes[i];
node.PosY = marginY + layer * spacingY;
node.PosX = centerX + (i - (count - 1) / 2.0) * spacingX;
}
}
}
private void ApplyBeautifulLayout(List<FlowAPI.Domain.Entities.TaskNode> nodes, List<FlowAPI.Domain.Entities.Edge> edges)
{
if (nodes == null || nodes.Count == 0) return;
var inDegree = new Dictionary<Guid, int>();
var adj = new Dictionary<Guid, List<Guid>>();
foreach (var node in nodes)
{
inDegree[node.Id] = 0;
adj[node.Id] = new List<Guid>();
}
foreach (var edge in edges)
{
if (inDegree.ContainsKey(edge.ToNodeId) && inDegree.ContainsKey(edge.FromNodeId))
{
inDegree[edge.ToNodeId]++;
adj[edge.FromNodeId].Add(edge.ToNodeId);
}
}
var layers = new Dictionary<Guid, int>();
foreach (var node in nodes) { layers[node.Id] = 0; }
var queue = new Queue<Guid>();
foreach (var node in nodes)
{
if (inDegree[node.Id] == 0) { queue.Enqueue(node.Id); }
}
if (queue.Count == 0 && nodes.Count > 0)
{
queue.Enqueue(nodes[0].Id);
}
var processed = new HashSet<Guid>();
while (queue.Count > 0)
{
var curr = queue.Dequeue();
processed.Add(curr);
int currentLayer = layers[curr];
foreach (var neighbor in adj[curr])
{
layers[neighbor] = Math.Max(layers[neighbor], currentLayer + 1);
if (!processed.Contains(neighbor) && !queue.Contains(neighbor))
{
queue.Enqueue(neighbor);
}
}
}
var nodesByLayer = new Dictionary<int, List<FlowAPI.Domain.Entities.TaskNode>>();
foreach (var node in nodes)
{
int layer = layers.ContainsKey(node.Id) ? layers[node.Id] : 0;
if (!nodesByLayer.ContainsKey(layer)) { nodesByLayer[layer] = new List<FlowAPI.Domain.Entities.TaskNode>(); }
nodesByLayer[layer].Add(node);
}
double marginY = 100;
double spacingY = 220;
double spacingX = 350;
double centerX = 450;
var sortedLayers = nodesByLayer.Keys.OrderBy(l => l).ToList();
foreach (var layer in sortedLayers)
{
var layerNodes = nodesByLayer[layer];
int count = layerNodes.Count;
for (int i = 0; i < count; i++)
{
var node = layerNodes[i];
node.PosY = marginY + layer * spacingY;
node.PosX = centerX + (i - (count - 1) / 2.0) * spacingX;
}
}
}
private class GeminiNode
{
public string Id { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = "sketch";
public string? Decision { get; set; }
public string? DependsOn { get; set; }
public string? Color { get; set; }
}
private class GeminiEdge
{
public string From { get; set; } = string.Empty;
public string To { get; set; } = string.Empty;
public string Relationship { get; set; } = "sequential";
public string? Condition { get; set; }
}
private class GeminiGoalResponse
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Explanation { get; set; } = string.Empty;
public List<GeminiNode> Nodes { get; set; } = new();
public List<GeminiEdge> Edges { get; set; } = new();
}
private class GeminiAssistResponse
{
public string Explanation { get; set; } = string.Empty;
public List<GeminiNode> NewNodes { get; set; } = new();
public List<GeminiEdge> NewEdges { get; set; } = new();
}
private async Task<string> CallDeepSeekProxyApiAsync(object[] contents, string? systemInstruction = null, string? responseSchemaJson = null)
{
var messages = new List<object>();
if (responseSchemaJson != null)
{
var schemaInstructions = $"\n\n[STRICT REQUIREMENT] You MUST respond ONLY with a JSON object that strictly adheres to the following JSON schema:\n{responseSchemaJson}\nDo not wrap the JSON in markdown code blocks like ```json ... ```, just output the raw JSON text.";
if (!string.IsNullOrEmpty(systemInstruction))
{
systemInstruction += schemaInstructions;
}
else
{
systemInstruction = schemaInstructions;
}
}
if (!string.IsNullOrEmpty(systemInstruction))
{
messages.Add(new { role = "system", content = systemInstruction });
}
foreach (var content in contents)
{
try
{
var json = JsonSerializer.Serialize(content);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
string role = "user";
if (root.TryGetProperty("role", out var roleProp))
{
var rStr = roleProp.GetString();
role = rStr == "model" ? "assistant" : (rStr == "system" ? "system" : "user");
}
string text = string.Empty;
if (root.TryGetProperty("parts", out var partsProp) && partsProp.ValueKind == JsonValueKind.Array && partsProp.GetArrayLength() > 0)
{
var firstPart = partsProp[0];
if (firstPart.TryGetProperty("text", out var textProp))
{
text = textProp.GetString() ?? string.Empty;
}
}
if (!string.IsNullOrEmpty(text))
{
messages.Add(new { role = role, content = text });
}
}
catch (Exception ex)
{
Console.WriteLine($"[DeepSeekProxy] Error mapping message: {ex.Message}");
}
}
var requestBody = new
{
model = "deepseek-chat",
messages = messages,
stream = false
};
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(5);
var jsonPayload = JsonSerializer.Serialize(requestBody);
using var requestContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
HttpResponseMessage? response = null;
string responseString = string.Empty;
int maxRetries = 3;
int delayMs = 1500;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
response = await httpClient.PostAsync("http://localhost:9655/v1/chat/completions", requestContent);
responseString = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
break;
}
int statusCode = (int)response.StatusCode;
if (attempt < maxRetries && (statusCode == 503 || statusCode == 429 || statusCode == 500 || statusCode == 502))
{
await Task.Delay(delayMs * attempt);
continue;
}
throw new Exception($"DeepSeek Proxy API error (Status {response.StatusCode}): {responseString}");
}
using var resDoc = JsonDocument.Parse(responseString);
var textResponse = resDoc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
if (string.IsNullOrEmpty(textResponse))
{
throw new Exception("Empty response received from DeepSeek Proxy API.");
}
return textResponse;
}
private async Task CheckAndIncrementAiGenerationsLimitAsync(Guid userId)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) throw new Exception("User not found.");
var today = DateTime.UtcNow.Date;
if (user.LastAiGenerationDate.Date != today)
{
user.DailyAiGenerationsCount = 0;
user.LastAiGenerationDate = DateTime.UtcNow;
}
int limit = user.SubscriptionTier switch
{
"Free" => 3,
"Pro" => 200,
"Ultra" => int.MaxValue,
_ => 3
};
if (user.DailyAiGenerationsCount >= limit)
{
throw new InvalidOperationException($"Daily AI generation limit reached for your tier ({limit} generations per day).");
}
user.DailyAiGenerationsCount++;
await _userRepo.UpdateAsync(user);
}
}
}