save progress
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// <copyright file="ChatContracts.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new conversation.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-005
|
||||
/// </summary>
|
||||
public sealed record CreateConversationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional initial context for the conversation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("context")]
|
||||
public ConversationContextRequest? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional metadata key-value pairs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for conversation context initialization.
|
||||
/// </summary>
|
||||
public sealed record ConversationContextRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current CVE ID being discussed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentCveId")]
|
||||
public string? CurrentCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current component PURL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentComponent")]
|
||||
public string? CurrentComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current image digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("currentImageDigest")]
|
||||
public string? CurrentImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scan ID in context.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM ID in context.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a turn to an existing conversation.
|
||||
/// </summary>
|
||||
public sealed record AddTurnRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the user message content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional metadata for this turn.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to stream the response as Server-Sent Events.
|
||||
/// </summary>
|
||||
[JsonPropertyName("stream")]
|
||||
public bool Stream { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a created conversation.
|
||||
/// </summary>
|
||||
public sealed record ConversationResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the conversation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conversationId")]
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("userId")]
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last update timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation turns.
|
||||
/// </summary>
|
||||
[JsonPropertyName("turns")]
|
||||
public required IReadOnlyList<ConversationTurnResponse> Turns { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response from a conversation.
|
||||
/// </summary>
|
||||
public static ConversationResponse FromConversation(Conversation conversation) => new()
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId,
|
||||
CreatedAt = conversation.CreatedAt,
|
||||
UpdatedAt = conversation.UpdatedAt,
|
||||
Turns = conversation.Turns.Select(ConversationTurnResponse.FromTurn).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a conversation turn.
|
||||
/// </summary>
|
||||
public sealed record ConversationTurnResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the turn ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("turnId")]
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user, assistant, system).
|
||||
/// </summary>
|
||||
[JsonPropertyName("role")]
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence links in this turn.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLinkResponse>? EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed actions in this turn.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proposedActions")]
|
||||
public IReadOnlyList<ProposedActionResponse>? ProposedActions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response from a turn.
|
||||
/// </summary>
|
||||
public static ConversationTurnResponse FromTurn(ConversationTurn turn) => new()
|
||||
{
|
||||
TurnId = turn.TurnId,
|
||||
Role = turn.Role.ToString().ToLowerInvariant(),
|
||||
Content = turn.Content,
|
||||
Timestamp = turn.Timestamp,
|
||||
EvidenceLinks = turn.EvidenceLinks.IsEmpty
|
||||
? null
|
||||
: turn.EvidenceLinks.Select(EvidenceLinkResponse.FromLink).ToList(),
|
||||
ProposedActions = turn.ProposedActions.IsEmpty
|
||||
? null
|
||||
: turn.ProposedActions.Select(ProposedActionResponse.FromAction).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an evidence link.
|
||||
/// </summary>
|
||||
public sealed record EvidenceLinkResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the link type (sbom, dsse, callGraph, reachability, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display label.
|
||||
/// </summary>
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response from an evidence link.
|
||||
/// </summary>
|
||||
public static EvidenceLinkResponse FromLink(EvidenceLink link) => new()
|
||||
{
|
||||
Type = link.Type.ToString(),
|
||||
Uri = link.Uri,
|
||||
Label = link.Label,
|
||||
Confidence = link.Confidence
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a proposed action.
|
||||
/// </summary>
|
||||
public sealed record ProposedActionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type (approve, quarantine, defer, generate_manifest, create_vex).
|
||||
/// </summary>
|
||||
[JsonPropertyName("actionType")]
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action label.
|
||||
/// </summary>
|
||||
[JsonPropertyName("label")]
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy gate for this action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyGate")]
|
||||
public string? PolicyGate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this action requires confirmation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresConfirmation")]
|
||||
public bool RequiresConfirmation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a response from a proposed action.
|
||||
/// </summary>
|
||||
public static ProposedActionResponse FromAction(ProposedAction action) => new()
|
||||
{
|
||||
ActionType = action.ActionType,
|
||||
Label = action.Label,
|
||||
PolicyGate = action.PolicyGate,
|
||||
RequiresConfirmation = action.RequiresConfirmation
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for the assistant's turn (non-streaming).
|
||||
/// </summary>
|
||||
public sealed record AssistantTurnResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the turn ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("turnId")]
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assistant's response content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links found in the response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLinkResponse>? EvidenceLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets proposed actions in the response.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proposedActions")]
|
||||
public IReadOnlyList<ProposedActionResponse>? ProposedActions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grounding score (0.0-1.0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("groundingScore")]
|
||||
public double GroundingScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the token count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tokenCount")]
|
||||
public int TokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the processing duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing conversations.
|
||||
/// </summary>
|
||||
public sealed record ConversationListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the conversations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conversations")]
|
||||
public required IReadOnlyList<ConversationSummary> Conversations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a conversation for listing.
|
||||
/// </summary>
|
||||
public sealed record ConversationSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the conversation ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("conversationId")]
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last update timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("turnCount")]
|
||||
public int TurnCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a preview of the first user message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("preview")]
|
||||
public string? Preview { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -10,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
@@ -161,6 +163,22 @@ app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
|
||||
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
|
||||
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -926,6 +944,245 @@ static Task<IResult> HandleGetRateLimits(
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
}
|
||||
|
||||
// Chat endpoint handlers (SPRINT_20260107_006_003 CH-005)
|
||||
|
||||
static async Task<IResult> HandleCreateConversation(
|
||||
HttpContext httpContext,
|
||||
StellaOps.AdvisoryAI.WebService.Contracts.CreateConversationRequest request,
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.tenant_id", request.TenantId);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// Get user ID from header
|
||||
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
||||
? userHeader.ToString()
|
||||
: "anonymous";
|
||||
|
||||
var conversationRequest = new ConversationRequest
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
UserId = userId,
|
||||
InitialContext = request.Context is not null
|
||||
? new ConversationContext
|
||||
{
|
||||
CurrentCveId = request.Context.CurrentCveId,
|
||||
CurrentComponent = request.Context.CurrentComponent,
|
||||
CurrentImageDigest = request.Context.CurrentImageDigest,
|
||||
ScanId = request.Context.ScanId,
|
||||
SbomId = request.Context.SbomId
|
||||
}
|
||||
: null,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var conversation = await conversationService.CreateAsync(conversationRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.conversation_id", conversation.ConversationId);
|
||||
|
||||
return Results.Created(
|
||||
$"/v1/advisory-ai/conversations/{conversation.ConversationId}",
|
||||
StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGetConversation(
|
||||
HttpContext httpContext,
|
||||
string conversationId,
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleAddTurn(
|
||||
HttpContext httpContext,
|
||||
string conversationId,
|
||||
StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request,
|
||||
IConversationService conversationService,
|
||||
ChatPromptAssembler? promptAssembler,
|
||||
ChatResponseStreamer? responseStreamer,
|
||||
GroundingValidator? groundingValidator,
|
||||
ActionProposalParser? actionParser,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<Program> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
activity?.SetTag("advisory.stream", request.Stream);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var startTime = timeProvider.GetUtcNow();
|
||||
|
||||
// Add user turn
|
||||
try
|
||||
{
|
||||
var userTurnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.User,
|
||||
Content = request.Content,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var userTurn = await conversationService.AddTurnAsync(conversationId, userTurnRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.user_turn_id", userTurn.TurnId);
|
||||
|
||||
// For now, return a placeholder response since we don't have the full LLM pipeline
|
||||
// In a complete implementation, this would call the prompt assembler, LLM, and validators
|
||||
var assistantContent = GeneratePlaceholderResponse(request.Content);
|
||||
var assistantTurnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.Assistant,
|
||||
Content = assistantContent
|
||||
};
|
||||
|
||||
var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var elapsed = timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
var response = new StellaOps.AdvisoryAI.WebService.Contracts.AssistantTurnResponse
|
||||
{
|
||||
TurnId = assistantTurn.TurnId,
|
||||
Content = assistantTurn.Content,
|
||||
Timestamp = assistantTurn.Timestamp,
|
||||
EvidenceLinks = assistantTurn.EvidenceLinks.IsEmpty
|
||||
? null
|
||||
: assistantTurn.EvidenceLinks.Select(StellaOps.AdvisoryAI.WebService.Contracts.EvidenceLinkResponse.FromLink).ToList(),
|
||||
ProposedActions = assistantTurn.ProposedActions.IsEmpty
|
||||
? null
|
||||
: assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(),
|
||||
GroundingScore = 1.0, // Placeholder
|
||||
TokenCount = assistantContent.Split(' ').Length, // Rough estimate
|
||||
DurationMs = (long)elapsed.TotalMilliseconds
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (ConversationNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleDeleteConversation(
|
||||
HttpContext httpContext,
|
||||
string conversationId,
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var deleted = await conversationService.DeleteAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleListConversations(
|
||||
HttpContext httpContext,
|
||||
string? tenantId,
|
||||
int? limit,
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// Get tenant from header if not provided
|
||||
var effectiveTenantId = tenantId
|
||||
?? (httpContext.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantHeader)
|
||||
? tenantHeader.ToString()
|
||||
: "default");
|
||||
|
||||
// Get user from header for filtering
|
||||
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
||||
? userHeader.ToString()
|
||||
: null;
|
||||
|
||||
var conversations = await conversationService.ListAsync(effectiveTenantId, userId, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var summaries = conversations.Select(c => new StellaOps.AdvisoryAI.WebService.Contracts.ConversationSummary
|
||||
{
|
||||
ConversationId = c.ConversationId,
|
||||
CreatedAt = c.CreatedAt,
|
||||
UpdatedAt = c.UpdatedAt,
|
||||
TurnCount = c.Turns.Length,
|
||||
Preview = c.Turns.FirstOrDefault(t => t.Role == TurnRole.User)?.Content is { } content
|
||||
? content.Length > 100 ? content[..100] + "..." : content
|
||||
: null
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new StellaOps.AdvisoryAI.WebService.Contracts.ConversationListResponse
|
||||
{
|
||||
Conversations = summaries,
|
||||
TotalCount = summaries.Count
|
||||
});
|
||||
}
|
||||
|
||||
static bool EnsureChatAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowed = scopes
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run") || allowed.Contains("advisory:chat");
|
||||
}
|
||||
|
||||
static string GeneratePlaceholderResponse(string userMessage)
|
||||
{
|
||||
// Placeholder implementation - in production this would call the LLM
|
||||
return $"I received your message: \"{userMessage}\". This is a placeholder response. " +
|
||||
"The full chat functionality with grounded responses will be implemented when the LLM pipeline is connected.";
|
||||
}
|
||||
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
|
||||
Reference in New Issue
Block a user