feat: Add Ai Profiles

This commit is contained in:
2026-09-26 20:26:39 +03:30
parent 4e8b14906f
commit 34f820b3ca
19 changed files with 3548 additions and 144 deletions

View File

@@ -1,25 +1,32 @@
using GreenHome.AI.DeepSeek;
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace GreenHome.Api.Controllers;
/// <summary>
/// AI endpoints. Every request is routed through the active AI profile
/// (see admin → AI Profiles); when no profile is active it falls back to the
/// configured legacy DeepSeek service. Each query is logged with its tokens
/// and estimated cost (CostUsd) for per-profile usage reporting.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AIController : ControllerBase
{
private readonly IDeepSeekService deepSeekService;
private readonly IAIProfileService aiProfileService;
private readonly IAIQueryService aiQueryService;
private readonly ILogger<AIController> logger;
public AIController(
IDeepSeekService deepSeekService,
IAIProfileService aiProfileService,
IAIQueryService aiQueryService,
ILogger<AIController> logger)
{
this.deepSeekService = deepSeekService;
this.aiProfileService = aiProfileService;
this.aiQueryService = aiQueryService;
this.logger = logger;
}
@@ -27,13 +34,11 @@ public class AIController : ControllerBase
/// <summary>
/// Send a simple question to the AI and get a response
/// </summary>
/// <param name="request">Question request</param>
/// <returns>AI response</returns>
[HttpPost("ask")]
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request)
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
if (string.IsNullOrWhiteSpace(request.Question))
@@ -43,7 +48,6 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI question: {Question}", request.Question);
// Build chat request to get token information
var messages = new List<ChatMessage>();
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
{
@@ -51,33 +55,19 @@ public class AIController : ControllerBase
}
messages.Add(new ChatMessage { Role = "user", Content = request.Question });
var chatRequest = new ChatRequest { Messages = messages };
var chatResponse = await deepSeekService.AskAsync(chatRequest);
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
stopwatch.Stop();
if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No response received from AI" });
}
var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
// Save to database
var aiQuery = new AIQuery
{
Question = request.Question,
Answer = answer,
DeviceId = request.DeviceId,
UserId = request.UserId,
Model = chatResponse.Model,
PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
ResponseTimeMs = stopwatch.ElapsedMilliseconds
};
await aiQueryService.SaveQueryAsync(aiQuery);
var aiQuery = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
return Ok(new
{
@@ -90,6 +80,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens
},
costUsd = aiQuery.CostUsd,
profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow
});
@@ -104,13 +96,11 @@ public class AIController : ControllerBase
/// <summary>
/// Send a complex chat request with multiple messages to the AI
/// </summary>
/// <param name="request">Extended chat request with deviceId</param>
/// <returns>AI chat response</returns>
[HttpPost("chat")]
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request)
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
if (request.Messages == null || !request.Messages.Any())
@@ -120,46 +110,41 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count);
var chatRequest = new ChatRequest
{
Messages = request.Messages,
Model = request.Model,
Temperature = request.Temperature,
MaxTokens = request.MaxTokens
};
var response = await deepSeekService.AskAsync(chatRequest);
// ponytail: the active profile's model wins over request.Model — analysis is
// always supposed to run on the configured profile; temperature/maxTokens still apply.
var result = await aiProfileService.AskAsync(
request.Messages,
temperature: request.Temperature,
maxTokens: request.MaxTokens,
cancellationToken: cancellationToken);
stopwatch.Stop();
if (response == null || response.Choices == null || !response.Choices.Any())
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No response received from AI" });
}
// Extract question and answer
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
var question = userMessage?.Content ?? "Complex chat";
var answer = response.Choices.FirstOrDefault()?.Message?.Content ?? "";
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
// Save to database
var aiQuery = new AIQuery
var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds);
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
var response = result.Response;
return Ok(new
{
Question = question,
Answer = answer,
DeviceId = request.DeviceId,
UserId = request.UserId,
Model = response.Model,
Temperature = request.Temperature,
PromptTokens = response.Usage?.PromptTokens ?? 0,
CompletionTokens = response.Usage?.CompletionTokens ?? 0,
TotalTokens = response.Usage?.TotalTokens ?? 0,
ResponseTimeMs = stopwatch.ElapsedMilliseconds
};
await aiQueryService.SaveQueryAsync(aiQuery);
return Ok(response);
response.Id,
response.Object,
response.Created,
response.Model,
response.Choices,
response.Usage,
costUsd = aiQuery.CostUsd,
profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs
});
}
catch (Exception ex)
{
@@ -171,13 +156,11 @@ public class AIController : ControllerBase
/// <summary>
/// Get suggestions for smart home automation based on device data
/// </summary>
/// <param name="request">Device context request</param>
/// <returns>AI suggestions</returns>
[HttpPost("suggest")]
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request)
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس داده‌های دستگاه‌های IoT، پیشنهادهای عملی و مفید برای بهینه‌سازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید.";
@@ -193,33 +176,19 @@ public class AIController : ControllerBase
new ChatMessage { Role = "user", Content = question }
};
var chatRequest = new ChatRequest { Messages = messages };
var chatResponse = await deepSeekService.AskAsync(chatRequest);
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
stopwatch.Stop();
if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No suggestions received from AI" });
}
var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
// Save to database
var aiQuery = new AIQuery
{
Question = question,
Answer = answer,
DeviceId = request.DeviceId,
UserId = request.UserId,
Model = chatResponse.Model,
PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
ResponseTimeMs = stopwatch.ElapsedMilliseconds
};
await aiQueryService.SaveQueryAsync(aiQuery);
var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
return Ok(new
{
@@ -231,6 +200,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens
},
costUsd = aiQuery.CostUsd,
profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow
});
@@ -246,12 +217,12 @@ public class AIController : ControllerBase
/// Get AI query history for a device
/// </summary>
[HttpGet("history/device/{deviceId}")]
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50)
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50, CancellationToken cancellationToken = default)
{
try
{
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take);
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId);
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take, cancellationToken);
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId, cancellationToken);
return Ok(new
{
@@ -263,7 +234,9 @@ public class AIController : ControllerBase
q.TotalTokens,
q.PromptTokens,
q.CompletionTokens,
q.CostUsd,
q.Model,
q.ProfileId,
q.ResponseTimeMs,
q.CreatedAt
}),
@@ -281,11 +254,11 @@ public class AIController : ControllerBase
/// Get AI query statistics
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
{
try
{
var stats = await aiQueryService.GetStatsAsync();
var stats = await aiQueryService.GetStatsAsync(cancellationToken);
return Ok(stats);
}
catch (Exception ex)
@@ -294,6 +267,42 @@ public class AIController : ControllerBase
return StatusCode(500, new { error = "An error occurred" });
}
}
/// <summary>
/// Builds an AIQuery row from a profile ask result, attaching profile,
/// tokens and the estimated cost.
/// </summary>
private static AIQuery BuildQuery(
AIAskResult result,
string question,
string answer,
int? deviceId,
int? userId,
double? temperature,
long responseTimeMs)
{
var profile = result.Profile;
var usage = result.Response!.Usage;
var query = new AIQuery
{
Question = question,
Answer = answer,
DeviceId = deviceId,
UserId = userId,
Model = result.Response.Model,
ProfileId = profile?.Id,
CostUsd = profile != null
? IAIProfileService.EstimateCost(profile.PromptPricePer1M, profile.CompletionPricePer1M, usage?.PromptTokens ?? 0, usage?.CompletionTokens ?? 0)
: 0m,
Temperature = temperature,
PromptTokens = usage?.PromptTokens ?? 0,
CompletionTokens = usage?.CompletionTokens ?? 0,
TotalTokens = usage?.TotalTokens ?? 0,
ResponseTimeMs = responseTimeMs
};
return query;
}
}
/// <summary>
@@ -301,24 +310,9 @@ public class AIController : ControllerBase
/// </summary>
public class SimpleQuestionRequest
{
/// <summary>
/// The question to ask the AI
/// </summary>
public required string Question { get; set; }
/// <summary>
/// Optional system prompt to set context for the AI
/// </summary>
public string? SystemPrompt { get; set; }
/// <summary>
/// Optional device ID to associate with this query
/// </summary>
public int? DeviceId { get; set; }
/// <summary>
/// Optional user ID to associate with this query
/// </summary>
public int? UserId { get; set; }
}
@@ -328,7 +322,7 @@ public class SimpleQuestionRequest
public class ExtendedChatRequest
{
public required List<ChatMessage> Messages { get; set; }
public string Model { get; set; } = "deepseek-chat";
public string? Model { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public int? DeviceId { get; set; }
@@ -340,19 +334,7 @@ public class ExtendedChatRequest
/// </summary>
public class SuggestionRequest
{
/// <summary>
/// Context about devices and their current state
/// </summary>
public required string DeviceContext { get; set; }
/// <summary>
/// Device ID for this suggestion request
/// </summary>
public int? DeviceId { get; set; }
/// <summary>
/// User ID for this suggestion request
/// </summary>
public int? UserId { get; set; }
}

View File

@@ -4,6 +4,7 @@ using GreenHome.Api.Filters;
using GreenHome.Application;
using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain;
using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers;
@@ -14,10 +15,12 @@ namespace GreenHome.Api.Controllers;
public sealed class AdminController : ControllerBase
{
private readonly IAdminService _adminService;
private readonly IAIProfileService _aiProfileService;
public AdminController(IAdminService adminService)
public AdminController(IAdminService adminService, IAIProfileService aiProfileService)
{
_adminService = adminService;
_aiProfileService = aiProfileService;
}
// ==================== Dashboard ====================
@@ -338,4 +341,116 @@ public sealed class AdminController : ControllerBase
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
return Ok(result);
}
// ==================== AI Profiles ====================
[HttpGet("ai-profiles")]
public async Task<ActionResult<List<AIProfileDto>>> AiProfiles(CancellationToken cancellationToken)
{
var result = await _aiProfileService.GetProfilesAsync(cancellationToken);
return Ok(result);
}
[HttpPost("ai-profiles")]
public async Task<IActionResult> CreateAiProfile([FromBody] AIProfileCreateRequest request, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
try
{
var result = await _aiProfileService.CreateProfileAsync(request, currentAdminId, cancellationToken);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpPut("ai-profiles/{id:int}")]
public async Task<IActionResult> UpdateAiProfile(int id, [FromBody] AIProfileUpdateRequest request, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
try
{
var result = await _aiProfileService.UpdateProfileAsync(id, request, currentAdminId, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new { message = ex.Message });
}
}
[HttpDelete("ai-profiles/{id:int}")]
public async Task<IActionResult> DeleteAiProfile(int id, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
var success = await _aiProfileService.DeleteProfileAsync(id, currentAdminId, cancellationToken);
return success ? Ok(new { success = true }) : NotFound();
}
[HttpPost("ai-profiles/{id:int}/activate")]
public async Task<IActionResult> ActivateAiProfile(int id, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
try
{
var result = await _aiProfileService.ActivateProfileAsync(id, currentAdminId, cancellationToken);
return Ok(result);
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
[HttpGet("ai-profiles/{id:int}/models")]
public async Task<IActionResult> GetAiProfileModels(int id, CancellationToken cancellationToken)
{
try
{
var models = await _aiProfileService.GetModelsAsync(id, cancellationToken);
return Ok(models);
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (Exception)
{
return StatusCode(502, new { message = "خطا در دریافت لیست مدل‌ها از سرویس" });
}
}
[HttpPost("ai-profiles/{id:int}/refresh-balance")]
public async Task<IActionResult> RefreshAiProfileBalance(int id, CancellationToken cancellationToken)
{
try
{
var balance = await _aiProfileService.RefreshBalanceAsync(id, cancellationToken);
return Ok(balance is null
? new { success = false, message = "دریافت موجودی برای این سرویس در دسترس نیست" }
: new { success = true, balance });
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (Exception)
{
return StatusCode(502, new { message = "خطا در دریافت موجودی از سرویس" });
}
}
[HttpGet("ai-profiles/{id:int}/usage/daily")]
public async Task<ActionResult<List<AIProfileUsageDayDto>>> GetAiProfileUsage(
int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken cancellationToken)
{
var (f, t) = ResolveReportRange(from, to);
var result = await _aiProfileService.GetUsageByDayAsync(id, f, t.AddDays(1), cancellationToken);
return Ok(result);
}
}