159 lines
6.9 KiB
C#
159 lines
6.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Security.Claims;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.Tasks;
|
|
using FinlyticBackend.Util;
|
|
using FinlyticCore.Dtos;
|
|
using FinlyticCore.Dtos.Trading;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace FinlyticBackend.Controllers;
|
|
|
|
/// <summary>
|
|
/// Request payload for triggering manual analysis (AOT-compliant).
|
|
/// </summary>
|
|
public record AnalyzeRequest(
|
|
[property: JsonPropertyName("symbol")] string? Symbol,
|
|
[property: JsonPropertyName("isin")] string? Isin,
|
|
[property: JsonPropertyName("sector")] string? Sector,
|
|
[property: JsonPropertyName("headline")] string? Headline,
|
|
[property: JsonPropertyName("currentPrice")] decimal? CurrentPrice,
|
|
[property: JsonPropertyName("riskScore")] int? RiskScore,
|
|
[property: JsonPropertyName("minTimeframeValue")] int? MinTimeframeValue,
|
|
[property: JsonPropertyName("maxTimeframeValue")] int? MaxTimeframeValue,
|
|
[property: JsonPropertyName("timeframeUnit")] string? TimeframeUnit,
|
|
[property: JsonPropertyName("instrumentType")] string? InstrumentType,
|
|
[property: JsonPropertyName("userNotes")] string? UserNotes
|
|
);
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/v1/analyze")]
|
|
[EnableCors("AllowAll")]
|
|
public class AnalyzeController : ControllerBase
|
|
{
|
|
private readonly BackendMqttBridge _mqttClient;
|
|
private readonly ILogger<AnalyzeController> _logger;
|
|
|
|
public AnalyzeController(BackendMqttBridge mqttClient, ILogger<AnalyzeController> logger)
|
|
{
|
|
_mqttClient = mqttClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, exactly like
|
|
/// <c>EngineController.GetUserIdFromClaims</c>. Every manual evaluation triggered through this controller
|
|
/// is tagged with this identity as <c>EngineEvaluationSnapshotEntity.TriggeredByUserId</c>, so a request
|
|
/// whose identity cannot be established must be rejected rather than silently recorded as anonymous.
|
|
/// </summary>
|
|
/// <exception cref="UnauthorizedAccessException">
|
|
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
|
|
/// parseable <see cref="Guid"/>.
|
|
/// </exception>
|
|
private Guid GetUserIdFromClaims()
|
|
{
|
|
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? User.FindFirstValue("sub")
|
|
?? User.FindFirstValue("nameid");
|
|
|
|
if (Guid.TryParse(claimUserId, out var userId))
|
|
{
|
|
return userId;
|
|
}
|
|
|
|
_logger.LogWarning("[AnalyzeController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
|
|
throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Triggers an on-demand analysis for an asset via FinlyticEngine.
|
|
/// Always returns <c>200 OK</c> with a full <see cref="AssetEvaluationResultDto"/> body — the analysis
|
|
/// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a
|
|
/// proposal (<c>Proposal == null</c>), so there is no longer a "silent" <c>204 No Content</c> outcome for a
|
|
/// completed-but-rejected evaluation (Rules.md §4). <c>204</c> is gone entirely: the only remaining failure
|
|
/// modes (missing ISIN/Symbol, unreachable engine, no RPC response, unexpected error) are standardized
|
|
/// Problem Details (Rules.md §11) instead of anonymous status objects.
|
|
/// </summary>
|
|
[HttpPost("manual")]
|
|
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
|
|
{
|
|
return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
if (!_mqttClient.IsConnected)
|
|
{
|
|
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
|
|
|
|
// UserId always comes from the JWT, never from the request body - matches every other engine
|
|
// request DTO with a UserId field (see EvaluateAssetRequest's doc comment).
|
|
var userId = GetUserIdFromClaims();
|
|
|
|
var evalRequest = new EvaluateAssetRequest(
|
|
Isin: targetIsin,
|
|
UserId: userId,
|
|
Ticker: null,
|
|
ForceAiEvaluation: true
|
|
);
|
|
|
|
var evaluation = await _mqttClient.SendRpcRequestAsync<AssetEvaluationResultDto, EvaluateAssetRequest>(
|
|
"engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15));
|
|
|
|
if (evaluation == null)
|
|
{
|
|
// Null here means the RPC call itself timed out / got no response - a transport failure, not
|
|
// a legitimate "evaluated, no proposal" business outcome (that is now always a populated DTO).
|
|
return Problem(title: "FinlyticEngine did not respond to the evaluation request.", statusCode: StatusCodes.Status502BadGateway);
|
|
}
|
|
|
|
return Ok(evaluation);
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine.");
|
|
return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches the currently active trade proposals from FinlyticEngine.
|
|
/// </summary>
|
|
[HttpGet("proposals")]
|
|
public async Task<IActionResult> GetActiveProposals()
|
|
{
|
|
if (!_mqttClient.IsConnected)
|
|
{
|
|
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
try
|
|
{
|
|
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
|
|
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
|
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
|
|
|
|
return Ok(proposals ?? new List<TradeProposalDto>());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine.");
|
|
return Problem(title: "Internal server error fetching proposals.", statusCode: StatusCodes.Status500InternalServerError);
|
|
}
|
|
}
|
|
} |