diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e2cab3dc..9275c23c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -98,6 +98,7 @@ Keep the docs focused on `CrestApps.Core`. If you need to mention the Orchard Co - Prefer SOLID and DRY refactors that consolidate duplicated provider, transport, or store logic into shared abstractions before adding new one-off implementations. - Favor additive shared infrastructure first, then migrate consumers in behavior-safe steps when a full replacement is too risky for a single change. - When working in framework code meant for external adoption, optimize for consistency and long-term maintainability across providers and hosts, not just local fixes. +- Keep AI analytics ownership in the framework instead of sample hosts: shared usage/chat analytics services and contracts belong under `Abstractions`/`Primitives`, while YesSql and EntityCore provide the provider-specific stores, and framework features should not use `Sample*` naming. - For optional provider integrations in sample hosts, do not eagerly read validated options in UI setup paths when an unconfigured provider should simply appear unavailable rather than crash the page. - For catalog entry models, always provide an authoritative `CatalogEntryHandlerBase` implementation that includes a `PopulateAsync` mapping path for every known property reachable from `JsonNode`/`JsonObject`, uses the shared JSON helper extensions instead of ad-hoc parsing where practical, sets create-time defaults (timestamps and current user/owner values when the model supports them) in `InitializedAsync`/`CreatingAsync`, and validates required fields in `ValidatingAsync`. - For any `INameAwareModel` flow that has an authoritative catalog handler, validate duplicate names in the handler so users see a validation error early, but keep the store-level uniqueness enforcement as the final safeguard instead of moving that responsibility into managers. diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventService.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventService.cs new file mode 100644 index 00000000..a46feb04 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventService.cs @@ -0,0 +1,83 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat; + +/// +/// Provides shared chat-session analytics operations for recording and querying +/// session lifecycle, performance, conversion, and feedback metrics. +/// +public interface IAIChatSessionEventService : IAIChatSessionAnalyticsRecorder, IAIChatSessionConversionGoalRecorder +{ + /// + /// Records that a chat session has started. + /// + /// The chat session. + /// A token to cancel the operation. + Task RecordSessionStartedAsync( + AIChatSession chatSession, + CancellationToken cancellationToken = default); + + /// + /// Records the final analytics state for a chat session. + /// + /// The chat session. + /// The total prompt count. + /// Whether the session was resolved. + /// A token to cancel the operation. + Task RecordSessionEndedAsync( + AIChatSession chatSession, + int promptCount, + bool isResolved, + CancellationToken cancellationToken = default); + + /// + /// Records token usage for a chat session. + /// + /// The chat session identifier. + /// The number of input tokens. + /// The number of output tokens. + /// A token to cancel the operation. + Task RecordCompletionUsageAsync( + string sessionId, + int inputTokens, + int outputTokens, + CancellationToken cancellationToken = default); + + /// + /// Records response-latency data for a chat session. + /// + /// The chat session identifier. + /// The response latency in milliseconds. + /// A token to cancel the operation. + Task RecordResponseLatencyAsync( + string sessionId, + double responseLatencyMs, + CancellationToken cancellationToken = default); + + /// + /// Records user-rating totals for a chat session. + /// + /// The chat session identifier. + /// The number of positive ratings. + /// The number of negative ratings. + /// A token to cancel the operation. + Task RecordUserRatingAsync( + string sessionId, + int thumbsUpCount, + int thumbsDownCount, + CancellationToken cancellationToken = default); + + /// + /// Retrieves chat-session analytics records matching the optional profile and date filters. + /// + /// The optional profile identifier filter. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// A token to cancel the operation. + /// The matching chat-session events ordered by session start descending. + Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventStore.cs new file mode 100644 index 00000000..88e193f2 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionEventStore.cs @@ -0,0 +1,42 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat; + +/// +/// Persists chat-session analytics records for reporting and post-session analysis. +/// +public interface IAIChatSessionEventStore +{ + /// + /// Finds a chat-session analytics record by session identifier. + /// + /// The chat session identifier. + /// A token to cancel the operation. + /// The matching analytics record, or if not found. + Task FindBySessionIdAsync( + string sessionId, + CancellationToken cancellationToken = default); + + /// + /// Saves a chat-session analytics record. + /// + /// The analytics record to save. + /// A token to cancel the operation. + Task SaveAsync( + AIChatSessionEvent chatSessionEvent, + CancellationToken cancellationToken = default); + + /// + /// Retrieves chat-session analytics records matching the optional profile and date filters. + /// + /// The optional profile identifier filter. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// A token to cancel the operation. + /// The matching chat-session events ordered by session start descending. + Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionExtractedDataStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionExtractedDataStore.cs new file mode 100644 index 00000000..1d95db3b --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionExtractedDataStore.cs @@ -0,0 +1,43 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat; + +/// +/// Persists and queries extracted-data snapshot records for chat sessions. +/// +public interface IAIChatSessionExtractedDataStore +{ + /// + /// Saves the extracted-data snapshot record, creating or updating the existing + /// record for the same chat session. + /// + /// The extracted-data snapshot record to save. + /// A token to cancel the operation. + Task SaveAsync( + AIChatSessionExtractedDataRecord record, + CancellationToken cancellationToken = default); + + /// + /// Deletes the extracted-data snapshot record for the specified chat session. + /// + /// The chat session identifier. + /// A token to cancel the operation. + /// when a record was deleted; otherwise . + Task DeleteAsync( + string sessionId, + CancellationToken cancellationToken = default); + + /// + /// Retrieves extracted-data snapshot records for the specified AI profile and + /// optional date range. + /// + /// The AI profile identifier. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// A token to cancel the operation. + Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageService.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageService.cs new file mode 100644 index 00000000..2b125c8f --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageService.cs @@ -0,0 +1,22 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Completions; + +/// +/// Provides shared AI completion usage tracking operations for recording and querying +/// provider usage across chat sessions and other completion flows. +/// +public interface IAICompletionUsageService : IAICompletionUsageObserver +{ + /// + /// Retrieves usage records captured within the optional UTC date range. + /// + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// A token to cancel the operation. + /// The matching usage records ordered by creation time descending. + Task> GetAsync( + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageStore.cs new file mode 100644 index 00000000..f46cb3d8 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionUsageStore.cs @@ -0,0 +1,30 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Completions; + +/// +/// Persists AI completion usage records for reporting, auditing, and analytics. +/// +public interface IAICompletionUsageStore +{ + /// + /// Saves a usage record. + /// + /// The usage record to persist. + /// A token to cancel the operation. + Task SaveAsync( + AICompletionUsageRecord record, + CancellationToken cancellationToken = default); + + /// + /// Retrieves usage records captured within the optional UTC date range. + /// + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// A token to cancel the operation. + /// The matching usage records ordered by creation time descending. + Task> GetAsync( + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDocumentChunkContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDocumentChunkContext.cs index 8494cde7..c5150c15 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDocumentChunkContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDocumentChunkContext.cs @@ -2,8 +2,7 @@ namespace CrestApps.Core.AI.Models; /// /// Represents a single chunk of an AI document passed to the vector indexing pipeline. -/// This model is used as the record in -/// when indexing document chunks via . +/// This model carries the chunk record used during document indexing. /// public sealed class AIDocumentChunkContext { diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionResult.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionResult.cs index 807111a0..50de1ae8 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionResult.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionResult.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace CrestApps.Core.AI.Models; /// @@ -23,8 +25,10 @@ public sealed class PostSessionResult public PostSessionTaskResultStatus Status { get; set; } /// - /// Gets or sets the error message if the task failed. + /// Gets or sets the current in-memory error message if the task failed. + /// Persisted troubleshooting details live in . /// + [JsonIgnore] public string ErrorMessage { get; set; } /// @@ -33,7 +37,13 @@ public sealed class PostSessionResult public int Attempts { get; set; } /// - /// Gets or sets the UTC timestamp when this result was processed. + /// Gets or sets the history of failed or incomplete attempts for this task. + /// + public List AttemptHistory { get; set; } = []; + + /// + /// Gets or sets the UTC timestamp when this task reached a terminal processed state. + /// This is only populated when the task succeeds or reaches a final failure state. /// - public DateTime ProcessedAtUtc { get; set; } + public DateTime? ProcessedAtUtc { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTaskAttempt.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTaskAttempt.cs new file mode 100644 index 00000000..98741096 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTaskAttempt.cs @@ -0,0 +1,27 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Stores the outcome of one failed or incomplete post-session task attempt. +/// +public sealed class PostSessionTaskAttempt +{ + /// + /// Gets or sets the 1-based attempt number for this task execution. + /// + public int AttemptNumber { get; set; } + + /// + /// Gets or sets the task status that remained after this attempt completed. + /// + public PostSessionTaskResultStatus Status { get; set; } + + /// + /// Gets or sets the persisted error message for this attempt. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets the UTC timestamp when this attempt outcome was recorded. + /// + public DateTime RecordedAtUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs index ba577fcb..44fa5c32 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs @@ -4,8 +4,7 @@ namespace CrestApps.Core; /// -/// Extension methods for to provide dynamic property storage, -/// matching the patterns from OrchardCore.Entities.Entity. +/// Extension methods for to provide dynamic property storage. /// public static class ExtensibleEntityExtensions { diff --git a/src/Abstractions/CrestApps.Core.Abstractions/JsonExtensions.cs b/src/Abstractions/CrestApps.Core.Abstractions/JsonExtensions.cs index 20466864..e10977ca 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/JsonExtensions.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/JsonExtensions.cs @@ -4,7 +4,7 @@ namespace CrestApps.Core; /// -/// Extension methods for JSON types to replace OrchardCore's JSON helpers. +/// Extension methods for working with JSON types used throughout CrestApps Core. /// public static class JsonExtensions { diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/Models/SearchIndexProfile.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/Models/SearchIndexProfile.cs index 53c27ee8..26cd8d11 100644 --- a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/Models/SearchIndexProfile.cs +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/Models/SearchIndexProfile.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using CrestApps.Core.Models; using CrestApps.Core.Services; @@ -44,6 +45,17 @@ public sealed class SearchIndexProfile : CatalogItem, IIndexProfileInfo, INameAw /// public string EmbeddingDeploymentName { get; set; } + /// + /// Gets or sets the legacy embedding deployment identifier. + /// + [Obsolete("Use EmbeddingDeploymentName instead. Retained for backward compatibility.")] + [JsonIgnore] + public string EmbeddingDeploymentId + { + get => EmbeddingDeploymentName; + set => EmbeddingDeploymentName = value; + } + /// /// Gets or sets the date and time when this index profile was created. /// @@ -59,6 +71,15 @@ public sealed class SearchIndexProfile : CatalogItem, IIndexProfileInfo, INameAw /// public string Author { get; set; } + /// + /// Sets the legacy embedding deployment identifier during deserialization. + /// + [JsonPropertyName("EmbeddingDeploymentId")] + public string LegacyEmbeddingDeploymentId + { + set => EmbeddingDeploymentName = value; + } + string IIndexProfileInfo.IndexProfileId => ItemId; /// diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 8b2a12af..cdb81da4 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -43,6 +43,7 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds per-message text-to-speech play/pause controls on assistant messages in the AI Chat and Chat Interaction UIs, keeps the action toolbar pinned to the bottom-right of each response without reserving a separate action row, automatically stops other message players before starting a new one, and hides manual playback controls during Conversation mode - renders sample-host `[doc:n]` citations as superscript markers and shows the resolved document links below each cited assistant response in both the MVC and Blazor chat UIs - adds `AddReferenceDownloads()` plus `AddDownloadAIDocumentEndpoint()` so attached-document citation links can be registered and downloaded explicitly in sample or custom hosts +- moves the duplicated MVC and Blazor citation-reference collector into shared `CrestApps.Core.AI.Chat` services as `CitationReferenceCollector`, registers it from `AddCoreAIChatInteractions()`, and lets hosts reuse the same citation-merging logic without copying sample code - detects when uploaded chat-interaction or chat-session documents are being used for whole-document tasks such as summarization, review, rewrite, translation, or complete extraction work, and injects the full document text instead of relying only on chunk-level RAG - upgrades the MVC sample host to Font Awesome 7 and adds draggable, resizable AI Chat widget layout persistence with a reset-size control that hosts can disable through widget config - adds Chat History page listing previous sessions per AI Profile sorted by creation date, with resume, delete, delete-all, and new-chat actions @@ -56,3 +57,12 @@ description: Initial standalone release notes for the CrestApps.Core repository. - keeps the MVC and Blazor sample-host index profile editors aligned with deployment-name-based indexing by posting embedding deployment names instead of catalog IDs and by accepting either selector during embedding profile validation - fixes sample-host content-root resolution when MVC or Blazor are launched through the Aspire AppHost so `App_Data\appsettings.json` and related local sample assets still load from the web-project directory instead of an Aspire output folder fallback - updates the shared A2A and MCP sample clients so one client app can target either the MVC or Blazor sample host through a built-in server selector, and wires the Aspire AppHost to advertise both endpoints to those samples +- moves AI chat extracted-data snapshot persistence into shared framework/store infrastructure by introducing `IAIChatSessionExtractedDataStore`, registering a default recorder automatically when YesSql or EntityCore chat session stores are enabled, and rewiring the sample extracted-data reports to consume the shared store instead of host-specific recorder services +- moves AI chat usage analytics and session analytics into shared framework abstractions/services, registers the default runtime analytics services from the framework, and lets YesSql or EntityCore provide the persisted `IAICompletionUsageStore` and `IAIChatSessionEventStore` implementations so the MVC and Blazor reports no longer depend on sample-only analytics services +- moves AI chat inactivity closing into a shared `AIChatSessionCloseBackgroundService` registered from `AddCoreAIChatSessionProcessing()`, so all hosts using the standard chat-session pipeline automatically evaluate inactive sessions and retry post-close work at startup and every 5 minutes instead of depending on sample-host-only background workers +- persists per-attempt post-session task failure history in `PostSessionResults`, records invalid structured task payloads explicitly, honors task-scoped post-session tool names during tool resolution, and makes `ProcessedAtUtc` nullable so pending tasks no longer serialize a default `0001-01-01` timestamp +- raises the shared AI chat post-close retry limit to 5 attempts, recalculates completion from the actual task results so stale terminal flags from older retry policies can recover, and splits the default inactivity-close worker into reusable `AIChatSessionCloseCycleService` and `AIChatSessionCloseRunner` services so non-`BackgroundService` hosts can reuse the same lifecycle logic through `RunOnceAsync`, `StartAsync`, and `StopAsync` +- makes the shared AI chat post-close retry cap configurable through `AIChatSessionProcessingOptions.MaxPostCloseAttempts` and the MVC admin site settings UI, and updates the shared processor to honor the live `IOptionsMonitor<>` value instead of a hard-coded constant +- treats valid post-session JSON with an empty `tasks` array as an explicit structured-result failure, persists that clearer error in `PostSessionResults`, and strengthens the shared post-session prompts so every configured task must still return a result even when no tool call is needed +- stops serializing redundant top-level post-session error fields on `PostSessionResult`, keeps attempt-specific failures in `AttemptHistory`, retries tool-enabled runs through structured recovery when the model returns invalid task entries such as blank names or blank values, and falls back to a no-tools structured retry when the tool path never actually invoked a tool +- refreshes site-settings-backed options through the standard `IOptionsMonitor<>` pipeline by documenting the minimal `IOptionsChangeTokenSource<>` pattern for custom hosts, and moves uploaded AI document vector indexing into a shared `DefaultAIDocumentIndexingService` so MVC and Blazor no longer carry duplicate sample-only indexer implementations diff --git a/src/CrestApps.Core.Docs/docs/core/ai-documents.md b/src/CrestApps.Core.Docs/docs/core/ai-documents.md index d59d09aa..7bbea52a 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-documents.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-documents.md @@ -164,7 +164,7 @@ The `ReferenceId` and `ReferenceType` pair ties the document to an owning resour | `AIReferenceTypes.Document.ChatInteraction` | `"chatinteraction"` | Document attached to a chat interaction | | `AIReferenceTypes.Document.ChatSession` | `"chatsession"` | Document attached to a chat session | -Hosts can layer extra behavior on top of this shared pipeline, such as indexing uploaded chunks into Elasticsearch or Azure AI Search. That host-specific indexing should be treated as a secondary step: uploads should complete after persistence, while any slower or failure-prone indexing work runs independently so the document remains attached and the host can log indexing failures explicitly. +Hosts can layer extra behavior on top of this shared pipeline, but the default follow-up indexing step now lives in the framework as `DefaultAIDocumentIndexingService`. Hosts can call that service after persisting `AIDocument` and `AIDocumentChunk` records so uploaded chunks are mirrored into the configured AI Documents vector index without duplicating provider-specific index management code. ### Step 2 — Read File Content diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 6c9f4d39..a1324313 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -66,14 +66,20 @@ In the MVC sample, Chat Interactions now reserve automatic spoken playback for * Both the MVC and Blazor sample hosts now render `[doc:n]` citations as superscript markers in assistant responses and show the resolved document references as clickable links directly below the cited message. When a citation points to an attached AI document, the reference link now downloads that file from the server after the host registers both `AddReferenceDownloads()` on the document-processing builder and `AddDownloadAIDocumentEndpoint()` on the endpoint route builder. +If a host needs to merge preemptive-RAG references with tool-generated references during streaming, `AddCoreAIChatInteractions()` now registers `CitationReferenceCollector` plus `CompositeAIReferenceLinkResolver` so the host can reuse the shared citation-merging logic instead of duplicating it per UI project. + ## Services Registered by `AddCoreAIChatInteractions()` | Service | Implementation | Lifetime | Purpose | |---------|---------------|----------|---------| | `ChatInteractionCompletionContextBuilderHandler` | — | Scoped | Enriches completion context with chat history | | `ChatInteractionEntryHandler` | — | Scoped | Catalog lifecycle handler for `ChatInteraction` | +| `CitationReferenceCollector` | `CitationReferenceCollector` | Scoped | Merges preemptive and tool-generated citations, resolves reference links, and tracks referenced articles | | `DataExtractionService` | `DataExtractionService` | Scoped | Extracts configured fields from completed chat turns | | `PostSessionProcessingService` | `PostSessionProcessingService` | Scoped | Runs AI-powered post-session tasks and evaluations | +| `AIChatSessionCloseCycleService` | `AIChatSessionCloseCycleService` | Singleton | Runs one shared inactivity-close and post-close retry cycle so any host can reuse the framework logic without owning the implementation | +| `AIChatSessionCloseRunner` | `AIChatSessionCloseRunner` | Singleton | Starts the shared session-close cycle on startup and then every 5 minutes through reusable `StartAsync` / `StopAsync` methods | +| `AIChatSessionCloseBackgroundService` | `AIChatSessionCloseBackgroundService` | Singleton (`IHostedService`) | Thin default hosted wrapper that delegates to `AIChatSessionCloseRunner` | | `DataExtractionChatSessionHandler` | — | Scoped | Runs shared extraction and closes sessions on natural farewells | | `PostSessionProcessingChatSessionHandler` | — | Scoped | Triggers the shared post-close processor when a session closes | @@ -193,7 +199,7 @@ NewAsync() SaveAsync() (inactivity / explicit close) | **Deletion** | `DeleteAsync()` removes the session and its associated prompts. `DeleteAllAsync()` removes all sessions for a given profile and user. | :::info -The framework now standardizes the post-close processing pipeline, but hosts still own the storage-specific background policy that decides when inactive sessions should be closed and retried. +`AddCoreAIChatSessionProcessing()` now registers the shared `AIChatSessionCloseCycleService`, reusable `AIChatSessionCloseRunner`, and the default hosted wrapper `AIChatSessionCloseBackgroundService`, so any host that enables the standard AI chat session pipeline and registers AI chat session stores gets the same inactivity-closing and post-close retry behavior without adding a host-specific worker. Hosts that use a different scheduling system can call the runner or one-cycle service directly instead of reimplementing the framework logic. The default runner starts immediately and then runs every 5 minutes. ::: ### Key Properties of `AIChatSession` @@ -214,21 +220,36 @@ The framework now standardizes the post-close processing pipeline, but hosts sti | `ExtractedData` | `Dictionary` | Extracted conversation fields | | `PostSessionProcessingStatus` | `PostSessionProcessingStatus` | Status of post-session tasks | +Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. + +Hosts can override that retry cap through the shared `AIChatSessionProcessingOptions.MaxPostCloseAttempts` site setting. The MVC admin settings page surfaces the same value as **Max post-close attempts**, and the shared processor reads it through `IOptionsMonitor<>` so both the default hosted runner and custom schedulers honor the same limit. + +When the model returns valid structured JSON with an empty `tasks` array, the framework now records that as an explicit post-session failure instead of a misleading JSON-parse error. If the tool-enabled response returns invalid task entries such as empty names or values, the framework now runs a structured recovery pass and, when no tool calls actually happened, retries the same work through the structured no-tools path before treating the attempt as failed. The shared post-session prompts also require one result per configured task, even when the task decides not to call a tool. + ### Extracted Data Reporting Snapshots -Hosts can persist queryable extracted-data snapshots by implementing `IAIChatSessionExtractedDataRecorder`. +The framework now exposes `IAIChatSessionExtractedDataStore` for querying extracted-data reporting snapshots and ships a default `IAIChatSessionExtractedDataRecorder` that writes to that store whenever extraction produces new values or a session naturally closes. ```csharp -public interface IAIChatSessionExtractedDataRecorder +public interface IAIChatSessionExtractedDataStore { - Task RecordExtractedDataAsync( - AIProfile profile, - AIChatSession session, + Task SaveAsync( + AIChatSessionExtractedDataRecord record, + CancellationToken cancellationToken = default); + + Task DeleteAsync( + string sessionId, + CancellationToken cancellationToken = default); + + Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, CancellationToken cancellationToken = default); } ``` -The shared `DataExtractionChatSessionHandler` now calls these recorders whenever extraction produces new values or naturally closes the session, so hosts can upsert reporting documents such as `AIChatSessionExtractedDataRecord` without duplicating extraction logic. +When you register chat session stores with YesSql or EntityCore, the extracted-data store and default recorder are registered automatically as part of the chat feature extensions. Hosts can still add additional `IAIChatSessionExtractedDataRecorder` implementations for custom side effects, but they no longer need to implement snapshot persistence just to power extracted-data reports. ## Session Management diff --git a/src/CrestApps.Core.Docs/docs/core/data-storage.md b/src/CrestApps.Core.Docs/docs/core/data-storage.md index efa229c8..f02e8729 100644 --- a/src/CrestApps.Core.Docs/docs/core/data-storage.md +++ b/src/CrestApps.Core.Docs/docs/core/data-storage.md @@ -172,7 +172,7 @@ Every CrestApps feature that needs persistent storage exposes `.AddYesSqlStores( | Feature | Builder | EntityCore | YesSql | Stores registered | |---------|---------|------------|--------|-------------------| -| **AI Services** | `CrestAppsAISuiteBuilder` | `.AddEntityCoreStores()` | `.AddYesSqlStores()` | `IAIProfileStore`, `AIProfileTemplate` catalog, `AIProviderConnection` binding source, `AIDeployment` binding source, `IAIChatSessionManager`, `IAIChatSessionPromptStore` | +| **AI Services** | `CrestAppsAISuiteBuilder` | `.AddEntityCoreStores()` | `.AddYesSqlStores()` | `IAIProfileStore`, `AIProfileTemplate` catalog, `AIProviderConnection` binding source, `AIDeployment` binding source, `IAIChatSessionManager`, `IAIChatSessionPromptStore`, `IAIChatSessionEventStore`, `IAICompletionUsageStore` | | **AI Profile Template** | — | `AddCoreAIProfileTemplateStoresEntityCore()` | `AddCoreAIProfileTemplateStoresYesSql()` | `AIProfileTemplate` catalog | | **A2A Client** | `CrestAppsA2AClientBuilder` | `.AddEntityCoreStores()` | `.AddYesSqlStores()` | `A2AConnection` catalog | | **MCP Client** | `CrestAppsMcpClientBuilder` | `.AddEntityCoreStores()` | `.AddYesSqlStores()` | `McpConnection` catalog | @@ -186,7 +186,7 @@ Every CrestApps feature that needs persistent storage exposes `.AddYesSqlStores( The **AI Services** builder method (`.AddYesSqlStores()` / `.AddEntityCoreStores()` on `CrestAppsAISuiteBuilder`) is a convenience that registers AI Profile Template and Chat Session stores together. Implementations that need finer-grained control (e.g., Orchard Core) can call the individual `IServiceCollection` extensions (`AddCoreAIProfileTemplateStoresYesSql()`, `AddCoreAIMcpServerStoresYesSql()`, etc.) directly. ::: -`AddCoreAIServices()` now registers a null fallback `IAIProfileStore`, plus the shared indexing runtime services (`ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`). Call `.AddAISuite(ai => ai.AddEntityCoreStores())` or `.AddYesSqlStores()` when you want persisted AI profile records instead of the fallback. +`AddCoreAIServices()` now registers a null fallback `IAIProfileStore`, the default framework usage/session analytics services, and the shared indexing runtime services (`ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`). Call `.AddAISuite(ai => ai.AddEntityCoreStores())` or `.AddYesSqlStores()` when you want persisted AI profile records and analytics records instead of the fallback stores. **Entity Framework Core example** — register stores inline with each feature: diff --git a/src/CrestApps.Core.Docs/docs/core/document-processing.md b/src/CrestApps.Core.Docs/docs/core/document-processing.md index 23a5d677..2183ad15 100644 --- a/src/CrestApps.Core.Docs/docs/core/document-processing.md +++ b/src/CrestApps.Core.Docs/docs/core/document-processing.md @@ -50,12 +50,15 @@ The document processing system handles the full pipeline from upload to retrieva | Service | Implementation | Lifetime | Purpose | |---------|---------------|----------|---------| | `IAIDocumentProcessingService` | `DefaultAIDocumentProcessingService` | Scoped | Reads, chunks, and materializes `AIDocument` / `AIDocumentChunk` records | +| `DefaultAIDocumentIndexingService` | `DefaultAIDocumentIndexingService` | Scoped | Mirrors persisted document chunks into the configured AI Documents vector index and removes them when hosts delete documents | | `ITabularBatchProcessor` | `TabularBatchProcessor` | Scoped | Processes CSV/Excel batch queries | | `ITabularBatchResultCache` | `TabularBatchResultCache` | Singleton | Caches tabular query results | | `DocumentOrchestrationHandler` | — | Scoped | Injects document context into orchestration | `AddCoreAIDocumentProcessing()` and the `AddDocumentProcessing(...)` builder extension are provided by `CrestApps.Core.AI.Documents`. +When a host persists uploaded `AIDocument` and `AIDocumentChunk` records, it can call `DefaultAIDocumentIndexingService` as the follow-up indexing step instead of duplicating index creation, chunk upserts, and cleanup logic in the application layer. + ### Citation download links Attached-document citations are an opt-in document-processing feature made of two registrations: diff --git a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md index 7b0cd8fb..36b67e8a 100644 --- a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md +++ b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md @@ -259,7 +259,61 @@ app.MapGroup("/api") If you already use another ORM or storage model, implement the same catalog/store abstractions against your preferred backend. See [Data Storage](data-storage.md) for the full per-feature store reference. -## 6. Add features one layer at a time +## 6. Keep site-settings-backed options live + +If your host maps admin-managed site settings into options, use `IOptionsMonitor` for consumers and notify the monitor through an `IOptionsChangeTokenSource` when the site settings store is saved. + +The shared sample hosts already follow this pattern. After they call `SiteSettingsStore.SaveChangesAsync()`, the store rotates its change token and `IOptionsMonitor<>` rebuilds the option values on the next `CurrentValue` access. + +For a custom host, the minimal pattern is: + +```csharp +public sealed class SiteSettingsStore +{ + private CancellationTokenSource _reloadTokenSource = new(); + + public IChangeToken GetChangeToken() + => new CancellationChangeToken(_reloadTokenSource.Token); + + public async Task SaveChangesAsync() + { + // Persist the updated site settings first. + await PersistAsync(); + + var previous = Interlocked.Exchange(ref _reloadTokenSource, new CancellationTokenSource()); + previous.Cancel(); + previous.Dispose(); + } +} + +internal sealed class SiteSettingsOptionsChangeTokenSource : IOptionsChangeTokenSource +{ + private readonly SiteSettingsStore _siteSettings; + + public SiteSettingsOptionsChangeTokenSource(SiteSettingsStore siteSettings) + { + _siteSettings = siteSettings; + } + + public string Name => Options.DefaultName; + + public IChangeToken GetChangeToken() => _siteSettings.GetChangeToken(); +} +``` + +Register the change-token source once, keep your `IConfigureOptions` mapping, and then consume `IOptionsMonitor` anywhere you need current values: + +```csharp +services.AddSingleton(); +services.TryAddEnumerable( + ServiceDescriptor.Singleton(typeof(IOptionsChangeTokenSource<>), typeof(SiteSettingsOptionsChangeTokenSource<>))); + +services.AddSingleton, SiteSettingsConfigureGeneralAIOptions>(); +``` + +That keeps settings refresh host-agnostic and avoids custom accessor interfaces. + +## 7. Add features one layer at a time The intended progression is: @@ -270,7 +324,7 @@ The intended progression is: That layering keeps small apps lightweight while letting larger apps grow into a full AI platform without changing architectural direction. -## 7. Use the MVC sample as the reference host +## 8. Use the MVC sample as the reference host `src\Startup\CrestApps.Core.Mvc.Web\Program.cs` is the canonical example for: diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs index cf74e04b..c85197f5 100644 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs @@ -35,6 +35,12 @@ public static IServiceCollection AddCoreAIAzureAIInference(this IServiceCollecti o.Description = new LocalizedString("Azure AI Inference", "Use Azure AI Inference or GitHub Models for AI completion."); }); + services.AddCoreAIDeploymentProvider(AzureAIInferenceConstants.ClientName, o => + { + o.DisplayName = new LocalizedString("Azure AI Inference", "Azure AI Inference / GitHub Models"); + o.Description = new LocalizedString("Azure AI Inference", "Use Azure AI Inference or GitHub Models for AI deployments."); + }); + return services; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index 9611a22e..92c7988a 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -30,9 +30,9 @@ namespace CrestApps.Core.AI.Chat.Hubs; /// session management, message rating, handler transfer, conversation mode support, /// and notification action dispatch. /// -/// All public hub methods are virtual so that framework-specific subclasses -/// (e.g., OrchardCore) can wrap each call with their own scoping or authorization -/// logic and then call the base implementation. +/// All public hub methods are virtual so that host-specific subclasses can +/// wrap each call with their own scoping or authorization logic and then call the +/// base implementation. /// /// public class AIChatHubCore : Hub @@ -62,9 +62,8 @@ protected AIChatHubCore( protected ILogger Logger { get; } /// - /// Executes an action within a service scope. Override in OrchardCore to use - /// ShellScope.UsingChildScopeAsync so that each hub invocation gets - /// its own ISession / IDocumentStore lifecycle. + /// Executes an action within a service scope. Override in a host that needs a + /// dedicated child scope for each hub invocation. /// /// The action. protected virtual Task ExecuteInScopeAsync(Func action) @@ -91,8 +90,7 @@ protected virtual DateTime GetUtcNow() } /// - /// Generates a unique identifier. Override to use a framework-specific - /// ID generator (e.g., OrchardCore's IdGenerator). + /// Generates a unique identifier. Override to use a host-specific ID generator. /// protected virtual string GenerateId() { @@ -282,7 +280,7 @@ protected virtual Task OnMessageCompletedAsync(IServiceProvider services, ChatMe /// The content item ids. protected virtual void CollectStreamingReferences(IServiceProvider services, ChatResponseHandlerContext handlerContext, Dictionary references, HashSet contentItemIds) { - // No-op. OC overrides to use CitationReferenceCollector. + // No-op. Hosts can override to integrate citation collection. } /// @@ -395,8 +393,8 @@ public static string GetSessionGroupName(string sessionId) } /// - /// Resolves the deployment settings for speech services. Override in - /// OrchardCore to read from ISiteService instead of IOptionsMonitor. + /// Resolves the deployment settings for speech services. Override in another + /// host to read from its preferred settings source. /// /// The service collection. protected virtual Task GetDeploymentSettingsAsync(IServiceProvider services) @@ -579,9 +577,28 @@ await ExecuteInScopeAsync(async services => /// The service collection. /// The chat session. /// The prompt store. - protected virtual Task OnMessageRatedAsync(IServiceProvider services, AIChatSession chatSession, IAIChatSessionPromptStore promptStore) + protected virtual async Task OnMessageRatedAsync(IServiceProvider services, AIChatSession chatSession, IAIChatSessionPromptStore promptStore) { - return Task.CompletedTask; + var eventService = services.GetService(); + + if (eventService is null) + { + return; + } + + var allPrompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + var ratings = allPrompts + .Where(prompt => prompt.UserRating.HasValue) + .Select(prompt => prompt.UserRating.Value) + .ToList(); + + if (ratings.Count > 0) + { + await eventService.RecordUserRatingAsync( + chatSession.SessionId, + ratings.Count(rating => rating), + ratings.Count(rating => !rating)); + } } /// diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs index 4bfc475d..9a0af9a1 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs @@ -24,9 +24,9 @@ namespace CrestApps.Core.AI.Chat.Hubs; /// interaction loading, settings persistence, history clearing, conversation mode, /// audio transcription, and text-to-speech synthesis. /// -/// All public hub methods are virtual so that framework-specific subclasses -/// (e.g., OrchardCore) can wrap each call with their own scoping or authorization -/// logic and then call the base implementation. +/// All public hub methods are virtual so that host-specific subclasses can +/// wrap each call with their own scoping or authorization logic and then call the +/// base implementation. /// /// public class ChatInteractionHubBase : Hub @@ -55,9 +55,8 @@ protected ChatInteractionHubBase( protected ILogger Logger { get; } /// - /// Executes an action within a service scope. Override in OrchardCore to use - /// ShellScope.UsingChildScopeAsync so that each hub invocation gets - /// its own ISession / IDocumentStore lifecycle. + /// Executes an action within a service scope. Override in a host that needs a + /// dedicated child scope for each hub invocation. /// // ------------------------- Scoping hook ------------------------- @@ -86,8 +85,7 @@ protected virtual DateTime GetUtcNow() } /// - /// Generates a unique identifier. Override to use a framework-specific - /// ID generator (e.g., OrchardCore's IdGenerator). + /// Generates a unique identifier. Override to use a host-specific ID generator. /// protected virtual string GenerateId() { @@ -127,8 +125,8 @@ protected virtual async Task CommitChangesAsync(IServiceProvider services) } /// - /// Resolves the deployment settings for speech services. Override in - /// OrchardCore to read from ISiteService instead of IOptionsMonitor. + /// Resolves the deployment settings for speech services. Override in another + /// host to read from its preferred settings source. /// // ------------------------- Deployment resolution ------------------------- diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Models/AIChatSessionProcessingOptions.cs b/src/Primitives/CrestApps.Core.AI.Chat/Models/AIChatSessionProcessingOptions.cs new file mode 100644 index 00000000..2c166b37 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Models/AIChatSessionProcessingOptions.cs @@ -0,0 +1,17 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Global site settings for shared AI chat session lifecycle processing. +/// +public sealed class AIChatSessionProcessingOptions +{ + /// + /// The default maximum number of post-close retry attempts for a session. + /// + public const int DefaultMaxPostCloseAttempts = 5; + + /// + /// Gets or sets the maximum number of post-close retry attempts before processing is treated as terminally failed. + /// + public int MaxPostCloseAttempts { get; set; } = DefaultMaxPostCloseAttempts; +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Chat/ServiceCollectionExtensions.cs index 290ce522..75014a86 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/ServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using CrestApps.Core.AI.Chat.Handlers; using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Handlers; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using CrestApps.Core.Services; using CrestApps.Core.Templates.Extensions; @@ -11,6 +13,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; namespace CrestApps.Core.AI.Chat; @@ -22,7 +25,7 @@ public static class ServiceCollectionExtensions /// /// Adds the default chat notification sender and built-in notification action handlers. /// The sender dispatches notifications to keyed - /// implementations, which must be registered separately by each host (OrchardCore, MVC, etc.). + /// implementations, which must be registered separately by each host application. /// /// The service collection. public static IServiceCollection AddCoreAIChatNotifications(this IServiceCollection services) @@ -67,10 +70,18 @@ public static IServiceCollection AddCoreAIChatSessionProcessing(this IServiceCol { ArgumentNullException.ThrowIfNull(services); + services.AddOptions(); + services.TryAddScoped(); + services.TryAddScoped(sp => sp.GetRequiredService()); + services.TryAddScoped(sp => sp.GetRequiredService()); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); @@ -94,6 +105,8 @@ public static IServiceCollection AddCoreAIChatInteractions(this IServiceCollecti services.TryAddSingleton(TimeProvider.System); services.AddCoreAIChatNotifications(); services.AddCoreAIChatSessionProcessing(); + services.TryAddScoped(); + services.TryAddScoped(); // Register templates embedded in this assembly. services.AddTemplatesFromAssembly(typeof(ServiceCollectionExtensions).Assembly); diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseBackgroundService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseBackgroundService.cs new file mode 100644 index 00000000..2966efdb --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseBackgroundService.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Hosting; + +namespace CrestApps.Core.AI.Chat.Services; + +/// +/// Default hosted-service wrapper for the reusable AI chat session close runner. +/// +public sealed class AIChatSessionCloseBackgroundService : IHostedService +{ + private readonly AIChatSessionCloseRunner _runner; + + /// + /// Initializes a new instance of the class. + /// + /// The reusable session close runner. + public AIChatSessionCloseBackgroundService(AIChatSessionCloseRunner runner) + { + _runner = runner; + } + + /// + /// Starts the shared AI chat session close runner. + /// + /// The cancellation token. + public Task StartAsync(CancellationToken cancellationToken) + { + return _runner.StartAsync(cancellationToken); + } + + /// + /// Stops the shared AI chat session close runner. + /// + /// The cancellation token. + public Task StopAsync(CancellationToken cancellationToken) + { + return _runner.StopAsync(cancellationToken); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs new file mode 100644 index 00000000..23619c9c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs @@ -0,0 +1,346 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Chat.Services; + +/// +/// Runs a single shared cycle that closes inactive AI chat sessions and retries pending post-close work. +/// Hosts can call this service directly when they need the framework logic without the default hosted runner. +/// +public sealed class AIChatSessionCloseCycleService +{ + private static readonly TimeSpan _defaultInactivityTimeout = TimeSpan.FromMinutes(30); + private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(5); + private const int _pageSize = 100; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service scope factory. + /// The time provider. + /// The logger. + public AIChatSessionCloseCycleService( + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + ILogger logger) + { + _scopeFactory = scopeFactory; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + /// Runs one AI chat session close cycle immediately. + /// + /// The cancellation token. + public async Task RunOnceAsync(CancellationToken cancellationToken = default) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var sessionManager = scope.ServiceProvider.GetRequiredService(); + var profileManager = scope.ServiceProvider.GetRequiredService(); + var postCloseProcessor = scope.ServiceProvider.GetRequiredService(); + var promptStore = scope.ServiceProvider.GetRequiredService(); + var storeCommitter = scope.ServiceProvider.GetRequiredService(); + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + var profiles = (await profileManager.GetAsync(AIProfileType.Chat, cancellationToken)).ToList(); + var closedCount = 0; + var abandonedCount = 0; + var retriedCount = 0; + var recoveredCount = 0; + var failedCount = 0; + + foreach (var profile in profiles) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var entries = await ListSessionEntriesAsync(sessionManager, profile.ItemId, cancellationToken); + var (profileClosedCount, profileAbandonedCount) = await CloseInactiveSessionsAsync( + sessionManager, + promptStore, + postCloseProcessor, + profile, + entries, + utcNow, + cancellationToken); + + closedCount += profileClosedCount; + abandonedCount += profileAbandonedCount; + + var (profileRetriedCount, profileRecoveredCount, profileFailedCount) = await RetryPendingProcessingAsync( + sessionManager, + promptStore, + postCloseProcessor, + profile, + entries, + utcNow, + cancellationToken); + + retriedCount += profileRetriedCount; + recoveredCount += profileRecoveredCount; + failedCount += profileFailedCount; + } + + await storeCommitter.CommitAsync(cancellationToken); + + if ((closedCount > 0 + || abandonedCount > 0 + || retriedCount > 0 + || recoveredCount > 0 + || failedCount > 0) + && _logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "AI chat session close cycle completed. Profiles={ProfileCount}, Closed={ClosedCount}, Abandoned={AbandonedCount}, Retried={RetriedCount}, Recovered={RecoveredCount}, Failed={FailedCount}.", + profiles.Count, + closedCount, + abandonedCount, + retriedCount, + recoveredCount, + failedCount); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "AI chat session close cycle completed with no work. Profiles evaluated: {ProfileCount}.", + profiles.Count); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while closing inactive AI chat sessions."); + } + } + + private static async Task> ListSessionEntriesAsync( + IAIChatSessionManager sessionManager, + string profileId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentException.ThrowIfNullOrEmpty(profileId); + + var entries = new List(); + var queryContext = new AIChatSessionQueryContext + { + ProfileId = profileId, + }; + var page = 1; + + while (true) + { + var result = await sessionManager.PageAsync(page, _pageSize, queryContext, cancellationToken); + var pageEntries = result.Sessions.ToList(); + + if (pageEntries.Count == 0) + { + break; + } + + entries.AddRange(pageEntries); + page++; + } + + return entries; + } + + private async Task<(int ClosedCount, int AbandonedCount)> CloseInactiveSessionsAsync( + IAIChatSessionManager sessionManager, + IAIChatSessionPromptStore promptStore, + AIChatSessionPostCloseProcessor postCloseProcessor, + AIProfile profile, + IReadOnlyList entries, + DateTime utcNow, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(promptStore); + ArgumentNullException.ThrowIfNull(postCloseProcessor); + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(entries); + + var settings = profile.GetOrCreateSettings(); + var timeout = settings?.SessionInactivityTimeoutInMinutes > 0 + ? TimeSpan.FromMinutes(settings.SessionInactivityTimeoutInMinutes) + : _defaultInactivityTimeout; + var cutoffUtc = utcNow - timeout; + var closedCount = 0; + var abandonedCount = 0; + + foreach (var entry in entries) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (entry.Status != ChatSessionStatus.Active || entry.LastActivityUtc >= cutoffUtc) + { + continue; + } + + var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); + + if (chatSession is null || chatSession.Status != ChatSessionStatus.Active) + { + continue; + } + + var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + chatSession.Status = DetermineInactiveSessionStatus(prompts); + chatSession.ClosedAtUtc = utcNow; + + if (postCloseProcessor.QueueIfNeeded(profile, chatSession)) + { + await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); + } + else + { + chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.None; + } + + await sessionManager.SaveAsync(chatSession, cancellationToken); + + if (chatSession.Status == ChatSessionStatus.Closed) + { + closedCount++; + } + else + { + abandonedCount++; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Finalized inactive session '{SessionId}' for profile '{ProfileId}' as '{Status}'. Post-processing: {NeedsProcessing}.", + chatSession.SessionId, + profile.ItemId, + chatSession.Status, + chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.None); + } + } + + return (closedCount, abandonedCount); + } + + private async Task<(int RetriedCount, int RecoveredCount, int FailedCount)> RetryPendingProcessingAsync( + IAIChatSessionManager sessionManager, + IAIChatSessionPromptStore promptStore, + AIChatSessionPostCloseProcessor postCloseProcessor, + AIProfile profile, + IReadOnlyList entries, + DateTime utcNow, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(promptStore); + ArgumentNullException.ThrowIfNull(postCloseProcessor); + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(entries); + + var retriedCount = 0; + var recoveredCount = 0; + var failedCount = 0; + + foreach (var entry in entries) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (entry.Status != ChatSessionStatus.Closed && entry.Status != ChatSessionStatus.Abandoned) + { + continue; + } + + var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); + + if (chatSession is null) + { + continue; + } + + var originalStatus = chatSession.PostSessionProcessingStatus; + var needsQueuedProcessing = postCloseProcessor.QueueIfNeeded(profile, chatSession); + + if (!needsQueuedProcessing) + { + continue; + } + + if (originalStatus != PostSessionProcessingStatus.Pending) + { + recoveredCount++; + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Recovered closed session '{SessionId}' for post-close processing. Previous processing status was '{PreviousStatus}'.", + chatSession.SessionId, + originalStatus); + } + } + + if (chatSession.PostSessionProcessingAttempts >= postCloseProcessor.MaxPostCloseAttempts) + { + chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.Failed; + await sessionManager.SaveAsync(chatSession, cancellationToken); + failedCount++; + + _logger.LogWarning( + "Post-session processing for session '{SessionId}' failed after {MaxAttempts} attempts.", + chatSession.SessionId, + postCloseProcessor.MaxPostCloseAttempts); + + continue; + } + + if (chatSession.PostSessionProcessingLastAttemptUtc.HasValue + && (utcNow - chatSession.PostSessionProcessingLastAttemptUtc.Value) < _retryDelay) + { + continue; + } + + var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); + await sessionManager.SaveAsync(chatSession, cancellationToken); + retriedCount++; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Processed pending post-close work for session '{SessionId}'.", + chatSession.SessionId); + } + } + + return (retriedCount, recoveredCount, failedCount); + } + + private static ChatSessionStatus DetermineInactiveSessionStatus(IReadOnlyList prompts) + { + ArgumentNullException.ThrowIfNull(prompts); + + return prompts.Any(prompt => prompt.Role == ChatRole.User) + ? ChatSessionStatus.Closed + : ChatSessionStatus.Abandoned; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseRunner.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseRunner.cs new file mode 100644 index 00000000..79f6087d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseRunner.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Chat.Services; + +/// +/// Runs the shared AI chat session close cycle on startup and then at a fixed interval. +/// Hosts that do not use can +/// reuse this service directly by calling and . +/// +public sealed class AIChatSessionCloseRunner +{ + private static readonly TimeSpan _interval = TimeSpan.FromMinutes(5); + + private readonly AIChatSessionCloseCycleService _cycleService; + private readonly ILogger _logger; + private readonly Lock _syncLock = new(); + + private CancellationTokenSource _stoppingTokenSource; + private Task _executingTask; + + /// + /// Initializes a new instance of the class. + /// + /// The shared cycle service. + /// The logger. + public AIChatSessionCloseRunner( + AIChatSessionCloseCycleService cycleService, + ILogger logger) + { + _cycleService = cycleService; + _logger = logger; + } + + /// + /// Starts the recurring AI chat session close runner. + /// + /// The cancellation token. + public Task StartAsync(CancellationToken cancellationToken = default) + { + lock (_syncLock) + { + if (_executingTask is { IsCompleted: false }) + { + return Task.CompletedTask; + } + + _stoppingTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _executingTask = Task.Run(() => RunAsync(_stoppingTokenSource.Token), CancellationToken.None); + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "AI chat session close runner started. Interval: {Interval}.", + _interval); + } + + return Task.CompletedTask; + } + + /// + /// Stops the recurring AI chat session close runner. + /// + /// The cancellation token. + public async Task StopAsync(CancellationToken cancellationToken = default) + { + Task executingTask; + CancellationTokenSource stoppingTokenSource; + + lock (_syncLock) + { + executingTask = _executingTask; + stoppingTokenSource = _stoppingTokenSource; + _executingTask = null; + _stoppingTokenSource = null; + } + + if (executingTask is null || stoppingTokenSource is null) + { + return; + } + + stoppingTokenSource.Cancel(); + + try + { + await executingTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (stoppingTokenSource.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + } + finally + { + stoppingTokenSource.Dispose(); + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("AI chat session close runner stopped."); + } + } + + /// + /// Runs the shared AI chat session close cycle immediately. + /// + /// The cancellation token. + public Task RunOnceAsync(CancellationToken cancellationToken = default) + { + return _cycleService.RunOnceAsync(cancellationToken); + } + + private async Task RunAsync(CancellationToken stoppingToken) + { + await _cycleService.RunOnceAsync(stoppingToken); + + using var timer = new PeriodicTimer(_interval); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await _cycleService.RunOnceAsync(stoppingToken); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs index e87d05d2..71d65510 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI.Models; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Chat.Services; @@ -9,12 +10,11 @@ namespace CrestApps.Core.AI.Chat.Services; /// public sealed class AIChatSessionPostCloseProcessor { - public const int MaxPostCloseAttempts = 3; - private readonly PostSessionProcessingService _postSessionProcessingService; private readonly IEnumerable _analyticsRecorders; private readonly IEnumerable _conversionGoalRecorders; private readonly TimeProvider _timeProvider; + private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; /// @@ -24,31 +24,45 @@ public sealed class AIChatSessionPostCloseProcessor /// The analytics recorders. /// The conversion goal recorders. /// The time provider. + /// The processing options monitor. /// The logger. public AIChatSessionPostCloseProcessor( PostSessionProcessingService postSessionProcessingService, IEnumerable analyticsRecorders, IEnumerable conversionGoalRecorders, TimeProvider timeProvider, + IOptionsMonitor optionsMonitor, ILogger logger) { _postSessionProcessingService = postSessionProcessingService; _analyticsRecorders = analyticsRecorders; _conversionGoalRecorders = conversionGoalRecorders; _timeProvider = timeProvider; + _optionsMonitor = optionsMonitor; _logger = logger; } + /// + /// Gets the effective maximum number of post-close retry attempts. + /// + public int MaxPostCloseAttempts + => NormalizeMaxPostCloseAttempts(_optionsMonitor.CurrentValue?.MaxPostCloseAttempts ?? AIChatSessionProcessingOptions.DefaultMaxPostCloseAttempts); + /// /// Needss processing. /// /// The profile. /// The chat session. - public static bool NeedsProcessing(AIProfile profile, AIChatSession chatSession) + public bool NeedsProcessing(AIProfile profile, AIChatSession chatSession) { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(chatSession); + var postSessionSettings = profile.GetOrCreateSettings(); + var tasksComplete = ArePostSessionTasksComplete(postSessionSettings, chatSession, MaxPostCloseAttempts); + chatSession.IsPostSessionTasksProcessed = tasksComplete; - var needsPostSessionTasks = !chatSession.IsPostSessionTasksProcessed + var needsPostSessionTasks = !tasksComplete && postSessionSettings.EnablePostSessionProcessing && postSessionSettings.PostSessionTasks.Count > 0; @@ -67,6 +81,26 @@ public static bool NeedsProcessing(AIProfile profile, AIChatSession chatSession) return needsPostSessionTasks; } + /// + /// Marks the session as pending shared post-close processing when work remains. + /// + /// The profile. + /// The chat session. + public bool QueueIfNeeded(AIProfile profile, AIChatSession chatSession) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(chatSession); + + if (!NeedsProcessing(profile, chatSession)) + { + return false; + } + + chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.Pending; + + return true; + } + /// /// Processs the operation. /// @@ -83,6 +117,7 @@ public async Task ProcessAsync( var result = new AIChatSessionPostCloseProcessingResult(); var postSessionSettings = profile.GetOrCreateSettings(); + chatSession.IsPostSessionTasksProcessed = ArePostSessionTasksComplete(postSessionSettings, chatSession, MaxPostCloseAttempts); var needsPostSessionTasks = !chatSession.IsPostSessionTasksProcessed && postSessionSettings.EnablePostSessionProcessing @@ -101,6 +136,8 @@ public async Task ProcessAsync( && analyticsMetadata.ConversionGoals.Count > 0; } + analyticsMetadata ??= new AnalyticsMetadata(); + if (!needsPostSessionTasks && !needsAnalytics && !needsConversionGoals) { result.IsCompleted = true; @@ -192,7 +229,7 @@ private async Task RunPostSessionTasksAsync( CancellationToken cancellationToken) { var postSessionSettings = profile.GetOrCreateSettings(); - var taskNames = postSessionSettings.PostSessionTasks.Select(t => t.Name).ToList(); + var taskNames = postSessionSettings.PostSessionTasks.Select(t => t.Name); try { @@ -221,6 +258,7 @@ private async Task RunPostSessionTasksAsync( if (chatSession.PostSessionResults.TryGetValue(taskName, out var existing) && existing.Status != PostSessionTaskResultStatus.Succeeded) { + existing.AttemptHistory ??= []; existing.Attempts++; } } @@ -233,7 +271,12 @@ private async Task RunPostSessionTasksAsync( { if (chatSession.PostSessionResults.TryGetValue(taskName, out var existing)) { - taskResult.Attempts = existing.Attempts; + CopyAttemptState(existing, taskResult); + } + + if (taskResult.Status == PostSessionTaskResultStatus.Succeeded) + { + taskResult.ErrorMessage = null; } chatSession.PostSessionResults[taskName] = taskResult; @@ -255,19 +298,30 @@ private async Task RunPostSessionTasksAsync( foreach (var taskName in taskNames) { if (chatSession.PostSessionResults.TryGetValue(taskName, out var taskResult) - && taskResult.Status != PostSessionTaskResultStatus.Succeeded - && taskResult.Attempts >= MaxPostCloseAttempts) + && taskResult.Status != PostSessionTaskResultStatus.Succeeded) { - taskResult.Status = PostSessionTaskResultStatus.Failed; - taskResult.ProcessedAtUtc = utcNow; - taskResult.ErrorMessage ??= $"Task produced no result after {taskResult.Attempts} attempt(s)."; + taskResult.ErrorMessage = string.IsNullOrWhiteSpace(taskResult.ErrorMessage) + ? taskResult.Attempts >= MaxPostCloseAttempts + ? $"Task produced no result after {taskResult.Attempts} attempt(s)." + : $"Task produced no result during attempt {taskResult.Attempts}." + : taskResult.ErrorMessage; + + if (taskResult.Attempts >= MaxPostCloseAttempts) + { + taskResult.Status = PostSessionTaskResultStatus.Failed; + taskResult.ProcessedAtUtc = utcNow; + } + else + { + taskResult.Status = PostSessionTaskResultStatus.Pending; + taskResult.ProcessedAtUtc = null; + } + + RecordAttemptFailure(taskResult, utcNow); } } - chatSession.IsPostSessionTasksProcessed = taskNames.All(name => - chatSession.PostSessionResults.TryGetValue(name, out var taskResult) - && (taskResult.Status == PostSessionTaskResultStatus.Succeeded - || (taskResult.Status == PostSessionTaskResultStatus.Failed && taskResult.Attempts >= MaxPostCloseAttempts))); + chatSession.IsPostSessionTasksProcessed = ArePostSessionTasksComplete(postSessionSettings, chatSession, MaxPostCloseAttempts); if (_logger.IsEnabled(LogLevel.Information)) { @@ -281,7 +335,7 @@ private async Task RunPostSessionTasksAsync( succeededCount, failedCount, pendingCount, - taskNames.Count); + taskNames.Count()); } } catch (Exception ex) @@ -307,11 +361,105 @@ private async Task RunPostSessionTasksAsync( taskResult.Status = PostSessionTaskResultStatus.Failed; taskResult.ProcessedAtUtc = utcNow; } + else + { + taskResult.Status = PostSessionTaskResultStatus.Pending; + taskResult.ProcessedAtUtc = null; + } + + RecordAttemptFailure(taskResult, utcNow); } } } } + private static void CopyAttemptState(PostSessionResult source, PostSessionResult destination) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); + + destination.Attempts = source.Attempts; + destination.AttemptHistory = source.AttemptHistory == null + ? [] + : [.. source.AttemptHistory.Select(attempt => new PostSessionTaskAttempt + { + AttemptNumber = attempt.AttemptNumber, + Status = attempt.Status, + ErrorMessage = attempt.ErrorMessage, + RecordedAtUtc = attempt.RecordedAtUtc, + })]; + } + + private static void RecordAttemptFailure(PostSessionResult taskResult, DateTime recordedAtUtc) + { + ArgumentNullException.ThrowIfNull(taskResult); + + taskResult.AttemptHistory ??= []; + + var existingAttempt = taskResult.AttemptHistory.FirstOrDefault(attempt => attempt.AttemptNumber == taskResult.Attempts); + + if (existingAttempt != null) + { + existingAttempt.Status = taskResult.Status; + existingAttempt.ErrorMessage = taskResult.ErrorMessage; + existingAttempt.RecordedAtUtc = recordedAtUtc; + + return; + } + + taskResult.AttemptHistory.Add(new PostSessionTaskAttempt + { + AttemptNumber = taskResult.Attempts, + Status = taskResult.Status, + ErrorMessage = taskResult.ErrorMessage, + RecordedAtUtc = recordedAtUtc, + }); + } + + private static bool ArePostSessionTasksComplete( + AIProfilePostSessionSettings postSessionSettings, + AIChatSession chatSession, + int maxPostCloseAttempts) + { + ArgumentNullException.ThrowIfNull(postSessionSettings); + ArgumentNullException.ThrowIfNull(chatSession); + + if (!postSessionSettings.EnablePostSessionProcessing || postSessionSettings.PostSessionTasks.Count == 0) + { + return true; + } + + foreach (var task in postSessionSettings.PostSessionTasks) + { + if (!chatSession.PostSessionResults.TryGetValue(task.Name, out var taskResult)) + { + return false; + } + + if (taskResult.Status == PostSessionTaskResultStatus.Succeeded) + { + continue; + } + + if (taskResult.Status == PostSessionTaskResultStatus.Failed + && taskResult.Attempts >= maxPostCloseAttempts) + { + continue; + } + + return false; + } + + return true; + } + + private static int NormalizeMaxPostCloseAttempts(int maxPostCloseAttempts) + { + return maxPostCloseAttempts < 1 + ? AIChatSessionProcessingOptions.DefaultMaxPostCloseAttempts + : maxPostCloseAttempts; + } + private async Task RecordSessionAnalyticsAsync( AIProfile profile, AIChatSession chatSession, diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/SampleCitationReferenceCollector.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/CitationReferenceCollector.cs similarity index 61% rename from src/Startup/CrestApps.Core.Mvc.Web/Services/SampleCitationReferenceCollector.cs rename to src/Primitives/CrestApps.Core.AI.Chat/Services/CitationReferenceCollector.cs index 2a25f70c..b986a00f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/SampleCitationReferenceCollector.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/CitationReferenceCollector.cs @@ -3,38 +3,64 @@ using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure.Indexing; -namespace CrestApps.Core.Mvc.Web.Services; +namespace CrestApps.Core.AI.Chat.Services; /// -/// Collects citation references for the sample host and resolves any configured -/// links before they are streamed to the chat client. +/// Collects chat citation references from orchestration and tool execution context, +/// resolves any configured links, and records article content item IDs for hosts +/// that need follow-up lookups. /// -public sealed class SampleCitationReferenceCollector +public sealed class CitationReferenceCollector { private const string DataSourceReferencesKey = "DataSourceReferences"; private const string DocumentReferencesKey = "DocumentReferences"; private readonly CompositeAIReferenceLinkResolver _linkResolver; - public SampleCitationReferenceCollector(CompositeAIReferenceLinkResolver linkResolver) + /// + /// Initializes a new instance of the class. + /// + /// The composite link resolver. + public CitationReferenceCollector(CompositeAIReferenceLinkResolver linkResolver) { _linkResolver = linkResolver; } + /// + /// Collects citation references stored on the orchestration context and resolves + /// any configured links. + /// + /// The orchestration context. + /// The target citation map. + /// The target article content item ID set. public void CollectPreemptiveReferences( OrchestrationContext orchestrationContext, Dictionary references, HashSet contentItemIds) { + ArgumentNullException.ThrowIfNull(orchestrationContext); + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(contentItemIds); + CollectFromProperties(orchestrationContext, DataSourceReferencesKey, references); CollectFromProperties(orchestrationContext, DocumentReferencesKey, references); ResolveLinks(references, contentItemIds); } + /// + /// Collects citation references captured during tool execution and resolves any + /// configured links. + /// + /// The target citation map. + /// The target article content item ID set. + /// when new references were added; otherwise, . public bool CollectToolReferences( Dictionary references, HashSet contentItemIds) { + ArgumentNullException.ThrowIfNull(references); + ArgumentNullException.ThrowIfNull(contentItemIds); + var invocationContext = AIInvocationScope.Current; if (invocationContext is null) @@ -60,7 +86,9 @@ public bool CollectToolReferences( return added; } - private void ResolveLinks(Dictionary references, HashSet contentItemIds) + private void ResolveLinks( + Dictionary references, + HashSet contentItemIds) { foreach (var (_, reference) in references) { diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs index c0a1eca5..1007b6b3 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs @@ -1,7 +1,11 @@ +using System.Text.Json; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; +using CrestApps.Core.Support; +using CrestApps.Core.Support.Json; +using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -16,6 +20,7 @@ public sealed class DataExtractionService private readonly IAIClientFactory _clientFactory; private readonly IAIDeploymentManager _deploymentManager; private readonly ITemplateService _aiTemplateService; + private readonly ITemplateParser _markdownTemplateParser; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -24,12 +29,14 @@ public sealed class DataExtractionService /// /// The client factory. /// The ai template service. + /// The registered template parsers. /// The time provider. /// The logger. /// The deployment manager. public DataExtractionService( IAIClientFactory clientFactory, ITemplateService aiTemplateService, + IEnumerable templateParsers, TimeProvider timeProvider, ILogger logger, IAIDeploymentManager deploymentManager = null) @@ -37,6 +44,7 @@ public DataExtractionService( _clientFactory = clientFactory; _deploymentManager = deploymentManager; _aiTemplateService = aiTemplateService; + _markdownTemplateParser = ResolveMarkdownTemplateParser(templateParsers); _timeProvider = timeProvider; _logger = logger; } @@ -180,12 +188,54 @@ private static List GetFieldsToExtract(AIProfileDataExtract MaxOutputTokens = 1024, }.AddUsageTracking(session: session), null, cancellationToken); - if (response.Result?.Fields == null || response.Result.Fields.Count == 0) + var responseText = GetLastAssistantMessageText(response.Messages); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Data extraction raw response for session '{SessionId}': '{ResponseText}'.", + session.SessionId, + CreateResponseLogPreview(responseText)); + } + + ExtractionResponse result = null; + + try + { + result = response.Result; + } + catch (InvalidOperationException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Data extraction response for session '{SessionId}' did not return JSON content.", + session.SessionId); + } + } + catch (JsonException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Data extraction response for session '{SessionId}' returned invalid JSON content.", + session.SessionId); + } + } + + if (result?.Fields != null && result.Fields.Count > 0) + { + return (result.Fields, result.SessionEnded); + } + + var recovered = TryParseExtractionResponse(session.SessionId, responseText); + + if (recovered?.Fields == null || recovered.Fields.Count == 0) { return ([], false); } - return (response.Result.Fields, response.Result.SessionEnded); + return (recovered.Fields, recovered.SessionEnded); } catch (Exception ex) { @@ -209,14 +259,24 @@ private ExtractionChangeSet ApplyExtraction( continue; } - var entry = settings.DataExtractionEntries.FirstOrDefault(e => - string.Equals(e.Name, result.Name, StringComparison.OrdinalIgnoreCase)); + var match = FindMatchingEntry(settings, result); - if (entry == null) + if (match == null) { + if (!string.IsNullOrWhiteSpace(result.Name)) + { + _logger.LogWarning( + "Ignoring extracted field '{FieldName}' for session '{SessionId}' because no configured extraction field matched. Configured fields: {ConfiguredFields}.", + result.Name.SanitizeForLog(), + session.SessionId, + string.Join(", ", settings.DataExtractionEntries.Select(entry => entry.Name).Where(name => !string.IsNullOrWhiteSpace(name)).Select(name => name.SanitizeForLog()))); + } + continue; } + var entry = match.Entry; + if (!session.ExtractedData.TryGetValue(entry.Name, out var state)) { state = new ExtractedFieldState(); @@ -244,7 +304,7 @@ private ExtractionChangeSet ApplyExtraction( } else { - var value = result.Values[0]; + var value = ResolveSingleValue(match, state, result); if (string.IsNullOrWhiteSpace(value)) { @@ -271,6 +331,136 @@ private ExtractionChangeSet ApplyExtraction( return changeSet; } + private MatchedExtractionEntry FindMatchingEntry( + AIProfileDataExtractionSettings settings, + ExtractionResult result) + { + if (string.IsNullOrWhiteSpace(result?.Name)) + { + return null; + } + + var directMatch = settings.DataExtractionEntries.FirstOrDefault(entry => + string.Equals(entry.Name, result.Name, StringComparison.OrdinalIgnoreCase)); + + if (directMatch != null) + { + return new MatchedExtractionEntry(directMatch, ClassifyField(result.Name), false); + } + + var normalizedResultName = NormalizeFieldName(result.Name); + + if (string.IsNullOrEmpty(normalizedResultName)) + { + return null; + } + + var normalizedMatch = settings.DataExtractionEntries.FirstOrDefault(entry => + string.Equals(NormalizeFieldName(entry.Name), normalizedResultName, StringComparison.OrdinalIgnoreCase)); + + if (normalizedMatch != null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Mapped extracted field '{ExtractedFieldName}' to configured field '{ConfiguredFieldName}' for session data extraction.", + result.Name.SanitizeForLog(), + normalizedMatch.Name.SanitizeForLog()); + } + + return new MatchedExtractionEntry(normalizedMatch, ClassifyField(result.Name), false); + } + + var resultFieldKind = ClassifyField(result.Name); + + if (resultFieldKind == ExtractionFieldKind.Unknown) + { + return null; + } + + var semanticMatch = settings.DataExtractionEntries.FirstOrDefault(entry => IsSemanticMatch(entry, resultFieldKind)); + + if (semanticMatch == null) + { + return null; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Mapped extracted field '{ExtractedFieldName}' to configured field '{ConfiguredFieldName}' for session data extraction using semantic aliasing.", + result.Name.SanitizeForLog(), + semanticMatch.Name.SanitizeForLog()); + } + + return new MatchedExtractionEntry(semanticMatch, resultFieldKind, true); + } + + private static bool IsSemanticMatch(DataExtractionEntry entry, ExtractionFieldKind resultFieldKind) + { + var entryFieldKind = ClassifyField(entry?.Name, entry?.Description); + + if (resultFieldKind == ExtractionFieldKind.PhoneNumber) + { + return entryFieldKind == ExtractionFieldKind.PhoneNumber; + } + + if (resultFieldKind is ExtractionFieldKind.FirstName or ExtractionFieldKind.LastName or ExtractionFieldKind.FullName) + { + return entryFieldKind == ExtractionFieldKind.FullName; + } + + return false; + } + + private static string ResolveSingleValue( + MatchedExtractionEntry match, + ExtractedFieldState state, + ExtractionResult result) + { + var value = result.Values[0]; + + if (!match.IsSemanticAlias || match.ResultFieldKind is not ExtractionFieldKind.FirstName and not ExtractionFieldKind.LastName) + { + return value; + } + + return MergeNameParts(state?.Values.FirstOrDefault(), value, match.ResultFieldKind); + } + + private static string MergeNameParts( + string existingValue, + string newValue, + ExtractionFieldKind resultFieldKind) + { + if (string.IsNullOrWhiteSpace(newValue)) + { + return existingValue; + } + + if (string.IsNullOrWhiteSpace(existingValue)) + { + return newValue.Trim(); + } + + var existingParts = existingValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var incomingValue = newValue.Trim(); + + if (resultFieldKind == ExtractionFieldKind.FirstName) + { + return existingParts.Length > 1 + ? string.Join(' ', [incomingValue, .. existingParts.Skip(1)]) + : incomingValue; + } + + if (existingParts.Length > 1) + { + return string.Join(' ', [.. existingParts.Take(existingParts.Length - 1), incomingValue]); + } + + return string.Concat(existingParts[0], " ", incomingValue); + } + private async Task GetChatClientAsync(AIProfile profile) { if (_deploymentManager != null) @@ -349,6 +539,239 @@ private async Task BuildExtractionPromptAsync( return await _aiTemplateService.RenderAsync(AITemplateIds.DataExtractionPrompt, arguments, cancellationToken); } + private ExtractionResponse TryParseExtractionResponse(string sessionId, string responseText) + { + if (TryDeserializeExtractionResponse(responseText, out var directResult)) + { + return directResult; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Data extraction response for session '{SessionId}' is not valid JSON. Trying fallback extraction.", + sessionId); + } + + var jsonBlock = JsonExtractor.ExtractFromCodeFence(responseText); + + if (TryDeserializeExtractionResponse(jsonBlock, out var fencedResult)) + { + return fencedResult; + } + + var jsonObject = JsonExtractor.ExtractJsonObject(responseText); + + if (jsonObject != null && + jsonObject != responseText && + TryDeserializeExtractionResponse(jsonObject, out var objectResult)) + { + return objectResult; + } + + var normalizedBody = _markdownTemplateParser.Parse(responseText).Body?.Trim(); + + if (!string.IsNullOrWhiteSpace(normalizedBody) && + !string.Equals(normalizedBody, responseText?.Trim(), StringComparison.Ordinal)) + { + if (TryDeserializeExtractionResponse(normalizedBody, out var normalizedResult)) + { + return normalizedResult; + } + + var normalizedJsonBlock = JsonExtractor.ExtractFromCodeFence(normalizedBody); + + if (TryDeserializeExtractionResponse(normalizedJsonBlock, out var normalizedFencedResult)) + { + return normalizedFencedResult; + } + + var normalizedJsonObject = JsonExtractor.ExtractJsonObject(normalizedBody); + + if (normalizedJsonObject != null && + normalizedJsonObject != normalizedBody && + TryDeserializeExtractionResponse(normalizedJsonObject, out var normalizedObjectResult)) + { + return normalizedObjectResult; + } + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Data extraction response for session '{SessionId}' could not be parsed as structured JSON after all extraction attempts.", + sessionId); + } + + return null; + } + + private static bool TryDeserializeExtractionResponse( + string responseText, + out ExtractionResponse response) + { + if (string.IsNullOrWhiteSpace(responseText)) + { + response = null; + return false; + } + + try + { + response = JsonSerializer.Deserialize(responseText, JSOptions.CaseInsensitive); + + return response is not null; + } + catch (JsonException) + { + response = null; + return false; + } + } + + private static string NormalizeFieldName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + var builder = new System.Text.StringBuilder(name.Length); + + foreach (var character in name) + { + if (char.IsLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + } + } + + return builder.Length == 0 + ? null + : builder.ToString(); + } + + private static ExtractionFieldKind ClassifyField( + string name, + string description = null) + { + var normalizedName = NormalizeFieldName(name); + var normalizedDescription = NormalizeFieldName(description); + + if (ContainsAny(normalizedName, normalizedDescription, "phone", "phonenumber", "telephone", "mobile", "cell")) + { + return ExtractionFieldKind.PhoneNumber; + } + + if (ContainsAny(normalizedName, normalizedDescription, "firstname", "givenname")) + { + return ExtractionFieldKind.FirstName; + } + + if (ContainsAny(normalizedName, normalizedDescription, "lastname", "surname", "familyname")) + { + return ExtractionFieldKind.LastName; + } + + if (ContainsAny(normalizedName, normalizedDescription, "fullname")) + { + return ExtractionFieldKind.FullName; + } + + if (ContainsAny(normalizedName, normalizedDescription, "customername")) + { + return ExtractionFieldKind.FullName; + } + + if (!string.IsNullOrEmpty(normalizedDescription) && + normalizedDescription.Contains("firstname", StringComparison.Ordinal) && + normalizedDescription.Contains("lastname", StringComparison.Ordinal)) + { + return ExtractionFieldKind.FullName; + } + + return ExtractionFieldKind.Unknown; + } + + private static bool ContainsAny( + string normalizedName, + string normalizedDescription, + params string[] candidates) + { + foreach (var candidate in candidates) + { + if ((!string.IsNullOrEmpty(normalizedName) && normalizedName.Contains(candidate, StringComparison.Ordinal)) || + (!string.IsNullOrEmpty(normalizedDescription) && normalizedDescription.Contains(candidate, StringComparison.Ordinal))) + { + return true; + } + } + + return false; + } + + private static ITemplateParser ResolveMarkdownTemplateParser(IEnumerable templateParsers) + { + ArgumentNullException.ThrowIfNull(templateParsers); + + return templateParsers.FirstOrDefault(parser => + parser.SupportedExtensions.Any(extension => string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase))) + ?? throw new InvalidOperationException("No markdown template parser is registered for data extraction response recovery."); + } + + private static string GetLastAssistantMessageText(IList messages) + { + if (messages is null) + { + return null; + } + + for (var i = messages.Count - 1; i >= 0; i--) + { + var messageText = GetMessageText(messages[i]); + + if (messages[i].Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(messageText)) + { + return messageText.Trim(); + } + } + + return null; + } + + private static string GetMessageText(ChatMessage message) + { + if (message == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + + var contentText = string.Concat(message.Contents?.OfType().Select(content => content.Text) ?? []); + + return string.IsNullOrWhiteSpace(contentText) + ? null + : contentText; + } + + private static string CreateResponseLogPreview(string responseText) + { + if (string.IsNullOrWhiteSpace(responseText)) + { + return ""; + } + + const int maxLength = 512; + + return responseText.Length <= maxLength + ? responseText + : responseText[..maxLength] + "..."; + } + private sealed class ExtractionResponse { public List Fields { get; set; } = []; @@ -364,4 +787,18 @@ private sealed class ExtractionResult public double Confidence { get; set; } } + + private sealed record MatchedExtractionEntry( + DataExtractionEntry Entry, + ExtractionFieldKind ResultFieldKind, + bool IsSemanticAlias); + + private enum ExtractionFieldKind + { + Unknown, + FullName, + FirstName, + LastName, + PhoneNumber, + } } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionEventService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionEventService.cs new file mode 100644 index 00000000..57533a99 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionEventService.cs @@ -0,0 +1,254 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat.Services; + +/// +/// Default framework service for recording and querying chat-session analytics. +/// +public sealed class DefaultAIChatSessionEventService : IAIChatSessionEventService +{ + private readonly IAIChatSessionEventStore _store; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The analytics store. + /// The time provider. + public DefaultAIChatSessionEventService( + IAIChatSessionEventStore store, + TimeProvider timeProvider) + { + _store = store; + _timeProvider = timeProvider; + } + + /// + /// Records that a chat session has started. + /// + /// The chat session. + /// The cancellation token. + public async Task RecordSessionStartedAsync( + AIChatSession chatSession, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSession); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); + var chatSessionEvent = new AIChatSessionEvent + { + SessionId = chatSession.SessionId, + ProfileId = chatSession.ProfileId, + VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, + UserId = chatSession.UserId, + IsAuthenticated = isAuthenticated, + SessionStartedUtc = now, + MessageCount = 0, + HandleTimeSeconds = 0, + IsResolved = false, + CompletionCount = 0, + CreatedUtc = now, + }; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } + + /// + /// Records the final analytics state for a chat session. + /// + /// The chat session. + /// The total prompt count. + /// Whether the session was resolved. + /// The cancellation token. + public async Task RecordSessionEndedAsync( + AIChatSession chatSession, + int promptCount, + bool isResolved, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSession); + + var chatSessionEvent = await _store.FindBySessionIdAsync(chatSession.SessionId, cancellationToken); + if (chatSessionEvent is null) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); + chatSessionEvent = new AIChatSessionEvent + { + SessionId = chatSession.SessionId, + ProfileId = chatSession.ProfileId, + VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, + UserId = chatSession.UserId, + IsAuthenticated = isAuthenticated, + SessionStartedUtc = chatSession.CreatedUtc, + SessionEndedUtc = chatSession.ClosedAtUtc ?? now, + MessageCount = promptCount, + HandleTimeSeconds = ((chatSession.ClosedAtUtc ?? now) - chatSession.CreatedUtc).TotalSeconds, + IsResolved = isResolved, + CompletionCount = 0, + CreatedUtc = now, + }; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + + return; + } + + var endTime = chatSession.ClosedAtUtc ?? _timeProvider.GetUtcNow().UtcDateTime; + chatSessionEvent.SessionEndedUtc = endTime; + chatSessionEvent.MessageCount = promptCount; + chatSessionEvent.IsResolved = isResolved; + chatSessionEvent.HandleTimeSeconds = (endTime - chatSessionEvent.SessionStartedUtc).TotalSeconds; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } + + /// + /// Records completion-usage totals for a chat session. + /// + /// The chat session identifier. + /// The number of input tokens. + /// The number of output tokens. + /// The cancellation token. + public async Task RecordCompletionUsageAsync( + string sessionId, + int inputTokens, + int outputTokens, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var chatSessionEvent = await _store.FindBySessionIdAsync(sessionId, cancellationToken); + if (chatSessionEvent is null) + { + return; + } + + chatSessionEvent.TotalInputTokens += inputTokens; + chatSessionEvent.TotalOutputTokens += outputTokens; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } + + /// + /// Records response-latency data for a chat session. + /// + /// The chat session identifier. + /// The response latency in milliseconds. + /// The cancellation token. + public async Task RecordResponseLatencyAsync( + string sessionId, + double responseLatencyMs, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var chatSessionEvent = await _store.FindBySessionIdAsync(sessionId, cancellationToken); + if (chatSessionEvent is null || responseLatencyMs <= 0) + { + return; + } + + chatSessionEvent.CompletionCount++; + chatSessionEvent.AverageResponseLatencyMs = + ((chatSessionEvent.AverageResponseLatencyMs * (chatSessionEvent.CompletionCount - 1)) + responseLatencyMs) + / chatSessionEvent.CompletionCount; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } + + /// + /// Records user-rating totals for a chat session. + /// + /// The chat session identifier. + /// The number of positive ratings. + /// The number of negative ratings. + /// The cancellation token. + public async Task RecordUserRatingAsync( + string sessionId, + int thumbsUpCount, + int thumbsDownCount, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var chatSessionEvent = await _store.FindBySessionIdAsync(sessionId, cancellationToken); + if (chatSessionEvent is null) + { + return; + } + + chatSessionEvent.ThumbsUpCount = thumbsUpCount; + chatSessionEvent.ThumbsDownCount = thumbsDownCount; + chatSessionEvent.UserRating = thumbsUpCount + thumbsDownCount > 0 ? thumbsUpCount >= thumbsDownCount : null; + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } + + /// + /// Retrieves chat-session analytics records matching the optional profile and date filters. + /// + /// The optional profile identifier filter. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + return _store.GetAsync(profileId, startDateUtc, endDateUtc, cancellationToken); + } + + /// + /// Records end-of-session analytics for the specified chat session. + /// + /// The AI profile associated with the session. + /// The chat session. + /// The prompts exchanged during the session. + /// Whether the session was resolved. + /// The cancellation token. + public Task RecordSessionEndedAsync( + AIProfile profile, + AIChatSession session, + IReadOnlyList prompts, + bool isResolved, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(prompts); + + return RecordSessionEndedAsync(session, prompts.Count, isResolved, cancellationToken); + } + + /// + /// Records evaluated conversion-goal results for the specified chat session. + /// + /// The AI profile associated with the session. + /// The chat session. + /// The evaluated conversion-goal results. + /// The cancellation token. + public async Task RecordConversionGoalsAsync( + AIProfile profile, + AIChatSession session, + IReadOnlyList goalResults, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(goalResults); + + var chatSessionEvent = await _store.FindBySessionIdAsync(session.SessionId, cancellationToken); + if (chatSessionEvent is null) + { + return; + } + + chatSessionEvent.ConversionGoalResults = goalResults.ToList(); + chatSessionEvent.ConversionScore = goalResults.Sum(result => result.Score); + chatSessionEvent.ConversionMaxScore = goalResults.Sum(result => result.MaxScore); + + await _store.SaveAsync(chatSessionEvent, cancellationToken); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionExtractedDataRecorder.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionExtractedDataRecorder.cs new file mode 100644 index 00000000..c7f245f4 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/DefaultAIChatSessionExtractedDataRecorder.cs @@ -0,0 +1,69 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat.Services; + +/// +/// Default framework recorder that persists extracted chat-session values through +/// the configured . +/// +public sealed class DefaultAIChatSessionExtractedDataRecorder : IAIChatSessionExtractedDataRecorder +{ + private readonly IAIChatSessionExtractedDataStore _store; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The extracted-data store. + /// The time provider. + public DefaultAIChatSessionExtractedDataRecorder( + IAIChatSessionExtractedDataStore store, + TimeProvider timeProvider) + { + _store = store; + _timeProvider = timeProvider; + } + + /// + /// Records the current extracted-data snapshot for the specified chat session. + /// + /// The AI profile associated with the session. + /// The chat session to record extracted data for. + /// A token to cancel the operation. + public async Task RecordExtractedDataAsync( + AIProfile profile, + AIChatSession session, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); + + var values = session.ExtractedData + .Where(pair => pair.Value.Values.Count > 0) + .ToDictionary( + pair => pair.Key, + pair => pair.Value.Values.ToList(), + StringComparer.OrdinalIgnoreCase); + + if (values.Count == 0) + { + await _store.DeleteAsync(session.SessionId, cancellationToken); + + return; + } + + await _store.SaveAsync( + new AIChatSessionExtractedDataRecord + { + ItemId = session.SessionId, + SessionId = session.SessionId, + ProfileId = profile.ItemId, + SessionStartedUtc = session.CreatedUtc, + SessionEndedUtc = session.ClosedAtUtc, + UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime, + Values = values, + }, + cancellationToken); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs index e8c7f426..f877e879 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs @@ -1,4 +1,5 @@ using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; @@ -24,7 +25,9 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio if (context.ChatType == ChatContextType.AIChatSession) { + var profileManager = context.Services.GetRequiredService(); var sessionManager = context.Services.GetRequiredService(); + var postCloseProcessor = context.Services.GetRequiredService(); var session = await sessionManager.FindByIdAsync(context.SessionId, cancellationToken); if (session is null) @@ -38,11 +41,29 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio session.Status = ChatSessionStatus.Closed; session.ClosedAtUtc = timeProvider.GetUtcNow().UtcDateTime; + var queuedPostCloseProcessing = false; + var profile = await profileManager.FindByIdAsync(session.ProfileId, cancellationToken); + + if (profile is null) + { + logger.LogWarning( + "End session for '{SessionId}' closed the session, but profile '{ProfileId}' was not found so post-close work could not be queued.", + context.SessionId, + session.ProfileId); + } + else + { + queuedPostCloseProcessing = postCloseProcessor.QueueIfNeeded(profile, session); + } + await sessionManager.SaveAsync(session, cancellationToken); - if (logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Information)) { - logger.LogDebug("Session '{SessionId}' ended via notification action.", context.SessionId); + logger.LogInformation( + "Session '{SessionId}' ended via notification action. Queued post-close processing: {QueuedPostCloseProcessing}.", + context.SessionId, + queuedPostCloseProcessing); } } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index 0bd277ff..90956c0e 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -146,12 +146,12 @@ public async Task> EvaluateConversionGoalsAsync( var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["goals"] = goals.Select(g => new + ["goals"] = goals.Select(g => new Dictionary { - g.Name, - g.Description, - g.MinScore, - g.MaxScore, + ["Name"] = g.Name, + ["Description"] = g.Description, + ["MinScore"] = g.MinScore, + ["MaxScore"] = g.MaxScore, }).ToList(), ["prompts"] = ProjectPrompts(prompts), }; @@ -293,13 +293,17 @@ public async Task> ProcessAsync( var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["tasks"] = tasksToProcess.Select(t => new + ["tasks"] = tasksToProcess.Select(t => new Dictionary { - t.Name, - Type = t.Type.ToString(), - t.Instructions, - t.AllowMultipleValues, - Options = t.Options?.Select(o => new { o.Value, o.Description }).ToList(), + ["Name"] = t.Name, + ["Type"] = t.Type.ToString(), + ["Instructions"] = t.Instructions, + ["AllowMultipleValues"] = t.AllowMultipleValues, + ["Options"] = t.Options?.Select(o => new Dictionary + { + ["Value"] = o.Value, + ["Description"] = o.Description, + }).ToList(), }).ToList(), ["prompts"] = ProjectPrompts(prompts), }; @@ -318,13 +322,25 @@ public async Task> ProcessAsync( var systemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.PostSessionAnalysis, cancellationToken: cancellationToken); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session prompt details for session '{SessionId}': SystemPromptPreview='{SystemPromptPreview}', UserPromptPreview='{UserPromptPreview}', Tasks=[{TaskNames}].", + session.SessionId, + CreateResponseLogPreview(systemPrompt), + CreateResponseLogPreview(prompt), + string.Join(", ", tasksToProcess.Select(t => t.Name))); + } + var messages = new List { new(ChatRole.System, systemPrompt), new(ChatRole.User, prompt), }; - var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames); + var toolNames = GetConfiguredToolNames(settings.ToolNames, tasksToProcess); + + var tools = await ResolveToolsAsync(session.SessionId, toolNames); // When tools are configured (e.g., sendEmail), use non-generic GetResponseAsync // to allow tool execution. The generic version uses structured output which @@ -351,33 +367,14 @@ public async Task> ProcessAsync( session.SessionId); } - var response = await chatClient.GetResponseAsync(messages, new ChatOptions - { - Temperature = 0f, - }.AddUsageTracking(session: session), null, cancellationToken); - - if (response.Result?.Tasks == null || response.Result.Tasks.Count == 0) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Post-session structured output for session '{SessionId}' returned no tasks.", - session.SessionId); - } - - return null; - } - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Post-session structured output for session '{SessionId}' returned {TaskCount} task result(s): [{TaskNames}].", - session.SessionId, - response.Result.Tasks.Count, - string.Join(", ", response.Result.Tasks.Select(t => t.Name))); - } - - return ApplyResults(tasksToProcess, response.Result.Tasks); + return await ProcessStructuredAsync( + session, + chatClient, + messages, + tasksToProcess, + "structured output path", + false, + cancellationToken); } private async Task> ProcessWithToolsAsync( @@ -388,6 +385,11 @@ private async Task> ProcessWithToolsAsync( List tasks, CancellationToken cancellationToken) { + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogResponseMessages(session.SessionId, "tools-request", messages); + } + // Wrap the raw client with FunctionInvokingChatClient so that tool_call // messages returned by the model are actually executed (e.g., sendEmail). var client = chatClient @@ -404,34 +406,32 @@ private async Task> ProcessWithToolsAsync( Tools = tools, }.AddUsageTracking(session: session), cancellationToken); + var toolCallCount = response.Messages? + .SelectMany(m => m.Contents?.OfType() ?? []) + .Count() ?? 0; + + var toolResultCount = response.Messages? + .SelectMany(m => m.Contents?.OfType() ?? []) + .Count() ?? 0; + // Log tool invocation details from the response messages. if (_logger.IsEnabled(LogLevel.Debug)) { - var toolCallCount = response.Messages? - - .SelectMany(m => m.Contents?.OfType() ?? []) - .Count() ?? 0; - - var toolResultCount = response.Messages? - - .SelectMany(m => m.Contents?.OfType() ?? []) - .Count() ?? 0; - _logger.LogDebug( "Post-session tools response for session '{SessionId}': MessageCount={MessageCount}, ToolCalls={ToolCallCount}, ToolResults={ToolResultCount}.", session.SessionId, response.Messages?.Count ?? 0, toolCallCount, toolResultCount); + + LogResponseMessages(session.SessionId, "tools", response.Messages); } // Extract the final assistant message text, ignoring intermediate tool // call and tool result messages. After FunctionInvokingChatClient resolves // all tool calls, the model produces a final assistant message with the JSON // task results - that is the only message we care about. - var responseText = response.Messages? - .LastOrDefault(m => m.Role == ChatRole.Assistant && !string.IsNullOrEmpty(m.Text)) - ?.Text?.Trim(); + var responseText = GetLastAssistantMessageText(response.Messages); // Always log the raw response text for troubleshooting. if (_logger.IsEnabled(LogLevel.Debug)) @@ -446,9 +446,33 @@ private async Task> ProcessWithToolsAsync( { var result = TryParsePostSessionResponse(session.SessionId, responseText); - if (result?.Tasks != null && result.Tasks.Count > 0) + if (result?.Tasks != null) { - return ApplyResults(tasks, result.Tasks); + if (result.Tasks.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session tools response for session '{SessionId}' returned an empty tasks array. Attempting structured recovery.", + session.SessionId); + } + } + else + { + var appliedResults = ApplyResults(tasks, result.Tasks); + + if (appliedResults.Count > 0) + { + return appliedResults; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session tools response for session '{SessionId}' returned invalid task entries. Attempting structured recovery.", + session.SessionId); + } + } } } else if (_logger.IsEnabled(LogLevel.Debug)) @@ -468,12 +492,173 @@ private async Task> ProcessWithToolsAsync( if (recoveredResults is not null && recoveredResults.Count > 0) { + var hasUsableRecoveredResults = recoveredResults.Values.Any(result => + result.Status == PostSessionTaskResultStatus.Succeeded + && !string.IsNullOrWhiteSpace(result.Value)); + + if (hasUsableRecoveredResults) + { + return recoveredResults; + } + + if (toolCallCount == 0 && toolResultCount == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session tool processing for session '{SessionId}' produced no tool calls/results and no usable structured recovery output. Retrying without tools.", + session.SessionId); + } + + return await ProcessStructuredAsync( + session, + chatClient, + messages, + tasks, + "no-tools fallback after tool path produced no usable output", + true, + cancellationToken); + } + return recoveredResults; } + if (toolCallCount == 0 && toolResultCount == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session tool processing for session '{SessionId}' produced no tool calls/results and no recoverable structured output. Retrying without tools.", + session.SessionId); + } + + return await ProcessStructuredAsync( + session, + chatClient, + messages, + tasks, + "no-tools fallback after empty tool path result", + true, + cancellationToken); + } + return CreateFailedResults(session.SessionId, tasks, responseText); } + private async Task> ProcessStructuredAsync( + AIChatSession session, + IChatClient chatClient, + List messages, + List tasksToProcess, + string reason, + bool failWhenStructuredResultMissing, + CancellationToken cancellationToken) + { + var response = await chatClient.GetResponseAsync(messages, new ChatOptions + { + Temperature = 0f, + }.AddUsageTracking(session: session), null, cancellationToken); + + var responseText = GetLastAssistantMessageText(response.Messages); + PostSessionProcessingResponse result = null; + + try + { + result = response.Result; + } + catch (InvalidOperationException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured output for session '{SessionId}' ({Reason}) did not contain JSON in the typed result path.", + session.SessionId, + reason); + } + } + catch (JsonException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured output for session '{SessionId}' ({Reason}) returned invalid JSON in the typed result path.", + session.SessionId, + reason); + } + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured raw response for session '{SessionId}' ({Reason}): '{ResponseText}'.", + session.SessionId, + reason, + CreateResponseLogPreview(responseText)); + + LogResponseMessages(session.SessionId, $"structured({reason})", response.Messages); + } + + if (result?.Tasks == null) + { + var parsedResult = TryParsePostSessionResponse(session.SessionId, responseText); + + if (parsedResult?.Tasks != null) + { + result = parsedResult; + } + } + + if (result?.Tasks == null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured output for session '{SessionId}' ({Reason}) returned no tasks.", + session.SessionId, + reason); + } + + if (failWhenStructuredResultMissing) + { + return CreateFailedResults(session.SessionId, tasksToProcess, responseText); + } + + return null; + } + + if (result.Tasks.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured output for session '{SessionId}' ({Reason}) returned an empty tasks array.", + session.SessionId, + reason); + } + + return CreateEmptyStructuredResults(session.SessionId, tasksToProcess, responseText); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Post-session structured output for session '{SessionId}' ({Reason}) returned {TaskCount} raw task result(s): [{TaskNames}].", + session.SessionId, + reason, + result.Tasks.Count, + CreateTaskResultSummary(result.Tasks)); + } + + var appliedResults = ApplyResults(tasksToProcess, result.Tasks); + + if (appliedResults.Count > 0) + { + return appliedResults; + } + + return CreateInvalidStructuredResults(session.SessionId, tasksToProcess, result.Tasks, responseText); + } + /// /// Attempts to parse the AI response text as a /// using progressively lenient strategies: @@ -597,8 +782,7 @@ private async Task> TryRecoverStructuredTo { var followUpMessages = new List(requestMessages); - var trailingAssistantText = responseMessages? - .LastOrDefault(message => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text)); + var trailingAssistantText = GetLastAssistantMessage(responseMessages); if (responseMessages is not null) { @@ -613,12 +797,23 @@ private async Task> TryRecoverStructuredTo } } + // Add an explicit user message to guide the model into producing + // structured JSON after tool calls have been executed. + var taskNamesList = string.Join(", ", tasks.Select(t => t.Name)); + followUpMessages.Add(new ChatMessage(ChatRole.User, + $""" + The tool calls above have been completed. Now return ONLY the required JSON output with the "tasks" array. + Each task must have a "name" and "value" field. The tasks you must include are: {taskNamesList}. + Do NOT wrap in markdown. Return raw JSON only. + """)); + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Attempting structured recovery for post-session tool response on session '{SessionId}' using the original post-session analysis context. TaskCount={TaskCount}.", + "Attempting structured recovery for post-session tool response on session '{SessionId}' using the original post-session analysis context. TaskCount={TaskCount}, TotalMessages={MessageCount}.", sessionId, - tasks.Count); + tasks.Count, + followUpMessages.Count); } var response = await chatClient.GetResponseAsync(followUpMessages, new ChatOptions @@ -626,10 +821,7 @@ private async Task> TryRecoverStructuredTo Temperature = 0f, }, null, cancellationToken); - var recoveryResponseText = response.Messages? - - .LastOrDefault(message => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text)) - ?.Text?.Trim(); + var recoveryResponseText = GetLastAssistantMessageText(response.Messages); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -637,6 +829,8 @@ private async Task> TryRecoverStructuredTo "Post-session structured recovery raw response for session '{SessionId}': '{ResponseText}'.", sessionId, CreateResponseLogPreview(recoveryResponseText)); + + LogResponseMessages(sessionId, "structured-recovery", response.Messages); } PostSessionProcessingResponse result = null; @@ -664,22 +858,41 @@ private async Task> TryRecoverStructuredTo } } - if (result?.Tasks is { Count: > 0 }) + if (result?.Tasks != null) { + if (result.Tasks.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Structured recovery for post-session tool response on session '{SessionId}' returned an empty tasks array.", + sessionId); + } + + return CreateEmptyStructuredResults(sessionId, tasks, recoveryResponseText); + } + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Structured recovery for post-session tool response on session '{SessionId}' succeeded with {TaskCount} task result(s).", + "Structured recovery for post-session tool response on session '{SessionId}' returned {TaskCount} raw task result(s).", sessionId, result.Tasks.Count); } - return ApplyResults(tasks, result.Tasks); + var appliedResults = ApplyResults(tasks, result.Tasks); + + if (appliedResults.Count > 0) + { + return appliedResults; + } + + return CreateInvalidStructuredResults(sessionId, tasks, result.Tasks, recoveryResponseText); } var recoveredFromText = TryParsePostSessionResponse(sessionId, recoveryResponseText); - if (recoveredFromText?.Tasks is null || recoveredFromText.Tasks.Count == 0) + if (recoveredFromText?.Tasks is null) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -691,15 +904,34 @@ private async Task> TryRecoverStructuredTo return null; } + if (recoveredFromText.Tasks.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Structured recovery for post-session tool response on session '{SessionId}' parsed an empty tasks array from assistant text.", + sessionId); + } + + return CreateEmptyStructuredResults(sessionId, tasks, recoveryResponseText); + } + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Structured recovery for post-session tool response on session '{SessionId}' succeeded by parsing assistant text with {TaskCount} task result(s).", + "Structured recovery for post-session tool response on session '{SessionId}' parsed {TaskCount} raw task result(s) from assistant text.", sessionId, recoveredFromText.Tasks.Count); } - return ApplyResults(tasks, recoveredFromText.Tasks); + var recoveredAppliedResults = ApplyResults(tasks, recoveredFromText.Tasks); + + if (recoveredAppliedResults.Count > 0) + { + return recoveredAppliedResults; + } + + return CreateInvalidStructuredResults(sessionId, tasks, recoveredFromText.Tasks, recoveryResponseText); } private static bool TryDeserializePostSessionResponse( @@ -718,7 +950,7 @@ private static bool TryDeserializePostSessionResponse( responseText, JSOptions.CaseInsensitive); - return response?.Tasks is { Count: > 0 }; + return response is not null; } catch (JsonException) { @@ -727,6 +959,53 @@ private static bool TryDeserializePostSessionResponse( } } + private static ChatMessage GetLastAssistantMessage(IList messages) + { + if (messages is null) + { + return null; + } + + for (var i = messages.Count - 1; i >= 0; i--) + { + if (messages[i].Role != ChatRole.Assistant) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(GetMessageText(messages[i]))) + { + return messages[i]; + } + } + + return null; + } + + private static string GetLastAssistantMessageText(IList messages) + { + return GetMessageText(GetLastAssistantMessage(messages))?.Trim(); + } + + private static string GetMessageText(ChatMessage message) + { + if (message == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + + var contentText = string.Concat(message.Contents?.OfType().Select(content => content.Text) ?? []); + + return string.IsNullOrWhiteSpace(contentText) + ? null + : contentText; + } + private static ITemplateParser ResolveMarkdownTemplateParser(IEnumerable templateParsers) { ArgumentNullException.ThrowIfNull(templateParsers); @@ -765,6 +1044,59 @@ private Dictionary CreateFailedResults( StringComparer.OrdinalIgnoreCase); } + private Dictionary CreateInvalidStructuredResults( + string sessionId, + List tasks, + List results, + string responseText) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + const string errorMessage = "The AI returned structured task results, but none contained a usable task name and value that matched the configured post-session tasks."; + + _logger.LogWarning( + "Post-session structured results for session '{SessionId}' were invalid. ReturnedResults=[{ReturnedResults}]. ResponsePreview='{ResponsePreview}'.", + sessionId, + CreateTaskResultSummary(results), + CreateResponseLogPreview(responseText)); + + return tasks.ToDictionary( + task => task.Name, + task => new PostSessionResult + { + Name = task.Name, + Status = PostSessionTaskResultStatus.Failed, + ErrorMessage = errorMessage, + ProcessedAtUtc = now, + }, + StringComparer.OrdinalIgnoreCase); + } + + private Dictionary CreateEmptyStructuredResults( + string sessionId, + List tasks, + string responseText) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + const string errorMessage = "The AI returned structured JSON, but the tasks array was empty. Each configured post-session task must return a result, even when no tool call is needed."; + + _logger.LogWarning( + "Post-session structured results for session '{SessionId}' were empty. ConfiguredTaskCount={ConfiguredTaskCount}. ResponsePreview='{ResponsePreview}'.", + sessionId, + tasks.Count, + CreateResponseLogPreview(responseText)); + + return tasks.ToDictionary( + task => task.Name, + task => new PostSessionResult + { + Name = task.Name, + Status = PostSessionTaskResultStatus.Failed, + ErrorMessage = errorMessage, + ProcessedAtUtc = now, + }, + StringComparer.OrdinalIgnoreCase); + } + private static string CreateResponseLogPreview(string responseText) { if (string.IsNullOrEmpty(responseText)) @@ -780,6 +1112,52 @@ private static string CreateResponseLogPreview(string responseText) return normalized.Length > 2000 ? normalized[..2000] + "..." : normalized; } + private static string CreateTaskResultSummary(IEnumerable results) + { + if (results == null) + { + return "(none)"; + } + + var summaries = results.Select(result => + $"Name='{result?.Name ?? "(null)"}', HasValue={!string.IsNullOrWhiteSpace(result?.Value)}"); + + return string.Join("; ", summaries); + } + + private static string[] GetConfiguredToolNames( + IEnumerable profileToolNames, + IEnumerable tasks) + { + var configuredNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (profileToolNames != null) + { + foreach (var toolName in profileToolNames) + { + if (!string.IsNullOrWhiteSpace(toolName)) + { + configuredNames.Add(toolName); + } + } + } + + if (tasks != null) + { + foreach (var toolName in tasks + .Where(task => task?.ToolNames != null) + .SelectMany(task => task.ToolNames)) + { + if (!string.IsNullOrWhiteSpace(toolName)) + { + configuredNames.Add(toolName); + } + } + } + + return configuredNames.Count > 0 ? [.. configuredNames] : []; + } + private Dictionary ApplyResults( List tasks, List results) @@ -964,13 +1342,75 @@ private static List ProjectPrompts(IReadOnlyList pr { return prompts .Where(p => !p.IsGeneratedPrompt) - .Select(p => new + .Select(p => (object)new Dictionary { - Role = p.Role == ChatRole.User ? "User" : "Assistant", - Content = p.Content?.Trim(), + ["Role"] = p.Role == ChatRole.User ? "User" : "Assistant", + ["Content"] = p.Content?.Trim(), }) - .Cast() - .ToList(); + .ToList(); + } + + private void LogResponseMessages(string sessionId, string phase, IList messages) + { + if (!_logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + if (messages is null || messages.Count == 0) + { + _logger.LogDebug( + "Post-session {Phase} messages for session '{SessionId}': (no messages).", + phase, + sessionId); + + return; + } + + for (var i = 0; i < messages.Count; i++) + { + var message = messages[i]; + var textContent = GetMessageText(message); + var functionCalls = message.Contents?.OfType().ToList() ?? []; + var functionResults = message.Contents?.OfType().ToList() ?? []; + + if (functionCalls.Count > 0) + { + var callSummaries = string.Join("; ", functionCalls.Select(fc => + $"{fc.Name}({CreateResponseLogPreview(fc.Arguments?.ToString())})")); + + _logger.LogDebug( + "Post-session {Phase} message[{Index}] for session '{SessionId}': Role={Role}, ToolCalls=[{ToolCalls}].", + phase, + i, + sessionId, + message.Role, + callSummaries); + } + else if (functionResults.Count > 0) + { + var resultSummaries = string.Join("; ", functionResults.Select(fr => + $"{fr.CallId}={CreateResponseLogPreview(fr.Result?.ToString())}")); + + _logger.LogDebug( + "Post-session {Phase} message[{Index}] for session '{SessionId}': Role={Role}, ToolResults=[{ToolResults}].", + phase, + i, + sessionId, + message.Role, + resultSummaries); + } + else + { + _logger.LogDebug( + "Post-session {Phase} message[{Index}] for session '{SessionId}': Role={Role}, Text='{Text}'.", + phase, + i, + sessionId, + message.Role, + CreateResponseLogPreview(textContent)); + } + } } private sealed class PostSessionProcessingResponse diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/ICopilotCredentialStore.cs b/src/Primitives/CrestApps.Core.AI.Copilot/ICopilotCredentialStore.cs index 4e55ea6b..34954d33 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/ICopilotCredentialStore.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/ICopilotCredentialStore.cs @@ -2,7 +2,8 @@ namespace CrestApps.Core.AI.Copilot; /// /// Abstracts storage and retrieval of GitHub OAuth credentials per user. -/// Implement with your preferred user store (OrchardCore users, EF Identity, etc.). +/// Implement with your preferred user store, such as ASP.NET Core Identity or +/// another host-specific account system. /// public interface ICopilotCredentialStore { diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Models/CopilotOptions.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Models/CopilotOptions.cs index 21f2af87..397355f3 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Models/CopilotOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Models/CopilotOptions.cs @@ -2,7 +2,7 @@ namespace CrestApps.Core.AI.Copilot.Models; /// /// Options for GitHub Copilot authentication and provider configuration. -/// Configured via IOptions. +/// Configured through the options pipeline and resolved at runtime through current options accessors. /// public sealed class CopilotOptions { diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs index 296ba3e9..a707fea7 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs @@ -42,7 +42,7 @@ public sealed class CopilotOrchestrator : IOrchestrator private readonly IToolRegistry _toolRegistry; private readonly GitHubOAuthService _oauthService; private readonly ICopilotCredentialStore _credentialStore; - private readonly IOptions _options; + private readonly IOptionsMonitor _options; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly ILogger _logger; @@ -59,7 +59,7 @@ public CopilotOrchestrator( IToolRegistry toolRegistry, GitHubOAuthService oauthService, ICopilotCredentialStore credentialStore, - IOptions options, + IOptionsMonitor options, IDataProtectionProvider dataProtectionProvider, ILogger logger) { @@ -140,7 +140,7 @@ public async IAsyncEnumerable ExecuteStreamingAsync(Orchestr sessionConfig.OnPermissionRequest = CreatePermissionRequestHandler(metadata.IsAllowAll); } - var settings = _options.Value; + var settings = _options.CurrentValue; if (!IsConfigured(settings)) { yield return CreateTextResponse("Copilot is not configured and cannot be used until it has been configured."); diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs index 1ebc5a11..855fe948 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs @@ -22,7 +22,7 @@ public sealed class GitHubOAuthService private readonly ICopilotCredentialStore _credentialStore; private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly IOptions _options; + private readonly IOptionsMonitor _options; private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpContextAccessor _httpContextAccessor; private readonly TimeProvider _timeProvider; @@ -40,7 +40,7 @@ public sealed class GitHubOAuthService public GitHubOAuthService( ICopilotCredentialStore credentialStore, IDataProtectionProvider dataProtectionProvider, - IOptions options, + IOptionsMonitor options, IHttpClientFactory httpClientFactory, TimeProvider timeProvider, ILogger logger, @@ -64,7 +64,7 @@ public string GetAuthorizationUrl(string callbackUrl, string returnUrl) { ArgumentException.ThrowIfNullOrWhiteSpace(callbackUrl); - var settings = _options.Value; + var settings = _options.CurrentValue; if (string.IsNullOrWhiteSpace(settings.ClientId)) { @@ -191,7 +191,7 @@ public async Task ExchangeCodeForTokenAsync( ArgumentException.ThrowIfNullOrWhiteSpace(code); ArgumentException.ThrowIfNullOrWhiteSpace(userId); - var settings = _options.Value; + var settings = _options.CurrentValue; if (string.IsNullOrWhiteSpace(settings.ClientId) || string.IsNullOrWhiteSpace(settings.ClientSecret)) { @@ -213,7 +213,7 @@ public async Task ExchangeCodeForTokenAsync( Content = JsonContent.Create(tokenRequest), }; tokenRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - tokenRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-OrchardCore-Copilot/1.0"); + tokenRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-Core-Copilot/1.0"); var tokenResponse = await httpClient.SendAsync(tokenRequestMessage, cancellationToken); tokenResponse.EnsureSuccessStatusCode(); @@ -230,7 +230,7 @@ public async Task ExchangeCodeForTokenAsync( // Get user information from GitHub. using var userRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); userRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - userRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-OrchardCore-Copilot/1.0"); + userRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-Core-Copilot/1.0"); userRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var userResponse = await httpClient.SendAsync(userRequestMessage, cancellationToken); diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs index ce6fc905..891c6bb1 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs @@ -89,11 +89,7 @@ public ValueTask CanHandleAsync(OrchestrationContextBuiltContext context) /// The context. public async Task HandleAsync(PreemptiveRagContext context) { - var snapshotSettings = _serviceProvider.GetService>()?.Value; - var optionsSettings = _serviceProvider.GetRequiredService>().Value; - var defaultSettings = !string.IsNullOrWhiteSpace(snapshotSettings?.IndexProfileName) - ? snapshotSettings - : optionsSettings; + var defaultSettings = _serviceProvider.GetRequiredService>().CurrentValue; var userSuppliedDocuments = DocumentContextInjectionModeResolver.ResolveUserSuppliedDocuments(context); var fullUserDocumentMode = DocumentContextInjectionModeResolver.Resolve(context.OrchestrationContext, userSuppliedDocuments.Count); diff --git a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs index 7b1dee65..b30b22e0 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs @@ -72,6 +72,7 @@ public static IServiceCollection AddCoreAIDocumentProcessing(this IServiceCollec }); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddSingleton(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DefaultAIDocumentIndexingService.cs similarity index 70% rename from src/Startup/CrestApps.Core.Blazor.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs rename to src/Primitives/CrestApps.Core.AI.Documents/Services/DefaultAIDocumentIndexingService.cs index d2997ce5..96e58d70 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DefaultAIDocumentIndexingService.cs @@ -1,39 +1,53 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.Infrastructure; - using CrestApps.Core.Infrastructure.Indexing; - using CrestApps.Core.Infrastructure.Indexing.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace CrestApps.Core.Blazor.Web.Areas.Indexing.Services; +namespace CrestApps.Core.AI.Documents.Services; /// -/// Indexes sample-host uploaded AI document chunks into the configured AI Documents search index. +/// Indexes uploaded AI document chunks into the configured AI Documents search index. /// -public sealed class SampleAIDocumentIndexingService +public sealed class DefaultAIDocumentIndexingService { - private readonly InteractionDocumentOptions _options; + private readonly IOptionsMonitor _options; private readonly ISearchIndexProfileStore _indexProfileStore; - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public SampleAIDocumentIndexingService( - IOptions options, + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction document options monitor. + /// The index profile store. + /// The service provider. + /// The logger. + public DefaultAIDocumentIndexingService( + IOptionsMonitor options, ISearchIndexProfileStore indexProfileStore, IServiceProvider serviceProvider, - ILogger logger) + ILogger logger) { - _options = options.Value; + _options = options; _indexProfileStore = indexProfileStore; _serviceProvider = serviceProvider; - _logger = logger; } - public async Task IndexAsync(AIDocument document, IReadOnlyCollection chunks, CancellationToken cancellationToken = default) + /// + /// Indexes the supplied document chunks. + /// + /// The document. + /// The document chunks. + /// The cancellation token. + public async Task IndexAsync( + AIDocument document, + IReadOnlyCollection chunks, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(chunks); @@ -109,6 +123,11 @@ public async Task IndexAsync(AIDocument document, IReadOnlyCollection + /// Deletes a document entry from the configured search index. + /// + /// The document ID. + /// The cancellation token. public async Task DeleteAsync(string documentId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(documentId); @@ -139,6 +158,11 @@ public async Task DeleteAsync(string documentId, CancellationToken cancellationT } } + /// + /// Deletes document chunks from the configured search index. + /// + /// The chunk IDs. + /// The cancellation token. public async Task DeleteChunksAsync(IEnumerable chunkIds, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(chunkIds); @@ -178,7 +202,7 @@ public async Task DeleteChunksAsync(IEnumerable chunkIds, CancellationTo private async Task GetConfiguredIndexProfileAsync(CancellationToken cancellationToken) { - var settings = _options; + var settings = _options.CurrentValue; if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) { @@ -188,6 +212,7 @@ private async Task GetConfiguredIndexProfileAsync(Cancellati } cancellationToken.ThrowIfCancellationRequested(); + var indexProfile = await _indexProfileStore.FindByNameAsync(settings.IndexProfileName, cancellationToken); if (indexProfile == null) @@ -213,53 +238,53 @@ private static IReadOnlyCollection BuildFields(int vectorDimen [ new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.ChunkId, - FieldType = SearchFieldType.Keyword, - IsKey = true, - IsFilterable = true, + Name = DocumentIndexConstants.ColumnNames.ChunkId, + FieldType = SearchFieldType.Keyword, + IsKey = true, + IsFilterable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.DocumentId, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, + Name = DocumentIndexConstants.ColumnNames.DocumentId, + FieldType = SearchFieldType.Keyword, + IsFilterable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.Content, - FieldType = SearchFieldType.Text, - IsSearchable = true, + Name = DocumentIndexConstants.ColumnNames.Content, + FieldType = SearchFieldType.Text, + IsSearchable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.FileName, - FieldType = SearchFieldType.Text, - IsFilterable = true, - IsSearchable = true, + Name = DocumentIndexConstants.ColumnNames.FileName, + FieldType = SearchFieldType.Text, + IsFilterable = true, + IsSearchable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.ReferenceId, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, + Name = DocumentIndexConstants.ColumnNames.ReferenceId, + FieldType = SearchFieldType.Keyword, + IsFilterable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.ReferenceType, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, + Name = DocumentIndexConstants.ColumnNames.ReferenceType, + FieldType = SearchFieldType.Keyword, + IsFilterable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.ChunkIndex, - FieldType = SearchFieldType.Integer, - IsFilterable = true, + Name = DocumentIndexConstants.ColumnNames.ChunkIndex, + FieldType = SearchFieldType.Integer, + IsFilterable = true, }, new SearchIndexField { - Name = DocumentIndexConstants.ColumnNames.Embedding, - FieldType = SearchFieldType.Vector, - VectorDimensions = vectorDimensions, + Name = DocumentIndexConstants.ColumnNames.Embedding, + FieldType = SearchFieldType.Vector, + VectorDimensions = vectorDimensions, }, ]; } diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs index 6478715c..09e82c27 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs @@ -110,11 +110,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a _ => false, }; - var snapshotSettings = arguments.Services.GetService>()?.Value; - var optionsSettings = arguments.Services.GetRequiredService>().Value; - var defaultSettings = !string.IsNullOrWhiteSpace(snapshotSettings?.IndexProfileName) - ? snapshotSettings - : optionsSettings; + var defaultSettings = arguments.Services.GetRequiredService>().CurrentValue; var settings = ResolveSettings(executionContext?.Resource, defaultSettings); if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs index 513ff8ee..a8e7b317 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs @@ -35,6 +35,12 @@ public static IServiceCollection AddCoreAIOllama(this IServiceCollection service o.Description = new LocalizedString("Ollama", "Use locally hosted Ollama models for AI completion."); }); + services.AddCoreAIDeploymentProvider(OllamaConstants.ClientName, o => + { + o.DisplayName = new LocalizedString("Ollama", "Ollama"); + o.Description = new LocalizedString("Ollama", "Use locally hosted Ollama models for AI deployments."); + }); + return services; } diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs index 23bc145f..5e529266 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs @@ -42,6 +42,12 @@ public static IServiceCollection AddCoreAIAzureOpenAI(this IServiceCollection se o.Description = new LocalizedString("Azure OpenAI", "Use Azure OpenAI models for AI completion."); }); + services.AddCoreAIDeploymentProvider(AzureOpenAIConstants.ClientName, o => + { + o.DisplayName = new LocalizedString("Azure OpenAI", "Azure OpenAI"); + o.Description = new LocalizedString("Azure OpenAI", "Use Azure OpenAI models for AI deployments."); + }); + services.AddCoreAIDeploymentProvider(AzureOpenAIConstants.AzureSpeechClientName, o => { o.DisplayName = new LocalizedString("Azure AI Services", "Azure AI Services"); diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs index d9c6acd4..de7fe10b 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs @@ -37,6 +37,12 @@ public static IServiceCollection AddCoreAIOpenAI(this IServiceCollection service o.Description = new LocalizedString("OpenAI", "Use OpenAI models for AI completion."); }); + services.AddCoreAIDeploymentProvider(OpenAIConstants.ClientName, o => + { + o.DisplayName = new LocalizedString("OpenAI", "OpenAI"); + o.Description = new LocalizedString("OpenAI", "Use OpenAI models for AI deployments."); + }); + return services; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs index 781ca27b..0ba6ac2d 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using CrestApps.Core.AI.Models; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Handlers; @@ -21,7 +20,7 @@ public static string GetAuthenticatedUserId(IHttpContextAccessor httpContextAcce /// /// The resource. /// The chat interaction memory options. - public static bool IsEnabled(object resource, IOptions chatInteractionMemoryOptions) + public static bool IsEnabled(object resource, ChatInteractionMemoryOptions chatInteractionMemoryOptions) { if (resource is AIProfile profile) { @@ -30,7 +29,7 @@ public static bool IsEnabled(object resource, IOptions _chatInteractionMemoryOptions; + private readonly IOptionsMonitor _chatInteractionMemoryOptions; private readonly AIToolDefinitionOptions _toolDefinitions; private readonly ILogger _logger; @@ -30,7 +30,7 @@ internal sealed class AIMemoryOrchestrationHandler : IOrchestrationContextBuilde public AIMemoryOrchestrationHandler( ITemplateService templateService, IHttpContextAccessor httpContextAccessor, - IOptions chatInteractionMemoryOptions, + IOptionsMonitor chatInteractionMemoryOptions, IOptions toolDefinitions, ILogger logger) { @@ -79,7 +79,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context, Cancellat return; } - var isEnabled = AIMemoryOrchestrationContextHelper.IsEnabled(context.Resource, _chatInteractionMemoryOptions); + var isEnabled = AIMemoryOrchestrationContextHelper.IsEnabled(context.Resource, _chatInteractionMemoryOptions.CurrentValue); if (!isEnabled) { if (_logger.IsEnabled(LogLevel.Debug)) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs index 0616224d..d67a3a13 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs @@ -13,8 +13,8 @@ internal sealed class AIMemoryPreemptiveRagHandler : IPreemptiveRagHandler { private readonly IAIMemorySearchService _memorySearchService; private readonly ITemplateService _templateService; - private readonly GeneralAIOptions _generalAIOptions; - private readonly IOptions _chatInteractionMemoryOptions; + private readonly IOptionsMonitor _generalAIOptions; + private readonly IOptionsMonitor _chatInteractionMemoryOptions; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; @@ -23,21 +23,21 @@ internal sealed class AIMemoryPreemptiveRagHandler : IPreemptiveRagHandler /// /// The memory search service. /// The template service. - /// The general ai options. + /// The general ai options monitor. /// The chat interaction memory options. /// The http context accessor. /// The logger. public AIMemoryPreemptiveRagHandler( IAIMemorySearchService memorySearchService, ITemplateService templateService, - IOptions generalAIOptions, - IOptions chatInteractionMemoryOptions, + IOptionsMonitor generalAIOptions, + IOptionsMonitor chatInteractionMemoryOptions, IHttpContextAccessor httpContextAccessor, ILogger logger) { _memorySearchService = memorySearchService; _templateService = templateService; - _generalAIOptions = generalAIOptions.Value; + _generalAIOptions = generalAIOptions; _chatInteractionMemoryOptions = chatInteractionMemoryOptions; _httpContextAccessor = httpContextAccessor; _logger = logger; @@ -61,7 +61,7 @@ public async ValueTask CanHandleAsync(OrchestrationContextBuiltContext con return false; } - if (!_generalAIOptions.EnablePreemptiveMemoryRetrieval) + if (!_generalAIOptions.CurrentValue.EnablePreemptiveMemoryRetrieval) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -71,7 +71,7 @@ public async ValueTask CanHandleAsync(OrchestrationContextBuiltContext con return false; } - var isEnabled = AIMemoryOrchestrationContextHelper.IsEnabled(context.Resource, _chatInteractionMemoryOptions); + var isEnabled = AIMemoryOrchestrationContextHelper.IsEnabled(context.Resource, _chatInteractionMemoryOptions.CurrentValue); if (!isEnabled && _logger.IsEnabled(LogLevel.Debug)) { diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs index 2c719821..d7e402ed 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs @@ -43,7 +43,7 @@ public DataSourcePreemptiveRagHandler( ITemplateService templateService, IAIDeploymentManager deploymentManager, IAITextNormalizer textNormalizer, - IOptions options, + IOptionsMonitor options, ILogger logger) { _serviceProvider = serviceProvider; @@ -51,7 +51,7 @@ public DataSourcePreemptiveRagHandler( _templateService = templateService; _deploymentManager = deploymentManager; _textNormalizer = textNormalizer; - _options = options.Value; + _options = options.CurrentValue; _logger = logger; } @@ -129,16 +129,6 @@ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, return; } - if (!indexProfile.TryGet(out DataSourceIndexProfileMetadata profileMetadata)) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Unable to retrieve profile metadata for index profile '{IndexProfileName}'.", indexProfile.Name); - } - - return; - } - var contentManager = _serviceProvider.GetKeyedService(indexProfile.ProviderName); if (contentManager == null) @@ -152,14 +142,18 @@ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, return; } - var deploymentName = profileMetadata.EmbeddingDeploymentName ?? indexProfile.EmbeddingDeploymentName; + var deploymentName = indexProfile.EmbeddingDeploymentName; + + if (indexProfile.TryGet(out var profileMetadata) && !string.IsNullOrEmpty(profileMetadata.EmbeddingDeploymentName)) + { + deploymentName = profileMetadata.EmbeddingDeploymentName; + } - if (string.IsNullOrWhiteSpace(deploymentName)) + if (string.IsNullOrEmpty(deploymentName)) { if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Unable to create embedding generator for provider '{ProviderName}'.", - indexProfile.ProviderName); + _logger.LogDebug("Unable to retrieve deployment name for index profile '{IndexProfileName}'.", indexProfile.Name); } return; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DefaultAIChatSessionAnalyticsHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DefaultAIChatSessionAnalyticsHandler.cs new file mode 100644 index 00000000..e306b4b9 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DefaultAIChatSessionAnalyticsHandler.cs @@ -0,0 +1,65 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// Records session analytics data during message processing when analytics are enabled +/// for the active AI profile. +/// +public sealed class DefaultAIChatSessionAnalyticsHandler : AIChatSessionHandlerBase +{ + private readonly IAIChatSessionEventService _eventService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The chat-session analytics service. + /// The logger. + public DefaultAIChatSessionAnalyticsHandler( + IAIChatSessionEventService eventService, + ILogger logger) + { + _eventService = eventService; + _logger = logger; + } + + /// + /// Records analytics data after a chat message has completed. + /// + /// The completed-message context. + /// The cancellation token. + public override async Task MessageCompletedAsync( + ChatMessageCompletedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Profile.TryGet(out var analyticsMetadata) || !analyticsMetadata.EnableSessionMetrics) + { + return; + } + + try + { + var userMessageCount = context.Prompts.Count(prompt => prompt.Role == ChatRole.User); + + if (userMessageCount == 1) + { + await _eventService.RecordSessionStartedAsync(context.ChatSession, cancellationToken); + } + + if (context.ResponseLatencyMs > 0) + { + await _eventService.RecordResponseLatencyAsync(context.ChatSession.SessionId, context.ResponseLatencyMs, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to record analytics event for session '{SessionId}'.", context.ChatSession.SessionId); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/DefaultSearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/DefaultSearchIndexProfileHandler.cs index e68b5327..56d91170 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/DefaultSearchIndexProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/DefaultSearchIndexProfileHandler.cs @@ -157,6 +157,12 @@ private static Task PopulateAsync(SearchIndexProfile indexProfile, JsonNode data json.TryUpdateTrimmedStringValue(nameof(SearchIndexProfile.IndexFullName), value => indexProfile.IndexFullName = value); json.TryUpdateTrimmedStringValue(nameof(SearchIndexProfile.Type), value => indexProfile.Type = value); json.TryUpdateTrimmedStringValue(nameof(SearchIndexProfile.EmbeddingDeploymentName), value => indexProfile.EmbeddingDeploymentName = value); + + if (string.IsNullOrWhiteSpace(indexProfile.EmbeddingDeploymentName)) + { + json.TryUpdateTrimmedStringValue("EmbeddingDeploymentId", value => indexProfile.EmbeddingDeploymentName = value); + } + json.TryUpdateTrimmedStringValue(nameof(SearchIndexProfile.OwnerId), value => indexProfile.OwnerId = value); json.TryUpdateTrimmedStringValue(nameof(SearchIndexProfile.Author), value => indexProfile.Author = value); diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 9080b2b9..4179a5eb 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -184,6 +184,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(sp => sp.GetRequiredService()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProfileHandler>()); @@ -406,7 +408,7 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection ArgumentNullException.ThrowIfNull(services); // Register embedded templates from this assembly so they are available - // regardless of the host (OrchardCore, MVC, or any ASP.NET Core app). + // regardless of the host application. services.AddTemplatesFromAssembly(typeof(ServiceCollectionExtensions).Assembly); services.TryAddSingleton(TimeProvider.System); @@ -418,16 +420,14 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection services.AddOptions(); // Register DefaultAIOptions as a scoped service that reads from IOptionsSnapshot - - // and applies GeneralAISettings overrides. Host applications (OrchardCore, MVC, etc.) - - // can replace this with their own implementation (e.g., reading from ISiteService). + // and applies the current GeneralAIOptions value. Host applications can replace + // this with their own implementation when they resolve settings differently. services.TryAddScoped(sp => { var snapshot = sp.GetRequiredService>(); - var settings = sp.GetRequiredService>(); + var settings = sp.GetRequiredService>(); - return snapshot.Value.ApplySiteOverrides(settings.Value); + return snapshot.Value.ApplySiteOverrides(settings.CurrentValue); }); // Register the Framework-level deployment manager. diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs index 38be69ed..063c542c 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs @@ -104,7 +104,7 @@ private async Task RecordUsageAsync( return; } - if (!_serviceProvider.GetRequiredService>().Value.EnableAIUsageTracking) + if (!_serviceProvider.GetRequiredService>().CurrentValue.EnableAIUsageTracking) { return; } diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs index 9a947851..f29432f6 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs @@ -41,22 +41,4 @@ public static string CreateDeploymentId(string providerName, string connectionNa return $"{_deploymentPrefix}{Convert.ToHexStringLower(hash)[..22]}"; } - - /// - /// Determines whether configuration connection id. - /// - /// The item id. - public static bool IsConfigurationConnectionId(string itemId) - { - return !string.IsNullOrWhiteSpace(itemId) && itemId.StartsWith(_connectionPrefix, StringComparison.OrdinalIgnoreCase); - } - - /// - /// Determines whether configuration deployment id. - /// - /// The item id. - public static bool IsConfigurationDeploymentId(string itemId) - { - return !string.IsNullOrWhiteSpace(itemId) && itemId.StartsWith(_deploymentPrefix, StringComparison.OrdinalIgnoreCase); - } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs index 16234302..8abc7932 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs @@ -17,7 +17,7 @@ namespace CrestApps.Core.AI.Services; public sealed class AIMemoryIndexingService { private readonly IAIMemoryStore _memoryStore; - private readonly AIMemoryOptions _memoryOptions; + private readonly IOptionsMonitor _memoryOptions; private readonly ISearchIndexProfileStore _indexProfileStore; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; @@ -36,7 +36,7 @@ public sealed class AIMemoryIndexingService /// The logger. public AIMemoryIndexingService( IAIMemoryStore memoryStore, - IOptions memoryOptions, + IOptionsMonitor memoryOptions, ISearchIndexProfileStore indexProfileStore, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, @@ -44,7 +44,7 @@ public AIMemoryIndexingService( ILogger logger) { _memoryStore = memoryStore; - _memoryOptions = memoryOptions.Value; + _memoryOptions = memoryOptions; _indexProfileStore = indexProfileStore; _deploymentManager = deploymentManager; _aiClientFactory = aiClientFactory; @@ -188,24 +188,26 @@ public async Task SyncAsync(CancellationToken cancellationToken = default) private async Task GetConfiguredIndexProfileAsync(CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(_memoryOptions.IndexProfileName)) + var memoryOptions = _memoryOptions.CurrentValue; + + if (string.IsNullOrWhiteSpace(memoryOptions.IndexProfileName)) { return null; } cancellationToken.ThrowIfCancellationRequested(); - var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName, cancellationToken); + var indexProfile = await _indexProfileStore.FindByNameAsync(memoryOptions.IndexProfileName, cancellationToken); if (indexProfile is null) { - _logger.LogWarning("AI memory indexing is configured to use '{IndexProfileName}', but that index profile was not found.", _memoryOptions.IndexProfileName); + _logger.LogWarning("AI memory indexing is configured to use '{IndexProfileName}', but that index profile was not found.", memoryOptions.IndexProfileName); return null; } if (!string.Equals(indexProfile.Type, IndexProfileTypes.AIMemory, StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("AI memory indexing requires an '{ExpectedType}' index profile, but '{IndexProfileName}' is '{ActualType}'.", IndexProfileTypes.AIMemory, _memoryOptions.IndexProfileName, indexProfile.Type); + _logger.LogWarning("AI memory indexing requires an '{ExpectedType}' index profile, but '{IndexProfileName}' is '{ActualType}'.", IndexProfileTypes.AIMemory, memoryOptions.IndexProfileName, indexProfile.Type); return null; } diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs index 1aee520b..7a24e51f 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs @@ -20,7 +20,7 @@ public sealed class AIMemorySearchService : IAIMemorySearchService private readonly ISearchIndexProfileStore _indexProfileStore; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; - private readonly AIMemoryOptions _memoryOptions; + private readonly IOptionsMonitor _memoryOptions; private readonly ILogger _logger; /// @@ -37,14 +37,14 @@ public AIMemorySearchService( ISearchIndexProfileStore indexProfileStore, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, - IOptions memoryOptions, + IOptionsMonitor memoryOptions, ILogger logger) { _serviceProvider = serviceProvider; _indexProfileStore = indexProfileStore; _deploymentManager = deploymentManager; _aiClientFactory = aiClientFactory; - _memoryOptions = memoryOptions.Value; + _memoryOptions = memoryOptions; _logger = logger; } @@ -81,14 +81,16 @@ public async Task> SearchAsync( return []; } - if (string.IsNullOrWhiteSpace(_memoryOptions.IndexProfileName)) + var memoryOptions = _memoryOptions.CurrentValue; + + if (string.IsNullOrWhiteSpace(memoryOptions.IndexProfileName)) { _logger.LogDebug("Skipping AI memory search because no AI Memory index profile is configured."); return []; } - var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName, cancellationToken); + var indexProfile = await _indexProfileStore.FindByNameAsync(memoryOptions.IndexProfileName, cancellationToken); if (indexProfile is null || !string.Equals(indexProfile.Type, IndexProfileTypes.AIMemory, StringComparison.OrdinalIgnoreCase)) { @@ -96,7 +98,7 @@ public async Task> SearchAsync( { _logger.LogDebug( "Skipping AI memory search because configured index profile '{IndexProfileName}' was not found or is not of type '{IndexProfileType}'.", - _memoryOptions.IndexProfileName, + memoryOptions.IndexProfileName, IndexProfileTypes.AIMemory); } @@ -138,7 +140,7 @@ public async Task> SearchAsync( return []; } - var configuredTopN = _memoryOptions.TopN > 0 ? _memoryOptions.TopN : 5; + var configuredTopN = memoryOptions.TopN > 0 ? memoryOptions.TopN : 5; var topN = requestedTopN.GetValueOrDefault(configuredTopN); topN = Math.Clamp(topN > 0 ? topN : configuredTopN, 1, 20); diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs index dc952a34..a427ad85 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs @@ -213,6 +213,8 @@ private static AIDeploymentConfigurationEntry ParseConfiguredDeploymentEntry(Jso private AIDeployment CreateConfiguredDeployment(AIDeploymentConfigurationEntry entry) { + entry.ClientName = AIProviderNameNormalizer.Normalize(entry.ClientName); + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionUsageService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionUsageService.cs new file mode 100644 index 00000000..84ae457d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionUsageService.cs @@ -0,0 +1,97 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Services; + +/// +/// Default framework service for recording and querying AI completion usage. +/// +public sealed class DefaultAICompletionUsageService : IAICompletionUsageService +{ + private readonly IAICompletionUsageStore _store; + private readonly IServiceProvider _serviceProvider; + private readonly TimeProvider _timeProvider; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IOptionsMonitor _generalAIOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The usage store. + /// The service provider. + /// The time provider. + /// The HTTP context accessor. + /// The general AI options monitor. + public DefaultAICompletionUsageService( + IAICompletionUsageStore store, + IServiceProvider serviceProvider, + TimeProvider timeProvider, + IHttpContextAccessor httpContextAccessor, + IOptionsMonitor generalAIOptions) + { + _store = store; + _serviceProvider = serviceProvider; + _timeProvider = timeProvider; + _httpContextAccessor = httpContextAccessor; + _generalAIOptions = generalAIOptions; + } + + /// + /// Records a completion usage record. + /// + /// The usage record. + /// The cancellation token. + public async Task UsageRecordedAsync( + AICompletionUsageRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + + if (!_generalAIOptions.CurrentValue.EnableAIUsageTracking) + { + return; + } + + record.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + + if (string.IsNullOrEmpty(record.UserName)) + { + record.UserName = _httpContextAccessor.HttpContext?.User?.Identity?.Name; + } + + await _store.SaveAsync(record, cancellationToken); + + if (!string.IsNullOrEmpty(record.SessionId) && + (record.InputTokenCount > 0 || record.OutputTokenCount > 0)) + { + var chatSessionEventService = _serviceProvider.GetService(); + + if (chatSessionEventService is not null) + { + await chatSessionEventService.RecordCompletionUsageAsync( + record.SessionId, + record.InputTokenCount, + record.OutputTokenCount, + cancellationToken); + } + } + } + + /// + /// Retrieves usage records for the optional UTC date range. + /// + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public Task> GetAsync( + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + return _store.GetAsync(startDateUtc, endDateUtc, cancellationToken); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md index 2d41be20..91a7e40f 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md @@ -9,6 +9,9 @@ Parameters: --- Analyze the following completed chat conversation and produce results for the requested tasks. +Return exactly one structured result for each task listed below. Do not omit tasks, and do not return an empty tasks array. If a task does not need a tool call, still return its result value. +IMPORTANT: If you call any tools, you MUST still return the JSON output with the "tasks" array as your final response after all tool calls complete. + Tasks to process: {% for task in tasks %} - {{ task.Name }} (type: {{ task.Type }}){% if task.Instructions %}: {{ task.Instructions }}{% endif %}{% if task.Type == "PredefinedOptions" and task.Options.size > 0 %}{% if task.AllowMultipleValues %} [allowMultiple=true]{% endif %} Options: [{% for option in task.Options %}{% if forloop.index0 > 0 %}, {% endif %}{{ option.Value }}{% if option.Description %} ({{ option.Description }}){% endif %}{% endfor %}]{% endif %} diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md index 230f428e..ae6a8750 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md @@ -12,7 +12,10 @@ You are a post-session analysis assistant. Your job is to analyze a completed ch 2. For PredefinedOptions tasks: select the best matching option(s) from the provided list. Use the option descriptions to guide your selection. If "allowMultiple" is true, you may select more than one option separated by commas. If false, select exactly one. 3. For Semantic tasks: follow the provided instructions and produce a freeform text result. 4. Return ONLY valid JSON only. Do NOT wrap the response in markdown code fences (```). No explanations, no comments. -5. Only return tasks that were requested. +5. Return exactly one result for every requested task, using the same task name. +6. Never return an empty "tasks" array. If a task does not require a tool call, still return the task result value. +7. Only return tasks that were requested. +8. If you are given tools and you call them, you MUST still produce the JSON output below AFTER all tool calls have completed. Tool execution does not replace the required JSON response. Your final message MUST always be the JSON output. [Output Format] { diff --git a/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs index d927a082..633dd96c 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs @@ -179,7 +179,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var ragMetadata = GetRagMetadata(executionContext); - var siteSettings = arguments.Services.GetRequiredService>().Value; + var siteSettings = arguments.Services.GetRequiredService>().CurrentValue; string providerFilter = null; diff --git a/src/Primitives/CrestApps.Core/Extensions/HandlerExtensions.cs b/src/Primitives/CrestApps.Core/Extensions/HandlerExtensions.cs index b680ea9a..58411725 100644 --- a/src/Primitives/CrestApps.Core/Extensions/HandlerExtensions.cs +++ b/src/Primitives/CrestApps.Core/Extensions/HandlerExtensions.cs @@ -10,7 +10,6 @@ public static class HandlerExtensions /// /// Invokes a handler delegate on each item in the enumerable, logging and swallowing /// any exceptions thrown by individual handlers. - /// This is the Framework-level equivalent of OrchardCore.Modules.InvokeAsync. /// public static async Task InvokeAsync( this IEnumerable handlers, diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs index cc07c738..6037f8e4 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs @@ -37,13 +37,6 @@ void WriteCrashEntry(string label, object data) WriteCrashEntry("Process exit signaled", $"Exit code: {Environment.ExitCode}"); }; -// When running under Visual Studio, all mutable data (database, logs, documents, -// site settings) must be stored outside the project source tree. VS monitors the -// source directory for file changes, and any new file triggers VS to stop the debug -// session. Redirecting App_Data and document storage to a temp location avoids this. -var appDataBasePath = Path.Combine(Path.GetTempPath(), "CrestApps", "AppData"); -var documentsBasePath = Path.Combine(Path.GetTempPath(), "CrestApps", "Documents"); - var builder = DistributedApplication.CreateBuilder(args); builder.Services.Configure(options => @@ -70,10 +63,6 @@ void WriteCrashEntry(string label, object data) .WithHttpsEndpoint(5001, name: "HttpsMvcWeb") .WithEnvironment((options) => { - var mvcAppData = Path.Combine(appDataBasePath, "MvcWeb"); - options.EnvironmentVariables.Add("CrestApps__AppDataPath", mvcAppData); - options.EnvironmentVariables.Add("CRESTAPPS_LOG_DIR", Path.Combine(mvcAppData, "logs")); - options.EnvironmentVariables.Add("CrestApps__AI__Documents__BasePath", documentsBasePath); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__DefaultDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); @@ -95,10 +84,6 @@ void WriteCrashEntry(string label, object data) .WithHttpsEndpoint(5201, name: "HttpsBlazorWeb") .WithEnvironment((options) => { - var blazorAppData = Path.Combine(appDataBasePath, "BlazorWeb"); - options.EnvironmentVariables.Add("CrestApps__AppDataPath", blazorAppData); - options.EnvironmentVariables.Add("CRESTAPPS_LOG_DIR", Path.Combine(blazorAppData, "logs")); - options.EnvironmentVariables.Add("CrestApps__AI__Documents__BasePath", documentsBasePath); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__DefaultDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs index e8754f66..b13f2b74 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs @@ -5,48 +5,54 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; -using CrestApps.Core.Blazor.Web.Areas.Indexing.Services; using Microsoft.Extensions.AI; namespace CrestApps.Core.Blazor.Web.Areas.AI.Services; +/// +/// Handles document upload and removal for AI profiles in the Blazor Server application. +/// Uses to create isolated scopes for database operations, +/// preventing when async work outlives the circuit's DI scope. +/// public sealed class AIProfileDocumentService { - private readonly IAIDocumentStore _documentStore; - private readonly IAIDocumentChunkStore _chunkStore; - private readonly IDocumentFileStore _fileStore; - private readonly IAIDocumentProcessingService _documentProcessingService; - private readonly IAIDeploymentManager _deploymentManager; - private readonly IAIClientFactory _aiClientFactory; - private readonly SampleAIDocumentIndexingService _documentIndexingService; + private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + /// + /// Initializes a new instance of the class. + /// + /// The service scope factory used to create isolated DI scopes. + /// The logger instance. public AIProfileDocumentService( - IAIDocumentStore documentStore, - IAIDocumentChunkStore chunkStore, - IDocumentFileStore fileStore, - IAIDocumentProcessingService documentProcessingService, - IAIDeploymentManager deploymentManager, - IAIClientFactory aiClientFactory, - SampleAIDocumentIndexingService documentIndexingService, + IServiceScopeFactory scopeFactory, ILogger logger) { - _documentStore = documentStore; - _chunkStore = chunkStore; - _fileStore = fileStore; - _documentProcessingService = documentProcessingService; - _deploymentManager = deploymentManager; - _aiClientFactory = aiClientFactory; - _documentIndexingService = documentIndexingService; + _scopeFactory = scopeFactory; _logger = logger; } + /// + /// Uploads and processes documents for the specified AI profile. + /// + /// The AI profile to attach documents to. + /// The collection of files to upload. + /// A cancellation token. public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection files, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); ArgumentNullException.ThrowIfNull(files); - var embeddingGenerator = await CreateEmbeddingGeneratorAsync(profile); + await using var scope = _scopeFactory.CreateAsyncScope(); + var documentStore = scope.ServiceProvider.GetRequiredService(); + var chunkStore = scope.ServiceProvider.GetRequiredService(); + var fileStore = scope.ServiceProvider.GetRequiredService(); + var documentProcessingService = scope.ServiceProvider.GetRequiredService(); + var deploymentManager = scope.ServiceProvider.GetRequiredService(); + var aiClientFactory = scope.ServiceProvider.GetRequiredService(); + var documentIndexingService = scope.ServiceProvider.GetRequiredService(); + + var embeddingGenerator = await CreateEmbeddingGeneratorAsync(profile, deploymentManager, aiClientFactory); foreach (var file in files) { @@ -59,7 +65,7 @@ public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection(); documentsMetadata.Documents ??= []; @@ -105,6 +111,12 @@ public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection + /// Removes the specified documents from the AI profile. + /// + /// The AI profile to remove documents from. + /// The IDs of documents to remove. + /// A cancellation token. public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection documentIds, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); @@ -117,6 +129,12 @@ public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection(); + var chunkStore = scope.ServiceProvider.GetRequiredService(); + var fileStore = scope.ServiceProvider.GetRequiredService(); + var documentIndexingService = scope.ServiceProvider.GetRequiredService(); + foreach (var documentId in documentIds) { cancellationToken.ThrowIfCancellationRequested(); @@ -138,25 +156,25 @@ public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection 0) { - await _documentIndexingService.DeleteChunksAsync(chunks.Select(c => c.ItemId).ToArray(), cancellationToken); + await documentIndexingService.DeleteChunksAsync(chunks.Select(c => c.ItemId).ToArray(), cancellationToken); } - await _chunkStore.DeleteByDocumentIdAsync(documentId); + await chunkStore.DeleteByDocumentIdAsync(documentId); - var document = await _documentStore.FindByIdAsync(documentId, cancellationToken); + var document = await documentStore.FindByIdAsync(documentId, cancellationToken); if (document != null) { if (!string.IsNullOrWhiteSpace(document.StoredFilePath)) { - await _fileStore.DeleteFileAsync(document.StoredFilePath); + await fileStore.DeleteFileAsync(document.StoredFilePath); } - await _documentStore.DeleteAsync(document, cancellationToken); + await documentStore.DeleteAsync(document, cancellationToken); } } catch (Exception ex) @@ -168,6 +186,11 @@ public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection + /// Removes all documents associated with the specified AI profile. + /// + /// The AI profile to remove all documents from. + /// A cancellation token. public Task RemoveAllDocumentsAsync(AIProfile profile, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); @@ -183,28 +206,31 @@ public Task RemoveAllDocumentsAsync(AIProfile profile, CancellationToken cancell return RemoveDocumentsAsync(profile, documentIds, cancellationToken); } - private async Task>> CreateEmbeddingGeneratorAsync(AIProfile profile) + private static async Task>> CreateEmbeddingGeneratorAsync( + AIProfile profile, + IAIDeploymentManager deploymentManager, + IAIClientFactory aiClientFactory) { - var deployment = await ResolveEmbeddingDeploymentAsync(profile); + var deployment = await ResolveEmbeddingDeploymentAsync(profile, deploymentManager); if (deployment == null || string.IsNullOrWhiteSpace(deployment.ConnectionName)) { return null; } - return await _aiClientFactory.CreateEmbeddingGeneratorAsync(deployment); + return await aiClientFactory.CreateEmbeddingGeneratorAsync(deployment); } - private async Task ResolveEmbeddingDeploymentAsync(AIProfile profile) + private static async Task ResolveEmbeddingDeploymentAsync(AIProfile profile, IAIDeploymentManager deploymentManager) { ArgumentNullException.ThrowIfNull(profile); - var profileDeployment = await ResolveProfileDeploymentAsync(profile); + var profileDeployment = await ResolveProfileDeploymentAsync(profile, deploymentManager); if (profileDeployment != null && !string.IsNullOrWhiteSpace(profileDeployment.ClientName)) { - var scopedEmbeddingDeployment = await _deploymentManager.ResolveOrDefaultAsync( + var scopedEmbeddingDeployment = await deploymentManager.ResolveOrDefaultAsync( AIDeploymentType.Embedding, clientName: profileDeployment.ClientName); @@ -214,14 +240,14 @@ private async Task ResolveEmbeddingDeploymentAsync(AIProfile profi } } - return await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Embedding); + return await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Embedding); } - private async Task ResolveProfileDeploymentAsync(AIProfile profile) + private static async Task ResolveProfileDeploymentAsync(AIProfile profile, IAIDeploymentManager deploymentManager) { if (!string.IsNullOrWhiteSpace(profile.ChatDeploymentName)) { - var chatDeployment = await _deploymentManager.ResolveOrDefaultAsync( + var chatDeployment = await deploymentManager.ResolveOrDefaultAsync( AIDeploymentType.Chat, deploymentName: profile.ChatDeploymentName); @@ -233,7 +259,7 @@ private async Task ResolveProfileDeploymentAsync(AIProfile profile if (!string.IsNullOrWhiteSpace(profile.UtilityDeploymentName)) { - var utilityDeployment = await _deploymentManager.ResolveOrDefaultAsync( + var utilityDeployment = await deploymentManager.ResolveOrDefaultAsync( AIDeploymentType.Utility, deploymentName: profile.UtilityDeploymentName); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs index 23332dc1..f84914f7 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs @@ -5,7 +5,6 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; -using CrestApps.Core.Blazor.Web.Areas.Indexing.Services; using Microsoft.Extensions.AI; namespace CrestApps.Core.Blazor.Web.Areas.AI.Services; @@ -18,7 +17,7 @@ public sealed class AIProfileTemplateDocumentService private readonly IAIDocumentProcessingService _documentProcessingService; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; - private readonly SampleAIDocumentIndexingService _documentIndexingService; + private readonly DefaultAIDocumentIndexingService _documentIndexingService; private readonly ILogger _logger; public AIProfileTemplateDocumentService( @@ -28,7 +27,7 @@ public AIProfileTemplateDocumentService( IAIDocumentProcessingService documentProcessingService, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, - SampleAIDocumentIndexingService documentIndexingService, + DefaultAIDocumentIndexingService documentIndexingService, ILogger logger) { _documentStore = documentStore; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs index ca4b98e2..7eacd167 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs @@ -1,5 +1,5 @@ +using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.Blazor.Web.Areas.AIChat.Services; -using CrestApps.Core.Blazor.Web.Areas.Indexing.Services; namespace CrestApps.Core.Blazor.Web.Areas.AIChat.BackgroundServices; @@ -26,7 +26,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { await using var scope = _scopeFactory.CreateAsyncScope(); - var indexingService = scope.ServiceProvider.GetRequiredService(); + var indexingService = scope.ServiceProvider.GetRequiredService(); switch (workItem.Type) { @@ -44,7 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "An error occurred while processing queued MVC chat document indexing work."); + _logger.LogError(ex, "An error occurred while processing queued chat document indexing work."); } } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs deleted file mode 100644 index 6efecfa7..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs +++ /dev/null @@ -1,237 +0,0 @@ -using CrestApps.Core.AI; -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Chat.Services; -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Profiles; -using CrestApps.Core.Services; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.BackgroundServices; - -/// -/// Periodically closes inactive AI chat sessions and marks them for post-session processing. -/// Mirrors the behavior of Orchard Core's AIChatSessionCloseBackgroundTask. -/// -public sealed class AIChatSessionCloseBackgroundService : BackgroundService -{ - private static readonly TimeSpan _interval = TimeSpan.FromMinutes(5); - private static readonly TimeSpan _defaultInactivityTimeout = TimeSpan.FromMinutes(30); - private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(5); - private const int _pageSize = 100; - - private readonly IServiceScopeFactory _scopeFactory; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public AIChatSessionCloseBackgroundService( - IServiceScopeFactory scopeFactory, - TimeProvider timeProvider, - ILogger logger) - { - _scopeFactory = scopeFactory; - _timeProvider = timeProvider; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using var timer = new PeriodicTimer(_interval); - - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - try - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var sessionManager = scope.ServiceProvider.GetRequiredService(); - var profileManager = scope.ServiceProvider.GetRequiredService(); - var postCloseProcessor = scope.ServiceProvider.GetRequiredService(); - var promptStore = scope.ServiceProvider.GetRequiredService(); - var storeCommitter = scope.ServiceProvider.GetRequiredService(); - var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - - await CloseInactiveSessionsAsync(sessionManager, profileManager, promptStore, postCloseProcessor, utcNow, stoppingToken); - await RetryPendingProcessingAsync(sessionManager, profileManager, promptStore, postCloseProcessor, utcNow, stoppingToken); - - await storeCommitter.CommitAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while closing inactive AI chat sessions."); - } - } - } - - /// - /// Finds active sessions that have exceeded their profile's inactivity timeout and closes them. - /// - private async Task CloseInactiveSessionsAsync( - IAIChatSessionManager sessionManager, - IAIProfileManager profileManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - DateTime utcNow, - CancellationToken cancellationToken) - { - var profiles = await profileManager.GetAsync(AIProfileType.Chat, cancellationToken); - - foreach (var profile in profiles) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - var settings = profile.GetOrCreateSettings(); - var timeout = settings?.SessionInactivityTimeoutInMinutes > 0 - ? TimeSpan.FromMinutes(settings.SessionInactivityTimeoutInMinutes) - : _defaultInactivityTimeout; - - var cutoffUtc = utcNow - timeout; - - var queryContext = new AIChatSessionQueryContext { ProfileId = profile.ItemId }; - var page = 1; - AIChatSessionResult result; - - do - { - result = await sessionManager.PageAsync(page, _pageSize, queryContext, cancellationToken); - - foreach (var entry in result.Sessions) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (entry.Status != ChatSessionStatus.Active || entry.LastActivityUtc >= cutoffUtc) - { - continue; - } - - var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); - - if (chatSession is null || chatSession.Status != ChatSessionStatus.Active) - { - continue; - } - - chatSession.Status = ChatSessionStatus.Closed; - chatSession.ClosedAtUtc = utcNow; - - if (AIChatSessionPostCloseProcessor.NeedsProcessing(profile, chatSession)) - { - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - } - else - { - chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.None; - } - - await sessionManager.SaveAsync(chatSession, cancellationToken); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Closed inactive session '{SessionId}' for profile '{ProfileId}'. Post-processing: {NeedsProcessing}.", - chatSession.SessionId, - profile.ItemId, - chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.None); - } - } - - page++; - } - while (result.Sessions.Any()); - } - } - - private async Task RetryPendingProcessingAsync( - IAIChatSessionManager sessionManager, - IAIProfileManager profileManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - DateTime utcNow, - CancellationToken cancellationToken) - { - var profiles = await profileManager.GetAsync(AIProfileType.Chat, cancellationToken); - - foreach (var profile in profiles) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - var queryContext = new AIChatSessionQueryContext { ProfileId = profile.ItemId }; - var page = 1; - AIChatSessionResult result; - - do - { - result = await sessionManager.PageAsync(page, _pageSize, queryContext, cancellationToken); - - foreach (var entry in result.Sessions) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - if (entry.Status != ChatSessionStatus.Closed) - { - continue; - } - - var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); - - if (chatSession is null) - { - continue; - } - - if (chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.Pending) - { - continue; - } - - if (chatSession.PostSessionProcessingAttempts >= AIChatSessionPostCloseProcessor.MaxPostCloseAttempts) - { - chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.Failed; - await sessionManager.SaveAsync(chatSession, cancellationToken); - - _logger.LogWarning( - "Post-session processing for session '{SessionId}' failed after {MaxAttempts} attempts.", - chatSession.SessionId, - AIChatSessionPostCloseProcessor.MaxPostCloseAttempts); - - continue; - } - - if (chatSession.PostSessionProcessingLastAttemptUtc.HasValue - && (utcNow - chatSession.PostSessionProcessingLastAttemptUtc.Value) < _retryDelay) - { - continue; - } - - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - await sessionManager.SaveAsync(chatSession, cancellationToken); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Processed pending post-close work for session '{SessionId}'.", - chatSession.SessionId); - } - } - - page++; - } - while (result.Sessions.Any()); - } - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs deleted file mode 100644 index 64b1d01f..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Handlers; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Blazor.Web.Areas.AIChat.Services; -using Microsoft.Extensions.AI; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Handlers; - -public sealed class AnalyticsChatSessionHandler : AIChatSessionHandlerBase -{ - private readonly SampleAIChatSessionEventService _eventService; - private readonly ILogger _logger; - - public AnalyticsChatSessionHandler( - SampleAIChatSessionEventService eventService, - ILogger logger) - { - _eventService = eventService; - _logger = logger; - } - - public override async Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default) - { - if (!context.Profile.TryGet(out var analyticsMetadata) || !analyticsMetadata.EnableSessionMetrics) - { - return; - } - - try - { - var userMessageCount = context.Prompts.Count(p => p.Role == ChatRole.User); - - if (userMessageCount == 1) - { - await _eventService.RecordSessionStartedAsync(context.ChatSession); - } - - if (context.ResponseLatencyMs > 0) - { - await _eventService.RecordResponseLatencyAsync(context.ChatSession.SessionId, context.ResponseLatencyMs); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record analytics event for session '{SessionId}'.", context.ChatSession.SessionId); - } - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Hubs/AIChatHub.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Hubs/AIChatHub.cs index 4a4d7f5c..1704d56b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Hubs/AIChatHub.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Hubs/AIChatHub.cs @@ -1,9 +1,7 @@ -using CrestApps.Core.AI; using CrestApps.Core.AI.Chat.Hubs; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; -using CrestApps.Core.Blazor.Web.Areas.AIChat.Services; -using CrestApps.Core.Blazor.Web.Services; using Microsoft.AspNetCore.Authorization; namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Hubs; @@ -29,7 +27,7 @@ protected override void CollectStreamingReferences( Dictionary references, HashSet contentItemIds) { - var citationCollector = services.GetRequiredService(); + var citationCollector = services.GetRequiredService(); if (handlerContext.Properties.TryGetValue("OrchestrationContext", out var ctxObj) && ctxObj is OrchestrationContext orchestrationContext) @@ -41,30 +39,4 @@ protected override void CollectStreamingReferences( citationCollector.CollectToolReferences(references, contentItemIds); } - protected override async Task OnMessageRatedAsync( - IServiceProvider services, - AIChatSession chatSession, - IAIChatSessionPromptStore promptStore) - { - var eventService = services.GetService(); - - if (eventService is null) - { - return; - } - - var allPrompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - var ratings = allPrompts - .Where(prompt => prompt.UserRating.HasValue) - .Select(prompt => prompt.UserRating.Value) - .ToList(); - - if (ratings.Count > 0) - { - await eventService.RecordUserRatingAsync( - chatSession.SessionId, - ratings.Count(rating => rating), - ratings.Count(rating => !rating)); - } - } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs deleted file mode 100644 index 7902fe43..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionEventPostCloseObserver : IAIChatSessionAnalyticsRecorder, IAIChatSessionConversionGoalRecorder -{ - private readonly SampleAIChatSessionEventService _eventService; - - public SampleAIChatSessionEventPostCloseObserver(SampleAIChatSessionEventService eventService) - { - _eventService = eventService; - } - - public async Task RecordSessionEndedAsync(AIProfile profile, AIChatSession session, IReadOnlyList prompts, bool isResolved, CancellationToken cancellationToken = default) - { - await _eventService.RecordSessionEndedAsync(session, prompts.Count, isResolved); - } - - public async Task RecordConversionGoalsAsync(AIProfile profile, AIChatSession session, IReadOnlyList goalResults, CancellationToken cancellationToken = default) - { - await _eventService.RecordConversionMetricsAsync(session.SessionId, goalResults.ToList()); - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs deleted file mode 100644 index 108fc2af..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Collections.Concurrent; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionEventService -{ - private static readonly ConcurrentDictionary _store = new(StringComparer.OrdinalIgnoreCase); - - public Task RecordSessionStartedAsync(AIChatSession chatSession) - { - ArgumentNullException.ThrowIfNull(chatSession); - - var now = TimeProvider.System.GetUtcNow().UtcDateTime; - var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); - var evt = new AIChatSessionEvent - { - SessionId = chatSession.SessionId, - ProfileId = chatSession.ProfileId, - VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, - UserId = chatSession.UserId, - IsAuthenticated = isAuthenticated, - SessionStartedUtc = now, - MessageCount = 0, - HandleTimeSeconds = 0, - IsResolved = false, - CompletionCount = 0, - CreatedUtc = now, - }; - _store[chatSession.SessionId] = evt; - - return Task.CompletedTask; - } - - public Task RecordSessionEndedAsync(AIChatSession chatSession, int promptCount, bool isResolved) - { - ArgumentNullException.ThrowIfNull(chatSession); - - if (!_store.TryGetValue(chatSession.SessionId, out var evt)) - { - var now = TimeProvider.System.GetUtcNow().UtcDateTime; - var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); - evt = new AIChatSessionEvent - { - SessionId = chatSession.SessionId, - ProfileId = chatSession.ProfileId, - VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, - UserId = chatSession.UserId, - IsAuthenticated = isAuthenticated, - SessionStartedUtc = chatSession.CreatedUtc, - SessionEndedUtc = chatSession.ClosedAtUtc ?? now, - MessageCount = promptCount, - HandleTimeSeconds = ((chatSession.ClosedAtUtc ?? now) - chatSession.CreatedUtc).TotalSeconds, - IsResolved = isResolved, - CompletionCount = 0, - CreatedUtc = now, - }; - _store[chatSession.SessionId] = evt; - - return Task.CompletedTask; - } - - var endTime = chatSession.ClosedAtUtc ?? TimeProvider.System.GetUtcNow().UtcDateTime; - evt.SessionEndedUtc = endTime; - evt.MessageCount = promptCount; - evt.IsResolved = isResolved; - evt.HandleTimeSeconds = (endTime - evt.SessionStartedUtc).TotalSeconds; - - return Task.CompletedTask; - } - - public Task RecordCompletionUsageAsync(string sessionId, int inputTokens, int outputTokens) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - if (!_store.TryGetValue(sessionId, out var evt)) - { - return Task.CompletedTask; - } - - evt.TotalInputTokens += inputTokens; - evt.TotalOutputTokens += outputTokens; - - return Task.CompletedTask; - } - - public Task RecordResponseLatencyAsync(string sessionId, double responseLatencyMs) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - if (!_store.TryGetValue(sessionId, out var evt) || responseLatencyMs <= 0) - { - return Task.CompletedTask; - } - - evt.CompletionCount++; - evt.AverageResponseLatencyMs = ((evt.AverageResponseLatencyMs * (evt.CompletionCount - 1)) + responseLatencyMs) / evt.CompletionCount; - - return Task.CompletedTask; - } - - public Task RecordConversionMetricsAsync(string sessionId, List goalResults) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - ArgumentNullException.ThrowIfNull(goalResults); - - if (!_store.TryGetValue(sessionId, out var evt)) - { - return Task.CompletedTask; - } - - evt.ConversionGoalResults = goalResults; - evt.ConversionScore = goalResults.Sum(result => result.Score); - evt.ConversionMaxScore = goalResults.Sum(result => result.MaxScore); - - return Task.CompletedTask; - } - - public Task RecordUserRatingAsync(string sessionId, int thumbsUpCount, int thumbsDownCount) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - if (!_store.TryGetValue(sessionId, out var evt)) - { - return Task.CompletedTask; - } - - evt.ThumbsUpCount = thumbsUpCount; - evt.ThumbsDownCount = thumbsDownCount; - evt.UserRating = thumbsUpCount + thumbsDownCount > 0 ? thumbsUpCount >= thumbsDownCount : null; - - return Task.CompletedTask; - } - - public Task> GetAsync(string profileId, DateTime? startDateUtc, DateTime? endDateUtc, CancellationToken cancellationToken = default) - { - var values = _store.Values.AsEnumerable(); - - if (!string.IsNullOrEmpty(profileId)) - { - values = values.Where(x => string.Equals(x.ProfileId, profileId, StringComparison.OrdinalIgnoreCase)); - } - - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - values = values.Where(x => x.SessionStartedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - values = values.Where(x => x.SessionStartedUtc < endExclusive); - } - - IReadOnlyList result = values - .OrderByDescending(x => x.SessionStartedUtc) - .ToList(); - - return Task.FromResult(result); - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs deleted file mode 100644 index 96ee06a1..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Concurrent; -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionExtractedDataService : IAIChatSessionExtractedDataRecorder -{ - private static readonly ConcurrentDictionary _store = new(StringComparer.OrdinalIgnoreCase); - - public Task RecordExtractedDataAsync(AIProfile profile, AIChatSession session, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(session); - - if (session.ExtractedData.Count == 0) - { - _store.TryRemove(session.SessionId, out _); - - return Task.CompletedTask; - } - - var record = _store.GetOrAdd(session.SessionId, _ => new AIChatSessionExtractedDataRecord - { - ItemId = session.SessionId, - SessionId = session.SessionId, - }); - - record.ProfileId = profile.ItemId; - record.SessionStartedUtc = session.CreatedUtc; - record.SessionEndedUtc = session.ClosedAtUtc; - record.UpdatedUtc = TimeProvider.System.GetUtcNow().UtcDateTime; - record.Values = session.ExtractedData - .Where(pair => pair.Value.Values.Count > 0) - .ToDictionary(pair => pair.Key, pair => pair.Value.Values.ToList(), StringComparer.OrdinalIgnoreCase); - - return Task.CompletedTask; - } - - public Task> GetAsync(string profileId, DateTime? startDateUtc, DateTime? endDateUtc, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(profileId); - - var values = _store.Values - .Where(x => string.Equals(x.ProfileId, profileId, StringComparison.OrdinalIgnoreCase)); - - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - values = values.Where(x => x.SessionStartedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - values = values.Where(x => x.SessionStartedUtc < endExclusive); - } - - IReadOnlyList result = values - .OrderByDescending(x => x.SessionStartedUtc) - .ToList(); - - return Task.FromResult(result); - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs deleted file mode 100644 index 5fcb395b..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Concurrent; -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Models; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.Blazor.Web.Areas.AIChat.Services; - -public sealed class SampleAICompletionUsageService : IAICompletionUsageObserver -{ - private static readonly ConcurrentBag _store = []; - - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly SampleAIChatSessionEventService _chatSessionEventService; - private readonly GeneralAIOptions _generalAIOptions; - - public SampleAICompletionUsageService( - IHttpContextAccessor httpContextAccessor, - SampleAIChatSessionEventService chatSessionEventService, - IOptions generalAIOptions) - { - _httpContextAccessor = httpContextAccessor; - _chatSessionEventService = chatSessionEventService; - _generalAIOptions = generalAIOptions.Value; - } - - public async Task UsageRecordedAsync(AICompletionUsageRecord record, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(record); - - if (!_generalAIOptions.EnableAIUsageTracking) - { - return; - } - - record.CreatedUtc = TimeProvider.System.GetUtcNow().UtcDateTime; - - if (string.IsNullOrEmpty(record.UserName)) - { - record.UserName = _httpContextAccessor.HttpContext?.User?.Identity?.Name; - } - - _store.Add(record); - - if (!string.IsNullOrEmpty(record.SessionId) && - (record.InputTokenCount > 0 || record.OutputTokenCount > 0)) - { - await _chatSessionEventService.RecordCompletionUsageAsync(record.SessionId, record.InputTokenCount, record.OutputTokenCount); - } - } - - public Task> GetAsync( - DateTime? startDateUtc, - DateTime? endDateUtc, - CancellationToken cancellationToken = default) - { - var values = _store.AsEnumerable(); - - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - values = values.Where(x => x.CreatedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - values = values.Where(x => x.CreatedUtc < endExclusive); - } - - IReadOnlyList result = values - .OrderByDescending(x => x.CreatedUtc) - .ToList(); - - return Task.FromResult(result); - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs index 482d12b9..68d71611 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs @@ -25,11 +25,30 @@ public SampleClaudeOptionsConfiguration( public void Configure(ClaudeOptions options) { - var settings = _siteSettings.Get(); - if (settings == null) - { - return; - } + Apply(_siteSettings.Get(), options, _dataProtectionProvider, _logger); + } + + public static ClaudeOptions Create( + ClaudeSettings settings, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + var options = new ClaudeOptions(); + Apply(settings, options, dataProtectionProvider, logger); + + return options; + } + + public static void Apply( + ClaudeSettings settings, + ClaudeOptions options, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dataProtectionProvider); + ArgumentNullException.ThrowIfNull(logger); options.BaseUrl = settings.BaseUrl; options.DefaultModel = settings.DefaultModel; @@ -46,12 +65,12 @@ public void Configure(ClaudeOptions options) try { - var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose); + var protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); options.ApiKey = protector.Unprotect(settings.ProtectedApiKey); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); + logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); } } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs index 87eaa6c3..183a393e 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs @@ -25,12 +25,30 @@ public SampleCopilotOptionsConfiguration( public void Configure(CopilotOptions options) { - var settings = _siteSettings.Get(); + Apply(_siteSettings.Get(), options, _dataProtectionProvider, _logger); + } - if (settings == null) - { - return; - } + public static CopilotOptions Create( + CopilotSettings settings, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + var options = new CopilotOptions(); + Apply(settings, options, dataProtectionProvider, logger); + + return options; + } + + public static void Apply( + CopilotSettings settings, + CopilotOptions options, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dataProtectionProvider); + ArgumentNullException.ThrowIfNull(logger); options.AuthenticationType = settings.AuthenticationType; options.ClientId = settings.ClientId; @@ -41,7 +59,7 @@ public void Configure(CopilotOptions options) options.DefaultModel = settings.DefaultModel; options.AzureApiVersion = settings.AzureApiVersion; - var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose); + var protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); if (!string.IsNullOrWhiteSpace(settings.ProtectedClientSecret)) { @@ -51,7 +69,7 @@ public void Configure(CopilotOptions options) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Copilot client secret."); + logger.LogWarning(ex, "Failed to unprotect Copilot client secret."); } } @@ -63,7 +81,7 @@ public void Configure(CopilotOptions options) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Copilot API key."); + logger.LogWarning(ex, "Failed to unprotect Copilot API key."); } } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs index 1bc880b2..f23b5873 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs @@ -1,8 +1,8 @@ using CrestApps.Core.AI.Chat.Hubs; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; using CrestApps.Core.Blazor.Web.Areas.ChatInteractions.Models; -using CrestApps.Core.Blazor.Web.Services; using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authorization; @@ -11,13 +11,13 @@ namespace CrestApps.Core.Blazor.Web.Areas.ChatInteractions.Hubs; [Authorize] public sealed class ChatInteractionHub : ChatInteractionHubBase { - private readonly SampleCitationReferenceCollector _citationCollector; + private readonly CitationReferenceCollector _citationCollector; private readonly SiteSettingsStore _siteSettings; public ChatInteractionHub( IServiceProvider serviceProvider, TimeProvider timeProvider, - SampleCitationReferenceCollector citationCollector, + CitationReferenceCollector citationCollector, SiteSettingsStore siteSettings, ILogger logger) : base(serviceProvider, timeProvider, logger) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Create.razor index 5fff7737..244458c9 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Create.razor @@ -32,6 +32,7 @@
@_nameError
}
Auto-generated from the title. You may override it before saving.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Edit.razor index f87443f7..12cc652a 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Edit.razor @@ -40,7 +40,6 @@ else if (_model != null) {
@_nameError
} -
The technical name cannot be changed after creation.
@@ -162,13 +161,6 @@ else if (_model != null) protected override async Task OnInitializedAsync() { - if (AIConfigurationRecordIds.IsConfigurationConnectionId(Id)) - { - Navigation.NavigateTo("/ai/connections"); - - return; - } - var connection = await Catalog.FindByIdAsync(Id); if (connection == null) { @@ -177,7 +169,7 @@ else if (_model != null) return; } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) + if (connection.IsReadOnly) { Navigation.NavigateTo("/ai/connections"); @@ -226,7 +218,7 @@ else if (_model != null) return; } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) + if (connection.IsReadOnly) { Navigation.NavigateTo("/ai/connections"); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Index.razor index 77f2966f..9a270e10 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIConnections/Index.razor @@ -135,13 +135,7 @@ else var connections = await Store.GetAllAsync(); _connections = connections - .Select(connection => - { - var model = AIConnectionViewModel.FromConnection(connection); - model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId); - - return model; - }) + .Select(AIConnectionViewModel.FromConnection) .OrderBy(static model => model.DisplayText ?? model.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -151,13 +145,6 @@ else private async Task DeleteAsync(string id) { - if (AIConfigurationRecordIds.IsConfigurationConnectionId(id)) - { - _errorMessage = "Connections defined in appsettings are read-only and cannot be deleted from the UI."; - - return; - } - var confirmed = await JS.InvokeAsync("confirm", "Delete this connection?"); if (!confirmed) @@ -173,7 +160,7 @@ else return; } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) + if (connection.IsReadOnly) { _errorMessage = "Connections defined in appsettings are read-only and cannot be deleted from the UI."; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor index 19609f87..6bf403da 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor @@ -44,6 +44,7 @@
This is the deployment identifier shown throughout the app. It is auto-generated from the model name and can be adjusted before saving.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Edit.razor index fb68db09..6e47ae97 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Edit.razor @@ -55,7 +55,6 @@ else
-
This deployment identifier is shown throughout the app and cannot be changed after creation.
@@ -199,7 +198,7 @@ else return; } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId)) + if (deployment.IsReadOnly) { Navigation.NavigateTo("/ai/deployments"); @@ -269,13 +268,6 @@ else { _validationErrors.Clear(); - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(_model.ItemId)) - { - Navigation.NavigateTo("/ai/deployments"); - - return; - } - if (string.IsNullOrWhiteSpace(_model.ModelName)) { _validationErrors.Add("Model name is required."); @@ -325,7 +317,7 @@ else return; } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId)) + if (deployment.IsReadOnly) { Navigation.NavigateTo("/ai/deployments"); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Index.razor index 17dd48d1..800bfda9 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Index.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Index.razor @@ -153,13 +153,7 @@ else var deployments = await Store.GetAllAsync(); _deployments = deployments - .Select(deployment => - { - var model = AIDeploymentViewModel.FromDeployment(deployment); - model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId); - - return model; - }) + .Select(AIDeploymentViewModel.FromDeployment) .OrderBy(static model => model.TechnicalName, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -169,13 +163,6 @@ else private async Task DeleteAsync(string id) { - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(id)) - { - _errorMessage = "Deployments defined in appsettings are read-only and cannot be deleted from the UI."; - - return; - } - var confirmed = await JS.InvokeAsync("confirm", "Delete this deployment?"); if (!confirmed) @@ -191,7 +178,7 @@ else return; } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId)) + if (deployment.IsReadOnly) { _errorMessage = "Deployments defined in appsettings are read-only and cannot be deleted from the UI."; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index eb5966e2..ff02280d 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -34,9 +34,9 @@ @inject IOptions OrchestratorOpts @inject IOptionsSnapshot ClaudeOpts @inject ClaudeClientService ClaudeClientService -@inject IOptions CopilotOpts +@inject IOptionsMonitor CopilotOpts @inject IOptions ToolOpts -@inject IOptions DocOpts +@inject IOptionsMonitor DocOpts @inject IOptions ChatDocumentOptions @inject ISpeechVoiceResolver SpeechVoiceResolver @inject AIProfileDocumentService ProfileDocumentService @@ -123,6 +123,7 @@ {
@_nameError
} +
The technical name cannot be changed after creation.
@@ -161,7 +162,7 @@ -
Choose text input, microphone dictation, or full conversation mode.
+
Controls whether this profile uses text input only, microphone dictation, or full two-way conversation mode.
} @@ -174,7 +175,7 @@ -
Show a play button on assistant messages when a text-to-speech deployment is configured.
+
When enabled, assistant messages show a play button that reads the response aloud using the configured text-to-speech deployment.
@if (_model.ChatMode == ChatMode.Conversation) @@ -193,7 +194,7 @@ } -
Leave empty to use the site's default text-to-speech voice.
+
Select a voice for conversation mode. Leave this empty to use the site's default voice.
} } @@ -231,7 +232,7 @@ } -
Optional deployment for utility tasks.
+
Optional deployment for utility tasks (planning, summarization).
@@ -270,7 +271,7 @@
-
Optionally override the model name.
+
Optionally override the model name. Leave empty to use the default model configured in Copilot settings.
@@ -280,7 +281,7 @@ } -
Select the reasoning effort level.
+
Select the reasoning effort level for the Copilot model.
@@ -317,11 +318,13 @@ } +
Select the reasoning effort level for the Copilot model.
-
+
+
When checked, the Copilot CLI runs with the --allow-all flag.
} else { @@ -361,7 +364,7 @@ } -
Select a Claude model override, or leave on the configured default.
+
Select a Claude model override for this item, or leave it on the configured default.
@@ -379,7 +382,7 @@
-
Optionally override the Claude model.
+
Optionally override the Claude model. Leave empty to use the default model configured in settings.
@@ -411,7 +414,7 @@
-
When enabled, each new chat session starts with an assistant message from the initial prompt.
+
When enabled, each new chat session starts with an assistant message from the initial prompt and the welcome message is ignored.
@if (!_model.AddInitialPrompt) @@ -419,7 +422,7 @@
-
The welcome message to show when a new session is created.
+
The welcome message to show on the user-interface when a new session is created.
} else @@ -427,7 +430,7 @@
-
This assistant message is added as the first chat history entry.
+
This assistant message is added as the first chat history entry when a new session is created.
} } @@ -605,11 +608,11 @@
AI Agents
@if (_model.AvailableAgents.Count == 0) { -
No agent profiles are available.
+
No agent profiles are available. Create an agent profile first.
} else { -

Select the agent profiles this profile can delegate tasks to.

+

Select the agent profiles this profile can delegate tasks to during conversations.

@foreach (var agent in _model.AvailableAgents) {
@@ -629,11 +632,11 @@
Agent to Agent Hosts
@if (_model.AvailableA2AConnections.Count == 0) { -
No A2A host connections are configured.
+
No A2A host connections are configured. Add them under Agent to Agent Hosts first.
} else { -

Select the remote A2A hosts this profile can use.

+

Select the remote A2A hosts this profile can use for agent orchestration.

@foreach (var connection in _model.AvailableA2AConnections) {
@@ -650,11 +653,11 @@
MCP Hosts
@if (_model.AvailableMcpConnections.Count == 0) { -
No MCP hosts are configured.
+
No MCP hosts are configured. Add them under MCP Hosts first.
} else { -

Select the MCP server connections this profile can use.

+

Select the MCP server connections this profile can use for external tools and resources.

@foreach (var connection in _model.AvailableMcpConnections) {
@@ -679,7 +682,7 @@ else {
-

Select the tools this profile can use.

+

Select the tools this profile can use during conversations.

@@ -746,7 +749,7 @@
-
Number of top matching chunks to include as AI context.
+
Number of top matching chunks or documents to include as AI context.
@@ -759,7 +762,7 @@ } -
Chunk keeps chunk-level context. Hierarchical injects full document text.
+
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
@@ -784,7 +787,7 @@ @if (!_model.HasDocumentIndexConfiguration) {
- Document knowledge-base search is not configured yet. Create an AI Documents index profile first. + Document knowledge-base search is not configured yet. Create an AI Documents index profile and select it under Settings → AI Settings → Documents before expecting uploaded documents to influence answers.
} @@ -793,7 +796,7 @@
-
Allow users to upload documents during chat sessions.
+
Allow users to upload documents during chat sessions with this profile.
@@ -871,7 +874,7 @@
-
Run AI-powered extraction on conversation messages.
+
Run AI-powered extraction on conversation messages to populate structured fields.
@if (_model.EnableDataExtraction) @@ -881,7 +884,7 @@
-
How often extraction runs (1 = every message).
+
How often extraction runs (1 = every message, 2 = every other message, etc.).
@@ -933,6 +936,7 @@ }
+

Configure session metrics and post-session processing for conversations using this profile.

Session Metrics
@@ -967,38 +971,133 @@ var index = i; var task = _model.PostSessionTasks[index]; -
+
- @(string.IsNullOrWhiteSpace(task.Name) ? "New Task" : task.Name) - + + + +
-
- - +
+
+
+
+ + +
+
+
+
+ + + @foreach (var tt in Enum.GetValues()) + { + + } + +
+
+
+
+ + +
+
+
+ + +
+
+ @if (task.Type == PostSessionTaskType.PredefinedOptions) + { +
+ + +
+ }
-
- - - @foreach (var tt in Enum.GetValues()) +
+

Select the capabilities available to this task.

+
AI Agents
+ @if (_model.AvailableAgents.Count == 0) + { +

No agent profiles available.

+ } + else + { + @foreach (var agent in _model.AvailableAgents) { - +
+ + +
} - -
-
- - -
-
- - -
-
- - + } +
A2A Hosts
+ @if (_model.AvailableA2AConnections.Count == 0) + { +

No A2A hosts configured.

+ } + else + { + @foreach (var conn in _model.AvailableA2AConnections) + { +
+ + +
+ } + } +
MCP Hosts
+ @if (_model.AvailableMcpConnections.Count == 0) + { +

No MCP hosts configured.

+ } + else + { + @foreach (var conn in _model.AvailableMcpConnections) + { +
+ + +
+ } + } +
AI Tools
+ @if (_model.AvailableTools.Count == 0) + { +

No AI tools registered.

+ } + else + { + @foreach (var group in _model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key)) + { +
@group.Key
+ @foreach (var tool in group) + { +
+ + +
+ } + } + }
@@ -1251,6 +1350,42 @@ private void AddConversionGoal() => _model.ConversionGoals.Add(new ConversionGoalItem()); private void AddPostSessionTask() => _model.PostSessionTasks.Add(new PostSessionTaskItem()); + private Dictionary _taskActiveTabs = new(); + private string GetTaskActiveTab(int index) => _taskActiveTabs.TryGetValue(index, out var tab) ? tab : "info"; + private void SetTaskActiveTab(int index, string tab) => _taskActiveTabs[index] = tab; + + private void ToggleTaskAgent(PostSessionTaskItem task, string name, bool selected) + { + var list = task.SelectedAgentNames.ToList(); + if (selected && !list.Contains(name)) list.Add(name); + else if (!selected) list.Remove(name); + task.SelectedAgentNames = list.ToArray(); + } + + private void ToggleTaskA2A(PostSessionTaskItem task, string id, bool selected) + { + var list = task.SelectedA2AConnectionIds.ToList(); + if (selected && !list.Contains(id)) list.Add(id); + else if (!selected) list.Remove(id); + task.SelectedA2AConnectionIds = list.ToArray(); + } + + private void ToggleTaskMcp(PostSessionTaskItem task, string id, bool selected) + { + var list = task.SelectedMcpConnectionIds.ToList(); + if (selected && !list.Contains(id)) list.Add(id); + else if (!selected) list.Remove(id); + task.SelectedMcpConnectionIds = list.ToArray(); + } + + private void ToggleTaskTool(PostSessionTaskItem task, string name, bool selected) + { + var list = task.SelectedToolNames.ToList(); + if (selected && !list.Contains(name)) list.Add(name); + else if (!selected) list.Remove(name); + task.SelectedToolNames = list.ToArray(); + } + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || @@ -1416,7 +1551,7 @@ .Select(ds => new KeyValuePair(ds.DisplayText, ds.ItemId)) .ToList(); - var documentSettings = DocOpts.Value; + var documentSettings = DocOpts.CurrentValue; _model.DocumentIndexProfileName = documentSettings.IndexProfileName; if (!string.IsNullOrWhiteSpace(documentSettings.IndexProfileName)) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index 13d47164..b4d26652 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -36,9 +36,9 @@ @inject IOptions OrchestratorOpts @inject IOptionsSnapshot ClaudeOpts @inject ClaudeClientService ClaudeClientService -@inject IOptions CopilotOpts +@inject IOptionsMonitor CopilotOpts @inject IOptions ToolOpts -@inject IOptions DocOpts +@inject IOptionsMonitor DocOpts @inject IOptions ChatDocumentOptions @inject ISpeechVoiceResolver SpeechVoiceResolver @inject AIProfileDocumentService ProfileDocumentService @@ -109,21 +109,21 @@ else if (_model != null) {
@_nameError
} -
The technical name cannot be changed after creation.
- -
+ +
Choose the type of the profile.
+
@if (_model.Type == AIProfileType.Chat) {
@@ -131,24 +131,26 @@ else if (_model != null) - @foreach (var tt in Enum.GetValues()) - { - - } - -
+ @foreach (var tt in Enum.GetValues()) + { + + } + +
How the chat session title is determined.
-
+
+
- - - -
+ + + +
Controls whether this profile uses text input only, microphone dictation, or full two-way conversation mode.
- } +
+ }
@if (_model.Type == AIProfileType.Chat) @@ -158,7 +160,7 @@ else if (_model != null)
-
Show a play button on assistant messages when a text-to-speech deployment is configured.
+
When enabled, assistant messages show a play button that reads the response aloud using the configured text-to-speech deployment.
@if (_model.ChatMode == ChatMode.Conversation) @@ -167,26 +169,27 @@ else if (_model != null) - @foreach (var group in _availableVoices.GroupBy(GetVoiceGroupLabel).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) - { + @foreach (var group in _availableVoices.GroupBy(GetVoiceGroupLabel).OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase)) + { @foreach (var voice in group.OrderBy(v => v.Name, StringComparer.OrdinalIgnoreCase)) { - } - } - -
Leave empty to use the site's default text-to-speech voice.
- - } - } + + } + +
Select a voice for conversation mode. Leave this empty to use the site's default voice.
+ + } + } @if (_model.Type == AIProfileType.Agent) {
- + +
A description of what this agent does. Used by the orchestrator to determine when to route requests to this agent.
} @@ -196,25 +199,27 @@ else if (_model != null) - @foreach (var d in _model.ChatDeployments) - { - - } - - + @foreach (var d in _model.ChatDeployments) + { + + } + +
The chat model deployment to use.
+
- @foreach (var d in _model.UtilityDeployments) - { - - } - -
+ @foreach (var d in _model.UtilityDeployments) + { + + } + +
Optional deployment for utility tasks (planning, summarization).
+
@@ -225,6 +230,7 @@ else if (_model != null) } +
The orchestrator that manages AI interactions.
@@ -244,7 +250,8 @@ else if (_model != null) {
- + +
Optionally override the model name. Leave empty to use the default model configured in Copilot settings.
@@ -254,11 +261,13 @@ else if (_model != null) } +
Select the reasoning effort level for the Copilot model.
-
+
+
When checked, the Copilot CLI runs with the --allow-all flag.
} else if (_model.CopilotIsAuthenticated) { @@ -271,6 +280,7 @@ else if (_model != null) } +
Select the Copilot model to use.
@@ -280,11 +290,13 @@ else if (_model != null) } +
Select the reasoning effort level for the Copilot model.
-
+
+
When checked, the Copilot CLI runs with the --allow-all flag.
}
@@ -312,6 +324,7 @@ else if (_model != null) } +
Select a Claude model override for this item, or leave it on the configured default.
@@ -321,13 +334,15 @@ else if (_model != null) } +
Select the reasoning effort level. Higher effort produces more thorough responses but uses more tokens.
} else {
- + +
Optionally override the Claude model. Leave empty to use the default model configured in settings.
@@ -337,6 +352,7 @@ else if (_model != null) } +
Select the reasoning effort level. Higher effort produces more thorough responses but uses more tokens.
} @@ -348,6 +364,7 @@ else if (_model != null)
+
Provides context to the AI about the primary subject of the conversation.
} @@ -358,6 +375,7 @@ else if (_model != null) +
When enabled, each new chat session starts with an assistant message from the initial prompt and the welcome message is ignored.
@if (!_model.AddInitialPrompt) @@ -365,6 +383,7 @@ else if (_model != null)
+
The welcome message to show on the user-interface when a new session is created.
} else @@ -372,6 +391,7 @@ else if (_model != null)
+
This assistant message is added as the first chat history entry when a new session is created.
} } @@ -381,6 +401,10 @@ else if (_model != null)
+
+ Liquid syntax is supported. Available variables: {{ User.UserName }}, {{ User.Email }}, + {{ Session.Id }}, {{ Profile.Name }}, and any custom parameters. +
} @@ -469,6 +493,7 @@ else if (_model != null)
+
Instructions that guide the AI's behavior throughout the conversation.
@@ -476,12 +501,14 @@ else if (_model != null)
+
Controls randomness. Lower = more focused, higher = more creative.
+
Nucleus sampling. Controls diversity of responses.
@@ -491,12 +518,14 @@ else if (_model != null)
+
Penalizes repeated tokens. Higher = less repetition.
+
Penalizes tokens already present. Higher = more topic variety.
@@ -506,12 +535,14 @@ else if (_model != null)
+
Maximum number of tokens in the response.
+
Number of previous messages to include in context.
@@ -521,6 +552,7 @@ else if (_model != null) +
Cache responses to improve performance for repeated queries.
@@ -529,10 +561,11 @@ else if (_model != null)
AI Agents
@if (_model.AvailableAgents.Count == 0) { -
No agent profiles available.
+
No agent profiles are available. Create an agent profile first.
} else { +

Select the agent profiles this profile can delegate tasks to during conversations.

@foreach (var agent in _model.AvailableAgents) {
@@ -551,10 +584,11 @@ else if (_model != null)
Agent to Agent Hosts
@if (_model.AvailableA2AConnections.Count == 0) { -
No A2A hosts configured.
+
No A2A host connections are configured. Add them under Agent to Agent Hosts first.
} else { +

Select the remote A2A hosts this profile can use for agent orchestration.

@foreach (var c in _model.AvailableA2AConnections) {
@@ -567,10 +601,11 @@ else if (_model != null)
MCP Hosts
@if (_model.AvailableMcpConnections.Count == 0) { -
No MCP hosts configured.
+
No MCP hosts are configured. Add them under MCP Hosts first.
} else { +

Select the MCP server connections this profile can use for external tools and resources.

@foreach (var c in _model.AvailableMcpConnections) {
@@ -589,12 +624,12 @@ else if (_model != null)
AI Tools
@if (_model.AvailableTools.Count == 0) { -
No selectable AI tools registered.
+
No selectable AI tools are registered.
} else {
-

Select the tools this profile can use.

+

Select the tools this profile can use during conversations.

@@ -627,6 +662,7 @@ else if (_model != null)
+
Store and recall user conversation history for personalized responses.
Data Source
@@ -639,11 +675,13 @@ else if (_model != null) } +
Select a data source to enable Retrieval-Augmented Generation (RAG) for this profile.
+
Optional filter expression passed to the configured data source provider.
@@ -651,6 +689,7 @@ else if (_model != null)
+
Controls how strictly the AI should follow retrieved data-source matches.
@@ -658,6 +697,7 @@ else if (_model != null)
+
Number of top matching chunks or documents to include as AI context.
@@ -670,6 +710,7 @@ else if (_model != null) } +
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
@@ -679,12 +720,14 @@ else if (_model != null)
+
Controls how strictly the AI should follow retrieved data-source matches.
+
How many data-source documents to retrieve for RAG.
@@ -694,6 +737,7 @@ else if (_model != null) +
Allow users to upload documents during chat sessions with this profile.
@@ -768,6 +812,7 @@ else if (_model != null)
+
Run AI-powered extraction on conversation messages to populate structured fields.
@if (_model.EnableDataExtraction) @@ -777,12 +822,14 @@ else if (_model != null)
+
How often extraction runs (1 = every message, 2 = every other message, etc.).
+
Sessions inactive longer than this will be automatically closed.
@@ -822,18 +869,22 @@ else if (_model != null) }
+

Configure session metrics and post-session processing for conversations using this profile.

+
Session Metrics
+
Capture usage, latency, and feedback metrics for analytics.
+
When enabled, the AI will determine whether the user's issue was resolved during the session.
Post-session Processing
@@ -851,22 +902,129 @@ else if (_model != null) var index = i; var task = _model.PostSessionTasks[index]; -
+
- @(string.IsNullOrWhiteSpace(task.Name) ? "New Task" : task.Name) - + +
-
-
- - - @foreach (var tt in Enum.GetValues()) { } - +
+
+
+
+ + +
+
+
+
+ + + @foreach (var tt in Enum.GetValues()) { } + +
+
+
+
+ + +
+
+
+ + +
+
+ @if (task.Type == PostSessionTaskType.PredefinedOptions) + { +
+ + +
+ } +
+
+

Select the capabilities available to this task.

+
AI Agents
+ @if (_model.AvailableAgents.Count == 0) + { +

No agent profiles available.

+ } + else + { + @foreach (var agent in _model.AvailableAgents) + { +
+ + +
+ } + } +
A2A Hosts
+ @if (_model.AvailableA2AConnections.Count == 0) + { +

No A2A hosts configured.

+ } + else + { + @foreach (var conn in _model.AvailableA2AConnections) + { +
+ + +
+ } + } +
MCP Hosts
+ @if (_model.AvailableMcpConnections.Count == 0) + { +

No MCP hosts configured.

+ } + else + { + @foreach (var conn in _model.AvailableMcpConnections) + { +
+ + +
+ } + } +
AI Tools
+ @if (_model.AvailableTools.Count == 0) + { +

No AI tools registered.

+ } + else + { + @foreach (var group in _model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key)) + { +
@group.Key
+ @foreach (var tool in group) + { +
+ + +
+ } + } + }
-
-
-
} @@ -880,6 +1038,7 @@ else if (_model != null)
+
Evaluate each session against defined goals and assign scores to measure session success.
@if (_model.EnableConversionMetrics) @@ -922,12 +1081,14 @@ else if (_model != null) +
Allow this profile to be deleted.
+
Prevent modification of the system message after creation.
@@ -1049,6 +1210,42 @@ else if (_model != null) private void AddConversionGoal() => _model.ConversionGoals.Add(new ConversionGoalItem()); private void AddPostSessionTask() => _model.PostSessionTasks.Add(new PostSessionTaskItem()); + private Dictionary _taskActiveTabs = new(); + private string GetTaskActiveTab(int index) => _taskActiveTabs.TryGetValue(index, out var tab) ? tab : "info"; + private void SetTaskActiveTab(int index, string tab) => _taskActiveTabs[index] = tab; + + private void ToggleTaskAgent(PostSessionTaskItem task, string name, bool selected) + { + var list = task.SelectedAgentNames.ToList(); + if (selected && !list.Contains(name)) list.Add(name); + else if (!selected) list.Remove(name); + task.SelectedAgentNames = list.ToArray(); + } + + private void ToggleTaskA2A(PostSessionTaskItem task, string id, bool selected) + { + var list = task.SelectedA2AConnectionIds.ToList(); + if (selected && !list.Contains(id)) list.Add(id); + else if (!selected) list.Remove(id); + task.SelectedA2AConnectionIds = list.ToArray(); + } + + private void ToggleTaskMcp(PostSessionTaskItem task, string id, bool selected) + { + var list = task.SelectedMcpConnectionIds.ToList(); + if (selected && !list.Contains(id)) list.Add(id); + else if (!selected) list.Remove(id); + task.SelectedMcpConnectionIds = list.ToArray(); + } + + private void ToggleTaskTool(PostSessionTaskItem task, string name, bool selected) + { + var list = task.SelectedToolNames.ToList(); + if (selected && !list.Contains(name)) list.Add(name); + else if (!selected) list.Remove(name); + task.SelectedToolNames = list.ToArray(); + } + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || @@ -1197,7 +1394,7 @@ else if (_model != null) var allDataSources = await DataSourceStore.GetAllAsync(); _model.DataSources = allDataSources.OrderBy(ds => ds.DisplayText, StringComparer.OrdinalIgnoreCase).Select(ds => new KeyValuePair(ds.DisplayText, ds.ItemId)).ToList(); - var documentSettings = DocOpts.Value; + var documentSettings = DocOpts.CurrentValue; _model.DocumentIndexProfileName = documentSettings.IndexProfileName; if (!string.IsNullOrWhiteSpace(documentSettings.IndexProfileName)) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor index 4512975e..05e2e96f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor @@ -33,9 +33,9 @@ @inject IOptions OrchestratorOpts @inject IOptionsSnapshot ClaudeOpts @inject ClaudeClientService ClaudeClientService -@inject IOptions CopilotOpts +@inject IOptionsMonitor CopilotOpts @inject IOptions ToolOpts -@inject IOptions DocOpts +@inject IOptionsMonitor DocOpts @inject IStoreCommitter StoreCommitter @inject NavigationManager Navigation @@ -53,7 +53,7 @@ }
- +
@@ -66,13 +66,14 @@ }
Auto-generated from the title.
+
The technical name cannot be changed after creation.
- +
@@ -88,28 +89,28 @@
-
Show this template in template pickers.
+
Whether this template appears in listing UIs.
@if (!string.IsNullOrEmpty(_sourceError)) {
@_sourceError
} -
System Prompt templates contain only a system message. Profile templates contain a full profile configuration.
+
System prompt templates define only a system message. Profile templates include full AI profile configuration.
@if (_model.Source == AITemplateSources.SystemPrompt) {
- -
The system message for this template.
+ +
The system message that defines the AI's behavior and role.
} else if (_model.Source == AITemplateSources.Profile) @@ -169,7 +170,7 @@
- + @foreach (var d in _model.ChatDeployments) { @@ -181,7 +182,7 @@
- + @foreach (var d in _model.UtilityDeployments) { @@ -193,7 +194,7 @@
- - + + @if (!string.IsNullOrEmpty(_sourceError)) {
@_sourceError
} -
System Prompt templates contain only a system message. Profile templates contain a full profile configuration.
+
System prompt templates define only a system message. Profile templates include full AI profile configuration.
@if (_model.Source == AITemplateSources.SystemPrompt) {
- -
The system message for this template.
+ +
The system message that defines the AI's behavior and role.
} else if (_model.Source == AITemplateSources.Profile) @@ -147,6 +147,7 @@ } +
Choose the type of the profile.
@if (_model.ProfileType == AIProfileType.Chat) @@ -160,6 +161,7 @@ }
+
How the chat session title is determined.
} @@ -168,36 +170,39 @@
- + @foreach (var d in _model.ChatDeployments) { } +
The chat model deployment to use.
- + @foreach (var d in _model.UtilityDeployments) { } +
Optional deployment for utility tasks.
+
The orchestrator that manages AI interactions.
@@ -217,6 +222,7 @@
+
Optionally override the model name. Leave empty to use the default model configured in Copilot settings.
@@ -226,8 +232,10 @@ } +
Select the reasoning effort level for the Copilot model.
+
When checked, the Copilot CLI runs with the --allow-all flag.
} @@ -248,12 +256,13 @@
- + @foreach (var m in _model.AnthropicAvailableModels) { } +
Select a Claude model override for this item, or leave it on the configured default.
@@ -263,6 +272,7 @@ } +
Select the reasoning effort level. Higher effort produces more thorough responses but uses more tokens.
} @@ -272,6 +282,7 @@
+
Provides context to the AI about the primary subject of the conversation.
@@ -279,6 +290,7 @@
+
When enabled, each new chat session starts with an assistant message from the initial prompt and the welcome message is ignored.
@if (!_model.AddInitialPrompt) @@ -286,6 +298,7 @@
+
The welcome message to show on the user-interface when a new session is created.
} else @@ -293,12 +306,17 @@
+
This assistant message is added as the first chat history entry when a new session is created.
}
+
+ Liquid syntax is supported. Available variables: {{ User.UserName }}, {{ User.Email }}, + {{ Session.Id }}, {{ Profile.Name }}, and any custom parameters. +
@@ -368,21 +386,22 @@
+
The system message that defines the AI's behavior and role.
-
-
+
Controls randomness. Lower = more focused, higher = more creative.
+
Nucleus sampling. Controls diversity of responses.
-
-
+
Penalizes repeated tokens. Higher = less repetition.
+
Penalizes tokens already present. Higher = more topic variety.
-
-
+
Maximum number of tokens in the response.
+
Number of previous messages to include in context.
-
+
Cache responses to improve performance for repeated queries.
@@ -430,24 +449,25 @@
-
+
Store and recall user conversation history for personalized responses.
Data Source
@foreach (var ds in _model.DataSources) { } +
Select a data source to enable Retrieval-Augmented Generation (RAG) for profiles created from this template.
-
-
+
Optional filter expression passed to the configured data source provider.
+
Controls how strictly the AI should follow retrieved data-source matches.
-
+
Number of top matching chunks or documents to include as AI context.
- @foreach (var m in Enum.GetValues()) { }
+ @foreach (var m in Enum.GetValues()) { }
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
-
-
+
Controls how strictly the AI should follow retrieved data-source matches.
+
How many data-source documents to retrieve for RAG.
@@ -462,12 +482,12 @@
-
+
Run AI-powered extraction on conversation messages to populate structured fields.
@if (_model.EnableDataExtraction) {
-
-
+
How often extraction runs (1 = every message, 2 = every other message, etc.).
+
Sessions inactive longer than this will be automatically closed.
@for (var i = 0; i < _model.DataExtractionEntries.Count; i++) { @@ -485,8 +505,8 @@ }
-
-
+
Capture usage, latency, and feedback metrics for analytics.
+
When enabled, the AI will determine whether the user's issue was resolved during the session.
Post-session Processing
@@ -510,7 +530,7 @@ }
-
+
Evaluate each session against defined goals and assign scores to measure session success.
@if (_model.EnableConversionMetrics) { @for (var i = 0; i < _model.ConversionGoals.Count; i++) @@ -534,8 +554,8 @@
-
-
+
Allow profiles created from this template to be deleted.
+
Prevent modification of the system message after creation.
} @@ -680,7 +700,7 @@ .OrderBy(template => template.Metadata.Title ?? template.Id, StringComparer.OrdinalIgnoreCase) .ToList(); - var docSettings = DocOpts.Value; + var docSettings = DocOpts.CurrentValue; _model.DocumentIndexProfileName = docSettings.IndexProfileName; if (!string.IsNullOrWhiteSpace(docSettings.IndexProfileName)) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatAnalytics.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatAnalytics.razor index 452558f7..4aa331d7 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatAnalytics.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatAnalytics.razor @@ -1,12 +1,12 @@ @page "/ai-chat/analytics" @attribute [Authorize(Policy = "Admin")] @using System.Text +@using CrestApps.Core.AI.Chat @using CrestApps.Core.AI.Models @using CrestApps.Core.AI.Profiles -@using CrestApps.Core.Blazor.Web.Areas.AIChat.Services @using CrestApps.Core.Blazor.Web.ViewModels @inject IAIProfileManager ProfileManager -@inject SampleAIChatSessionEventService EventService +@inject IAIChatSessionEventService EventService @inject TimeProvider TimeProvider @inject IJSRuntime JS diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatExtractedData.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatExtractedData.razor index 5ee40347..e9e1a1aa 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatExtractedData.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/ChatExtractedData.razor @@ -1,12 +1,12 @@ @page "/ai-chat/extracted-data" @attribute [Authorize(Policy = "Admin")] @using System.Text +@using CrestApps.Core.AI.Chat @using CrestApps.Core.AI.Models @using CrestApps.Core.AI.Profiles -@using CrestApps.Core.Blazor.Web.Areas.AIChat.Services @using CrestApps.Core.Blazor.Web.ViewModels @inject IAIProfileManager ProfileManager -@inject SampleAIChatSessionExtractedDataService ExtractedDataService +@inject IAIChatSessionExtractedDataStore ExtractedDataStore @inject TimeProvider TimeProvider @inject IJSRuntime JS @@ -134,7 +134,7 @@ else return; } - var records = await ExtractedDataService.GetAsync(_model.ProfileId, _model.StartDateUtc, _model.EndDateUtc); + var records = await ExtractedDataStore.GetAsync(_model.ProfileId, _model.StartDateUtc, _model.EndDateUtc); var rows = records.Select(record => new ChatExtractedDataRowViewModel { SessionId = record.SessionId, diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/UsageAnalytics.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/UsageAnalytics.razor index 93ec9a64..403b0ea0 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/UsageAnalytics.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AIChat/UsageAnalytics.razor @@ -1,11 +1,11 @@ @page "/ai-chat/usage-analytics" @attribute [Authorize(Policy = "Admin")] +@using CrestApps.Core.AI.Completions @using CrestApps.Core.AI.Models -@using CrestApps.Core.Blazor.Web.Areas.AIChat.Services +@using CrestApps.Core.AI.Services @using CrestApps.Core.Blazor.Web.ViewModels -@using Microsoft.Extensions.Options -@inject SampleAICompletionUsageService UsageService -@inject IOptions GeneralAIOptionsAccessor +@inject IAICompletionUsageService UsageService +@inject Microsoft.Extensions.Options.IOptionsMonitor GeneralAIOptions AI Usage Analytics - Blazor AI Integration Sample @@ -105,15 +105,21 @@ else protected override void OnInitialized() { - _model.IsAIUsageTrackingEnabled = GeneralAIOptionsAccessor.Value.EnableAIUsageTracking; + RefreshUsageTrackingState(); } private async Task RunReportAsync() { + RefreshUsageTrackingState(); var records = await UsageService.GetAsync(_model.StartDateUtc, _model.EndDateUtc); ApplyReport(records); } + private void RefreshUsageTrackingState() + { + _model.IsAIUsageTrackingEnabled = GeneralAIOptions.CurrentValue.EnableAIUsageTracking; + } + private void ApplyReport(IReadOnlyList records) { _model.ShowReport = true; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor index 7f9d3faa..53858418 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Chat.razor @@ -31,10 +31,10 @@ @inject NavigationManager NavigationManager @inject IJSRuntime JSRuntime @inject SiteSettingsStore SiteSettingsStore -@inject IOptions DataSourceOptions +@inject IOptionsMonitor DataSourceOptions @inject IOptions OrchestratorOptionsAccessor @inject IOptions ToolOptionsAccessor -@inject IOptions InteractionDocumentOptionsAccessor +@inject IOptionsMonitor InteractionDocumentOptionsAccessor @inject IOptions ChatDocumentOptions @inject ISearchIndexProfileStore IndexProfileStore @inject IAIDocumentStore DocumentStore @@ -259,11 +259,11 @@ else
- +
- +
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor index 3c00dfbe..f88ffba7 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor @@ -19,6 +19,7 @@ @using CrestApps.Core.Services @using CrestApps.Core.Templates.Services @using Microsoft.Extensions.Options +@inject IOptions ChatDocumentOptions @inject ICatalogManager InteractionManager @inject ICatalog DeploymentCatalog @inject ICatalog A2AConnectionCatalog @@ -34,7 +35,7 @@ @inject IOptions ToolOptionsAccessor @inject IOptionsSnapshot ClaudeOptionsAccessor @inject ClaudeClientService ClaudeClientService -@inject IOptions CopilotOptionsAccessor +@inject IOptionsMonitor CopilotOptionsAccessor @inject ISearchIndexProfileStore IndexProfileStore New Chat Interaction - Blazor AI Integration Sample @@ -402,7 +403,7 @@
-
Number of documents retrieved for context.
+
How many data-source documents to retrieve for RAG.
@@ -412,7 +413,7 @@
-
Upload documents to be indexed for this interaction.
+
Supported formats: @ChatDocumentOptions.Value.GetAllowedFileExtensionsDisplayValue()
} } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor index a4e649bc..aabc92e1 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor @@ -79,14 +79,29 @@ else {
- -
The embedding deployment cannot be changed after the index is created.
+ + @if (EmbeddingDeploymentSelectionLocked) + { + +
The embedding deployment cannot be changed after a valid deployment has been selected.
+ } + else + { + + + @foreach (var deployment in _model.EmbeddingDeployments) + { + + } + +
Choose an embedding deployment before rebuilding this index.
+ }
} @@ -114,6 +129,11 @@ else !string.IsNullOrEmpty(_model.Type) && !string.Equals(_model.Type, IndexProfileTypes.Articles, StringComparison.OrdinalIgnoreCase); + private bool EmbeddingDeploymentSelectionLocked => + ShowEmbeddingDeployment && + _model.EmbeddingDeployments.Any(deployment => + string.Equals(deployment.Key, _model.EmbeddingDeploymentName, StringComparison.Ordinal)); + protected override async Task OnInitializedAsync() { var profile = await IndexProfileManager.FindByIdAsync(Id); @@ -127,6 +147,7 @@ else _model = IndexProfileViewModel.FromProfile(profile); await PopulateDropdownsAsync(); + _model.EmbeddingDeploymentName = await NormalizeDeploymentSelectorAsync(_model.EmbeddingDeploymentName); } private async Task HandleSubmitAsync() @@ -141,7 +162,12 @@ else return; } - profile.DisplayText = _model.DisplayText; + profile.DisplayText = _model.DisplayText?.Trim(); + + if (ShowEmbeddingDeployment) + { + profile.EmbeddingDeploymentName = await NormalizeDeploymentSelectorAsync(_model.EmbeddingDeploymentName); + } var validationResult = await IndexProfileManager.ValidateAsync(profile); @@ -194,4 +220,16 @@ else ? deployment.Name : $"{deployment.Name} ({deployment.ModelName})"; } + + private async Task NormalizeDeploymentSelectorAsync(string selector) + { + if (string.IsNullOrWhiteSpace(selector)) + { + return selector; + } + + var deployment = await DeploymentManager.FindByIdAsync(selector); + + return deployment?.Name ?? selector.Trim(); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Create.razor index 57e256bb..54c3b33a 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Create.razor @@ -35,10 +35,11 @@
- + -
A unique machine-readable name. You may override it before saving.
+
Auto-generated from the title. You may override it before saving.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Edit.razor index 54aa6740..3cdbbef9 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Mcp/McpPrompts/Edit.razor @@ -46,7 +46,6 @@ else
-
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index f047ea43..8ef85d8e 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -167,7 +167,6 @@ // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE // ============================================================================= -builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); @@ -195,10 +194,7 @@ { branch.Use(async (context, next) => { - var siteSettings = context.RequestServices.GetRequiredService(); - var settings = siteSettings.TryGet(out var storedSettings) - ? storedSettings - : context.RequestServices.GetRequiredService>().Value; + var settings = context.RequestServices.GetRequiredService>().CurrentValue; if (settings.AuthenticationType == McpServerAuthenticationType.None) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs index 59e79e40..6754c6d2 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs @@ -72,7 +72,7 @@ private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) taskManager.OnAgentCardQuery = async (agentUrl, cancellationToken) => { var services = httpContextAccessor.HttpContext!.RequestServices; - var options = services.GetRequiredService>().Value; + var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); @@ -100,7 +100,7 @@ private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) private static async Task HandleWellKnownEndpointAsync(HttpContext context) { - var options = context.RequestServices.GetRequiredService>().Value; + var options = context.RequestServices.GetRequiredService>().CurrentValue; var profileManager = context.RequestServices.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent); var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}"; @@ -264,7 +264,7 @@ private static async Task ResolveTargetProfileAsync( IHttpContextAccessor httpContextAccessor, AgentMessage lastMessage) { - var options = services.GetRequiredService>().Value; + var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs index 6995764a..cf94527f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs @@ -1,8 +1,9 @@ +using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Copilot; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Documents; +using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Services; @@ -16,6 +17,7 @@ using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace CrestApps.Core.Blazor.Web.Services; @@ -40,22 +42,10 @@ public static IServiceCollection AddBlazorSampleHostServices(this IServiceCollec .AddSharedArticleServices() .AddSharedTemplateProviders() .AddKeyedScoped(IndexProfileTypes.Articles) - .AddScoped() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped() .AddScoped, Areas.AI.Handlers.AIMemoryEntryHandler>() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -63,6 +53,8 @@ public static IServiceCollection AddBlazorSampleHostServices(this IServiceCollec .AddScoped(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.AddSingleton, SiteSettingsConfigureStoredOptions>(); + services.AddSingleton, SiteSettingsConfigureStoredOptions>(); services.ConfigureOptions(); services.ConfigureOptions(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/SampleCitationReferenceCollector.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/SampleCitationReferenceCollector.cs deleted file mode 100644 index 8e4b757b..00000000 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/SampleCitationReferenceCollector.cs +++ /dev/null @@ -1,102 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Orchestration; -using CrestApps.Core.AI.Services; -using CrestApps.Core.Infrastructure.Indexing; - -namespace CrestApps.Core.Blazor.Web.Services; - -/// -/// Collects citation references for the sample host and resolves any configured -/// links before they are streamed to the chat client. -/// -public sealed class SampleCitationReferenceCollector -{ - private const string DataSourceReferencesKey = "DataSourceReferences"; - private const string DocumentReferencesKey = "DocumentReferences"; - - private readonly CompositeAIReferenceLinkResolver _linkResolver; - - public SampleCitationReferenceCollector(CompositeAIReferenceLinkResolver linkResolver) - { - _linkResolver = linkResolver; - } - - public void CollectPreemptiveReferences( - OrchestrationContext orchestrationContext, - Dictionary references, - HashSet contentItemIds) - { - CollectFromProperties(orchestrationContext, DataSourceReferencesKey, references); - CollectFromProperties(orchestrationContext, DocumentReferencesKey, references); - ResolveLinks(references, contentItemIds); - } - - public bool CollectToolReferences( - Dictionary references, - HashSet contentItemIds) - { - var invocationContext = AIInvocationScope.Current; - - if (invocationContext is null) - { - return false; - } - - var added = false; - - foreach (var (key, value) in invocationContext.ToolReferences) - { - if (references.TryAdd(key, value)) - { - added = true; - } - } - - if (added) - { - ResolveLinks(references, contentItemIds); - } - - return added; - } - - private void ResolveLinks(Dictionary references, HashSet contentItemIds) - { - foreach (var (_, reference) in references) - { - if (string.IsNullOrEmpty(reference.Link) && - !string.IsNullOrEmpty(reference.ReferenceId) && - !string.IsNullOrEmpty(reference.ReferenceType)) - { - reference.Link = _linkResolver.ResolveLink( - reference.ReferenceId, - reference.ReferenceType, - new Dictionary - { - ["Title"] = reference.Title, - }); - } - - if (!string.IsNullOrEmpty(reference.ReferenceId) && - string.Equals(reference.ReferenceType, IndexProfileTypes.Articles, StringComparison.OrdinalIgnoreCase)) - { - contentItemIds.Add(reference.ReferenceId); - } - } - } - - private static void CollectFromProperties( - OrchestrationContext orchestrationContext, - string propertyKey, - Dictionary target) - { - if (orchestrationContext.Properties.TryGetValue(propertyKey, out var refsObj) && - refsObj is Dictionary refs) - { - foreach (var (key, value) in refs) - { - target.TryAdd(key, value); - } - } - } -} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIConnectionViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIConnectionViewModel.cs index db7c8527..90d00474 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIConnectionViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIConnectionViewModel.cs @@ -35,6 +35,7 @@ public static AIConnectionViewModel FromConnection(AIProviderConnection connecti Name = connection.Name, DisplayText = connection.DisplayText, Source = AIProviderNameNormalizer.Normalize(connection.Source), + IsReadOnly = connection.IsReadOnly, }; if (connection.Properties != null) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs index 306a2da0..3d037ed1 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs @@ -48,9 +48,10 @@ public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) TechnicalName = deployment.Name, SelectedTypes = deployment.Type.GetSupportedTypes() .Select(static type => type.ToString()) - .ToArray(), + .ToArray(), ConnectionName = deployment.ConnectionName, ClientName = AIProviderNameNormalizer.Normalize(deployment.ClientName), + IsReadOnly = deployment.IsReadOnly, }; if (deployment.Properties != null) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs index 317160eb..7ab941bf 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs @@ -1,6 +1,5 @@ using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -42,15 +41,11 @@ public AIConnectionController( public async Task Index() { var connections = await _store.GetAllAsync(); - var models = connections.Select(connection => - { - var model = AIConnectionViewModel.FromConnection(connection); - model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId); - - return model; - }).ToList(); + IReadOnlyCollection models = connections.Select(AIConnectionViewModel.FromConnection) + .OrderBy(static model => model.DisplayText ?? model.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - return View(models.OrderBy(static model => model.DisplayText ?? model.Name, StringComparer.OrdinalIgnoreCase).ToList()); + return View(models); } public IActionResult Create() @@ -113,20 +108,13 @@ public async Task Create(AIConnectionViewModel model) public async Task Edit(string id) { - if (AIConfigurationRecordIds.IsConfigurationConnectionId(id)) - { - TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; - - return RedirectToAction(nameof(Index)); - } - var connection = await _catalog.FindByIdAsync(id); if (connection == null) { return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) + if (connection.IsReadOnly) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; @@ -144,20 +132,13 @@ public async Task Edit(string id) [ValidateAntiForgeryToken] public async Task Edit(AIConnectionViewModel model) { - if (AIConfigurationRecordIds.IsConfigurationConnectionId(model.ItemId)) - { - TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; - - return RedirectToAction(nameof(Index)); - } - var existing = await _catalog.FindByIdAsync(model.ItemId); if (existing == null) { return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(existing.ItemId)) + if (existing.IsReadOnly) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; @@ -204,20 +185,13 @@ public async Task Edit(AIConnectionViewModel model) [ValidateAntiForgeryToken] public async Task Delete(string id) { - if (AIConfigurationRecordIds.IsConfigurationConnectionId(id)) - { - TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be deleted from the UI."; - - return RedirectToAction(nameof(Index)); - } - var connection = await _catalog.FindByIdAsync(id); if (connection == null) { return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) + if (connection.IsReadOnly) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be deleted from the UI."; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs index 2cb9b5a9..03e1e1b0 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs @@ -1,7 +1,6 @@ using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -49,13 +48,7 @@ public async Task Index() var deployments = await _deploymentStore.GetAllAsync(); return View(deployments - .Select(deployment => - { - var model = AIDeploymentViewModel.FromDeployment(deployment); - model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId); - - return model; - }) + .Select(AIDeploymentViewModel.FromDeployment) .OrderBy(static deployment => deployment.TechnicalName, StringComparer.OrdinalIgnoreCase) .ToList()); } @@ -135,7 +128,7 @@ public async Task Edit(string id) return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId)) + if (deployment.IsReadOnly) { TempData["ErrorMessage"] = "Deployments defined in appsettings are read-only and cannot be edited from the UI."; @@ -152,13 +145,6 @@ public async Task Edit(string id) [ValidateAntiForgeryToken] public async Task Edit(AIDeploymentViewModel model) { - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(model.ItemId)) - { - TempData["ErrorMessage"] = "Deployments defined in appsettings are read-only and cannot be edited from the UI."; - - return RedirectToAction(nameof(Index)); - } - if (string.IsNullOrWhiteSpace(model.ModelName)) { ModelState.AddModelError(nameof(model.ModelName), "Model name is required."); @@ -200,7 +186,7 @@ public async Task Edit(AIDeploymentViewModel model) return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(existing.ItemId)) + if (existing.IsReadOnly) { TempData["ErrorMessage"] = "Deployments defined in appsettings are read-only and cannot be edited from the UI."; @@ -232,7 +218,7 @@ public async Task Delete(string id) return NotFound(); } - if (AIConfigurationRecordIds.IsConfigurationDeploymentId(deployment.ItemId)) + if (deployment.IsReadOnly) { TempData["ErrorMessage"] = "Deployments defined in appsettings are read-only and cannot be deleted from the UI."; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index a40f540d..10c1ae26 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -41,7 +41,7 @@ public sealed class AIProfileController : Controller private readonly IAIDocumentStore _documentStore; private readonly AIProfileDocumentService _profileDocumentService; private readonly AIProfileTemplateDocumentService _templateDocumentService; - private readonly InteractionDocumentOptions _interactionDocumentOptions; + private readonly IOptionsMonitor _interactionDocumentOptions; private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _aiTemplateService; private readonly OrchestratorOptions _orchestratorOptions; @@ -51,7 +51,25 @@ public sealed class AIProfileController : Controller private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; private readonly IAIDataSourceStore _dataSourceStore; - public AIProfileController(IAIProfileManager profileManager, ICatalog deploymentCatalog, IAIProfileTemplateManager templateManager, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDocumentStore documentStore, AIProfileDocumentService profileDocumentService, AIProfileTemplateDocumentService templateDocumentService, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptionsSnapshot anthropicOptions, ClaudeClientService anthropicClientService, IOptionsSnapshot copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions, IAIDataSourceStore dataSourceStore) + public AIProfileController( + IAIProfileManager profileManager, + ICatalog deploymentCatalog, + IAIProfileTemplateManager templateManager, + ICatalog a2aConnectionCatalog, + ICatalog mcpConnectionCatalog, + IAIDocumentStore documentStore, + AIProfileDocumentService profileDocumentService, + AIProfileTemplateDocumentService templateDocumentService, + IOptionsMonitor interactionDocumentOptions, + ISearchIndexProfileStore indexProfileStore, + ITemplateService aiTemplateService, + IOptions orchestratorOptions, + IOptionsSnapshot anthropicOptions, + ClaudeClientService anthropicClientService, + IOptionsSnapshot copilotOptions, + GitHubOAuthService oauthService, + IOptions toolOptions, + IAIDataSourceStore dataSourceStore) { _profileManager = profileManager; _deploymentCatalog = deploymentCatalog; @@ -61,7 +79,7 @@ public AIProfileController(IAIProfileManager profileManager, ICatalog !string.IsNullOrEmpty(a.Description)).OrderBy(a => a.DisplayText ?? a.Name, StringComparer.OrdinalIgnoreCase).Select(a => new AgentSelectionItem { Name = a.Name, DisplayText = a.DisplayText ?? a.Name, Description = a.Description, IsSelected = selectedAgentNames.Contains(a.Name), }).ToList(); var allDataSources = await _dataSourceStore.GetAllAsync(); model.DataSources = allDataSources.OrderBy(ds => ds.DisplayText, StringComparer.OrdinalIgnoreCase).Select(ds => new SelectListItem(ds.DisplayText, ds.ItemId)).ToList(); - var documentSettings = _interactionDocumentOptions; + var documentSettings = _interactionDocumentOptions.CurrentValue; model.DocumentIndexProfileName = documentSettings.IndexProfileName; if (!string.IsNullOrWhiteSpace(documentSettings.IndexProfileName)) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs index bf14fb69..841f2b55 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs @@ -39,7 +39,7 @@ public sealed class AITemplateController : Controller private readonly IAIDataSourceStore _dataSourceStore; private readonly IAIProfileManager _profileManager; private readonly IAIProfileTemplateManager _templateManager; - private readonly InteractionDocumentOptions _interactionDocumentOptions; + private readonly IOptionsMonitor _interactionDocumentOptions; private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _aiTemplateService; private readonly OrchestratorOptions _orchestratorOptions; @@ -48,7 +48,23 @@ public sealed class AITemplateController : Controller private readonly IOptionsSnapshot _copilotOptions; private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; - public AITemplateController(ICatalog catalog, ICatalog deploymentCatalog, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDataSourceStore dataSourceStore, IAIProfileManager profileManager, IAIProfileTemplateManager templateManager, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptionsSnapshot anthropicOptions, ClaudeClientService anthropicClientService, IOptionsSnapshot copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions) + public AITemplateController( + ICatalog catalog, + ICatalog deploymentCatalog, + ICatalog a2aConnectionCatalog, + ICatalog mcpConnectionCatalog, + IAIDataSourceStore dataSourceStore, + IAIProfileManager profileManager, + IAIProfileTemplateManager templateManager, + IOptionsMonitor interactionDocumentOptions, + ISearchIndexProfileStore indexProfileStore, + ITemplateService aiTemplateService, + IOptions orchestratorOptions, + IOptionsSnapshot anthropicOptions, + ClaudeClientService anthropicClientService, + IOptionsSnapshot copilotOptions, + GitHubOAuthService oauthService, + IOptions toolOptions) { _catalog = catalog; _deploymentCatalog = deploymentCatalog; @@ -57,7 +73,7 @@ public AITemplateController(ICatalog catalog, ICatalog t.Metadata.IsListable).OrderBy(t => t.Metadata.Category ?? string.Empty, StringComparer.OrdinalIgnoreCase).ThenBy(t => t.Metadata.Title ?? t.Id, StringComparer.OrdinalIgnoreCase).Select(t => new PromptTemplateOptionItem { TemplateId = t.Id, Title = t.Metadata.Title ?? t.Id, Description = t.Metadata.Description, Category = t.Metadata.Category ?? "General", Parameters = (t.Metadata.Parameters ?? []).Select(p => new PromptTemplateParameterItem { Name = p.Name, Description = p.Description, }).ToList(), }).ToList(); - var documentSettings = _interactionDocumentOptions; + var documentSettings = _interactionDocumentOptions.CurrentValue; model.DocumentIndexProfileName = documentSettings.IndexProfileName; if (!string.IsNullOrWhiteSpace(documentSettings.IndexProfileName)) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs index 60cb6a36..ed3aa56c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs @@ -5,7 +5,6 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; using Microsoft.Extensions.AI; namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; @@ -18,7 +17,7 @@ public sealed class AIProfileDocumentService private readonly IAIDocumentProcessingService _documentProcessingService; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; - private readonly SampleAIDocumentIndexingService _documentIndexingService; + private readonly DefaultAIDocumentIndexingService _documentIndexingService; private readonly ILogger _logger; public AIProfileDocumentService( @@ -28,7 +27,7 @@ public AIProfileDocumentService( IAIDocumentProcessingService documentProcessingService, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, - SampleAIDocumentIndexingService documentIndexingService, + DefaultAIDocumentIndexingService documentIndexingService, ILogger logger) { _documentStore = documentStore; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs index 3a6f9c53..ba902573 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs @@ -5,7 +5,6 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; using Microsoft.Extensions.AI; namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; @@ -18,7 +17,7 @@ public sealed class AIProfileTemplateDocumentService private readonly IAIDocumentProcessingService _documentProcessingService; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; - private readonly SampleAIDocumentIndexingService _documentIndexingService; + private readonly DefaultAIDocumentIndexingService _documentIndexingService; private readonly ILogger _logger; public AIProfileTemplateDocumentService( @@ -28,7 +27,7 @@ public AIProfileTemplateDocumentService( IAIDocumentProcessingService documentProcessingService, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, - SampleAIDocumentIndexingService documentIndexingService, + DefaultAIDocumentIndexingService documentIndexingService, ILogger logger) { _documentStore = documentStore; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs index 5530b7a0..88526bc4 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs @@ -33,7 +33,9 @@ public static AIConnectionViewModel FromConnection(AIProviderConnection connecti Name = connection.Name, DisplayText = connection.DisplayText, Source = AIProviderNameNormalizer.Normalize(connection.Source), + IsReadOnly = connection.IsReadOnly, }; + // Read provider-specific settings from Properties dictionary. if (connection.Properties != null) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs index 9edbb24a..8fea2eca 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs @@ -52,9 +52,10 @@ public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) TechnicalName = deployment.Name, SelectedTypes = deployment.Type.GetSupportedTypes() .Select(static type => type.ToString()) - .ToArray(), + .ToArray(), ConnectionName = deployment.ConnectionName, ClientName = AIProviderNameNormalizer.Normalize(deployment.ClientName), + IsReadOnly = deployment.IsReadOnly, }; if (deployment.Properties != null) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Create.cshtml index d2bbf141..27ba9c26 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Create.cshtml @@ -19,6 +19,7 @@
Auto-generated from the title. You may override it before saving.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Edit.cshtml index 5a6b7d0a..7b3c9c5c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIConnection/Edit.cshtml @@ -20,7 +20,6 @@ -
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml index 27a0e13a..5f6dcc3e 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml @@ -21,6 +21,7 @@
This is the deployment identifier shown throughout the app. It is auto-generated from the model name and can be adjusted before saving.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Edit.cshtml index f631cf53..86936dd7 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Edit.cshtml @@ -22,7 +22,6 @@ -
This deployment identifier is shown throughout the app and cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index f7e6b92d..d267d171 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -85,6 +85,7 @@ +
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index cf2138ac..c4252bc8 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -641,6 +641,7 @@
+
Allow users to upload documents during chat sessions with this profile.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml index 904c0cdf..dc46e74f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml @@ -20,13 +20,14 @@
- +
- +
Auto-generated from the title.
+
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml index 04b2d16c..919d7d9f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml @@ -21,13 +21,12 @@
- +
-
The technical name cannot be changed after creation.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs index a70248ad..e837d778 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs @@ -1,5 +1,5 @@ +using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.BackgroundServices; @@ -26,7 +26,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { await using var scope = _scopeFactory.CreateAsyncScope(); - var indexingService = scope.ServiceProvider.GetRequiredService(); + var indexingService = scope.ServiceProvider.GetRequiredService(); switch (workItem.Type) { @@ -44,7 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "An error occurred while processing queued MVC chat document indexing work."); + _logger.LogError(ex, "An error occurred while processing queued chat document indexing work."); } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs deleted file mode 100644 index c2613c42..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatSessionCloseBackgroundService.cs +++ /dev/null @@ -1,207 +0,0 @@ -using CrestApps.Core.AI; -using CrestApps.Core.AI.Chat.Services; -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Profiles; -using CrestApps.Core.Data.YesSql; -using CrestApps.Core.Data.YesSql.Indexes.AIChat; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Options; -using YesSql; -using ISession = YesSql.ISession; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.BackgroundServices; - -/// -/// Periodically finalizes inactive AI chat sessions and marks them for post-session processing. -/// Mirrors the behavior of Orchard Core's AIChatSessionCloseBackgroundTask. -/// -public sealed class AIChatSessionCloseBackgroundService : BackgroundService -{ - private static readonly TimeSpan _interval = TimeSpan.FromMinutes(5); - private static readonly TimeSpan _defaultInactivityTimeout = TimeSpan.FromMinutes(30); - private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(5); - - private readonly IServiceScopeFactory _scopeFactory; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public AIChatSessionCloseBackgroundService( - IServiceScopeFactory scopeFactory, - TimeProvider timeProvider, - ILogger logger) - { - _scopeFactory = scopeFactory; - _timeProvider = timeProvider; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - using var timer = new PeriodicTimer(_interval); - - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - try - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var session = scope.ServiceProvider.GetRequiredService(); - var profileManager = scope.ServiceProvider.GetRequiredService(); - var postCloseProcessor = scope.ServiceProvider.GetRequiredService(); - var promptStore = scope.ServiceProvider.GetRequiredService(); - var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - var yesSqlsOptions = scope.ServiceProvider.GetRequiredService>(); - - await CloseInactiveSessionsAsync(session, profileManager, promptStore, postCloseProcessor, utcNow, yesSqlsOptions.Value, stoppingToken); - await RetryPendingProcessingAsync(session, profileManager, promptStore, postCloseProcessor, utcNow, yesSqlsOptions.Value, stoppingToken); - - await session.SaveChangesAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while closing inactive AI chat sessions."); - } - } - } - - /// - /// Finds active sessions that have exceeded their profile's inactivity timeout and finalizes them. - /// - private async Task CloseInactiveSessionsAsync( - ISession session, - IAIProfileManager profileManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - DateTime utcNow, - YesSqlStoreOptions yesSqlStoreOptions, - CancellationToken cancellationToken) - { - var profiles = await profileManager.GetAsync(AIProfileType.Chat, cancellationToken); - - foreach (var profile in profiles) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - var settings = profile.GetOrCreateSettings(); - var timeout = settings?.SessionInactivityTimeoutInMinutes > 0 - ? TimeSpan.FromMinutes(settings.SessionInactivityTimeoutInMinutes) - : _defaultInactivityTimeout; - - var cutoffUtc = utcNow - timeout; - - var inactiveSessions = await session - .Query( - i => i.ProfileId == profile.ItemId - && i.Status == ChatSessionStatus.Active - && i.LastActivityUtc < cutoffUtc, collection: yesSqlStoreOptions.AICollectionName) - .ListAsync(cancellationToken); - - foreach (var chatSession in inactiveSessions) - { - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - chatSession.Status = DetermineInactiveSessionStatus(prompts); - chatSession.ClosedAtUtc = utcNow; - - if (AIChatSessionPostCloseProcessor.NeedsProcessing(profile, chatSession)) - { - await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - } - else - { - chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.None; - } - - await session.SaveAsync(chatSession); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Finalized inactive session '{SessionId}' for profile '{ProfileId}' as '{Status}'. Post-processing: {NeedsProcessing}.", - chatSession.SessionId, - profile.ItemId, - chatSession.Status, - chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.None); - } - } - } - } - private async Task RetryPendingProcessingAsync( - ISession session, - IAIProfileManager profileManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - DateTime utcNow, - YesSqlStoreOptions yesSqlStoreOptions, - CancellationToken cancellationToken) - { - var pendingSessions = await session - .Query( - i => i.Status == ChatSessionStatus.Closed - || i.Status == ChatSessionStatus.Abandoned, collection: yesSqlStoreOptions.AICollectionName) - .ListAsync(cancellationToken); - - foreach (var chatSession in pendingSessions) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - if (chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.Pending) - { - continue; - } - - if (chatSession.PostSessionProcessingAttempts >= AIChatSessionPostCloseProcessor.MaxPostCloseAttempts) - { - chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.Failed; - await session.SaveAsync(chatSession); - - _logger.LogWarning( - "Post-session processing for session '{SessionId}' failed after {MaxAttempts} attempts.", - chatSession.SessionId, - AIChatSessionPostCloseProcessor.MaxPostCloseAttempts); - continue; - } - - if (chatSession.PostSessionProcessingLastAttemptUtc.HasValue - && (utcNow - chatSession.PostSessionProcessingLastAttemptUtc.Value) < _retryDelay) - { - continue; - } - - var profile = await profileManager.FindByIdAsync(chatSession.ProfileId, cancellationToken); - - if (profile == null) - { - continue; - } - - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - await session.SaveAsync(chatSession); - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Processed pending post-close work for session '{SessionId}'.", - chatSession.SessionId); - } - } - } - - private static ChatSessionStatus DetermineInactiveSessionStatus(IReadOnlyList prompts) - { - ArgumentNullException.ThrowIfNull(prompts); - - return prompts.Any(prompt => prompt.Role == ChatRole.User) - ? ChatSessionStatus.Closed - : ChatSessionStatus.Abandoned; - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatAnalyticsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatAnalyticsController.cs index 7072fa02..c3819f23 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatAnalyticsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatAnalyticsController.cs @@ -1,7 +1,7 @@ using System.Text; +using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,12 +14,12 @@ namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; public sealed class ChatAnalyticsController : Controller { private readonly IAIProfileManager _profileManager; - private readonly SampleAIChatSessionEventService _eventService; + private readonly IAIChatSessionEventService _eventService; private readonly TimeProvider _timeProvider; public ChatAnalyticsController( IAIProfileManager profileManager, - SampleAIChatSessionEventService eventService, + IAIChatSessionEventService eventService, TimeProvider timeProvider) { _profileManager = profileManager; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs index 08e4f7e4..d281db74 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs @@ -1,7 +1,7 @@ using System.Text; +using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,15 +14,22 @@ namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; public sealed class ChatExtractedDataController : Controller { private readonly IAIProfileManager _profileManager; - private readonly SampleAIChatSessionExtractedDataService _extractedDataService; + private readonly IAIChatSessionExtractedDataStore _extractedDataStore; private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The AI profile manager. + /// The extracted-data snapshot store. + /// The time provider. public ChatExtractedDataController( IAIProfileManager profileManager, - SampleAIChatSessionExtractedDataService extractedDataService, + IAIChatSessionExtractedDataStore extractedDataStore, TimeProvider timeProvider) { _profileManager = profileManager; - _extractedDataService = extractedDataService; + _extractedDataStore = extractedDataStore; _timeProvider = timeProvider; } @@ -48,7 +55,7 @@ public async Task IndexPost(ChatExtractedDataIndexViewModel model return View("Index", model); } - var records = await _extractedDataService.GetAsync(model.ProfileId, model.StartDateUtc, model.EndDateUtc); + var records = await _extractedDataStore.GetAsync(model.ProfileId, model.StartDateUtc, model.EndDateUtc); ApplyReport(model, records); return View("Index", model); @@ -63,7 +70,7 @@ public async Task Export(ChatExtractedDataIndexViewModel model) return BadRequest(); } - var records = await _extractedDataService.GetAsync(model.ProfileId, model.StartDateUtc, model.EndDateUtc); + var records = await _extractedDataStore.GetAsync(model.ProfileId, model.StartDateUtc, model.EndDateUtc); var rows = BuildRows(records); var columns = rows.SelectMany(row => row.Values.Keys).Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs index 17e1caa2..06f3986d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs @@ -1,5 +1,5 @@ +using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Models; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,20 +11,24 @@ namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; [Authorize(Policy = "Admin")] public sealed class UsageAnalyticsController : Controller { - private readonly SampleAICompletionUsageService _usageService; - private readonly GeneralAIOptions _generalAIOptions; + private readonly IAICompletionUsageService _usageService; + private readonly IOptionsMonitor _generalAIOptions; + public UsageAnalyticsController( - SampleAICompletionUsageService usageService, - IOptions generalAIOptions) + IAICompletionUsageService usageService, + IOptionsMonitor generalAIOptions) { _usageService = usageService; - _generalAIOptions = generalAIOptions.Value; + _generalAIOptions = generalAIOptions; } [HttpGet] public IActionResult Index() { - return View(new UsageAnalyticsIndexViewModel { IsAIUsageTrackingEnabled = _generalAIOptions.EnableAIUsageTracking, }); + return View(new UsageAnalyticsIndexViewModel + { + IsAIUsageTrackingEnabled = _generalAIOptions.CurrentValue.EnableAIUsageTracking, + }); } [HttpPost] @@ -32,7 +36,7 @@ public IActionResult Index() [ActionName(nameof(Index))] public async Task IndexPost(UsageAnalyticsIndexViewModel model) { - model.IsAIUsageTrackingEnabled = _generalAIOptions.EnableAIUsageTracking; + model.IsAIUsageTrackingEnabled = _generalAIOptions.CurrentValue.EnableAIUsageTracking; var records = await _usageService.GetAsync(model.StartDateUtc, model.EndDateUtc); ApplyReport(model, records); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs deleted file mode 100644 index 8c0a4a62..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Handlers; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; -using Microsoft.Extensions.AI; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Handlers; - -public sealed class AnalyticsChatSessionHandler : AIChatSessionHandlerBase -{ - private readonly SampleAIChatSessionEventService _eventService; - private readonly ILogger _logger; - - public AnalyticsChatSessionHandler( - SampleAIChatSessionEventService eventService, - ILogger logger) - { - _eventService = eventService; - _logger = logger; - } - - public override async Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default) - { - if (!context.Profile.TryGet(out var analyticsMetadata) || !analyticsMetadata.EnableSessionMetrics) - { - return; - } - - try - { - var userMessageCount = context.Prompts.Count(p => p.Role == ChatRole.User); - - if (userMessageCount == 1) - { - await _eventService.RecordSessionStartedAsync(context.ChatSession); - } - - if (context.ResponseLatencyMs > 0) - { - await _eventService.RecordResponseLatencyAsync(context.ChatSession.SessionId, context.ResponseLatencyMs); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to record analytics event for session '{SessionId}'.", context.ChatSession.SessionId); - } - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Hubs/AIChatHub.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Hubs/AIChatHub.cs index a4d70611..8a82d906 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Hubs/AIChatHub.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Hubs/AIChatHub.cs @@ -1,9 +1,7 @@ -using CrestApps.Core.AI; using CrestApps.Core.AI.Chat.Hubs; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; -using CrestApps.Core.Mvc.Web.Services; using Microsoft.AspNetCore.Authorization; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Hubs; @@ -29,7 +27,7 @@ protected override void CollectStreamingReferences( Dictionary references, HashSet contentItemIds) { - var citationCollector = services.GetRequiredService(); + var citationCollector = services.GetRequiredService(); if (handlerContext.Properties.TryGetValue("OrchestrationContext", out var ctxObj) && ctxObj is OrchestrationContext orchestrationContext) @@ -41,30 +39,4 @@ protected override void CollectStreamingReferences( citationCollector.CollectToolReferences(references, contentItemIds); } - protected override async Task OnMessageRatedAsync( - IServiceProvider services, - AIChatSession chatSession, - IAIChatSessionPromptStore promptStore) - { - var eventService = services.GetService(); - - if (eventService is null) - { - return; - } - - var allPrompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - var ratings = allPrompts - .Where(prompt => prompt.UserRating.HasValue) - .Select(prompt => prompt.UserRating.Value) - .ToList(); - - if (ratings.Count > 0) - { - await eventService.RecordUserRatingAsync( - chatSession.SessionId, - ratings.Count(rating => rating), - ratings.Count(rating => !rating)); - } - } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs deleted file mode 100644 index f06a85a6..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventPostCloseObserver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionEventPostCloseObserver : IAIChatSessionAnalyticsRecorder, IAIChatSessionConversionGoalRecorder -{ - private readonly SampleAIChatSessionEventService _eventService; - - public SampleAIChatSessionEventPostCloseObserver(SampleAIChatSessionEventService eventService) - { - _eventService = eventService; - } - - public async Task RecordSessionEndedAsync(AIProfile profile, AIChatSession session, IReadOnlyList prompts, bool isResolved, CancellationToken cancellationToken = default) - { - await _eventService.RecordSessionEndedAsync(session, prompts.Count, isResolved); - } - - public async Task RecordConversionGoalsAsync(AIProfile profile, AIChatSession session, IReadOnlyList goalResults, CancellationToken cancellationToken = default) - { - await _eventService.RecordConversionMetricsAsync(session.SessionId, goalResults.ToList()); - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs deleted file mode 100644 index 93fb3eef..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionEventService.cs +++ /dev/null @@ -1,178 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.Data.YesSql; -using CrestApps.Core.Data.YesSql.Indexes.AIChat; -using Microsoft.Extensions.Options; -using YesSql; -using ISession = YesSql.ISession; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionEventService -{ - private readonly ISession _session; - private readonly YesSqlStoreOptions _options; - private readonly TimeProvider _timeProvider; - - public SampleAIChatSessionEventService( - ISession session, - IOptions options, - TimeProvider timeProvider) - { - _session = session; - _options = options.Value; - _timeProvider = timeProvider; - } - - public async Task RecordSessionStartedAsync(AIChatSession chatSession) - { - ArgumentNullException.ThrowIfNull(chatSession); - - var now = _timeProvider.GetUtcNow().UtcDateTime; - var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); - var evt = new AIChatSessionEvent - { - SessionId = chatSession.SessionId, - ProfileId = chatSession.ProfileId, - VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, - UserId = chatSession.UserId, - IsAuthenticated = isAuthenticated, - SessionStartedUtc = now, - MessageCount = 0, - HandleTimeSeconds = 0, - IsResolved = false, - CompletionCount = 0, - CreatedUtc = now, - }; - await _session.SaveAsync(evt); - } - - public async Task RecordSessionEndedAsync(AIChatSession chatSession, int promptCount, bool isResolved) - { - ArgumentNullException.ThrowIfNull(chatSession); - - var evt = await FindEventBySessionIdAsync(chatSession.SessionId); - if (evt is null) - { - var now = _timeProvider.GetUtcNow().UtcDateTime; - var isAuthenticated = !string.IsNullOrEmpty(chatSession.UserId); - evt = new AIChatSessionEvent - { - SessionId = chatSession.SessionId, - ProfileId = chatSession.ProfileId, - VisitorId = isAuthenticated ? chatSession.UserId : chatSession.ClientId ?? string.Empty, - UserId = chatSession.UserId, - IsAuthenticated = isAuthenticated, - SessionStartedUtc = chatSession.CreatedUtc, - SessionEndedUtc = chatSession.ClosedAtUtc ?? now, - MessageCount = promptCount, - HandleTimeSeconds = ((chatSession.ClosedAtUtc ?? now) - chatSession.CreatedUtc).TotalSeconds, - IsResolved = isResolved, - CompletionCount = 0, - CreatedUtc = now, - }; - await _session.SaveAsync(evt); - - return; - } - - var endTime = chatSession.ClosedAtUtc ?? _timeProvider.GetUtcNow().UtcDateTime; - evt.SessionEndedUtc = endTime; - evt.MessageCount = promptCount; - evt.IsResolved = isResolved; - evt.HandleTimeSeconds = (endTime - evt.SessionStartedUtc).TotalSeconds; - await _session.SaveAsync(evt); - } - - public async Task RecordCompletionUsageAsync(string sessionId, int inputTokens, int outputTokens) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - var evt = await FindEventBySessionIdAsync(sessionId); - if (evt is null) - { - return; - } - - evt.TotalInputTokens += inputTokens; - evt.TotalOutputTokens += outputTokens; - await _session.SaveAsync(evt); - } - - public async Task RecordResponseLatencyAsync(string sessionId, double responseLatencyMs) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - var evt = await FindEventBySessionIdAsync(sessionId); - if (evt is null || responseLatencyMs <= 0) - { - return; - } - - evt.CompletionCount++; - evt.AverageResponseLatencyMs = ((evt.AverageResponseLatencyMs * (evt.CompletionCount - 1)) + responseLatencyMs) / evt.CompletionCount; - await _session.SaveAsync(evt); - } - - public async Task RecordConversionMetricsAsync(string sessionId, List goalResults) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - ArgumentNullException.ThrowIfNull(goalResults); - - var evt = await FindEventBySessionIdAsync(sessionId); - if (evt is null) - { - return; - } - - evt.ConversionGoalResults = goalResults; - evt.ConversionScore = goalResults.Sum(result => result.Score); - evt.ConversionMaxScore = goalResults.Sum(result => result.MaxScore); - await _session.SaveAsync(evt); - } - - public async Task RecordUserRatingAsync(string sessionId, int thumbsUpCount, int thumbsDownCount) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - - var evt = await FindEventBySessionIdAsync(sessionId); - if (evt is null) - { - return; - } - - evt.ThumbsUpCount = thumbsUpCount; - evt.ThumbsDownCount = thumbsDownCount; - evt.UserRating = thumbsUpCount + thumbsDownCount > 0 ? thumbsUpCount >= thumbsDownCount : null; - await _session.SaveAsync(evt); - } - - public async Task> GetAsync(string profileId, DateTime? startDateUtc, DateTime? endDateUtc, CancellationToken cancellationToken = default) - { - var query = _session.Query(collection: _options.AICollectionName); - if (!string.IsNullOrEmpty(profileId)) - { - query = query.Where(x => x.ProfileId == profileId); - } - - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - query = query.Where(x => x.SessionStartedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - query = query.Where(x => x.SessionStartedUtc < endExclusive); - } - - var events = await query.ListAsync(cancellationToken); - - return events.OrderByDescending(x => x.SessionStartedUtc).ToList(); - } - - private async Task FindEventBySessionIdAsync(string sessionId) - { - return await _session.Query(x => x.SessionId == sessionId, collection: _options.AICollectionName).FirstOrDefaultAsync(); - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs deleted file mode 100644 index 33db4011..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAIChatSessionExtractedDataService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Data.YesSql.Indexes.AIChat; -using YesSql; -using ISession = YesSql.ISession; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; - -public sealed class SampleAIChatSessionExtractedDataService : IAIChatSessionExtractedDataRecorder -{ - private readonly ISession _session; - private readonly TimeProvider _timeProvider; - - public SampleAIChatSessionExtractedDataService( - ISession session, - TimeProvider timeProvider) - { - _session = session; - _timeProvider = timeProvider; - } - - public async Task RecordExtractedDataAsync(AIProfile profile, AIChatSession session, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(session); - - var existing = await FindBySessionIdAsync(session.SessionId); - if (session.ExtractedData.Count == 0) - { - if (existing is not null) - { - _session.Delete(existing); - } - - return; - } - - var record = existing ?? new AIChatSessionExtractedDataRecord - { - ItemId = session.SessionId, - SessionId = session.SessionId, - }; - record.ProfileId = profile.ItemId; - record.SessionStartedUtc = session.CreatedUtc; - record.SessionEndedUtc = session.ClosedAtUtc; - record.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - record.Values = session.ExtractedData.Where(pair => pair.Value.Values.Count > 0).ToDictionary(pair => pair.Key, pair => pair.Value.Values.ToList(), StringComparer.OrdinalIgnoreCase); - - await _session.SaveAsync(record); - } - - public async Task> GetAsync(string profileId, DateTime? startDateUtc, DateTime? endDateUtc, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(profileId); - - var query = _session.Query(x => x.ProfileId == profileId); - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - query = query.Where(x => x.SessionStartedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - query = query.Where(x => x.SessionStartedUtc < endExclusive); - } - - var records = await query.ListAsync(cancellationToken); - - return records.OrderByDescending(x => x.SessionStartedUtc).ToList(); - } - - private async Task FindBySessionIdAsync(string sessionId) - { - return await _session.Query(x => x.SessionId == sessionId).FirstOrDefaultAsync(); - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs deleted file mode 100644 index a3c7e5f2..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleAICompletionUsageService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Data.YesSql; -using CrestApps.Core.Data.YesSql.Indexes.AIChat; -using Microsoft.Extensions.Options; -using YesSql; - -using ISession = YesSql.ISession; - -namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; - -public sealed class SampleAICompletionUsageService : IAICompletionUsageObserver -{ - private readonly ISession _session; - private readonly TimeProvider _timeProvider; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly SampleAIChatSessionEventService _chatSessionEventService; - private readonly GeneralAIOptions _generalAIOptions; - private readonly YesSqlStoreOptions _yesSqlStoreOptions; - - public SampleAICompletionUsageService( - ISession session, - TimeProvider timeProvider, - IHttpContextAccessor httpContextAccessor, - SampleAIChatSessionEventService chatSessionEventService, - IOptions generalAIOptions, - IOptions yesSqlStoreOptions) - { - _session = session; - _timeProvider = timeProvider; - _httpContextAccessor = httpContextAccessor; - _chatSessionEventService = chatSessionEventService; - _generalAIOptions = generalAIOptions.Value; - _yesSqlStoreOptions = yesSqlStoreOptions.Value; - } - - public async Task UsageRecordedAsync(AICompletionUsageRecord record, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(record); - - if (!_generalAIOptions.EnableAIUsageTracking) - { - return; - } - - record.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; - - if (string.IsNullOrEmpty(record.UserName)) - { - record.UserName = _httpContextAccessor.HttpContext?.User?.Identity?.Name; - } - - await _session.SaveAsync(record, cancellationToken: cancellationToken); - - if (!string.IsNullOrEmpty(record.SessionId) && - (record.InputTokenCount > 0 || record.OutputTokenCount > 0)) - { - await _chatSessionEventService.RecordCompletionUsageAsync(record.SessionId, record.InputTokenCount, record.OutputTokenCount); - } - } - - public async Task> GetAsync( - DateTime? startDateUtc, - DateTime? endDateUtc, - CancellationToken cancellationToken = default) - { - var query = _session.Query(collection: _yesSqlStoreOptions.AICollectionName); - - if (startDateUtc.HasValue) - { - var start = startDateUtc.Value.Date; - query = query.Where(x => x.CreatedUtc >= start); - } - - if (endDateUtc.HasValue) - { - var endExclusive = endDateUtc.Value.Date.AddDays(1); - query = query.Where(x => x.CreatedUtc < endExclusive); - } - - var records = await query.ListAsync(cancellationToken); - - return records.OrderByDescending(x => x.CreatedUtc).ToList(); - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs index f8be8517..3d3e52aa 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs @@ -25,11 +25,30 @@ public SampleClaudeOptionsConfiguration( public void Configure(ClaudeOptions options) { - var settings = _siteSettings.Get(); - if (settings == null) - { - return; - } + Apply(_siteSettings.Get(), options, _dataProtectionProvider, _logger); + } + + public static ClaudeOptions Create( + ClaudeSettings settings, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + var options = new ClaudeOptions(); + Apply(settings, options, dataProtectionProvider, logger); + + return options; + } + + public static void Apply( + ClaudeSettings settings, + ClaudeOptions options, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dataProtectionProvider); + ArgumentNullException.ThrowIfNull(logger); options.BaseUrl = settings.BaseUrl; options.DefaultModel = settings.DefaultModel; @@ -46,12 +65,12 @@ public void Configure(ClaudeOptions options) try { - var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose); + var protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); options.ApiKey = protector.Unprotect(settings.ProtectedApiKey); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); + logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs index ba873e46..ae104ad1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs @@ -25,12 +25,30 @@ public SampleCopilotOptionsConfiguration( public void Configure(CopilotOptions options) { - var settings = _siteSettings.Get(); + Apply(_siteSettings.Get(), options, _dataProtectionProvider, _logger); + } - if (settings == null) - { - return; - } + public static CopilotOptions Create( + CopilotSettings settings, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + var options = new CopilotOptions(); + Apply(settings, options, dataProtectionProvider, logger); + + return options; + } + + public static void Apply( + CopilotSettings settings, + CopilotOptions options, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dataProtectionProvider); + ArgumentNullException.ThrowIfNull(logger); options.AuthenticationType = settings.AuthenticationType; options.ClientId = settings.ClientId; @@ -41,7 +59,7 @@ public void Configure(CopilotOptions options) options.DefaultModel = settings.DefaultModel; options.AzureApiVersion = settings.AzureApiVersion; - var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose); + var protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); if (!string.IsNullOrWhiteSpace(settings.ProtectedClientSecret)) { @@ -51,7 +69,7 @@ public void Configure(CopilotOptions options) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Copilot client secret."); + logger.LogWarning(ex, "Failed to unprotect Copilot client secret."); } } @@ -63,7 +81,7 @@ public void Configure(CopilotOptions options) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to unprotect Copilot API key."); + logger.LogWarning(ex, "Failed to unprotect Copilot API key."); } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml index f54b0319..02713b35 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml @@ -48,12 +48,22 @@ -
-

@(Model.DisplayText ?? Model.Name)

- - Back - -
+
+
+

@(Model.DisplayText ?? Model.Name)

+ + Back + +
-
-
-
-
-
- @placeholderMessage +
+
+
+
+
+ @placeholderMessage +
+
-
-
-
-
-
- - - @if (conversationModeEnabled) - { - - } - + @if (conversationModeEnabled) + { + + } + +
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs index 3b96f52f..3ec12ba2 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs @@ -73,12 +73,14 @@ public async Task Index() var anthropicSettings = _siteSettings.Get(); var paginationSettings = _siteSettings.Get(); var adminWidgetSettings = _siteSettings.Get(); + var chatSessionProcessingSettings = _siteSettings.Get(); var model = new SettingsViewModel { EnableAIUsageTracking = settings.EnableAIUsageTracking, EnablePreemptiveMemoryRetrieval = settings.EnablePreemptiveMemoryRetrieval, MaximumIterationsPerRequest = settings.MaximumIterationsPerRequest, + MaxPostCloseAttempts = chatSessionProcessingSettings.MaxPostCloseAttempts, EnableDistributedCaching = settings.EnableDistributedCaching, EnableOpenTelemetry = settings.EnableOpenTelemetry, ChatInteractionChatMode = chatInteractionSettings.ChatMode, @@ -140,6 +142,11 @@ public async Task Save(SettingsViewModel model) ModelState.AddModelError(nameof(model.MaximumIterationsPerRequest), "Must be at least 1."); } + if (model.MaxPostCloseAttempts < 1) + { + ModelState.AddModelError(nameof(model.MaxPostCloseAttempts), "Must be at least 1."); + } + if (model.DocumentTopN < 1) { ModelState.AddModelError(nameof(model.DocumentTopN), "Must be at least 1."); @@ -228,6 +235,11 @@ public async Task Save(SettingsViewModel model) settings.EnableOpenTelemetry = model.EnableOpenTelemetry; }); + _siteSettings.Set(new AIChatSessionProcessingOptions + { + MaxPostCloseAttempts = model.MaxPostCloseAttempts, + }); + _siteSettings.Set(settings => { settings.ChatMode = model.ChatInteractionChatMode; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs index ff2d1e6a..84d03c05 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs @@ -16,6 +16,8 @@ public sealed class SettingsViewModel public int MaximumIterationsPerRequest { get; set; } = 10; + public int MaxPostCloseAttempts { get; set; } = AIChatSessionProcessingOptions.DefaultMaxPostCloseAttempts; + public bool EnableDistributedCaching { get; set; } = true; public bool EnableOpenTelemetry { get; set; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml index 7f2c856c..a532f125 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml @@ -169,6 +169,13 @@
This caps the number of orchestrator iterations allowed per request. Default is 10.
+
+ + + +
Controls how many times closed or abandoned sessions retry post-session processing before being marked failed. Default is 5.
+
+
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs index c448220f..aff5246d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs @@ -21,7 +21,6 @@ using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Models; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; @@ -53,8 +52,7 @@ public sealed class ChatInteractionController : Controller private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; private readonly SiteSettingsStore _siteSettings; - private readonly SampleAIDocumentIndexingService _documentIndexingService; - private readonly InteractionDocumentOptions _interactionDocumentOptions; + private readonly DefaultAIDocumentIndexingService _documentIndexingService; private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _aiTemplateService; private readonly OrchestratorOptions _orchestratorOptions; @@ -82,8 +80,7 @@ public ChatInteractionController( IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, SiteSettingsStore siteSettings, - SampleAIDocumentIndexingService documentIndexingService, - IOptions interactionDocumentOptions, + DefaultAIDocumentIndexingService documentIndexingService, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, @@ -110,7 +107,6 @@ public ChatInteractionController( _aiClientFactory = aiClientFactory; _siteSettings = siteSettings; _documentIndexingService = documentIndexingService; - _interactionDocumentOptions = interactionDocumentOptions.Value; _indexProfileStore = indexProfileStore; _aiTemplateService = aiTemplateService; _orchestratorOptions = orchestratorOptions.Value; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs index 052cc033..a9dc7258 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs @@ -1,8 +1,8 @@ using CrestApps.Core.AI.Chat.Hubs; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Models; -using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.Authorization; @@ -11,13 +11,13 @@ namespace CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs; [Authorize] public sealed class ChatInteractionHub : ChatInteractionHubBase { - private readonly SampleCitationReferenceCollector _citationCollector; + private readonly CitationReferenceCollector _citationCollector; private readonly SiteSettingsStore _siteSettings; public ChatInteractionHub( IServiceProvider serviceProvider, TimeProvider timeProvider, - SampleCitationReferenceCollector citationCollector, + CitationReferenceCollector citationCollector, SiteSettingsStore siteSettings, ILogger logger) : base(serviceProvider, timeProvider, logger) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs index 33a8576d..87a64af3 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs @@ -6,7 +6,6 @@ using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,7 +23,7 @@ public sealed class AIDocumentController : Controller private readonly IAIDocumentProcessingService _documentProcessingService; private readonly IAIDeploymentManager _deploymentManager; private readonly IAIClientFactory _aiClientFactory; - private readonly SampleAIDocumentIndexingService _documentIndexingService; + private readonly DefaultAIDocumentIndexingService _documentIndexingService; public AIDocumentController( IAIDocumentStore documentStore, @@ -34,7 +33,7 @@ public AIDocumentController( IAIDocumentProcessingService documentProcessingService, IAIDeploymentManager deploymentManager, IAIClientFactory aiClientFactory, - SampleAIDocumentIndexingService documentIndexingService) + DefaultAIDocumentIndexingService documentIndexingService) { _documentStore = documentStore; _chunkStore = chunkStore; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/IndexProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/IndexProfileController.cs index 2b32f292..026bfce8 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/IndexProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/IndexProfileController.cs @@ -102,6 +102,7 @@ public async Task Edit(string id) } var model = IndexProfileViewModel.FromProfile(profile); + model.EmbeddingDeploymentName = await NormalizeDeploymentSelectorAsync(model.EmbeddingDeploymentName); await PopulateDropdownsAsync(model); return View(model); @@ -118,6 +119,7 @@ public async Task Edit(IndexProfileViewModel model) return NotFound(); } + model.EmbeddingDeploymentName = await NormalizeDeploymentSelectorAsync(model.EmbeddingDeploymentName); await ValidateAsync(model, profile, profile.ItemId); if (!ModelState.IsValid) @@ -127,7 +129,8 @@ public async Task Edit(IndexProfileViewModel model) return View(model); } - profile.DisplayText = model.DisplayText; + profile.DisplayText = model.DisplayText?.Trim(); + profile.EmbeddingDeploymentName = model.EmbeddingDeploymentName; await _indexProfileManager.UpdateAsync(profile); await _indexProfileManager.SynchronizeAsync(profile, HttpContext.RequestAborted); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs deleted file mode 100644 index 92218a7d..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/SampleAIDocumentIndexingService.cs +++ /dev/null @@ -1,266 +0,0 @@ -using CrestApps.Core.AI.Documents.Models; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Infrastructure; - -using CrestApps.Core.Infrastructure.Indexing; - -using CrestApps.Core.Infrastructure.Indexing.Models; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.Mvc.Web.Areas.Indexing.Services; - -/// -/// Indexes sample-host uploaded AI document chunks into the configured AI Documents search index. -/// -public sealed class SampleAIDocumentIndexingService -{ - private readonly InteractionDocumentOptions _options; - private readonly ISearchIndexProfileStore _indexProfileStore; - - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public SampleAIDocumentIndexingService( - IOptions options, - ISearchIndexProfileStore indexProfileStore, - IServiceProvider serviceProvider, - ILogger logger) - { - _options = options.Value; - _indexProfileStore = indexProfileStore; - _serviceProvider = serviceProvider; - - _logger = logger; - } - - public async Task IndexAsync(AIDocument document, IReadOnlyCollection chunks, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(document); - ArgumentNullException.ThrowIfNull(chunks); - - var indexedChunks = chunks - .Where(chunk => chunk.Embedding is { Length: > 0 } && !string.IsNullOrWhiteSpace(chunk.Content)) - .ToList(); - - if (indexedChunks.Count == 0) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Skipping AI document indexing for '{FileName}' because no embedded chunks were created.", document.FileName); - } - - return; - } - - try - { - var indexProfile = await GetConfiguredIndexProfileAsync(cancellationToken); - - if (indexProfile == null) - { - return; - } - - var indexManager = _serviceProvider.GetKeyedService(indexProfile.ProviderName); - var documentManager = _serviceProvider.GetKeyedService(indexProfile.ProviderName); - - if (indexManager == null || documentManager == null) - { - _logger.LogWarning("Skipping AI document indexing because provider '{ProviderName}' is not configured for search indexing.", indexProfile.ProviderName); - - return; - } - - if (!await indexManager.ExistsAsync(indexProfile, cancellationToken)) - { - var dimensions = indexedChunks[0].Embedding!.Length; - - await indexManager.CreateAsync(indexProfile, BuildFields(dimensions), cancellationToken); - } - - var documents = indexedChunks - .Select(chunk => new IndexDocument - { - Id = chunk.ItemId, - Fields = new Dictionary - { - [DocumentIndexConstants.ColumnNames.ChunkId] = chunk.ItemId, - [DocumentIndexConstants.ColumnNames.DocumentId] = document.ItemId, - [DocumentIndexConstants.ColumnNames.Content] = chunk.Content, - [DocumentIndexConstants.ColumnNames.FileName] = document.FileName, - [DocumentIndexConstants.ColumnNames.ReferenceId] = chunk.ReferenceId, - [DocumentIndexConstants.ColumnNames.ReferenceType] = chunk.ReferenceType, - [DocumentIndexConstants.ColumnNames.Embedding] = chunk.Embedding, - [DocumentIndexConstants.ColumnNames.ChunkIndex] = chunk.Index, - }, - }) - .ToArray(); - - var indexed = await documentManager.AddOrUpdateAsync(indexProfile, documents, cancellationToken); - - if (!indexed) - { - _logger.LogWarning("AI document indexing reported failure for file '{FileName}' into index '{IndexName}'.", document.FileName, indexProfile.IndexFullName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to index uploaded AI document '{FileName}'. The document will remain attached, but search indexing was skipped.", document.FileName); - } - } - - public async Task DeleteAsync(string documentId, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(documentId); - - try - { - var indexProfile = await GetConfiguredIndexProfileAsync(cancellationToken); - - if (indexProfile == null) - { - return; - } - - var documentManager = _serviceProvider.GetKeyedService(indexProfile.ProviderName); - - if (documentManager == null) - { - _logger.LogWarning("Skipping AI document index cleanup because provider '{ProviderName}' is not configured for search indexing.", indexProfile.ProviderName); - - return; - } - - await documentManager.DeleteAsync(indexProfile, [documentId], cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove AI document '{DocumentId}' from the configured search index.", documentId); - } - } - - public async Task DeleteChunksAsync(IEnumerable chunkIds, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(chunkIds); - - var ids = chunkIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToArray(); - - if (ids.Length == 0) - { - return; - } - - try - { - var indexProfile = await GetConfiguredIndexProfileAsync(cancellationToken); - - if (indexProfile == null) - { - return; - } - - var documentManager = _serviceProvider.GetKeyedService(indexProfile.ProviderName); - - if (documentManager == null) - { - _logger.LogWarning("Skipping AI document chunk cleanup because provider '{ProviderName}' is not configured for search indexing.", indexProfile.ProviderName); - - return; - } - - await documentManager.DeleteAsync(indexProfile, ids, cancellationToken); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to remove {ChunkCount} AI document chunk(s) from the configured search index.", ids.Length); - } - } - - private async Task GetConfiguredIndexProfileAsync(CancellationToken cancellationToken) - { - var settings = _options; - - if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) - { - _logger.LogDebug("AI document indexing is disabled because no default AI Documents index is configured."); - - return null; - } - - cancellationToken.ThrowIfCancellationRequested(); - var indexProfile = await _indexProfileStore.FindByNameAsync(settings.IndexProfileName, cancellationToken); - - if (indexProfile == null) - { - _logger.LogWarning("AI document indexing is configured to use '{IndexProfileName}', but that index profile was not found.", settings.IndexProfileName); - - return null; - } - - if (!string.Equals(indexProfile.Type, IndexProfileTypes.AIDocuments, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("AI document indexing requires an '{ExpectedType}' index profile, but '{IndexProfileName}' is '{ActualType}'.", IndexProfileTypes.AIDocuments, settings.IndexProfileName, indexProfile.Type); - - return null; - } - - return indexProfile; - } - - private static IReadOnlyCollection BuildFields(int vectorDimensions) - { - return - [ - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.ChunkId, - FieldType = SearchFieldType.Keyword, - IsKey = true, - IsFilterable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.DocumentId, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.Content, - FieldType = SearchFieldType.Text, - IsSearchable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.FileName, - FieldType = SearchFieldType.Text, - IsFilterable = true, - IsSearchable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.ReferenceId, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.ReferenceType, - FieldType = SearchFieldType.Keyword, - IsFilterable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.ChunkIndex, - FieldType = SearchFieldType.Integer, - IsFilterable = true, - }, - new SearchIndexField - { - Name = DocumentIndexConstants.ColumnNames.Embedding, - FieldType = SearchFieldType.Vector, - VectorDimensions = vectorDimensions, - }, - ]; - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Views/IndexProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Views/IndexProfile/Edit.cshtml index 4d605fe7..de3b034d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Views/IndexProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Views/IndexProfile/Edit.cshtml @@ -3,6 +3,10 @@ @model IndexProfileViewModel @{ ViewData["Title"] = "Edit Index Profile"; + var showEmbeddingDeployment = !string.IsNullOrWhiteSpace(Model.Type) && + !string.Equals(Model.Type, IndexProfileTypes.Articles, StringComparison.OrdinalIgnoreCase); + var embeddingDeploymentSelectionLocked = showEmbeddingDeployment && + Model.EmbeddingDeployments.Any(deployment => string.Equals(deployment.Value, Model.EmbeddingDeploymentName, StringComparison.Ordinal)); }

Edit Index Profile

@@ -48,15 +52,28 @@
The index type cannot be changed after the index is created.
-
- - - - -
The embedding deployment cannot be changed after the index is created.
-
+ @if (showEmbeddingDeployment) + { +
+ + @if (embeddingDeploymentSelectionLocked) + { + + +
The embedding deployment cannot be changed after a valid deployment has been selected.
+ } + else + { + +
Choose an embedding deployment before rebuilding this index.
+ } + +
+ }
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 86914f26..f5a6e7b4 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -16,7 +16,6 @@ using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; -using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Ollama; using CrestApps.Core.AI.OpenAI; using CrestApps.Core.AI.OpenAI.Azure; @@ -92,14 +91,6 @@ builder.Services.AddAuthorizationBuilder() .AddPolicy("Admin", policy => policy.RequireRole("Administrator")); -builder.Services.Configure(options => -{ - options.ConnectionSections.Clear(); - options.ConnectionSections.Add("CrestApps:AI:Connections"); - - options.ProviderSections.Clear(); -}); - builder.Services .AddMvcSampleHostServices(appDataPath) .AddCrestAppsCore(crestApps => crestApps @@ -184,10 +175,8 @@ // ============================================================================= // 5. BACKGROUND TASKS AND PIPELINE // ============================================================================= -// These hosted services keep chat sessions, document indexing, and data-source -// synchronization moving in the background. Keep only the workers your app needs. +// These hosted services keep sample-only document indexing moving in the background. // ============================================================================= -builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); @@ -214,10 +203,7 @@ { branch.Use(async (context, next) => { - var siteSettings = context.RequestServices.GetRequiredService(); - var settings = siteSettings.TryGet(out var storedSettings) - ? storedSettings - : context.RequestServices.GetRequiredService>().Value; + var settings = context.RequestServices.GetRequiredService>().CurrentValue; if (settings.AuthenticationType == McpServerAuthenticationType.None) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs index 42d9b4b2..1b8abd64 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs @@ -72,7 +72,7 @@ private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) taskManager.OnAgentCardQuery = async (agentUrl, cancellationToken) => { var services = httpContextAccessor.HttpContext!.RequestServices; - var options = services.GetRequiredService>().Value; + var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); @@ -100,7 +100,7 @@ private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) private static async Task HandleWellKnownEndpointAsync(HttpContext context) { - var options = context.RequestServices.GetRequiredService>().Value; + var options = context.RequestServices.GetRequiredService>().CurrentValue; var profileManager = context.RequestServices.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent); var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}"; @@ -264,7 +264,7 @@ private static async Task ResolveTargetProfileAsync( IHttpContextAccessor httpContextAccessor, AgentMessage lastMessage) { - var options = services.GetRequiredService>().Value; + var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); var profiles = await profileManager.GetAsync(AIProfileType.Agent); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index cd1ef976..74a441ff 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -1,9 +1,11 @@ using System.Data.Common; +using System.Text.Json; +using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; -using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Copilot; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Documents; +using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Services; @@ -21,7 +23,6 @@ using CrestApps.Core.Mvc.Web.Areas.Admin.Indexes; using CrestApps.Core.Mvc.Web.Areas.AI.Handlers; using CrestApps.Core.Mvc.Web.Areas.AI.Services; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Handlers; using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; using CrestApps.Core.Services; @@ -33,6 +34,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using YesSql; +using YesSql.Indexes; using YesSql.Provider.Sqlite; using YesSql.Sql; @@ -40,9 +42,6 @@ namespace CrestApps.Core.Mvc.Web.Services; internal static class YesSqlServiceCollectionExtensions { - private static readonly string LegacyArticleDocumentType = $"{typeof(global::CrestApps.Core.Mvc.Web.Areas.Admin.Models.Article).FullName}, {typeof(global::CrestApps.Core.Mvc.Web.Areas.Admin.Models.Article).Assembly.GetName().Name}"; - private static readonly string CurrentArticleDocumentType = $"{typeof(Article).FullName}, {typeof(Article).Assembly.GetName().Name}"; - /// /// Registers the MVC sample host services that sit around the framework: /// YesSql storage, sample-only managers, article demo services, and the @@ -72,36 +71,26 @@ public static IServiceCollection AddMvcSampleHostServices(this IServiceCollectio .AddScoped() .AddScoped(); - services - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(); - services .AddScoped, AIMemoryEntryHandler>() - .AddScoped() .AddScoped() .AddScoped() .AddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services .AddYesSqlDocumentCatalog() .AddScoped, ArticleHandler>() .AddSharedArticleServices() .AddSharedTemplateProviders() .AddKeyedScoped(IndexProfileTypes.Articles) - .AddScoped() - .AddScoped() .AddScoped() .AddScoped(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.AddSingleton, SiteSettingsConfigureStoredOptions>(); + services.AddSingleton, SiteSettingsConfigureStoredOptions>(); services.ConfigureOptions(); services.ConfigureOptions(); services.Configure(options => options @@ -135,7 +124,6 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi var logger = services.GetRequiredService().CreateLogger("CrestApps.Core.Mvc.Web.YesSql"); var storeOptions = services.GetRequiredService>().Value; - RegisterIndexes(store); await InitializeCollectionsAsync(store, storeOptions); await using var connection = store.Configuration.ConnectionFactory.CreateConnection(); await connection.OpenAsync(); @@ -166,6 +154,8 @@ await TryCreateTableAsync(() => schemaBuilder.CreateMapIndexTableAsync(nameof(ArticleIndex.ItemId), c => c.WithLength(26)) .Column(nameof(ArticleIndex.Title), c => c.WithLength(255)))); await transaction.CommitAsync(); + + await MigrateLegacyExtractedDataRecordsAsync(services, storeOptions, logger); } private static async Task InitializeCollectionsAsync(IStore store, YesSqlStoreOptions storeOptions) @@ -196,9 +186,7 @@ private static async Task InitializeCollectionsAsync(IStore store, YesSqlStoreOp private static async Task ConfigureSqliteConnectionAsync(DbConnection connection) { await using var command = connection.CreateCommand(); - command.CommandText = "PRAGMA journal_mode=WAL;"; - _ = await command.ExecuteScalarAsync(); - command.CommandText = "PRAGMA synchronous=NORMAL;"; + command.CommandText = "PRAGMA synchronous=FULL;"; await command.ExecuteNonQueryAsync(); command.CommandText = "PRAGMA busy_timeout=30000;"; await command.ExecuteNonQueryAsync(); @@ -215,10 +203,138 @@ private static async Task TryCreateTableAsync(Func createTable) } } - private static void RegisterIndexes(IStore store) + private static async Task MigrateLegacyExtractedDataRecordsAsync( + IServiceProvider services, + YesSqlStoreOptions storeOptions, + ILogger logger) + { + if (string.Equals(storeOptions.AICollectionName, storeOptions.DefaultCollectionName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var legacyRows = await GetLegacyExtractedDataRowsAsync(services, storeOptions); + if (legacyRows.Count == 0) + { + return; + } + + await using var scope = services.CreateAsyncScope(); + var extractedDataStore = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + + foreach (var record in legacyRows + .Select(row => row.Record) + .Where(record => record is not null) + .GroupBy(record => record.SessionId, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(record => record.UpdatedUtc) + .First())) + { + await extractedDataStore.SaveAsync(record); + } + + await committer.CommitAsync(); + await DeleteLegacyExtractedDataRowsAsync(services, storeOptions, legacyRows.Select(row => row.Id).ToList()); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("Migrated {Count} legacy AI chat extracted-data snapshot records into the AI collection.", legacyRows.Count); + } + } + + private static async Task> GetLegacyExtractedDataRowsAsync( + IServiceProvider services, + YesSqlStoreOptions storeOptions) + { + var rows = new List(); + var tableName = GetDefaultCollectionDocumentTableName(storeOptions); + + await using var connection = services.GetRequiredService().Configuration.ConnectionFactory.CreateConnection(); + await connection.OpenAsync(); + await ConfigureSqliteConnectionAsync(connection); + + if (!await TableExistsAsync(connection, tableName)) + { + return rows; + } + + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT Id, Content FROM [{tableName}] WHERE Type = @type"; + AddParameter(command, "@type", "CrestApps.Core.AI.Models.AIChatSessionExtractedDataRecord, CrestApps.Core.AI.Abstractions"); + + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + if (reader.IsDBNull(0) || reader.IsDBNull(1)) + { + continue; + } + + var record = JsonSerializer.Deserialize(reader.GetString(1)); + if (record is null || string.IsNullOrWhiteSpace(record.SessionId) || string.IsNullOrWhiteSpace(record.ProfileId)) + { + continue; + } + + rows.Add(new LegacyExtractedDataRow(reader.GetInt32(0), record)); + } + + return rows; + } + + private static async Task DeleteLegacyExtractedDataRowsAsync( + IServiceProvider services, + YesSqlStoreOptions storeOptions, + List ids) + { + if (ids.Count == 0) + { + return; + } + + var tableName = GetDefaultCollectionDocumentTableName(storeOptions); + + await using var connection = services.GetRequiredService().Configuration.ConnectionFactory.CreateConnection(); + await connection.OpenAsync(); + await ConfigureSqliteConnectionAsync(connection); + await using var transaction = await connection.BeginTransactionAsync(); + + foreach (var id in ids.Distinct()) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"DELETE FROM [{tableName}] WHERE Id = @id"; + AddParameter(command, "@id", id); + await command.ExecuteNonQueryAsync(); + } + + await transaction.CommitAsync(); + } + + private static async Task TableExistsAsync(DbConnection connection, string tableName) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @name"; + AddParameter(command, "@name", tableName); + + return Convert.ToInt32(await command.ExecuteScalarAsync()) > 0; + } + + private static string GetDefaultCollectionDocumentTableName(YesSqlStoreOptions storeOptions) { - // Host-specific index provider. Shared index providers are registered - // automatically via DI in the per-feature AddCoreAI*StoresYesSql() methods. - store.RegisterIndexes(); + return string.IsNullOrWhiteSpace(storeOptions.DefaultCollectionName) + ? "Document" + : $"{storeOptions.DefaultCollectionName}_Document"; } + + private static void AddParameter(DbCommand command, string name, object value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private sealed record LegacyExtractedDataRow(int Id, AIChatSessionExtractedDataRecord Record); } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/appsettings.json b/src/Startup/CrestApps.Core.Mvc.Web/appsettings.json index e9abf648..09cd347f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/appsettings.json +++ b/src/Startup/CrestApps.Core.Mvc.Web/appsettings.json @@ -12,60 +12,60 @@ "Password": "Admin123!" }, "AI": { - "Connections": [ - // { - // "Name": "some unique name", - // "ClientName": "Azure", // OpenAI, Azure, Ollama, AzureAIInference - // "Endpoint": "your service endpoint", - // "AuthenticationType": "ApiKey", - // "ApiKey": "The API Key", - // } - ], - "AzureClient": { - // "EnableLogging": false, - // "EnableMessageLogging": false, - // "EnableMessageContentLogging": false - }, - "Deployments": [ - // { - // "ClientName": "AzureSpeech", - // "Name": "whisper", - // "Type": "SpeechToText", - // "Endpoint": "https://eastus.stt.speech.microsoft.com", - // "AuthenticationType": "ApiKey", - // "ApiKey": "your api key" - // }, - // { - // "ClientName": "AzureSpeech", - // "Name": "AzureTextToSpeech", - // "Type": "TextToSpeech", - // "Endpoint": "https://eastus.tts.speech.microsoft.com", - // "AuthenticationType": "ApiKey", - // "ApiKey": "your api key" - // } - ] - }, - "Elasticsearch": { - "ConnectionType": "CloudConnectionPool", - "Url": "", - "Ports": [ 9243 ], - "CloudId": "", - "Username": "", - "Password": "", - "AuthenticationType": "Base64ApiKey", - "Base64ApiKey": "", - "EnableApiVersioningHeader": false, - "IndexPrefix": "" - }, - "AzureAISearch": { - "Endpoint": "", - "IndexesPrefix": "", - "AuthenticationType": "ApiKey", - "IdentityClientId": null, - "DisableUIConfiguration": false, - "Credential": { - "Key": "" - } + // "Connections": [ + // { + // "Name": "some unique name", + // "ClientName": "Azure", // OpenAI, Azure, Ollama, AzureAIInference + // "Endpoint": "your service endpoint", + // "AuthenticationType": "ApiKey", + // "ApiKey": "The API Key", + // } + // ], + // "AzureClient": { + // "EnableLogging": false, + // "EnableMessageLogging": false, + // "EnableMessageContentLogging": false + // }, + // "Deployments": [ + // { + // "ClientName": "AzureSpeech", + // "Name": "whisper", + // "Type": "SpeechToText", + // "Endpoint": "https://eastus.stt.speech.microsoft.com", + // "AuthenticationType": "ApiKey", + // "ApiKey": "your api key" + // }, + // { + // "ClientName": "AzureSpeech", + // "Name": "AzureTextToSpeech", + // "Type": "TextToSpeech", + // "Endpoint": "https://eastus.tts.speech.microsoft.com", + // "AuthenticationType": "ApiKey", + // "ApiKey": "your api key" + // } + // ] } + // ,"Elasticsearch": { + // "ConnectionType": "CloudConnectionPool", + // "Url": "", + // "Ports": [ 9243 ], + // "CloudId": "", + // "Username": "", + // "Password": "", + // "AuthenticationType": "Base64ApiKey", + // "Base64ApiKey": "", + // "EnableApiVersioningHeader": false, + // "IndexPrefix": "" + // }, + // "AzureAISearch": { + // "Endpoint": "", + // "IndexesPrefix": "", + // "AuthenticationType": "ApiKey", + // "IdentityClientId": null, + // "DisableUIConfiguration": false, + // "Credential": { + // "Key": "" + // } + // } } } diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/OptionsValidationExtensions.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/OptionsValidationExtensions.cs index ed46bcc6..51ab1216 100644 --- a/src/Startup/CrestApps.Core.Startup.Shared/Services/OptionsValidationExtensions.cs +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/OptionsValidationExtensions.cs @@ -41,4 +41,23 @@ public static bool TryGetValidValue(this IOptionsSnapshot op return false; } } + + public static bool TryGetValidValue(this IOptionsMonitor optionsAccessor, out TOptions value) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(optionsAccessor); + + try + { + value = optionsAccessor.CurrentValue; + + return true; + } + catch (OptionsValidationException) + { + value = null; + + return false; + } + } } diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/SharedWebApplicationBuilderExtensions.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/SharedWebApplicationBuilderExtensions.cs index 9648d60c..21f55190 100644 --- a/src/Startup/CrestApps.Core.Startup.Shared/Services/SharedWebApplicationBuilderExtensions.cs +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/SharedWebApplicationBuilderExtensions.cs @@ -2,8 +2,10 @@ using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -41,13 +43,15 @@ public static string AddSharedSampleHostDefaults(this WebApplicationBuilder buil var appDataPath = !string.IsNullOrWhiteSpace(configuredAppDataPath) ? configuredAppDataPath : Path.Combine(builder.Environment.ContentRootPath, "App_Data"); + var projectAppDataPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(appDataPath); - builder.Configuration.AddJsonFile( - Path.Combine(appDataPath, "appsettings.json"), - optional: true, - reloadOnChange: false); + var keysDirectory = new DirectoryInfo(Path.Combine(appDataPath, "DataProtection-Keys")); + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(keysDirectory); + + AddSharedAppDataConfigurationSources(builder.Configuration, projectAppDataPath, appDataPath); builder.Services.AddSharedSiteSettings(appDataPath); @@ -64,8 +68,10 @@ public static IServiceCollection AddSharedSiteSettings(this IServiceCollection s ArgumentException.ThrowIfNullOrEmpty(appDataPath); services.AddSingleton(new SiteSettingsStore(appDataPath)); + services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IOptionsChangeTokenSource<>), typeof(SiteSettingsOptionsChangeTokenSource<>))); services.AddSingleton, SiteSettingsConfigureGeneralAIOptions>(); services.AddSingleton, SiteSettingsConfigureAIMemoryOptions>(); + services.AddSingleton, SiteSettingsConfigureStoredOptions>(); services.AddSingleton, SiteSettingsConfigureInteractionDocumentOptions>(); services.AddSingleton, SiteSettingsConfigureAIDataSourceOptions>(); services.AddSingleton, SiteSettingsConfigureChatInteractionMemoryOptions>(); @@ -73,4 +79,33 @@ public static IServiceCollection AddSharedSiteSettings(this IServiceCollection s return services; } + + private static void AddSharedAppDataConfigurationSources( + ConfigurationManager configuration, + string projectAppDataPath, + string appDataPath) + { + configuration.AddJsonFile( + Path.Combine(projectAppDataPath, "appsettings.json"), + optional: true, + reloadOnChange: false); + + if (PathsMatch(projectAppDataPath, appDataPath)) + { + return; + } + + configuration.AddJsonFile( + Path.Combine(appDataPath, "appsettings.json"), + optional: true, + reloadOnChange: false); + } + + private static bool PathsMatch(string left, string right) + { + return string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsConfigureStoredOptions.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsConfigureStoredOptions.cs new file mode 100644 index 00000000..04bd8305 --- /dev/null +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsConfigureStoredOptions.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Startup.Shared.Services; + +public sealed class SiteSettingsConfigureStoredOptions : IConfigureOptions + where TOptions : class, new() +{ + private static readonly PropertyInfo[] _properties = typeof(TOptions) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0) + .ToArray(); + + private readonly SiteSettingsStore _siteSettings; + + public SiteSettingsConfigureStoredOptions(SiteSettingsStore siteSettings) + { + _siteSettings = siteSettings; + } + + public void Configure(TOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var storedOptions = _siteSettings.Get(); + + foreach (var property in _properties) + { + property.SetValue(options, property.GetValue(storedOptions)); + } + } +} diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsChangeTokenSource.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsChangeTokenSource.cs new file mode 100644 index 00000000..f5ca4eed --- /dev/null +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsChangeTokenSource.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace CrestApps.Core.Startup.Shared.Services; + +internal sealed class SiteSettingsOptionsChangeTokenSource : IOptionsChangeTokenSource +{ + private readonly SiteSettingsStore _siteSettings; + + public SiteSettingsOptionsChangeTokenSource(SiteSettingsStore siteSettings) + { + _siteSettings = siteSettings; + } + + public string Name => Options.DefaultName; + + public IChangeToken GetChangeToken() + { + return _siteSettings.GetChangeToken(); + } +} diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsConfigurations.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsConfigurations.cs index 7c7e1262..abfe3b78 100644 --- a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsConfigurations.cs +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsOptionsConfigurations.cs @@ -16,7 +16,27 @@ public SiteSettingsConfigureGeneralAIOptions(SiteSettingsStore siteSettings) public void Configure(GeneralAIOptions options) { - var settings = _siteSettings.Get(); + SiteSettingsGeneralAIOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsGeneralAIOptionsMapper +{ + public static GeneralAIOptions Create(GeneralAISettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new GeneralAIOptions(); + Apply(settings, options); + + return options; + } + + public static void Apply(GeneralAISettings settings, GeneralAIOptions options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.EnableAIUsageTracking = settings.EnableAIUsageTracking; options.EnablePreemptiveMemoryRetrieval = settings.EnablePreemptiveMemoryRetrieval; options.OverrideMaximumIterationsPerRequest = settings.OverrideMaximumIterationsPerRequest; @@ -39,7 +59,27 @@ public SiteSettingsConfigureAIMemoryOptions(SiteSettingsStore siteSettings) public void Configure(AIMemoryOptions options) { - var settings = _siteSettings.Get(); + SiteSettingsAIMemoryOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsAIMemoryOptionsMapper +{ + public static AIMemoryOptions Create(AIMemoryOptions settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new AIMemoryOptions(); + Apply(settings, options); + + return options; + } + + public static void Apply(AIMemoryOptions settings, AIMemoryOptions options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.IndexProfileName = settings.IndexProfileName; options.TopN = settings.TopN; } @@ -56,7 +96,27 @@ public SiteSettingsConfigureInteractionDocumentOptions(SiteSettingsStore siteSet public void Configure(InteractionDocumentOptions options) { - var settings = _siteSettings.Get(); + SiteSettingsInteractionDocumentOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsInteractionDocumentOptionsMapper +{ + public static InteractionDocumentOptions Create(InteractionDocumentSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new InteractionDocumentOptions(); + Apply(settings, options); + + return options; + } + + public static void Apply(InteractionDocumentSettings settings, InteractionDocumentOptions options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.IndexProfileName = settings.IndexProfileName; options.TopN = settings.TopN; options.RetrievalMode = settings.RetrievalMode; @@ -74,7 +134,27 @@ public SiteSettingsConfigureAIDataSourceOptions(SiteSettingsStore siteSettings) public void Configure(AIDataSourceOptions options) { - var settings = _siteSettings.Get(); + SiteSettingsAIDataSourceOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsAIDataSourceOptionsMapper +{ + public static AIDataSourceOptions Create(AIDataSourceSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new AIDataSourceOptions(); + Apply(settings, options); + + return options; + } + + public static void Apply(AIDataSourceSettings settings, AIDataSourceOptions options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.DefaultStrictness = settings.DefaultStrictness; options.DefaultTopNDocuments = settings.DefaultTopNDocuments; } @@ -91,7 +171,27 @@ public SiteSettingsConfigureChatInteractionMemoryOptions(SiteSettingsStore siteS public void Configure(ChatInteractionMemoryOptions options) { - var settings = _siteSettings.Get(); + SiteSettingsChatInteractionMemoryOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsChatInteractionMemoryOptionsMapper +{ + public static ChatInteractionMemoryOptions Create(MemoryMetadata settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new ChatInteractionMemoryOptions(); + Apply(settings, options); + + return options; + } + + public static void Apply(MemoryMetadata settings, ChatInteractionMemoryOptions options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.EnableUserMemory = settings.EnableUserMemory ?? true; } } @@ -107,7 +207,27 @@ public SiteSettingsConfigureDefaultDeploymentOptions(SiteSettingsStore siteSetti public void Configure(DefaultAIDeploymentSettings options) { - var settings = _siteSettings.Get(); + SiteSettingsDefaultAIDeploymentOptionsMapper.Apply(_siteSettings.Get(), options); + } +} + +internal static class SiteSettingsDefaultAIDeploymentOptionsMapper +{ + public static DefaultAIDeploymentSettings Create(DefaultAIDeploymentSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + var options = new DefaultAIDeploymentSettings(); + Apply(settings, options); + + return options; + } + + public static void Apply(DefaultAIDeploymentSettings settings, DefaultAIDeploymentSettings options) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(options); + options.DefaultChatDeploymentName = settings.DefaultChatDeploymentName; options.DefaultUtilityDeploymentName = settings.DefaultUtilityDeploymentName; options.DefaultEmbeddingDeploymentName = settings.DefaultEmbeddingDeploymentName; diff --git a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsStore.cs b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsStore.cs index ca6e64a1..c4e46a23 100644 --- a/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsStore.cs +++ b/src/Startup/CrestApps.Core.Startup.Shared/Services/SiteSettingsStore.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.Primitives; namespace CrestApps.Core.Startup.Shared.Services; @@ -50,6 +51,7 @@ public sealed class SiteSettingsStore private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly string _filePath; private readonly JsonObject _root; + private CancellationTokenSource _reloadTokenSource = new(); public SiteSettingsStore(string appDataPath) { @@ -125,6 +127,14 @@ public void Set(T value) where T : class Set(current); } + public IChangeToken GetChangeToken() + { + lock (_stateLock) + { + return new CancellationChangeToken(_reloadTokenSource.Token); + } + } + public async Task SaveChangesAsync() { string json; @@ -148,6 +158,7 @@ public async Task SaveChangesAsync() var tempPath = _filePath + ".tmp"; await File.WriteAllTextAsync(tempPath, json); File.Move(tempPath, _filePath, overwrite: true); + SignalChanged(); } finally { @@ -228,4 +239,18 @@ private static void MigrateKeys(JsonObject root) } } } + + private void SignalChanged() + { + CancellationTokenSource previousTokenSource; + + lock (_stateLock) + { + previousTokenSource = _reloadTokenSource; + _reloadTokenSource = new CancellationTokenSource(); + } + + previousTokenSource.Cancel(); + previousTokenSource.Dispose(); + } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/CrestAppsEntityDbContext.cs b/src/Stores/CrestApps.Core.Data.EntityCore/CrestAppsEntityDbContext.cs index f59b480d..fd712016 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/CrestAppsEntityDbContext.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/CrestAppsEntityDbContext.cs @@ -34,6 +34,11 @@ public CrestAppsEntityDbContext( _modelConfigurers = modelConfigurers ?? []; } + /// + /// Gets the for rows. + /// + public DbSet Documents => Set(); + /// /// Gets the for rows. /// @@ -44,6 +49,21 @@ public CrestAppsEntityDbContext( /// public DbSet AIChatSessionRecords => Set(); + /// + /// Gets the for rows. + /// + public DbSet AIChatSessionEventRecords => Set(); + + /// + /// Gets the for rows. + /// + public DbSet AICompletionUsageRecords => Set(); + + /// + /// Gets the for rows. + /// + public DbSet AIChatSessionExtractedDataRecords => Set(); + /// /// Configures the CrestApps Entity Framework Core model. /// @@ -52,10 +72,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { var tablePrefix = _options.TablePrefix ?? string.Empty; + modelBuilder.Entity(entity => + { + entity.ToTable($"{tablePrefix}Documents"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Type).IsRequired(); + entity.Property(x => x.Content).IsRequired(); + + entity.HasIndex(x => x.Type); + }); + modelBuilder.Entity(entity => { entity.ToTable($"{tablePrefix}CatalogRecords"); - entity.HasKey(x => new { x.EntityType, x.ItemId }); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Document).WithMany().HasForeignKey(x => x.DocumentId); entity.Property(x => x.EntityType).IsRequired(); entity.Property(x => x.ItemId).HasMaxLength(26); entity.Property(x => x.Name); @@ -68,8 +99,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(x => x.AIDocumentId); entity.Property(x => x.UserId); entity.Property(x => x.Type); - entity.Property(x => x.Payload).IsRequired(); + entity.HasIndex(x => new { x.EntityType, x.ItemId }).IsUnique(); entity.HasIndex(x => new { x.EntityType, x.Name }); entity.HasIndex(x => new { x.EntityType, x.Source }); entity.HasIndex(x => new { x.EntityType, x.SessionId }); @@ -90,18 +121,58 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.ToTable($"{tablePrefix}AIChatSessions"); - entity.HasKey(x => x.SessionId); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Document).WithMany().HasForeignKey(x => x.DocumentId); entity.Property(x => x.SessionId).HasMaxLength(26); entity.Property(x => x.ProfileId); entity.Property(x => x.Title); entity.Property(x => x.UserId); entity.Property(x => x.ClientId); - entity.Property(x => x.Payload).IsRequired(); + entity.HasIndex(x => x.SessionId).IsUnique(); entity.HasIndex(x => x.ProfileId); entity.HasIndex(x => x.LastActivityUtc); }); + modelBuilder.Entity(entity => + { + entity.ToTable($"{tablePrefix}AIChatSessionEvents"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Document).WithMany().HasForeignKey(x => x.DocumentId); + entity.Property(x => x.SessionId).HasMaxLength(26); + entity.Property(x => x.ProfileId); + + entity.HasIndex(x => x.SessionId).IsUnique(); + entity.HasIndex(x => x.ProfileId); + entity.HasIndex(x => x.SessionStartedUtc); + entity.HasIndex(x => x.CreatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable($"{tablePrefix}AICompletionUsage"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Document).WithMany().HasForeignKey(x => x.DocumentId); + + entity.HasIndex(x => x.CreatedUtc); + entity.HasIndex(x => x.SessionId); + entity.HasIndex(x => x.InteractionId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable($"{tablePrefix}AIChatSessionExtractedData"); + entity.HasKey(x => x.Id); + entity.HasOne(x => x.Document).WithMany().HasForeignKey(x => x.DocumentId); + entity.Property(x => x.SessionId).HasMaxLength(26); + entity.Property(x => x.ProfileId).IsRequired(); + + entity.HasIndex(x => x.SessionId).IsUnique(); + entity.HasIndex(x => x.ProfileId); + entity.HasIndex(x => x.SessionStartedUtc); + entity.HasIndex(x => x.UpdatedUtc); + }); + foreach (var configurer in _modelConfigurers) { configurer.Configure(modelBuilder, _options); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionEventStoreRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionEventStoreRecord.cs new file mode 100644 index 00000000..f4f77a16 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionEventStoreRecord.cs @@ -0,0 +1,43 @@ +namespace CrestApps.Core.Data.EntityCore.Models; + +/// +/// Represents the Entity Framework Core database record for a chat-session analytics event. +/// +public sealed class AIChatSessionEventStoreRecord +{ + /// + /// Gets or sets the database-generated identity for this record. + /// + public long Id { get; set; } + + /// + /// Gets or sets the foreign key to the that holds + /// the serialized JSON payload for this analytics event. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the navigation property to the associated . + /// + public DocumentRecord Document { get; set; } + + /// + /// Gets or sets the unique identifier of the chat session. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the identifier of the AI profile associated with the session. + /// + public string ProfileId { get; set; } + + /// + /// Gets or sets the UTC date and time when the session started. + /// + public DateTime SessionStartedUtc { get; set; } + + /// + /// Gets or sets the UTC date and time when the analytics record was created. + /// + public DateTime CreatedUtc { get; set; } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionExtractedDataStoreRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionExtractedDataStoreRecord.cs new file mode 100644 index 00000000..23fbcba5 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionExtractedDataStoreRecord.cs @@ -0,0 +1,49 @@ +namespace CrestApps.Core.Data.EntityCore.Models; + +/// +/// Represents the Entity Framework Core database record for an extracted-data +/// snapshot captured from a chat session. +/// +public sealed class AIChatSessionExtractedDataStoreRecord +{ + /// + /// Gets or sets the database-generated identity for this record. + /// + public long Id { get; set; } + + /// + /// Gets or sets the foreign key to the that holds + /// the serialized JSON payload for this extracted-data snapshot. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the navigation property to the associated . + /// + public DocumentRecord Document { get; set; } + + /// + /// Gets or sets the unique identifier of the chat session. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the identifier of the AI profile associated with the session. + /// + public string ProfileId { get; set; } + + /// + /// Gets or sets the UTC date and time when the session started. + /// + public DateTime SessionStartedUtc { get; set; } + + /// + /// Gets or sets the UTC date and time when the session ended. + /// + public DateTime? SessionEndedUtc { get; set; } + + /// + /// Gets or sets the UTC date and time when the extracted-data snapshot was last updated. + /// + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionRecord.cs index f830ae97..c52be02b 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionRecord.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AIChatSessionRecord.cs @@ -7,6 +7,22 @@ namespace CrestApps.Core.Data.EntityCore.Models; /// public sealed class AIChatSessionRecord { + /// + /// Gets or sets the database-generated identity for this record. + /// + public long Id { get; set; } + + /// + /// Gets or sets the foreign key to the that holds + /// the serialized JSON payload for this session. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the navigation property to the associated . + /// + public DocumentRecord Document { get; set; } + /// /// Gets or sets the unique identifier of the chat session. /// @@ -46,9 +62,4 @@ public sealed class AIChatSessionRecord /// Gets or sets the UTC date and time of the most recent activity in the session. /// public DateTime LastActivityUtc { get; set; } - - /// - /// Gets or sets the serialized JSON payload containing the full session data. - /// - public string Payload { get; set; } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/AICompletionUsageStoreRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AICompletionUsageStoreRecord.cs new file mode 100644 index 00000000..6989394d --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/AICompletionUsageStoreRecord.cs @@ -0,0 +1,38 @@ +namespace CrestApps.Core.Data.EntityCore.Models; + +/// +/// Represents the Entity Framework Core database record for an AI completion usage event. +/// +public sealed class AICompletionUsageStoreRecord +{ + /// + /// Gets or sets the database-generated identity for this record. + /// + public long Id { get; set; } + + /// + /// Gets or sets the foreign key to the that holds + /// the serialized JSON payload for this usage record. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the navigation property to the associated . + /// + public DocumentRecord Document { get; set; } + + /// + /// Gets or sets the UTC date and time when the usage record was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the optional chat session identifier associated with the usage record. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the optional interaction identifier associated with the usage record. + /// + public string InteractionId { get; set; } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/CatalogRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/CatalogRecord.cs index 0e906e17..287deded 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Models/CatalogRecord.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/CatalogRecord.cs @@ -7,14 +7,28 @@ namespace CrestApps.Core.Data.EntityCore.Models; public sealed class CatalogRecord { /// - /// Gets or sets the CLR or logical type name of the catalogued entity, - /// used as part of the composite primary key. + /// Gets or sets the database-generated identity for this record. + /// + public long Id { get; set; } + + /// + /// Gets or sets the foreign key to the that holds + /// the serialized JSON payload for this catalogue entry. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the navigation property to the associated . + /// + public DocumentRecord Document { get; set; } + + /// + /// Gets or sets the CLR or logical type name of the catalogued entity. /// public string EntityType { get; set; } /// - /// Gets or sets the unique identifier of the catalogued item, - /// used as part of the composite primary key. + /// Gets or sets the unique identifier of the catalogued item. /// public string ItemId { get; set; } @@ -78,9 +92,4 @@ public sealed class CatalogRecord /// Gets or sets the UTC date and time when the record was last updated, if tracked. /// public DateTime? UpdatedUtc { get; set; } - - /// - /// Gets or sets the serialized JSON payload containing the full entity data. - /// - public string Payload { get; set; } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Models/DocumentRecord.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Models/DocumentRecord.cs new file mode 100644 index 00000000..b98ea85e --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Models/DocumentRecord.cs @@ -0,0 +1,29 @@ +namespace CrestApps.Core.Data.EntityCore.Models; + +/// +/// Represents a centralized document record that stores the serialized JSON +/// payload for any entity managed by the EntityCore stores. +/// +/// +/// Individual index tables (such as and +/// ) reference their document through the +/// DocumentId foreign key, following a pattern similar to YesSql's +/// shared Document table. +/// +public sealed class DocumentRecord +{ + /// + /// Gets or sets the database-generated identity for this document. + /// + public long Id { get; set; } + + /// + /// Gets or sets the CLR or logical type name of the entity stored in this document. + /// + public string Type { get; set; } + + /// + /// Gets or sets the serialized JSON content of the entity. + /// + public string Content { get; set; } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 2ed88e2b..b8a50ef6 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; using CrestApps.Core.AI.Mcp.Models; @@ -16,9 +18,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CrestApps.Core.Data.EntityCore; @@ -196,11 +198,28 @@ public static IServiceCollection AddCoreAIChatSessionStoresEntityCore(this IServ services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + services.AddCoreAIChatSessionExtractedDataStoresEntityCore(); services.AddScoped>(sp => sp.GetRequiredService()); return services; } + /// + /// Registers EntityCore-backed stores for chat session extracted-data snapshots. + /// + /// The service collection. + public static IServiceCollection AddCoreAIChatSessionExtractedDataStoresEntityCore(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Replace(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + /// /// Registers EntityCore-backed stores for the document processing feature. /// This includes and . @@ -485,7 +504,729 @@ public static async Task InitializeEntityCoreSchemaAsync(this IServiceProvider s using var scope = services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + var storeOptions = scope.ServiceProvider.GetRequiredService>().Value; await dbContext.Database.EnsureCreatedAsync(); + + var tablePrefix = storeOptions.TablePrefix ?? string.Empty; + +#pragma warning disable CS0618 + await MigrateFromLegacySchemaIfNeededAsync(dbContext, tablePrefix); +#pragma warning restore CS0618 + await EnsureOptionalTablesAsync(dbContext, tablePrefix); + } + + /// + /// Detects the pre-v1 (Payload-per-table) schema and migrates data into the + /// centralized Documents table, adding identity and foreign-key columns + /// to every index table. Existing databases are transformed in a single + /// transaction; brand-new databases created by EnsureCreatedAsync + /// are not affected. + /// + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateFromLegacySchemaIfNeededAsync( + CrestAppsEntityDbContext dbContext, + string tablePrefix) + { + var catalogTableName = GetSafeSqlIdentifier($"{tablePrefix}CatalogRecords"); + + if (!await HasColumnAsync(dbContext, catalogTableName, "Payload")) + { + return; + } + + var documentsTableName = GetSafeSqlIdentifier($"{tablePrefix}Documents"); + var sessionsTableName = GetSafeSqlIdentifier($"{tablePrefix}AIChatSessions"); + var eventsTableName = GetSafeSqlIdentifier($"{tablePrefix}AIChatSessionEvents"); + var usageTableName = GetSafeSqlIdentifier($"{tablePrefix}AICompletionUsage"); + var extractedTableName = GetSafeSqlIdentifier($"{tablePrefix}AIChatSessionExtractedData"); + + var connection = dbContext.Database.GetDbConnection(); + await connection.OpenAsync(); + + using var transaction = connection.BeginTransaction(); + + try + { + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE IF NOT EXISTS "{documentsTableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "Type" TEXT NOT NULL, + "Content" TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}Documents_Type")}" ON "{documentsTableName}" ("Type"); + """); + + await MigrateCatalogRecordsAsync(connection, transaction, catalogTableName, documentsTableName, tablePrefix); + await MigrateAIChatSessionsAsync(connection, transaction, sessionsTableName, documentsTableName, tablePrefix); + + if (await TableExistsAsync(connection, transaction, eventsTableName)) + { + await MigrateAIChatSessionEventsAsync(connection, transaction, eventsTableName, documentsTableName, tablePrefix); + } + + if (await TableExistsAsync(connection, transaction, usageTableName)) + { + await MigrateAICompletionUsageAsync(connection, transaction, usageTableName, documentsTableName, tablePrefix); + } + + if (await TableExistsAsync(connection, transaction, extractedTableName)) + { + await MigrateAIChatSessionExtractedDataAsync(connection, transaction, extractedTableName, documentsTableName, tablePrefix); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateCatalogRecordsAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string documentsTableName, + string tablePrefix) + { + var backupName = $"{tableName}_v0"; + + await ExecuteNonQueryAsync(connection, transaction, + $"""ALTER TABLE "{tableName}" RENAME TO "{backupName}";"""); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE "{tableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "EntityType" TEXT NOT NULL, + "ItemId" TEXT NOT NULL, + "Name" TEXT NULL, + "DisplayText" TEXT NULL, + "Source" TEXT NULL, + "SessionId" TEXT NULL, + "ChatInteractionId" TEXT NULL, + "ReferenceId" TEXT NULL, + "ReferenceType" TEXT NULL, + "AIDocumentId" TEXT NULL, + "UserId" TEXT NULL, + "Type" TEXT NULL, + "CreatedUtc" TEXT NULL, + "UpdatedUtc" TEXT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + """); + + var availableCols = await GetColumnNamesAsync(connection, transaction, backupName); + + string Col(string name) => + availableCols.Contains(name) ? $"\"{name}\"" : $"NULL AS \"{name}\""; + + using (var readCmd = connection.CreateCommand()) + { + readCmd.Transaction = transaction; + readCmd.CommandText = + $""" + SELECT "EntityType", "ItemId", {Col("Name")}, {Col("DisplayText")}, {Col("Source")}, + {Col("SessionId")}, {Col("ChatInteractionId")}, {Col("ReferenceId")}, {Col("ReferenceType")}, + {Col("AIDocumentId")}, {Col("UserId")}, {Col("Type")}, {Col("CreatedUtc")}, {Col("UpdatedUtc")}, + "Payload" + FROM "{backupName}"; + """; + + using var reader = await readCmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var docId = await InsertDocumentAsync(connection, transaction, documentsTableName, + reader.GetString(0), reader.GetString(14)); + + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = + $""" + INSERT INTO "{tableName}" ("DocumentId","EntityType","ItemId","Name","DisplayText","Source","SessionId","ChatInteractionId","ReferenceId","ReferenceType","AIDocumentId","UserId","Type","CreatedUtc","UpdatedUtc") + VALUES (@d,@et,@ii,@n,@dt,@s,@si,@ci,@ri,@rt,@ai,@ui,@t,@cu,@uu); + """; + + AddParam(insertCmd, "@d", docId); + AddParam(insertCmd, "@et", reader, 0); + AddParam(insertCmd, "@ii", reader, 1); + AddParam(insertCmd, "@n", reader, 2); + AddParam(insertCmd, "@dt", reader, 3); + AddParam(insertCmd, "@s", reader, 4); + AddParam(insertCmd, "@si", reader, 5); + AddParam(insertCmd, "@ci", reader, 6); + AddParam(insertCmd, "@ri", reader, 7); + AddParam(insertCmd, "@rt", reader, 8); + AddParam(insertCmd, "@ai", reader, 9); + AddParam(insertCmd, "@ui", reader, 10); + AddParam(insertCmd, "@t", reader, 11); + AddParam(insertCmd, "@cu", reader, 12); + AddParam(insertCmd, "@uu", reader, 13); + await insertCmd.ExecuteNonQueryAsync(); + } + } + + await ExecuteNonQueryAsync(connection, transaction, $"""DROP TABLE "{backupName}";"""); + + await CreateCatalogIndexes(connection, transaction, tableName, tablePrefix); + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateAIChatSessionsAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string documentsTableName, + string tablePrefix) + { + var backupName = $"{tableName}_v0"; + + await ExecuteNonQueryAsync(connection, transaction, + $"""ALTER TABLE "{tableName}" RENAME TO "{backupName}";"""); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE "{tableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "SessionId" TEXT NOT NULL, + "ProfileId" TEXT NULL, + "Title" TEXT NULL, + "UserId" TEXT NULL, + "ClientId" TEXT NULL, + "Status" INTEGER NOT NULL DEFAULT 0, + "CreatedUtc" TEXT NOT NULL, + "LastActivityUtc" TEXT NOT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + """); + + var sessionType = typeof(CrestApps.Core.AI.Models.AIChatSession).FullName!; + var availableCols = await GetColumnNamesAsync(connection, transaction, backupName); + var epoch = "0001-01-01T00:00:00"; + + string Col(string name) => + availableCols.Contains(name) ? $"\"{name}\"" : $"NULL AS \"{name}\""; + + string ColOrDefault(string name, string defaultValue) => + availableCols.Contains(name) + ? $"COALESCE(\"{name}\", '{defaultValue}') AS \"{name}\"" + : $"'{defaultValue}' AS \"{name}\""; + + using (var readCmd = connection.CreateCommand()) + { + readCmd.Transaction = transaction; + readCmd.CommandText = + $""" + SELECT "SessionId", {Col("ProfileId")}, {Col("Title")}, {Col("UserId")}, + {Col("ClientId")}, {ColOrDefault("Status", "0")}, + {ColOrDefault("CreatedUtc", epoch)}, {ColOrDefault("LastActivityUtc", epoch)}, + "Payload" + FROM "{backupName}"; + """; + + using var reader = await readCmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var docId = await InsertDocumentAsync(connection, transaction, documentsTableName, + sessionType, reader.GetString(8)); + + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = + $""" + INSERT INTO "{tableName}" ("DocumentId","SessionId","ProfileId","Title","UserId","ClientId","Status","CreatedUtc","LastActivityUtc") + VALUES (@d,@si,@pi,@t,@ui,@ci,@st,@cu,@la); + """; + + AddParam(insertCmd, "@d", docId); + AddParam(insertCmd, "@si", reader, 0); + AddParam(insertCmd, "@pi", reader, 1); + AddParam(insertCmd, "@t", reader, 2); + AddParam(insertCmd, "@ui", reader, 3); + AddParam(insertCmd, "@ci", reader, 4); + AddParam(insertCmd, "@st", reader, 5); + AddParam(insertCmd, "@cu", reader, 6); + AddParam(insertCmd, "@la", reader, 7); + await insertCmd.ExecuteNonQueryAsync(); + } + } + + await ExecuteNonQueryAsync(connection, transaction, $"""DROP TABLE "{backupName}";"""); + + var sessionIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessions_SessionId"); + var profileIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessions_ProfileId"); + var lastActivityIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessions_LastActivityUtc"); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE UNIQUE INDEX IF NOT EXISTS "{sessionIdIndexName}" ON "{tableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{profileIdIndexName}" ON "{tableName}" ("ProfileId"); + CREATE INDEX IF NOT EXISTS "{lastActivityIndexName}" ON "{tableName}" ("LastActivityUtc"); + """); + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateAIChatSessionEventsAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string documentsTableName, + string tablePrefix) + { + var backupName = $"{tableName}_v0"; + + await ExecuteNonQueryAsync(connection, transaction, + $"""ALTER TABLE "{tableName}" RENAME TO "{backupName}";"""); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE "{tableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "SessionId" TEXT NOT NULL, + "ProfileId" TEXT NULL, + "SessionStartedUtc" TEXT NOT NULL, + "CreatedUtc" TEXT NOT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + """); + + var eventType = typeof(CrestApps.Core.AI.Models.AIChatSessionEvent).FullName!; + + using (var readCmd = connection.CreateCommand()) + { + readCmd.Transaction = transaction; + readCmd.CommandText = $"""SELECT "SessionId","ProfileId","SessionStartedUtc","CreatedUtc","Payload" FROM "{backupName}";"""; + + using var reader = await readCmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var docId = await InsertDocumentAsync(connection, transaction, documentsTableName, + eventType, reader.GetString(4)); + + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = + $""" + INSERT INTO "{tableName}" ("DocumentId","SessionId","ProfileId","SessionStartedUtc","CreatedUtc") + VALUES (@d,@si,@pi,@ss,@cu); + """; + + AddParam(insertCmd, "@d", docId); + AddParam(insertCmd, "@si", reader, 0); + AddParam(insertCmd, "@pi", reader, 1); + AddParam(insertCmd, "@ss", reader, 2); + AddParam(insertCmd, "@cu", reader, 3); + await insertCmd.ExecuteNonQueryAsync(); + } + } + + await ExecuteNonQueryAsync(connection, transaction, $"""DROP TABLE "{backupName}";"""); + + var sessionIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_SessionId"); + var profileIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_ProfileId"); + var sessionStartedIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_SessionStartedUtc"); + var createdUtcIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_CreatedUtc"); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE UNIQUE INDEX IF NOT EXISTS "{sessionIdIndexName}" ON "{tableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{profileIdIndexName}" ON "{tableName}" ("ProfileId"); + CREATE INDEX IF NOT EXISTS "{sessionStartedIndexName}" ON "{tableName}" ("SessionStartedUtc"); + CREATE INDEX IF NOT EXISTS "{createdUtcIndexName}" ON "{tableName}" ("CreatedUtc"); + """); + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateAICompletionUsageAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string documentsTableName, + string tablePrefix) + { + var backupName = $"{tableName}_v0"; + + await ExecuteNonQueryAsync(connection, transaction, + $"""ALTER TABLE "{tableName}" RENAME TO "{backupName}";"""); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE "{tableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "CreatedUtc" TEXT NOT NULL, + "SessionId" TEXT NULL, + "InteractionId" TEXT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + """); + + var usageType = typeof(CrestApps.Core.AI.Models.AICompletionUsageRecord).FullName!; + + using (var readCmd = connection.CreateCommand()) + { + readCmd.Transaction = transaction; + readCmd.CommandText = $"""SELECT "CreatedUtc","SessionId","InteractionId","Payload" FROM "{backupName}";"""; + + using var reader = await readCmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var docId = await InsertDocumentAsync(connection, transaction, documentsTableName, + usageType, reader.GetString(3)); + + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = + $""" + INSERT INTO "{tableName}" ("DocumentId","CreatedUtc","SessionId","InteractionId") + VALUES (@d,@cu,@si,@ii); + """; + + AddParam(insertCmd, "@d", docId); + AddParam(insertCmd, "@cu", reader, 0); + AddParam(insertCmd, "@si", reader, 1); + AddParam(insertCmd, "@ii", reader, 2); + await insertCmd.ExecuteNonQueryAsync(); + } + } + + await ExecuteNonQueryAsync(connection, transaction, $"""DROP TABLE "{backupName}";"""); + + var createdUtcIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_CreatedUtc"); + var sessionIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_SessionId"); + var interactionIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_InteractionId"); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE INDEX IF NOT EXISTS "{createdUtcIndexName}" ON "{tableName}" ("CreatedUtc"); + CREATE INDEX IF NOT EXISTS "{sessionIdIndexName}" ON "{tableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{interactionIdIndexName}" ON "{tableName}" ("InteractionId"); + """); + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task MigrateAIChatSessionExtractedDataAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string documentsTableName, + string tablePrefix) + { + var backupName = $"{tableName}_v0"; + + await ExecuteNonQueryAsync(connection, transaction, + $"""ALTER TABLE "{tableName}" RENAME TO "{backupName}";"""); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE TABLE "{tableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "SessionId" TEXT NOT NULL, + "ProfileId" TEXT NOT NULL, + "SessionStartedUtc" TEXT NOT NULL, + "SessionEndedUtc" TEXT NULL, + "UpdatedUtc" TEXT NOT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + """); + + var extractedType = typeof(CrestApps.Core.AI.Models.AIChatSessionExtractedDataRecord).FullName!; + + using (var readCmd = connection.CreateCommand()) + { + readCmd.Transaction = transaction; + readCmd.CommandText = $"""SELECT "SessionId","ProfileId","SessionStartedUtc","SessionEndedUtc","UpdatedUtc","Payload" FROM "{backupName}";"""; + + using var reader = await readCmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var docId = await InsertDocumentAsync(connection, transaction, documentsTableName, + extractedType, reader.GetString(5)); + + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = + $""" + INSERT INTO "{tableName}" ("DocumentId","SessionId","ProfileId","SessionStartedUtc","SessionEndedUtc","UpdatedUtc") + VALUES (@d,@si,@pi,@ss,@se,@uu); + """; + + AddParam(insertCmd, "@d", docId); + AddParam(insertCmd, "@si", reader, 0); + AddParam(insertCmd, "@pi", reader, 1); + AddParam(insertCmd, "@ss", reader, 2); + AddParam(insertCmd, "@se", reader, 3); + AddParam(insertCmd, "@uu", reader, 4); + await insertCmd.ExecuteNonQueryAsync(); + } + } + + await ExecuteNonQueryAsync(connection, transaction, $"""DROP TABLE "{backupName}";"""); + + var sessionIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_SessionId"); + var profileIdIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_ProfileId"); + var sessionStartedIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_SessionStartedUtc"); + var updatedUtcIndexName = GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_UpdatedUtc"); + + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE UNIQUE INDEX IF NOT EXISTS "{sessionIdIndexName}" ON "{tableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{profileIdIndexName}" ON "{tableName}" ("ProfileId"); + CREATE INDEX IF NOT EXISTS "{sessionStartedIndexName}" ON "{tableName}" ("SessionStartedUtc"); + CREATE INDEX IF NOT EXISTS "{updatedUtcIndexName}" ON "{tableName}" ("UpdatedUtc"); + """); + } + + [Obsolete("Schema migration from pre-v1 layout. Will be removed before v1.0.0 ships.")] + private static async Task CreateCatalogIndexes( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName, + string tablePrefix) + { + await ExecuteNonQueryAsync(connection, transaction, + $""" + CREATE UNIQUE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_ItemId")}" ON "{tableName}" ("EntityType", "ItemId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_Name")}" ON "{tableName}" ("EntityType", "Name"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_Source")}" ON "{tableName}" ("EntityType", "Source"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_SessionId")}" ON "{tableName}" ("EntityType", "SessionId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_ChatInteractionId")}" ON "{tableName}" ("EntityType", "ChatInteractionId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_ReferenceId_ReferenceType")}" ON "{tableName}" ("EntityType", "ReferenceId", "ReferenceType"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_AIDocumentId")}" ON "{tableName}" ("EntityType", "AIDocumentId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_UserId_Name")}" ON "{tableName}" ("EntityType", "UserId", "Name"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}CatalogRecords_EntityType_Type")}" ON "{tableName}" ("EntityType", "Type"); + """); + } + + private static async Task EnsureOptionalTablesAsync( + CrestAppsEntityDbContext dbContext, + string tablePrefix) + { + var documentsTableName = GetSafeSqlIdentifier($"{tablePrefix}Documents"); + var eventsTableName = GetSafeSqlIdentifier($"{tablePrefix}AIChatSessionEvents"); + var usageTableName = GetSafeSqlIdentifier($"{tablePrefix}AICompletionUsage"); + var extractedTableName = GetSafeSqlIdentifier($"{tablePrefix}AIChatSessionExtractedData"); + + string documentsSql = + $""" + CREATE TABLE IF NOT EXISTS "{documentsTableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "Type" TEXT NOT NULL, + "Content" TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}Documents_Type")}" ON "{documentsTableName}" ("Type"); + """; + + await dbContext.Database.ExecuteSqlRawAsync(documentsSql); + + string eventsSql = + $""" + CREATE TABLE IF NOT EXISTS "{eventsTableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "SessionId" TEXT NOT NULL, + "ProfileId" TEXT NULL, + "SessionStartedUtc" TEXT NOT NULL, + "CreatedUtc" TEXT NOT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + CREATE UNIQUE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_SessionId")}" ON "{eventsTableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_ProfileId")}" ON "{eventsTableName}" ("ProfileId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_SessionStartedUtc")}" ON "{eventsTableName}" ("SessionStartedUtc"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionEvents_CreatedUtc")}" ON "{eventsTableName}" ("CreatedUtc"); + """; + + await dbContext.Database.ExecuteSqlRawAsync(eventsSql); + + string usageSql = + $""" + CREATE TABLE IF NOT EXISTS "{usageTableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "CreatedUtc" TEXT NOT NULL, + "SessionId" TEXT NULL, + "InteractionId" TEXT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_CreatedUtc")}" ON "{usageTableName}" ("CreatedUtc"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_SessionId")}" ON "{usageTableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AICompletionUsage_InteractionId")}" ON "{usageTableName}" ("InteractionId"); + """; + + await dbContext.Database.ExecuteSqlRawAsync(usageSql); + + string extractedSql = + $""" + CREATE TABLE IF NOT EXISTS "{extractedTableName}" ( + "Id" INTEGER PRIMARY KEY AUTOINCREMENT, + "DocumentId" INTEGER NOT NULL, + "SessionId" TEXT NOT NULL, + "ProfileId" TEXT NOT NULL, + "SessionStartedUtc" TEXT NOT NULL, + "SessionEndedUtc" TEXT NULL, + "UpdatedUtc" TEXT NOT NULL, + FOREIGN KEY ("DocumentId") REFERENCES "{documentsTableName}" ("Id") + ); + CREATE UNIQUE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_SessionId")}" ON "{extractedTableName}" ("SessionId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_ProfileId")}" ON "{extractedTableName}" ("ProfileId"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_SessionStartedUtc")}" ON "{extractedTableName}" ("SessionStartedUtc"); + CREATE INDEX IF NOT EXISTS "{GetSafeSqlIdentifier($"IX_{tablePrefix}AIChatSessionExtractedData_UpdatedUtc")}" ON "{extractedTableName}" ("UpdatedUtc"); + """; + + await dbContext.Database.ExecuteSqlRawAsync(extractedSql); + } + + private static async Task HasColumnAsync( + CrestAppsEntityDbContext dbContext, + string tableName, + string columnName) + { + var connection = dbContext.Database.GetDbConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"""PRAGMA table_info("{tableName}");"""; + + using var reader = await cmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static async Task TableExistsAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"""SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name;"""; + + var param = cmd.CreateParameter(); + param.ParameterName = "@name"; + param.Value = tableName; + cmd.Parameters.Add(param); + + var result = await cmd.ExecuteScalarAsync(); + + return Convert.ToInt64(result) > 0; + } + + private static async Task InsertDocumentAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string documentsTableName, + string type, + string content) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"""INSERT INTO "{documentsTableName}" ("Type","Content") VALUES (@t,@c); SELECT last_insert_rowid();"""; + + var typeParam = cmd.CreateParameter(); + typeParam.ParameterName = "@t"; + typeParam.Value = type; + cmd.Parameters.Add(typeParam); + + var contentParam = cmd.CreateParameter(); + contentParam.ParameterName = "@c"; + contentParam.Value = content; + cmd.Parameters.Add(contentParam); + + return (long)(await cmd.ExecuteScalarAsync())!; + } + + private static async Task ExecuteNonQueryAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string sql) + { + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + private static void AddParam( + System.Data.Common.DbCommand command, + string name, + long value) + { + var param = command.CreateParameter(); + param.ParameterName = name; + param.Value = value; + command.Parameters.Add(param); + } + + private static void AddParam( + System.Data.Common.DbCommand command, + string name, + System.Data.Common.DbDataReader reader, + int ordinal) + { + var param = command.CreateParameter(); + param.ParameterName = name; + param.Value = reader.IsDBNull(ordinal) ? DBNull.Value : reader.GetValue(ordinal); + command.Parameters.Add(param); + } + + /// + /// Gets the set of column names present in the specified table. + /// + /// The open database connection. + /// The active transaction. + /// The sanitized table name to inspect. + /// A case-insensitive set of column names. + private static async Task> GetColumnNamesAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string tableName) + { + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var cmd = connection.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"""PRAGMA table_info("{tableName}");"""; + + using var reader = await cmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + columns.Add(reader.GetString(1)); + } + + return columns; + } + + private static string GetSafeSqlIdentifier(string identifier) + { + ArgumentException.ThrowIfNullOrEmpty(identifier); + + if (identifier.Any(character => !char.IsLetterOrDigit(character) && character != '_')) + { + throw new InvalidOperationException($"Unsupported SQLite identifier '{identifier}'."); + } + + return identifier; } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs index 5eac6bd7..e342b980 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs @@ -8,7 +8,7 @@ namespace CrestApps.Core.Data.EntityCore.Services; internal static class CatalogRecordFactory { /// - /// Gets entity type. + /// Gets the entity type discriminator for the specified CLR type. /// public static string GetEntityType() { @@ -16,7 +16,7 @@ public static string GetEntityType() } /// - /// Gets entity type. + /// Gets the entity type discriminator for the specified CLR type. /// /// The type. public static string GetEntityType(Type type) @@ -25,21 +25,29 @@ public static string GetEntityType(Type type) } /// - /// Creates the operation. + /// Creates a new and its associated + /// from the supplied domain model. /// public static CatalogRecord Create(T model) where T : CatalogItem { ArgumentNullException.ThrowIfNull(model); + + var entityType = GetEntityType(); var record = new CatalogRecord { - EntityType = GetEntityType(), + Document = new DocumentRecord + { + Type = entityType, + Content = EntityCoreStoreSerializer.Serialize(model), + }, + EntityType = entityType, ItemId = model.ItemId, Name = (model as INameAwareModel)?.Name, DisplayText = (model as IDisplayTextAwareModel)?.DisplayText, Source = (model as ISourceAwareModel)?.Source, - Payload = EntityCoreStoreSerializer.Serialize(model), }; + switch (model) { case AIProfile profile: @@ -80,13 +88,15 @@ public static CatalogRecord Create(T model) } /// - /// Updates the operation. + /// Updates an existing and its + /// from the supplied domain model. /// public static void Update(CatalogRecord record, T model) where T : CatalogItem { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(model); + var updated = Create(model); record.Name = updated.Name; record.DisplayText = updated.DisplayText; @@ -100,17 +110,21 @@ public static void Update(CatalogRecord record, T model) record.Type = updated.Type; record.CreatedUtc = updated.CreatedUtc; record.UpdatedUtc = updated.UpdatedUtc; - record.Payload = updated.Payload; + record.Document.Content = updated.Document.Content; + + // Detach the Document created by Create() so EF doesn't try to add a duplicate. + updated.Document = null; } /// - /// Materializes the operation. + /// Materializes a domain model from the + /// of the supplied . /// public static T Materialize(CatalogRecord record) where T : CatalogItem { ArgumentNullException.ThrowIfNull(record); - return EntityCoreStoreSerializer.Deserialize(record.Payload); + return EntityCoreStoreSerializer.Deserialize(record.Document.Content); } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs index 537d42e7..07a7c4d0 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs @@ -14,7 +14,7 @@ public class DocumentCatalog : ICatalog where T : CatalogItem protected readonly ILogger Logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The db context. /// The logger. @@ -27,7 +27,7 @@ public DocumentCatalog( } /// - /// Deletes the operation. + /// Deletes the specified catalogue entry and its associated document. /// /// The entry. /// The cancellation token. @@ -37,6 +37,7 @@ public async ValueTask DeleteAsync(T entry, CancellationToken cancellation await DeletingAsync(entry); var existing = await GetTrackedQuery().FirstOrDefaultAsync(x => x.ItemId == entry.ItemId, cancellationToken); + if (existing is null) { return false; @@ -44,11 +45,16 @@ public async ValueTask DeleteAsync(T entry, CancellationToken cancellation DbContext.CatalogRecords.Remove(existing); + if (existing.Document is not null) + { + DbContext.Documents.Remove(existing.Document); + } + return true; } /// - /// Finds by id. + /// Finds a catalogue entry by its unique identifier. /// /// The id. /// The cancellation token. @@ -62,7 +68,7 @@ public async ValueTask FindByIdAsync(string id, CancellationToken cancellatio } /// - /// Gets the operation. + /// Retrieves catalogue entries whose identifiers are in the supplied set. /// /// The ids. /// The cancellation token. @@ -71,6 +77,7 @@ public async ValueTask> GetAsync(IEnumerable ids, ArgumentNullException.ThrowIfNull(ids); var itemIds = ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToArray(); + if (itemIds.Length == 0) { return []; @@ -82,13 +89,14 @@ public async ValueTask> GetAsync(IEnumerable ids, } /// - /// Pages the operation. + /// Returns a paged result set of catalogue entries matching the supplied query context. /// public async ValueTask> PageAsync(int page, int pageSize, TQuery context, CancellationToken cancellationToken = default) where TQuery : QueryContext { var query = GetReadQuery(); var ordered = false; + if (context is not null) { if (!string.IsNullOrEmpty(context.Name)) @@ -96,6 +104,7 @@ public async ValueTask> PageAsync(int page, int pageSize, if (typeof(INameAwareModel).IsAssignableFrom(typeof(T))) { query = query.Where(x => x.Name != null && x.Name.Contains(context.Name)); + if (context.Sorted) { query = query.OrderBy(x => x.Name); @@ -105,6 +114,7 @@ public async ValueTask> PageAsync(int page, int pageSize, else if (typeof(IDisplayTextAwareModel).IsAssignableFrom(typeof(T))) { query = query.Where(x => x.DisplayText != null && x.DisplayText.Contains(context.Name)); + if (context.Sorted) { query = query.OrderBy(x => x.DisplayText); @@ -138,7 +148,7 @@ public async ValueTask> PageAsync(int page, int pageSize, } /// - /// Applies paging. + /// Applies additional paging filters specific to a subclass. /// protected virtual IQueryable ApplyPaging(IQueryable query, TQuery context) where TQuery : QueryContext @@ -147,7 +157,7 @@ protected virtual IQueryable ApplyPaging(IQueryable - /// Gets all. + /// Returns all catalogue entries of this type, up to an internal cap. /// /// The cancellation token. public async ValueTask> GetAllAsync(CancellationToken cancellationToken = default) @@ -167,7 +177,7 @@ public async ValueTask> GetAllAsync(CancellationToken can } /// - /// Creates the operation. + /// Creates a new catalogue entry and its associated document. /// /// The record. /// The cancellation token. @@ -186,7 +196,7 @@ public async ValueTask CreateAsync(T record, CancellationToken cancellationToken } /// - /// Updates the operation. + /// Updates an existing catalogue entry, or creates it if it does not exist. /// /// The record. /// The cancellation token. @@ -201,6 +211,7 @@ public async ValueTask UpdateAsync(T record, CancellationToken cancellationToken await SavingAsync(record); var existing = await GetTrackedQuery().FirstOrDefaultAsync(x => x.ItemId == record.ItemId, cancellationToken); + if (existing is null) { DbContext.CatalogRecords.Add(CatalogRecordFactory.Create(record)); @@ -212,23 +223,28 @@ public async ValueTask UpdateAsync(T record, CancellationToken cancellationToken } /// - /// Gets read query. + /// Returns an untracked query over catalogue records of this type, including the document. /// protected IQueryable GetReadQuery() { - return DbContext.CatalogRecords.AsNoTracking().Where(x => x.EntityType == CatalogRecordFactory.GetEntityType()); + return DbContext.CatalogRecords + .AsNoTracking() + .Include(x => x.Document) + .Where(x => x.EntityType == CatalogRecordFactory.GetEntityType()); } /// - /// Gets tracked query. + /// Returns a tracked query over catalogue records of this type, including the document. /// protected IQueryable GetTrackedQuery() { - return DbContext.CatalogRecords.Where(x => x.EntityType == CatalogRecordFactory.GetEntityType()); + return DbContext.CatalogRecords + .Include(x => x.Document) + .Where(x => x.EntityType == CatalogRecordFactory.GetEntityType()); } /// - /// Deletings the operation. + /// Hook invoked before a catalogue entry is deleted. /// /// The model. protected virtual ValueTask DeletingAsync(T model) @@ -237,7 +253,7 @@ protected virtual ValueTask DeletingAsync(T model) } /// - /// Savings the operation. + /// Hook invoked before a catalogue entry is created or updated. /// /// The record. protected virtual ValueTask SavingAsync(T record) diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionEventStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionEventStore.cs new file mode 100644 index 00000000..6437346b --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionEventStore.cs @@ -0,0 +1,140 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.EntityCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace CrestApps.Core.Data.EntityCore.Services; + +/// +/// Entity Framework Core-backed store for chat-session analytics events. +/// +public sealed class EntityCoreAIChatSessionEventStore : IAIChatSessionEventStore +{ + private readonly CrestAppsEntityDbContext _dbContext; + + /// + /// Initializes a new instance of the class. + /// + /// The Entity Framework Core database context. + public EntityCoreAIChatSessionEventStore(CrestAppsEntityDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// Finds a chat-session analytics record by session identifier. + /// + /// The chat session identifier. + /// The cancellation token. + public async Task FindBySessionIdAsync( + string sessionId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var record = await _dbContext.AIChatSessionEventRecords + .AsNoTracking() + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == sessionId, cancellationToken); + + return record is null ? null : Materialize(record); + } + + /// + /// Saves a chat-session analytics record. + /// + /// The analytics record. + /// The cancellation token. + public async Task SaveAsync( + AIChatSessionEvent chatSessionEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSessionEvent); + ArgumentException.ThrowIfNullOrWhiteSpace(chatSessionEvent.SessionId); + + var existing = await _dbContext.AIChatSessionEventRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == chatSessionEvent.SessionId, cancellationToken); + + if (existing is null) + { + _dbContext.AIChatSessionEventRecords.Add(CreateRecord(chatSessionEvent)); + + return; + } + + UpdateRecord(existing, chatSessionEvent); + } + + /// + /// Retrieves chat-session analytics records matching the optional profile and date filters. + /// + /// The optional profile identifier filter. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + var query = _dbContext.AIChatSessionEventRecords + .AsNoTracking() + .Include(x => x.Document); + + IQueryable filtered = query; + + if (!string.IsNullOrEmpty(profileId)) + { + filtered = filtered.Where(x => x.ProfileId == profileId); + } + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + filtered = filtered.Where(x => x.SessionStartedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + filtered = filtered.Where(x => x.SessionStartedUtc < endExclusive); + } + + var records = await filtered + .OrderByDescending(x => x.SessionStartedUtc) + .ToListAsync(cancellationToken); + + return records.Select(Materialize).ToList(); + } + + private static AIChatSessionEventStoreRecord CreateRecord(AIChatSessionEvent chatSessionEvent) + { + return new() + { + Document = new DocumentRecord + { + Type = typeof(AIChatSessionEvent).FullName!, + Content = EntityCoreStoreSerializer.Serialize(chatSessionEvent), + }, + SessionId = chatSessionEvent.SessionId, + ProfileId = chatSessionEvent.ProfileId, + SessionStartedUtc = chatSessionEvent.SessionStartedUtc, + CreatedUtc = chatSessionEvent.CreatedUtc, + }; + } + + private static AIChatSessionEvent Materialize(AIChatSessionEventStoreRecord record) + { + return EntityCoreStoreSerializer.Deserialize(record.Document.Content); + } + + private static void UpdateRecord(AIChatSessionEventStoreRecord destination, AIChatSessionEvent source) + { + destination.ProfileId = source.ProfileId; + destination.SessionStartedUtc = source.SessionStartedUtc; + destination.CreatedUtc = source.CreatedUtc; + destination.Document.Content = EntityCoreStoreSerializer.Serialize(source); + } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionExtractedDataStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionExtractedDataStore.cs new file mode 100644 index 00000000..7739a50d --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionExtractedDataStore.cs @@ -0,0 +1,152 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.EntityCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace CrestApps.Core.Data.EntityCore.Services; + +/// +/// Entity Framework Core-backed extracted-data snapshot store for AI chat sessions. +/// +public sealed class EntityCoreAIChatSessionExtractedDataStore : IAIChatSessionExtractedDataStore +{ + private readonly CrestAppsEntityDbContext _dbContext; + + /// + /// Initializes a new instance of the class. + /// + /// The Entity Framework Core database context. + public EntityCoreAIChatSessionExtractedDataStore(CrestAppsEntityDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// Saves the extracted-data snapshot record. + /// + /// The record to save. + /// The cancellation token. + public async Task SaveAsync( + AIChatSessionExtractedDataRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.SessionId); + + var existing = await _dbContext.AIChatSessionExtractedDataRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == record.SessionId, cancellationToken); + + if (existing is null) + { + _dbContext.AIChatSessionExtractedDataRecords.Add(CreateRecord(record)); + + return; + } + + UpdateRecord(existing, record); + } + + /// + /// Deletes the extracted-data snapshot record for a session. + /// + /// The session identifier. + /// The cancellation token. + /// when a record was deleted; otherwise . + public async Task DeleteAsync( + string sessionId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var existing = await _dbContext.AIChatSessionExtractedDataRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == sessionId, cancellationToken); + + if (existing is null) + { + return false; + } + + _dbContext.AIChatSessionExtractedDataRecords.Remove(existing); + + if (existing.Document is not null) + { + _dbContext.Documents.Remove(existing.Document); + } + + return true; + } + + /// + /// Retrieves extracted-data snapshot records for the specified AI profile. + /// + /// The AI profile identifier. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileId); + + var query = _dbContext.AIChatSessionExtractedDataRecords + .AsNoTracking() + .Include(x => x.Document) + .Where(x => x.ProfileId == profileId); + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + query = query.Where(x => x.SessionStartedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + query = query.Where(x => x.SessionStartedUtc < endExclusive); + } + + var records = await query + .OrderByDescending(x => x.SessionStartedUtc) + .ToListAsync(cancellationToken); + + return records + .Select(Materialize) + .ToList(); + } + + private static AIChatSessionExtractedDataStoreRecord CreateRecord(AIChatSessionExtractedDataRecord record) + { + return new() + { + Document = new DocumentRecord + { + Type = typeof(AIChatSessionExtractedDataRecord).FullName!, + Content = EntityCoreStoreSerializer.Serialize(record), + }, + SessionId = record.SessionId, + ProfileId = record.ProfileId, + SessionStartedUtc = record.SessionStartedUtc, + SessionEndedUtc = record.SessionEndedUtc, + UpdatedUtc = record.UpdatedUtc, + }; + } + + private static AIChatSessionExtractedDataRecord Materialize(AIChatSessionExtractedDataStoreRecord record) + { + return EntityCoreStoreSerializer.Deserialize(record.Document.Content); + } + + private static void UpdateRecord(AIChatSessionExtractedDataStoreRecord destination, AIChatSessionExtractedDataRecord source) + { + destination.ProfileId = source.ProfileId; + destination.SessionStartedUtc = source.SessionStartedUtc; + destination.SessionEndedUtc = source.SessionEndedUtc; + destination.UpdatedUtc = source.UpdatedUtc; + destination.Document.Content = EntityCoreStoreSerializer.Serialize(source); + } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs index a2f750df..64c8a49b 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs @@ -32,7 +32,7 @@ public EntityCoreAIChatSessionManager( } /// - /// Finds by id. + /// Finds a chat session by its unique identifier. /// /// The id. /// The cancellation token. @@ -40,13 +40,16 @@ public async Task FindByIdAsync(string id, CancellationToken canc { ArgumentException.ThrowIfNullOrEmpty(id); - var record = await _dbContext.AIChatSessionRecords.AsNoTracking().FirstOrDefaultAsync(x => x.SessionId == id, cancellationToken); + var record = await _dbContext.AIChatSessionRecords + .AsNoTracking() + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == id, cancellationToken); return record is null ? null : Materialize(record); } /// - /// Finds the operation. + /// Finds a chat session by its unique identifier. /// /// The id. /// The cancellation token. @@ -58,7 +61,7 @@ public Task FindAsync(string id, CancellationToken cancellationTo } /// - /// Pages the operation. + /// Returns a paged list of lightweight session entries. /// /// The page. /// The page size. @@ -67,6 +70,7 @@ public Task FindAsync(string id, CancellationToken cancellationTo public async Task PageAsync(int page, int pageSize, AIChatSessionQueryContext context = null, CancellationToken cancellationToken = default) { var query = _dbContext.AIChatSessionRecords.AsNoTracking(); + if (!string.IsNullOrEmpty(context?.ProfileId)) { query = query.Where(x => x.ProfileId == context.ProfileId); @@ -84,7 +88,7 @@ public async Task PageAsync(int page, int pageSize, AIChatS } /// - /// News the operation. + /// Creates a new chat session for the specified profile. /// /// The profile. /// The context. @@ -144,7 +148,7 @@ public Task NewAsync(AIProfile profile, NewAIChatSessionContext c } /// - /// Saves the operation. + /// Saves or updates a chat session. /// /// The chat session. /// The cancellation token. @@ -153,7 +157,9 @@ public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancell ArgumentNullException.ThrowIfNull(chatSession); chatSession.LastActivityUtc = _timeProvider.GetUtcNow().UtcDateTime; - var record = await _dbContext.AIChatSessionRecords.FirstOrDefaultAsync(x => x.SessionId == chatSession.SessionId, cancellationToken); + var record = await _dbContext.AIChatSessionRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == chatSession.SessionId, cancellationToken); if (record is null) { @@ -166,7 +172,7 @@ public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancell } /// - /// Deletes the operation. + /// Deletes a chat session and its associated prompts and document. /// /// The session id. /// The cancellation token. @@ -174,7 +180,9 @@ public async Task DeleteAsync(string sessionId, CancellationToken cancella { ArgumentException.ThrowIfNullOrEmpty(sessionId); - var record = await _dbContext.AIChatSessionRecords.FirstOrDefaultAsync(x => x.SessionId == sessionId, cancellationToken); + var record = await _dbContext.AIChatSessionRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == sessionId, cancellationToken); if (record is null) { @@ -183,21 +191,28 @@ public async Task DeleteAsync(string sessionId, CancellationToken cancella var promptEntityType = CatalogRecordFactory.GetEntityType(); var promptRecords = await _dbContext.CatalogRecords + .Include(x => x.Document) .Where(x => x.EntityType == promptEntityType && x.SessionId == sessionId) .ToListAsync(cancellationToken); if (promptRecords.Count > 0) { + _dbContext.Documents.RemoveRange(promptRecords.Select(x => x.Document).Where(x => x is not null)); _dbContext.CatalogRecords.RemoveRange(promptRecords); } _dbContext.AIChatSessionRecords.Remove(record); + if (record.Document is not null) + { + _dbContext.Documents.Remove(record.Document); + } + return true; } /// - /// Deletes all. + /// Deletes all sessions for a profile and their associated prompts and documents. /// /// The profile id. /// The cancellation token. @@ -205,7 +220,10 @@ public async Task DeleteAllAsync(string profileId, CancellationToken cancel { ArgumentException.ThrowIfNullOrEmpty(profileId); - var records = await _dbContext.AIChatSessionRecords.Where(x => x.ProfileId == profileId).ToListAsync(cancellationToken); + var records = await _dbContext.AIChatSessionRecords + .Include(x => x.Document) + .Where(x => x.ProfileId == profileId) + .ToListAsync(cancellationToken); if (records.Count == 0) { @@ -215,14 +233,17 @@ public async Task DeleteAllAsync(string profileId, CancellationToken cancel var sessionIds = records.Select(x => x.SessionId).ToList(); var promptEntityType = CatalogRecordFactory.GetEntityType(); var promptRecords = await _dbContext.CatalogRecords + .Include(x => x.Document) .Where(x => x.EntityType == promptEntityType && sessionIds.Contains(x.SessionId)) .ToListAsync(cancellationToken); if (promptRecords.Count > 0) { + _dbContext.Documents.RemoveRange(promptRecords.Select(x => x.Document).Where(x => x is not null)); _dbContext.CatalogRecords.RemoveRange(promptRecords); } + _dbContext.Documents.RemoveRange(records.Select(x => x.Document).Where(x => x is not null)); _dbContext.AIChatSessionRecords.RemoveRange(records); return records.Count; @@ -230,13 +251,18 @@ public async Task DeleteAllAsync(string profileId, CancellationToken cancel private static AIChatSession Materialize(AIChatSessionRecord record) { - return EntityCoreStoreSerializer.Deserialize(record.Payload); + return EntityCoreStoreSerializer.Deserialize(record.Document.Content); } private static AIChatSessionRecord CreateRecord(AIChatSession session) { return new() { + Document = new DocumentRecord + { + Type = typeof(AIChatSession).FullName!, + Content = EntityCoreStoreSerializer.Serialize(session), + }, SessionId = session.SessionId, ProfileId = session.ProfileId, Title = session.Title, @@ -245,7 +271,6 @@ private static AIChatSessionRecord CreateRecord(AIChatSession session) Status = session.Status, CreatedUtc = session.CreatedUtc, LastActivityUtc = session.LastActivityUtc, - Payload = EntityCoreStoreSerializer.Serialize(session), }; } @@ -258,6 +283,6 @@ private static void UpdateRecord(AIChatSessionRecord record, AIChatSession sessi record.Status = session.Status; record.CreatedUtc = session.CreatedUtc; record.LastActivityUtc = session.LastActivityUtc; - record.Payload = EntityCoreStoreSerializer.Serialize(session); + record.Document.Content = EntityCoreStoreSerializer.Serialize(session); } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAICompletionUsageStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAICompletionUsageStore.cs new file mode 100644 index 00000000..78f77b5a --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAICompletionUsageStore.cs @@ -0,0 +1,86 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.EntityCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace CrestApps.Core.Data.EntityCore.Services; + +/// +/// Entity Framework Core-backed store for AI completion usage records. +/// +public sealed class EntityCoreAICompletionUsageStore : IAICompletionUsageStore +{ + private readonly CrestAppsEntityDbContext _dbContext; + + /// + /// Initializes a new instance of the class. + /// + /// The Entity Framework Core database context. + public EntityCoreAICompletionUsageStore(CrestAppsEntityDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// Saves a usage record. + /// + /// The usage record. + /// The cancellation token. + public Task SaveAsync( + AICompletionUsageRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + + _dbContext.AICompletionUsageRecords.Add(new AICompletionUsageStoreRecord + { + Document = new DocumentRecord + { + Type = typeof(AICompletionUsageRecord).FullName!, + Content = EntityCoreStoreSerializer.Serialize(record), + }, + CreatedUtc = record.CreatedUtc, + SessionId = record.SessionId, + InteractionId = record.InteractionId, + }); + + return Task.CompletedTask; + } + + /// + /// Retrieves usage records captured within the optional UTC date range. + /// + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + var query = _dbContext.AICompletionUsageRecords + .AsNoTracking() + .Include(x => x.Document) + .AsQueryable(); + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + query = query.Where(x => x.CreatedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + query = query.Where(x => x.CreatedUtc < endExclusive); + } + + var records = await query + .OrderByDescending(x => x.CreatedUtc) + .ToListAsync(cancellationToken); + + return records + .Select(record => EntityCoreStoreSerializer.Deserialize(record.Document.Content)) + .ToList(); + } +} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIProfileStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIProfileStore.cs index 2a1c68a3..8573625e 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIProfileStore.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIProfileStore.cs @@ -27,9 +27,12 @@ public async ValueTask> GetByTypeAsync(AIProfileT { var profileType = type.ToString(); var records = await GetReadQuery() - .Where(x => x.Type == profileType) + .Where(x => x.Type == profileType || x.Type == null || x.Type == string.Empty) .ToListAsync(cancellationToken); - return records.Select(CatalogRecordFactory.Materialize).ToArray(); + return records + .Select(CatalogRecordFactory.Materialize) + .Where(profile => profile.Type == type) + .ToArray(); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 3215f7fa..a75b92b6 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.DataSources; using CrestApps.Core.AI.Documents; using CrestApps.Core.AI.Mcp.Models; @@ -50,23 +52,44 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection var store = StoreFactory.CreateAndInitializeAsync(config).GetAwaiter().GetResult(); var options = sp.GetRequiredService>().Value; + var indexGroups = sp.GetServices() + .GroupBy(index => index.CollectionName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var initializedCollections = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(options.AICollectionName)) { store.InitializeCollectionAsync(options.AICollectionName).GetAwaiter().GetResult(); + initializedCollections.Add(options.AICollectionName); } if (!string.IsNullOrWhiteSpace(options.AIDocsCollectionName)) { store.InitializeCollectionAsync(options.AIDocsCollectionName).GetAwaiter().GetResult(); + initializedCollections.Add(options.AIDocsCollectionName); } if (!string.IsNullOrWhiteSpace(options.AIMemoryCollectionName)) { store.InitializeCollectionAsync(options.AIMemoryCollectionName).GetAwaiter().GetResult(); + initializedCollections.Add(options.AIMemoryCollectionName); } - store.RegisterIndexes(sp.GetServices()); + if (!string.IsNullOrWhiteSpace(options.DefaultCollectionName)) + { + store.InitializeCollectionAsync(options.DefaultCollectionName).GetAwaiter().GetResult(); + initializedCollections.Add(options.DefaultCollectionName); + } + + foreach (var group in indexGroups) + { + if (!string.IsNullOrWhiteSpace(group.Key) && initializedCollections.Add(group.Key)) + { + store.InitializeCollectionAsync(group.Key).GetAwaiter().GetResult(); + } + + store.RegisterIndexes(group, string.IsNullOrWhiteSpace(group.Key) ? null : group.Key); + } return store; }); @@ -373,6 +396,7 @@ public static IServiceCollection AddCoreAIChatSessionMetricsStoresYesSql(this IS { ArgumentNullException.ThrowIfNull(services); + services.Replace(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; @@ -386,6 +410,7 @@ public static IServiceCollection AddCoreAICompletionUsageStoresYesSql(this IServ { ArgumentNullException.ThrowIfNull(services); + services.Replace(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; @@ -399,6 +424,8 @@ public static IServiceCollection AddCoreAIChatSessionExtractedDataStoresYesSql(t { ArgumentNullException.ThrowIfNull(services); + services.Replace(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionEventStore.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionEventStore.cs new file mode 100644 index 00000000..a453301d --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionEventStore.cs @@ -0,0 +1,97 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using Microsoft.Extensions.Options; +using YesSql; + +using ISession = YesSql.ISession; + +namespace CrestApps.Core.Data.YesSql.Services; + +/// +/// YesSql-backed store for chat-session analytics events. +/// +public sealed class YesSqlAIChatSessionEventStore : IAIChatSessionEventStore +{ + private readonly ISession _session; + private readonly string _collectionName; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The YesSql store options. + public YesSqlAIChatSessionEventStore( + ISession session, + IOptions options) + { + _session = session; + _collectionName = options.Value.AICollectionName; + } + + /// + /// Finds a chat-session analytics record by session identifier. + /// + /// The chat session identifier. + /// The cancellation token. + public Task FindBySessionIdAsync( + string sessionId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + return _session.Query(x => x.SessionId == sessionId, collection: _collectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// Saves a chat-session analytics record. + /// + /// The analytics record. + /// The cancellation token. + public async Task SaveAsync( + AIChatSessionEvent chatSessionEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSessionEvent); + + await _session.SaveAsync(chatSessionEvent, false, _collectionName, cancellationToken); + } + + /// + /// Retrieves chat-session analytics records matching the optional profile and date filters. + /// + /// The optional profile identifier filter. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + var query = _session.Query(collection: _collectionName); + + if (!string.IsNullOrEmpty(profileId)) + { + query = query.Where(x => x.ProfileId == profileId); + } + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + query = query.Where(x => x.SessionStartedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + query = query.Where(x => x.SessionStartedUtc < endExclusive); + } + + var events = await query.ListAsync(cancellationToken); + + return events.OrderByDescending(x => x.SessionStartedUtc).ToList(); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionExtractedDataStore.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionExtractedDataStore.cs new file mode 100644 index 00000000..1c55eaac --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionExtractedDataStore.cs @@ -0,0 +1,139 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using Microsoft.Extensions.Options; +using YesSql; + +using ISession = YesSql.ISession; + +namespace CrestApps.Core.Data.YesSql.Services; + +/// +/// YesSql-backed extracted-data snapshot store for AI chat sessions. +/// +public sealed class YesSqlAIChatSessionExtractedDataStore : IAIChatSessionExtractedDataStore +{ + private readonly ISession _session; + private readonly string _collection; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The store options. + public YesSqlAIChatSessionExtractedDataStore( + ISession session, + IOptions options) + { + _session = session; + _collection = options.Value.AICollectionName; + } + + /// + /// Saves the extracted-data snapshot record. + /// + /// The record to save. + /// The cancellation token. + public async Task SaveAsync( + AIChatSessionExtractedDataRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(record.SessionId); + + var existing = await _session.Query( + x => x.SessionId == record.SessionId, + collection: _collection) + .FirstOrDefaultAsync(cancellationToken); + + if (existing is null) + { + await _session.SaveAsync(record, _collection); + + return; + } + + if (!ReferenceEquals(existing, record)) + { + existing.ItemId = record.ItemId; + existing.SessionId = record.SessionId; + existing.ProfileId = record.ProfileId; + existing.SessionStartedUtc = record.SessionStartedUtc; + existing.SessionEndedUtc = record.SessionEndedUtc; + existing.UpdatedUtc = record.UpdatedUtc; + existing.Values = record.Values == null + ? [] + : record.Values.ToDictionary( + pair => pair.Key, + pair => pair.Value?.ToList() ?? [], + StringComparer.OrdinalIgnoreCase); + } + + await _session.SaveAsync(existing, _collection); + } + + /// + /// Deletes the extracted-data snapshot record for a session. + /// + /// The session identifier. + /// The cancellation token. + /// when a record was deleted; otherwise . + public async Task DeleteAsync( + string sessionId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var existing = await _session.Query( + x => x.SessionId == sessionId, + collection: _collection) + .FirstOrDefaultAsync(cancellationToken); + + if (existing is null) + { + return false; + } + + _session.Delete(existing, _collection); + + return true; + } + + /// + /// Retrieves extracted-data snapshot records for the specified AI profile. + /// + /// The AI profile identifier. + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + string profileId, + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileId); + + var query = _session.Query( + x => x.ProfileId == profileId, + collection: _collection); + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + query = query.Where(x => x.SessionStartedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + query = query.Where(x => x.SessionStartedUtc < endExclusive); + } + + var records = await query.ListAsync(cancellationToken); + + return records + .OrderByDescending(x => x.SessionStartedUtc) + .ToList(); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs index ce610119..47a08cf1 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs @@ -49,9 +49,7 @@ public YesSqlAIChatSessionManager( /// The cancellation token. public async Task FindByIdAsync(string id, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(id); - - return await _session.Query(x => x.SessionId == id, collection: _collection).FirstOrDefaultAsync(cancellationToken); + return await FindStoredSessionAsync(id, cancellationToken); } /// @@ -170,7 +168,21 @@ public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancell ArgumentNullException.ThrowIfNull(chatSession); chatSession.LastActivityUtc = _timeProvider.GetUtcNow().UtcDateTime; - await _session.SaveAsync(chatSession, _collection); + var storedSession = await FindStoredSessionAsync(chatSession.SessionId, cancellationToken); + + if (storedSession == null) + { + await _session.SaveAsync(chatSession, _collection); + + return; + } + + if (!ReferenceEquals(storedSession, chatSession)) + { + CopySession(chatSession, storedSession); + } + + await _session.SaveAsync(storedSession, _collection); } /// @@ -180,9 +192,7 @@ public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancell /// The cancellation token. public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); - - var session = await _session.Query(x => x.SessionId == sessionId, collection: _collection).FirstOrDefaultAsync(cancellationToken); + var session = await FindStoredSessionAsync(sessionId, cancellationToken); if (session == null) { @@ -216,4 +226,38 @@ public async Task DeleteAllAsync(string profileId, CancellationToken cancel return count; } + + private async Task FindStoredSessionAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + + return await _session.Query(x => x.SessionId == sessionId, collection: _collection).FirstOrDefaultAsync(cancellationToken); + } + + private static void CopySession(AIChatSession source, AIChatSession destination) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); + + destination.SessionId = source.SessionId; + destination.ProfileId = source.ProfileId; + destination.Title = source.Title; + destination.UserId = source.UserId; + destination.ClientId = source.ClientId; + destination.Documents = source.Documents == null ? [] : [.. source.Documents]; + destination.CreatedUtc = source.CreatedUtc; + destination.LastActivityUtc = source.LastActivityUtc; + destination.ClosedAtUtc = source.ClosedAtUtc; + destination.Status = source.Status; + destination.ResponseHandlerName = source.ResponseHandlerName; + destination.ExtractedData = source.ExtractedData == null ? [] : new Dictionary(source.ExtractedData); + destination.PostSessionResults = source.PostSessionResults == null ? [] : new Dictionary(source.PostSessionResults); + destination.PostSessionProcessingStatus = source.PostSessionProcessingStatus; + destination.PostSessionProcessingAttempts = source.PostSessionProcessingAttempts; + destination.PostSessionProcessingLastAttemptUtc = source.PostSessionProcessingLastAttemptUtc; + destination.IsPostSessionTasksProcessed = source.IsPostSessionTasksProcessed; + destination.IsAnalyticsRecorded = source.IsAnalyticsRecorded; + destination.IsConversionGoalsEvaluated = source.IsConversionGoalsEvaluated; + destination.Properties = source.Properties == null ? [] : new Dictionary(source.Properties); + } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAICompletionUsageStore.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAICompletionUsageStore.cs new file mode 100644 index 00000000..d4648a82 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAICompletionUsageStore.cs @@ -0,0 +1,75 @@ +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using Microsoft.Extensions.Options; +using YesSql; + +using ISession = YesSql.ISession; + +namespace CrestApps.Core.Data.YesSql.Services; + +/// +/// YesSql-backed store for AI completion usage records. +/// +public sealed class YesSqlAICompletionUsageStore : IAICompletionUsageStore +{ + private readonly ISession _session; + private readonly string _collectionName; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The YesSql store options. + public YesSqlAICompletionUsageStore( + ISession session, + IOptions options) + { + _session = session; + _collectionName = options.Value.AICollectionName; + } + + /// + /// Saves a usage record. + /// + /// The usage record. + /// The cancellation token. + public async Task SaveAsync( + AICompletionUsageRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + + await _session.SaveAsync(record, false, _collectionName, cancellationToken); + } + + /// + /// Retrieves usage records captured within the optional UTC date range. + /// + /// The inclusive UTC start date filter. + /// The inclusive UTC end date filter. + /// The cancellation token. + public async Task> GetAsync( + DateTime? startDateUtc, + DateTime? endDateUtc, + CancellationToken cancellationToken = default) + { + var query = _session.Query(collection: _collectionName); + + if (startDateUtc.HasValue) + { + var start = startDateUtc.Value.Date; + query = query.Where(x => x.CreatedUtc >= start); + } + + if (endDateUtc.HasValue) + { + var endExclusive = endDateUtc.Value.Date.AddDays(1); + query = query.Where(x => x.CreatedUtc < endExclusive); + } + + var records = await query.ListAsync(cancellationToken); + + return records.OrderByDescending(x => x.CreatedUtc).ToList(); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIProfileStore.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIProfileStore.cs index 81a4d707..a640c90b 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIProfileStore.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIProfileStore.cs @@ -31,9 +31,13 @@ public YesSqlAIProfileStore( public async ValueTask> GetByTypeAsync(AIProfileType type, CancellationToken cancellationToken = default) { var profileType = type.ToString(); - var items = await Session.Query(x => x.Type == profileType, collection: CollectionName) + var items = await Session.Query( + x => x.Type == profileType || x.Type == null || x.Type == string.Empty, + collection: CollectionName) .ListAsync(cancellationToken); - return items.ToArray(); + return items + .Where(item => item.Type == type) + .ToArray(); } } diff --git a/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs b/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs index 7b10a883..3f2eec7f 100644 --- a/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs +++ b/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs @@ -1,8 +1,8 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; using CrestApps.Core.Templates.Models; using CrestApps.Core.Templates.Rendering; using CrestApps.Core.Templates.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace CrestApps.Core.Tests.AITemplates.Prompting; diff --git a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs index d75e2af6..60f02130 100644 --- a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs @@ -1,10 +1,17 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI; +using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; +using CrestApps.Core.Templates.Parsing; +using CrestApps.Core.Templates.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.ChatNotifications; @@ -28,6 +35,8 @@ public async Task HandleAsync_AIChatSession_ClosesSessionAndShowsSessionEndedNot var sessionManagerMock = new Mock(); sessionManagerMock.Setup(m => m.FindByIdAsync("session-1")).ReturnsAsync(session); sessionManagerMock.Setup(m => m.SaveAsync(session)).Returns(Task.CompletedTask).Verifiable(); + var profileManagerMock = new Mock(); + profileManagerMock.Setup(m => m.FindByIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((AIProfile)null); ChatNotification captured = null; var senderMock = new Mock(); @@ -39,7 +48,11 @@ public async Task HandleAsync_AIChatSession_ClosesSessionAndShowsSessionEndedNot var timeProviderMock = new Mock(); timeProviderMock.Setup(t => t.GetUtcNow()).Returns(new DateTimeOffset(now)); - var services = BuildServiceProvider(sessionManager: sessionManagerMock.Object, notificationSender: senderMock.Object, timeProvider: timeProviderMock.Object); + var services = BuildServiceProvider( + sessionManager: sessionManagerMock.Object, + profileManager: profileManagerMock.Object, + notificationSender: senderMock.Object, + timeProvider: timeProviderMock.Object); var context = CreateContext("session-1", ChatContextType.AIChatSession, services); var handler = new EndSessionNotificationActionHandler(); @@ -62,12 +75,16 @@ public async Task HandleAsync_AIChatSession_ClosesSessionAndShowsSessionEndedNot public async Task HandleAsync_AIChatSession_SessionNotFound_DoesNotSendSessionEndedNotification() { // Arrange + var profileManagerMock = new Mock(); var sessionManagerMock = new Mock(); sessionManagerMock.Setup(m => m.FindByIdAsync("missing")).ReturnsAsync((AIChatSession)null); var senderMock = new Mock(); - var services = BuildServiceProvider(sessionManager: sessionManagerMock.Object, notificationSender: senderMock.Object); + var services = BuildServiceProvider( + sessionManager: sessionManagerMock.Object, + profileManager: profileManagerMock.Object, + notificationSender: senderMock.Object); var context = CreateContext("missing", ChatContextType.AIChatSession, services); var handler = new EndSessionNotificationActionHandler(); @@ -106,6 +123,64 @@ public async Task HandleAsync_ChatInteraction_ShowsSessionEndedNotification() Assert.Equal(ChatNotificationTypes.SessionEnded, captured.Type); } + [Fact] + public async Task HandleAsync_AIChatSession_WithPendingPostCloseWork_QueuesProcessing() + { + var profile = new AIProfile + { + ItemId = "profile-1", + Type = AIProfileType.Chat, + }; + profile.AlterSettings(settings => + { + settings.EnablePostSessionProcessing = true; + settings.PostSessionTasks = + [ + new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + }, + ]; + }); + + var session = new AIChatSession + { + SessionId = "session-queued", + ProfileId = profile.ItemId, + Status = ChatSessionStatus.Active, + }; + + var sessionManagerMock = new Mock(); + sessionManagerMock.Setup(m => m.FindByIdAsync("session-queued")).ReturnsAsync(session); + sessionManagerMock.Setup(m => m.SaveAsync(session)).Returns(Task.CompletedTask).Verifiable(); + + var profileManagerMock = new Mock(); + profileManagerMock.Setup(m => m.FindByIdAsync(profile.ItemId, It.IsAny())).ReturnsAsync(profile); + + ChatNotification captured = null; + var senderMock = new Mock(); + senderMock.Setup(s => s + .SendAsync("session-queued", ChatContextType.AIChatSession, It.IsAny())) + .Callback((_, _, n) => captured = n) + .Returns(Task.CompletedTask); + + var services = BuildServiceProvider( + sessionManager: sessionManagerMock.Object, + profileManager: profileManagerMock.Object, + notificationSender: senderMock.Object); + var context = CreateContext("session-queued", ChatContextType.AIChatSession, services); + var handler = new EndSessionNotificationActionHandler(); + + await handler.HandleAsync(context, CancellationToken.None); + + Assert.Equal(ChatSessionStatus.Closed, session.Status); + Assert.Equal(PostSessionProcessingStatus.Pending, session.PostSessionProcessingStatus); + sessionManagerMock.Verify(); + Assert.NotNull(captured); + } + // ─────────────────────────────────────────────────────────────── // Helpers // ─────────────────────────────────────────────────────────────── @@ -122,7 +197,11 @@ private static ChatNotificationActionContext CreateContext(string sessionId, Cha }; } - private static ServiceProvider BuildServiceProvider(IAIChatSessionManager sessionManager = null, IChatNotificationSender notificationSender = null, TimeProvider timeProvider = null) + private static ServiceProvider BuildServiceProvider( + IAIChatSessionManager sessionManager = null, + IAIProfileManager profileManager = null, + IChatNotificationSender notificationSender = null, + TimeProvider timeProvider = null) { var services = new ServiceCollection(); services.AddSingleton(NullLoggerFactory.Instance); @@ -133,6 +212,11 @@ private static ServiceProvider BuildServiceProvider(IAIChatSessionManager sessio services.AddSingleton(sessionManager); } + if (profileManager is not null) + { + services.AddSingleton(profileManager); + } + if (notificationSender is not null) { services.AddSingleton(notificationSender); @@ -143,9 +227,40 @@ private static ServiceProvider BuildServiceProvider(IAIChatSessionManager sessio services.AddSingleton(timeProvider); } + services.AddSingleton(CreatePostCloseProcessor(timeProvider ?? TimeProvider.System)); + return services.BuildServiceProvider(); } + private static AIChatSessionPostCloseProcessor CreatePostCloseProcessor(TimeProvider timeProvider) + { + var templateService = new Mock(); + var markdownParser = new Mock(); + markdownParser.SetupGet(parser => parser.SupportedExtensions).Returns([".md"]); + + var postSessionProcessingService = new PostSessionProcessingService( + Mock.Of(), + Mock.Of(), + templateService.Object, + [markdownParser.Object], + new DefaultAIOptions(), + Mock.Of(), + timeProvider, + NullLoggerFactory.Instance, + Mock.Of()); + + var optionsMonitor = new Mock>(); + optionsMonitor.SetupGet(monitor => monitor.CurrentValue).Returns(new AIChatSessionProcessingOptions()); + + return new AIChatSessionPostCloseProcessor( + postSessionProcessingService, + [], + [], + timeProvider, + optionsMonitor.Object, + NullLogger.Instance); + } + private sealed class PassthroughStringLocalizer : IStringLocalizer { public LocalizedString this[string name] diff --git a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs index ba8032eb..115772d2 100644 --- a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs @@ -9,6 +9,7 @@ using CrestApps.Core.Infrastructure.Indexing.Models; using CrestApps.Core.Templates.Models; using CrestApps.Core.Templates.Services; +using CrestApps.Core.Tests.Support; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -66,11 +67,14 @@ public async Task HandleAsync_ProfileKnowledgeDocuments_InjectsRetrievedChunksAn .AddSingleton(deploymentManager.Object) .AddSingleton(indexProfileStore.Object) .AddSingleton() - .AddSingleton>(Options.Create(new InteractionDocumentOptions + .AddSingleton>(new TestOptionsMonitor { - IndexProfileName = "docs-index", - TopN = 3, - })) + CurrentValue = new InteractionDocumentOptions + { + IndexProfileName = "docs-index", + TopN = 3, + }, + }) .AddLogging() .AddKeyedSingleton("test-provider", vectorSearchService.Object) .AddCoreAIDocumentProcessing() @@ -106,7 +110,10 @@ public async Task HandleAsync_NoIndexProfileConfigured_DoesNotModifySystemMessag .AddSingleton(Mock.Of()) .AddSingleton(Mock.Of()) .AddSingleton() - .AddSingleton>(Options.Create(new InteractionDocumentOptions())) + .AddSingleton>(new TestOptionsMonitor + { + CurrentValue = new InteractionDocumentOptions(), + }) .AddLogging() .AddCoreAIDocumentProcessing() .BuildServiceProvider(); @@ -162,7 +169,10 @@ public async Task HandleAsync_SummarizeUploadedChatInteractionDocument_InjectsFu .AddSingleton(documentStore.Object) .AddSingleton(chunkStore.Object) .AddSingleton() - .AddSingleton>(Options.Create(new InteractionDocumentOptions())) + .AddSingleton>(new TestOptionsMonitor + { + CurrentValue = new InteractionDocumentOptions(), + }) .AddLogging() .AddCoreAIDocumentProcessing() .BuildServiceProvider(); @@ -236,7 +246,10 @@ public async Task HandleAsync_SummarizeSessionDocument_InjectsFullSessionDocumen .AddSingleton(documentStore.Object) .AddSingleton(chunkStore.Object) .AddSingleton() - .AddSingleton>(Options.Create(new InteractionDocumentOptions())) + .AddSingleton>(new TestOptionsMonitor + { + CurrentValue = new InteractionDocumentOptions(), + }) .AddLogging() .AddCoreAIDocumentProcessing() .BuildServiceProvider(); @@ -355,12 +368,15 @@ public async Task HandleAsync_HierarchicalMode_InjectsFullMatchedDocumentText() .AddSingleton(documentStore.Object) .AddSingleton(chunkStore.Object) .AddSingleton() - .AddSingleton>(Options.Create(new InteractionDocumentOptions + .AddSingleton>(new TestOptionsMonitor { - IndexProfileName = "docs-index", - TopN = 2, - RetrievalMode = DocumentRetrievalMode.Hierarchical, - })) + CurrentValue = new InteractionDocumentOptions + { + IndexProfileName = "docs-index", + TopN = 2, + RetrievalMode = DocumentRetrievalMode.Hierarchical, + }, + }) .AddLogging() .AddKeyedSingleton("test-provider", vectorSearchService.Object) .AddCoreAIDocumentProcessing() diff --git a/tests/CrestApps.Core.Tests/Core/Indexing/EmbeddingSearchIndexProfileHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Indexing/EmbeddingSearchIndexProfileHandlerTests.cs index 0785bd8e..f4712cdc 100644 --- a/tests/CrestApps.Core.Tests/Core/Indexing/EmbeddingSearchIndexProfileHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Indexing/EmbeddingSearchIndexProfileHandlerTests.cs @@ -6,7 +6,6 @@ using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; using CrestApps.Core.Models; -using CrestApps.Core.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; @@ -18,15 +17,18 @@ public sealed class EmbeddingSearchIndexProfileHandlerTests public async Task ValidateAsync_ShouldResolveEmbeddingDeploymentById() { var deployment = CreateEmbeddingDeployment(); + var handler = new AIDocumentSearchIndexProfileHandler( new FakeDeploymentStore(deployment), new FakeAIClientFactory(new FakeEmbeddingGenerator([0.1f, 0.2f])), NullLogger.Instance); + var profile = new SearchIndexProfile { Type = IndexProfileTypes.AIDocuments, EmbeddingDeploymentName = deployment.ItemId, }; + var result = new ValidationResultDetails(); await handler.ValidateAsync(profile, result, TestContext.Current.CancellationToken); @@ -38,10 +40,12 @@ public async Task ValidateAsync_ShouldResolveEmbeddingDeploymentById() public async Task GetFieldsAsync_ShouldResolveEmbeddingDeploymentById() { var deployment = CreateEmbeddingDeployment(); + var handler = new AIDocumentSearchIndexProfileHandler( new FakeDeploymentStore(deployment), new FakeAIClientFactory(new FakeEmbeddingGenerator([0.1f, 0.2f, 0.3f])), NullLogger.Instance); + var profile = new SearchIndexProfile { Type = IndexProfileTypes.AIDocuments, diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/RemoteFileResourceHandlerBaseTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/RemoteFileResourceHandlerBaseTests.cs index 5bb298af..66be4735 100644 --- a/tests/CrestApps.Core.Tests/Core/Mcp/RemoteFileResourceHandlerBaseTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Mcp/RemoteFileResourceHandlerBaseTests.cs @@ -1,5 +1,4 @@ using CrestApps.Core.AI.Mcp; -using CrestApps.Core.AI.Mcp.IO; using CrestApps.Core.AI.Mcp.Models; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; diff --git a/tests/CrestApps.Core.Tests/Core/Services/AIMemoryIndexingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/AIMemoryIndexingServiceTests.cs index 3d935d9e..f1d57bbe 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/AIMemoryIndexingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/AIMemoryIndexingServiceTests.cs @@ -5,10 +5,10 @@ using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Tests.Support; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.Core.Services; @@ -64,7 +64,7 @@ public async Task IndexAsync_WhenConfigured_CreatesIndexAndAddsDocument() var service = new AIMemoryIndexingService( Mock.Of(), - Options.Create(new AIMemoryOptions { IndexProfileName = "memory-profile" }), + new TestOptionsMonitor { CurrentValue = new AIMemoryOptions { IndexProfileName = "memory-profile" } }, indexProfileStore.Object, deploymentManager.Object, aiClientFactory.Object, @@ -120,7 +120,7 @@ public async Task DeleteAsync_WhenConfigured_RemovesMemoryDocuments() var service = new AIMemoryIndexingService( Mock.Of(), - Options.Create(new AIMemoryOptions { IndexProfileName = "memory-profile" }), + new TestOptionsMonitor { CurrentValue = new AIMemoryOptions { IndexProfileName = "memory-profile" } }, indexProfileStore.Object, Mock.Of(), Mock.Of(), diff --git a/tests/CrestApps.Core.Tests/Core/Services/AIMemorySearchServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/AIMemorySearchServiceTests.cs index 568b1661..bff61df2 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/AIMemorySearchServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/AIMemorySearchServiceTests.cs @@ -5,10 +5,10 @@ using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Tests.Support; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.Core.Services; @@ -198,7 +198,7 @@ private static AIMemorySearchService CreateService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService(), - Options.Create(options), + new TestOptionsMonitor { CurrentValue = options }, NullLogger.Instance); } diff --git a/tests/CrestApps.Core.Tests/Core/Services/GitHubOAuthServiceStateTests.cs b/tests/CrestApps.Core.Tests/Core/Services/GitHubOAuthServiceStateTests.cs index 3675da95..c8647652 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/GitHubOAuthServiceStateTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/GitHubOAuthServiceStateTests.cs @@ -1,10 +1,10 @@ using System.Text; using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; +using CrestApps.Core.Tests.Support; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace CrestApps.Core.Tests.Core.Services; @@ -131,12 +131,15 @@ public void TryValidateCallbackState_ExpiredCookie_ReturnsFalse() private static (GitHubOAuthService service, TestHttpContextAccessor accessor) CreateService(TimeProvider time) { var dpp = new EphemeralDataProtectionProvider(); - var options = Options.Create(new CopilotOptions + var options = new TestOptionsMonitor { - ClientId = "test-client-id", - ClientSecret = "test-client-secret", - Scopes = ["user:email", "read:org"], - }); + CurrentValue = new CopilotOptions + { + ClientId = "test-client-id", + ClientSecret = "test-client-secret", + Scopes = ["user:email", "read:org"], + }, + }; var accessor = new TestHttpContextAccessor(); var service = new GitHubOAuthService( diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs new file mode 100644 index 00000000..a54e914b --- /dev/null +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs @@ -0,0 +1,334 @@ +using CrestApps.Core.AI; +using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Templates.Parsing; +using CrestApps.Core.Templates.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Core.Services.PostSession; + +public sealed class AIChatSessionPostCloseProcessorTests +{ + private const string TestProviderName = "TestProvider"; + private const string TestConnectionName = "TestConnection"; + private const string TestDeploymentName = "gpt-4o"; + + [Fact] + public void QueueIfNeeded_WithRemainingPostSessionTasks_SetsPendingStatus() + { + var processor = CreateProcessor(DateTime.UtcNow); + var profile = CreateTaskProfile(); + var session = new AIChatSession + { + SessionId = "session-1", + PostSessionProcessingStatus = PostSessionProcessingStatus.None, + }; + + var queued = processor.QueueIfNeeded(profile, session); + + Assert.True(queued); + Assert.Equal(PostSessionProcessingStatus.Pending, session.PostSessionProcessingStatus); + } + + [Fact] + public void QueueIfNeeded_WhenNoRemainingWork_LeavesStatusUnchanged() + { + var processor = CreateProcessor(DateTime.UtcNow); + var profile = CreateTaskProfile(); + var session = new AIChatSession + { + SessionId = "session-1", + IsPostSessionTasksProcessed = true, + PostSessionProcessingStatus = PostSessionProcessingStatus.Completed, + PostSessionResults = + { + ["summary"] = new PostSessionResult + { + Name = "summary", + Status = PostSessionTaskResultStatus.Succeeded, + Value = "Done", + ProcessedAtUtc = new DateTime(2026, 5, 1, 19, 0, 0, DateTimeKind.Utc), + }, + }, + }; + + var queued = processor.QueueIfNeeded(profile, session); + + Assert.False(queued); + Assert.Equal(PostSessionProcessingStatus.Completed, session.PostSessionProcessingStatus); + } + + [Fact] + public void QueueIfNeeded_WhenLegacyFailureHasRemainingRetries_RequeuesProcessing() + { + var processor = CreateProcessor(DateTime.UtcNow); + + Assert.Equal(5, processor.MaxPostCloseAttempts); + + var profile = CreateTaskProfile(); + var session = new AIChatSession + { + SessionId = "session-legacy", + IsPostSessionTasksProcessed = true, + PostSessionProcessingStatus = PostSessionProcessingStatus.Failed, + PostSessionResults = + { + ["summary"] = new PostSessionResult + { + Name = "summary", + Status = PostSessionTaskResultStatus.Failed, + Attempts = 3, + ProcessedAtUtc = new DateTime(2026, 5, 1, 20, 0, 0, DateTimeKind.Utc), + }, + }, + }; + + var queued = processor.QueueIfNeeded(profile, session); + + Assert.True(queued); + Assert.False(session.IsPostSessionTasksProcessed); + Assert.Equal(PostSessionProcessingStatus.Pending, session.PostSessionProcessingStatus); + } + + [Fact] + public async Task ProcessAsync_WhenTaskReturnsNoStructuredResult_PersistsAttemptErrorHistory() + { + var now = new DateTime(2026, 5, 1, 21, 0, 0, DateTimeKind.Utc); + var processor = CreateProcessor(now, renderedPrompt: string.Empty); + var profile = CreateTaskProfile(); + var session = CreateClosedSession(); + + await processor.ProcessAsync(profile, session, CreatePrompts(), TestContext.Current.CancellationToken); + + var result = session.PostSessionResults["summary"]; + Assert.Equal(1, result.Attempts); + Assert.Equal(PostSessionTaskResultStatus.Pending, result.Status); + Assert.Equal("Task produced no result during attempt 1.", result.ErrorMessage); + Assert.Null(result.ProcessedAtUtc); + Assert.Single(result.AttemptHistory); + Assert.Equal(1, result.AttemptHistory[0].AttemptNumber); + Assert.Equal(PostSessionTaskResultStatus.Pending, result.AttemptHistory[0].Status); + Assert.Equal("Task produced no result during attempt 1.", result.AttemptHistory[0].ErrorMessage); + Assert.Equal(now, result.AttemptHistory[0].RecordedAtUtc); + } + + [Fact] + public async Task ProcessAsync_WhenMaxAttemptsReached_SetsTerminalProcessedTimestamp() + { + var now = new DateTime(2026, 5, 1, 21, 5, 0, DateTimeKind.Utc); + var processor = CreateProcessor(now, renderedPrompt: string.Empty); + var profile = CreateTaskProfile(); + var session = CreateClosedSession(); + session.PostSessionResults["summary"] = new PostSessionResult + { + Name = "summary", + Status = PostSessionTaskResultStatus.Pending, + Attempts = processor.MaxPostCloseAttempts - 1, + }; + + await processor.ProcessAsync(profile, session, CreatePrompts(), TestContext.Current.CancellationToken); + + var result = session.PostSessionResults["summary"]; + Assert.Equal(processor.MaxPostCloseAttempts, result.Attempts); + Assert.Equal(PostSessionTaskResultStatus.Failed, result.Status); + Assert.Equal($"Task produced no result after {processor.MaxPostCloseAttempts} attempt(s).", result.ErrorMessage); + Assert.Equal(now, result.ProcessedAtUtc); + Assert.Single(result.AttemptHistory); + Assert.Equal(processor.MaxPostCloseAttempts, result.AttemptHistory[0].AttemptNumber); + Assert.Equal(PostSessionTaskResultStatus.Failed, result.AttemptHistory[0].Status); + } + + [Fact] + public async Task ProcessAsync_WhenRetrySucceeds_PreservesEarlierAttemptHistory() + { + var now = new DateTime(2026, 5, 1, 21, 10, 0, DateTimeKind.Utc); + var responseJson = "{\"tasks\":[{\"name\":\"summary\",\"value\":\"Summarized the conversation.\"}]}"; + var processor = CreateProcessor(now, renderedPrompt: "Rendered prompt", responseJson: responseJson); + var profile = CreateTaskProfile(); + var session = CreateClosedSession(); + session.PostSessionResults["summary"] = new PostSessionResult + { + Name = "summary", + Status = PostSessionTaskResultStatus.Pending, + ErrorMessage = "Task produced no result during attempt 1.", + Attempts = 1, + AttemptHistory = + [ + new PostSessionTaskAttempt + { + AttemptNumber = 1, + Status = PostSessionTaskResultStatus.Pending, + ErrorMessage = "Task produced no result during attempt 1.", + RecordedAtUtc = now.AddMinutes(-5), + }, + ], + }; + + await processor.ProcessAsync(profile, session, CreatePrompts(), TestContext.Current.CancellationToken); + + var result = session.PostSessionResults["summary"]; + Assert.Equal(2, result.Attempts); + Assert.Equal(PostSessionTaskResultStatus.Succeeded, result.Status); + Assert.Null(result.ErrorMessage); + Assert.Equal(now, result.ProcessedAtUtc); + Assert.Single(result.AttemptHistory); + Assert.Equal(1, result.AttemptHistory[0].AttemptNumber); + Assert.Equal("Task produced no result during attempt 1.", result.AttemptHistory[0].ErrorMessage); + } + + [Fact] + public void MaxPostCloseAttempts_WhenConfigured_UsesConfiguredValue() + { + var timeProviderMock = new Mock(); + var postSessionService = CreatePostSessionService(timeProviderMock.Object, string.Empty, null); + var optionsMonitor = new Mock>(); + optionsMonitor.SetupGet(monitor => monitor.CurrentValue).Returns(new AIChatSessionProcessingOptions + { + MaxPostCloseAttempts = 7, + }); + + var processor = new AIChatSessionPostCloseProcessor( + postSessionService, + [], + [], + timeProviderMock.Object, + optionsMonitor.Object, + NullLogger.Instance); + + Assert.Equal(7, processor.MaxPostCloseAttempts); + } + + private static AIProfile CreateTaskProfile() + { + var profile = new AIProfile + { + ItemId = "profile-1", + Type = AIProfileType.Chat, + ChatDeploymentName = TestDeploymentName, + UtilityDeploymentName = TestDeploymentName, + }; + profile.AlterSettings(settings => + { + settings.EnablePostSessionProcessing = true; + settings.PostSessionTasks = + [ + new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + }, + ]; + }); + + return profile; + } + + private static AIChatSession CreateClosedSession() + { + return new AIChatSession + { + SessionId = "session-1", + ProfileId = "profile-1", + Status = ChatSessionStatus.Closed, + }; + } + + private static List CreatePrompts() + { + return + [ + new AIChatSessionPrompt + { + Role = ChatRole.User, + Content = "I need help.", + CreatedUtc = new DateTime(2026, 5, 1, 20, 0, 0, DateTimeKind.Utc), + }, + new AIChatSessionPrompt + { + Role = ChatRole.Assistant, + Content = "Sure, I can help.", + CreatedUtc = new DateTime(2026, 5, 1, 20, 1, 0, DateTimeKind.Utc), + }, + ]; + } + + private static AIChatSessionPostCloseProcessor CreateProcessor( + DateTime now, + string renderedPrompt = "", + string responseJson = null) + { + var timeProviderMock = new Mock(); + timeProviderMock.Setup(timeProvider => timeProvider.GetUtcNow()).Returns(new DateTimeOffset(now)); + + var postSessionService = CreatePostSessionService(timeProviderMock.Object, renderedPrompt, responseJson); + var optionsMonitor = new Mock>(); + optionsMonitor.SetupGet(monitor => monitor.CurrentValue).Returns(new AIChatSessionProcessingOptions()); + + return new AIChatSessionPostCloseProcessor( + postSessionService, + [], + [], + timeProviderMock.Object, + optionsMonitor.Object, + NullLogger.Instance); + } + + private static PostSessionProcessingService CreatePostSessionService( + TimeProvider timeProvider, + string renderedPrompt, + string responseJson) + { + var mockChatClient = new Mock(); + if (responseJson != null) + { + mockChatClient.Setup(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, responseJson))); + } + + var mockClientFactory = new Mock(); + mockClientFactory.Setup(factory => factory + .CreateChatClientAsync(It.Is(deployment => + deployment.ClientName == TestProviderName + && deployment.ConnectionName == TestConnectionName + && deployment.ModelName == TestDeploymentName))) + .ReturnsAsync(mockChatClient.Object); + + var mockDeploymentManager = new Mock(); + mockDeploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AIDeployment + { + ItemId = "deployment-1", + Name = TestDeploymentName, + ClientName = TestProviderName, + ConnectionName = TestConnectionName, + Type = AIDeploymentType.Chat, + }); + + var mockTemplateService = new Mock(); + mockTemplateService.Setup(service => service + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(renderedPrompt); + + return new PostSessionProcessingService( + mockClientFactory.Object, + Mock.Of(), + mockTemplateService.Object, + [new DefaultMarkdownTemplateParser()], + new DefaultAIOptions + { + MaximumIterationsPerRequest = 10, + }, + Mock.Of(), + timeProvider, + NullLoggerFactory.Instance, + mockDeploymentManager.Object); + } +} diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index 71034530..df02aa98 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -136,9 +136,9 @@ public async Task ProcessAsync_WithTasksAndNoTools_ShouldUseStructuredOutputPath } [Fact] - public async Task ProcessAsync_WithToolNames_ShouldResolveToolsAndUseToolsPath() + public async Task ProcessAsync_WithTaskScopedToolNames_ShouldResolveToolsAndUseToolsPath() { - // Arrange: tasks with tool names configured — should use tools path. + // Arrange: task-scoped tool names should flow into the shared tools path. var profile = CreateProfile(); profile.AlterSettings(s => { @@ -148,8 +148,8 @@ public async Task ProcessAsync_WithToolNames_ShouldResolveToolsAndUseToolsPath() Name = "summary", Type = PostSessionTaskType.Semantic, Instructions = "Summarize the conversation.", + ToolNames = ["sendEmail"], }, ]; - s.ToolNames = ["sendEmail"]; }); var session = CreateSession(); var prompts = CreatePrompts(); @@ -180,6 +180,85 @@ public async Task ProcessAsync_WithToolNames_ShouldResolveToolsAndUseToolsPath() mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); } + [Fact] + public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWithoutToolCalls_ShouldFallBackToNoToolsStructuredPass() + { + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + ToolNames = ["sendEmail"], + }, ]; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("sendEmail"); + var mockToolsService = new Mock(); + mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockChatClient = new Mock(); + mockChatClient.SetupSequence(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"\",\"value\":\"\"}]}"))) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"\",\"value\":\"\"}]}"))) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"summary\",\"value\":\"Summarized the conversation.\"}]}"))); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + + var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + var taskResult = Assert.Single(result); + Assert.Equal("summary", taskResult.Key); + Assert.Equal(PostSessionTaskResultStatus.Succeeded, taskResult.Value.Status); + Assert.Equal("Summarized the conversation.", taskResult.Value.Value); + mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async Task ProcessAsync_WhenToolResponseContainsEmptyTasksArray_ShouldReturnSpecificFailure() + { + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + ToolNames = ["sendEmail"], + }, ]; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("sendEmail"); + var mockToolsService = new Mock(); + mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"tasks\":[]}"))); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + + var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + var taskResult = Assert.Single(result); + Assert.Equal("summary", taskResult.Key); + Assert.Equal(PostSessionTaskResultStatus.Failed, taskResult.Value.Status); + Assert.Equal("The AI returned structured JSON, but the tasks array was empty. Each configured post-session task must return a result, even when no tool call is needed.", taskResult.Value.ErrorMessage); + } + [Fact] public async Task ProcessAsync_WhenToolNotFound_ShouldLogWarningAndFallToStructuredOutput() { @@ -642,6 +721,52 @@ public async Task ProcessAsync_WithTools_WhenResponseIsJsonWithSurroundingText_S Assert.Equal(PostSessionTaskResultStatus.Succeeded, result["summary"].Status); } + [Fact] + public async Task ProcessAsync_WithTools_WhenAssistantResponseUsesContentsText_ShouldParseSuccessfully() + { + // Arrange: provider returns assistant text through Contents instead of ChatMessage.Text. + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + }, ]; + s.ToolNames = ["sendEmail"]; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("sendEmail"); + var mockToolsService = new Mock(); + mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var responseMessage = new ChatMessage + { + Role = ChatRole.Assistant, + Contents = [new TextContent("""{"tasks":[{"name":"summary","value":"Customer asked about pricing."}]}""")], + }; + var mockChatClient = new Mock(); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(responseMessage)); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + + // Act + var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.ContainsKey("summary")); + Assert.Equal("Customer asked about pricing.", result["summary"].Value); + Assert.Equal(PostSessionTaskResultStatus.Succeeded, result["summary"].Status); + } + [Fact] public async Task ProcessAsync_WithTools_WhenResponseIsTruncatedJson_ShouldRecoverWithStructuredRetry() { diff --git a/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj b/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj index a230417a..0812ac85 100644 --- a/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj +++ b/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs index 0d9dc7c7..7ee2e30d 100644 --- a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs +++ b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs @@ -5,11 +5,13 @@ using CrestApps.Core.AI.Documents; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; using CrestApps.Core.Data.EntityCore; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; using CrestApps.Core.Models; using CrestApps.Core.Services; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -72,6 +74,7 @@ public async Task Entity_core_stores_support_specialized_queries() var documentStore = services.GetRequiredService(); var chunkStore = services.GetRequiredService(); var sessionPromptStore = services.GetRequiredService(); + var extractedDataStore = services.GetRequiredService(); var interactionPromptStore = services.GetRequiredService(); var indexProfileStore = services.GetRequiredService(); var profileCatalog = services.GetRequiredService>(); @@ -159,6 +162,26 @@ public async Task Entity_core_stores_support_specialized_queries() Assert.Equal(1, await sessionPromptStore.DeleteAllPromptsAsync("session-1")); await committer.CommitAsync(cancellationToken); + await extractedDataStore.SaveAsync( + new AIChatSessionExtractedDataRecord + { + ItemId = "session-1", + SessionId = "session-1", + ProfileId = "profile-1", + SessionStartedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow, + Values = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["customer_name"] = ["Mike Alhayek"], + }, + }, + cancellationToken); + await committer.CommitAsync(cancellationToken); + Assert.Single(await extractedDataStore.GetAsync("profile-1", null, null, cancellationToken)); + Assert.True(await extractedDataStore.DeleteAsync("session-1", cancellationToken)); + await committer.CommitAsync(cancellationToken); + Assert.Empty(await extractedDataStore.GetAsync("profile-1", null, null, cancellationToken)); + var interactionPrompt = new ChatInteractionPrompt { ChatInteractionId = "interaction-1", @@ -244,6 +267,283 @@ public async Task Entity_core_store_committer_flushes_staged_changes() Assert.NotNull(await catalog.FindByNameAsync(profile.Name, cancellationToken)); } + [Fact] + public async Task Initialize_entity_core_schema_async_adds_missing_tables_to_existing_database() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.db"); + + try + { + await using (var connection = new SqliteConnection($"Data Source={databasePath}")) + { + await connection.OpenAsync(TestContext.Current.CancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TABLE "CA_CatalogRecords" ( + "EntityType" TEXT NOT NULL, + "ItemId" TEXT NOT NULL, + "Payload" TEXT NOT NULL, + CONSTRAINT "PK_CA_CatalogRecords" PRIMARY KEY ("EntityType", "ItemId") + ); + CREATE TABLE "CA_AIChatSessions" ( + "SessionId" TEXT NOT NULL, + "Payload" TEXT NOT NULL, + CONSTRAINT "PK_CA_AIChatSessions" PRIMARY KEY ("SessionId") + ); + """; + + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + var tableNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var services = CreateEntityCoreServices(databasePath); + + await using (var provider = services.BuildServiceProvider()) + { + await provider.InitializeEntityCoreSchemaAsync(); + + await using var verificationConnection = new SqliteConnection($"Data Source={databasePath}"); + await verificationConnection.OpenAsync(TestContext.Current.CancellationToken); + + var query = verificationConnection.CreateCommand(); + query.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table';"; + + await using var reader = await query.ExecuteReaderAsync(TestContext.Current.CancellationToken); + + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + { + tableNames.Add(reader.GetString(0)); + } + } + + Assert.Contains("CA_Documents", tableNames); + Assert.Contains("CA_AIChatSessionEvents", tableNames); + Assert.Contains("CA_AICompletionUsage", tableNames); + Assert.Contains("CA_AIChatSessionExtractedData", tableNames); + } + finally + { + if (File.Exists(databasePath)) + { + try + { + File.Delete(databasePath); + } + catch (IOException) + { + } + } + } + } + + [Fact] + public async Task Initialize_entity_core_schema_async_migrates_legacy_tables_to_document_schema() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.db"); + + try + { + await using (var connection = new SqliteConnection($"Data Source={databasePath}")) + { + await connection.OpenAsync(TestContext.Current.CancellationToken); + + var command = connection.CreateCommand(); + command.CommandText = + """ + CREATE TABLE "CA_CatalogRecords" ( + "EntityType" TEXT NOT NULL, + "ItemId" TEXT NOT NULL, + "Payload" TEXT NOT NULL, + "Name" TEXT NULL, + "DisplayText" TEXT NULL, + "Source" TEXT NULL, + "SessionId" TEXT NULL, + "ChatInteractionId" TEXT NULL, + "ReferenceId" TEXT NULL, + "ReferenceType" TEXT NULL, + "AIDocumentId" TEXT NULL, + "UserId" TEXT NULL, + "Type" TEXT NULL, + "CreatedUtc" TEXT NULL, + "UpdatedUtc" TEXT NULL, + CONSTRAINT "PK_CA_CatalogRecords" PRIMARY KEY ("EntityType", "ItemId") + ); + CREATE TABLE "CA_AIChatSessions" ( + "SessionId" TEXT NOT NULL, + "Payload" TEXT NOT NULL, + "Title" TEXT NULL, + "ProfileId" TEXT NULL, + "ProfileName" TEXT NULL, + "UserId" TEXT NULL, + "CreatedUtc" TEXT NULL, + CONSTRAINT "PK_CA_AIChatSessions" PRIMARY KEY ("SessionId") + ); + INSERT INTO "CA_CatalogRecords" ("EntityType","ItemId","Payload","Name","Type") + VALUES ('CrestApps.Core.AI.Profiles.AIProfile','item1','{"Name":"test-profile"}','test-profile','Chat'); + INSERT INTO "CA_AIChatSessions" ("SessionId","Payload","Title","ProfileId") + VALUES ('sess1','{"Title":"Hello"}','Hello','prof1'); + """; + + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + var services = CreateEntityCoreServices(databasePath); + + await using (var provider = services.BuildServiceProvider()) + { + await provider.InitializeEntityCoreSchemaAsync(); + + await using var verificationConnection = new SqliteConnection($"Data Source={databasePath}"); + await verificationConnection.OpenAsync(TestContext.Current.CancellationToken); + + var hasDocuments = false; + var catalogHasDocumentId = false; + var catalogHasNoPayload = true; + var sessionHasDocumentId = false; + var documentCount = 0L; + + var tableQuery = verificationConnection.CreateCommand(); + tableQuery.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='CA_Documents';"; + hasDocuments = Convert.ToInt64(await tableQuery.ExecuteScalarAsync(TestContext.Current.CancellationToken)) == 1; + + var catalogColQuery = verificationConnection.CreateCommand(); + catalogColQuery.CommandText = "PRAGMA table_info('CA_CatalogRecords');"; + + await using (var colReader = await catalogColQuery.ExecuteReaderAsync(TestContext.Current.CancellationToken)) + { + while (await colReader.ReadAsync(TestContext.Current.CancellationToken)) + { + var columnName = colReader.GetString(1); + + if (columnName == "DocumentId") + { + catalogHasDocumentId = true; + } + + if (columnName == "Payload") + { + catalogHasNoPayload = false; + } + } + } + + var sessionColQuery = verificationConnection.CreateCommand(); + sessionColQuery.CommandText = "PRAGMA table_info('CA_AIChatSessions');"; + + await using (var colReader = await sessionColQuery.ExecuteReaderAsync(TestContext.Current.CancellationToken)) + { + while (await colReader.ReadAsync(TestContext.Current.CancellationToken)) + { + if (colReader.GetString(1) == "DocumentId") + { + sessionHasDocumentId = true; + } + } + } + + var docCountQuery = verificationConnection.CreateCommand(); + docCountQuery.CommandText = "SELECT COUNT(*) FROM CA_Documents;"; + documentCount = Convert.ToInt64(await docCountQuery.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + + Assert.True(hasDocuments, "CA_Documents table should exist after migration."); + Assert.True(catalogHasDocumentId, "CA_CatalogRecords should have DocumentId column."); + Assert.True(catalogHasNoPayload, "CA_CatalogRecords should not have Payload column after migration."); + Assert.True(sessionHasDocumentId, "CA_AIChatSessions should have DocumentId column."); + Assert.Equal(2, documentCount); + } + } + finally + { + if (File.Exists(databasePath)) + { + try + { + File.Delete(databasePath); + } + catch (IOException) + { + } + } + } + } + + [Fact] + public async Task Entity_core_ai_profile_store_reads_legacy_profiles_with_missing_type_column() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await using var harness = await EntityCoreTestHarness.CreateAsync(); + using var scope = harness.Services.CreateScope(); + var services = scope.ServiceProvider; + var profileCatalog = services.GetRequiredService>(); + var profileStore = services.GetRequiredService(); + var committer = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); + + var profile = new AIProfile + { + Name = "legacy-chat-profile", + DisplayText = "Legacy chat profile", + Type = AIProfileType.Chat, + CreatedUtc = DateTime.UtcNow, + }; + + await profileCatalog.CreateAsync(profile, cancellationToken); + await committer.CommitAsync(cancellationToken); + + _ = await dbContext.Database.ExecuteSqlRawAsync( + "UPDATE CA_CatalogRecords SET Type = NULL WHERE EntityType = {0} AND ItemId = {1}", + typeof(AIProfile).FullName!, + profile.ItemId); + + var profiles = await profileStore.GetByTypeAsync(AIProfileType.Chat, cancellationToken); + + Assert.Contains(profiles, item => item.ItemId == profile.ItemId); + } + + [Fact] + public async Task Entity_core_search_index_profile_store_reads_legacy_embedding_deployment_id_payload() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await using var harness = await EntityCoreTestHarness.CreateAsync(); + using var scope = harness.Services.CreateScope(); + var services = scope.ServiceProvider; + var indexProfileStore = services.GetRequiredService(); + var committer = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); + + var indexProfile = new SearchIndexProfile + { + Name = "legacy-docs-index", + DisplayText = "Legacy docs index", + IndexName = "legacy-docs-index", + ProviderName = "Elasticsearch", + Type = IndexProfileTypes.AIDocuments, + EmbeddingDeploymentName = "legacy-embedding-id", + CreatedUtc = DateTime.UtcNow, + }; + + await indexProfileStore.CreateAsync(indexProfile, cancellationToken); + await committer.CommitAsync(cancellationToken); + + _ = await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE CA_Documents + SET Content = REPLACE(Content, '"EmbeddingDeploymentName":"legacy-embedding-id"', '"EmbeddingDeploymentId":"legacy-embedding-id"') + WHERE Id = (SELECT DocumentId FROM CA_CatalogRecords WHERE EntityType = {0} AND ItemId = {1}) + """, + typeof(SearchIndexProfile).FullName!, + indexProfile.ItemId); + + var storedProfile = await indexProfileStore.FindByNameAsync(indexProfile.Name, cancellationToken); + + Assert.NotNull(storedProfile); + Assert.Equal("legacy-embedding-id", storedProfile.EmbeddingDeploymentName); + } + [Fact] public async Task EnforceNamedSourceUniqueness_RejectsDuplicateNameAndSourceWithinEntityType() { @@ -259,7 +559,11 @@ public async Task EnforceNamedSourceUniqueness_RejectsDuplicateNameAndSourceWith ItemId = Guid.NewGuid().ToString("N"), Name = "duplicate", Source = "OpenAI", - Payload = "{}", + Document = new CrestApps.Core.Data.EntityCore.Models.DocumentRecord + { + Type = "TestEntity", + Content = "{}", + }, }); dbContext.CatalogRecords.Add(new CrestApps.Core.Data.EntityCore.Models.CatalogRecord { @@ -267,7 +571,11 @@ public async Task EnforceNamedSourceUniqueness_RejectsDuplicateNameAndSourceWith ItemId = Guid.NewGuid().ToString("N"), Name = "duplicate", Source = "OpenAI", - Payload = "{}", + Document = new CrestApps.Core.Data.EntityCore.Models.DocumentRecord + { + Type = "TestEntity", + Content = "{}", + }, }); await Assert.ThrowsAsync(async () => await dbContext.SaveChangesAsync(cancellationToken)); @@ -288,7 +596,11 @@ public async Task EnforceNamedSourceUniqueness_DefaultsToFalseAndAllowsDuplicate ItemId = Guid.NewGuid().ToString("N"), Name = "shared", Source = "OpenAI", - Payload = "{}", + Document = new CrestApps.Core.Data.EntityCore.Models.DocumentRecord + { + Type = "TestEntity", + Content = "{}", + }, }); dbContext.CatalogRecords.Add(new CrestApps.Core.Data.EntityCore.Models.CatalogRecord { @@ -296,7 +608,11 @@ public async Task EnforceNamedSourceUniqueness_DefaultsToFalseAndAllowsDuplicate ItemId = Guid.NewGuid().ToString("N"), Name = "shared", Source = "OpenAI", - Payload = "{}", + Document = new CrestApps.Core.Data.EntityCore.Models.DocumentRecord + { + Type = "TestEntity", + Content = "{}", + }, }); await dbContext.SaveChangesAsync(cancellationToken); @@ -319,19 +635,7 @@ private EntityCoreTestHarness( public static async Task CreateAsync(Action configureStore = null) { var databasePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.db"); - var services = new ServiceCollection(); - - services.AddHttpContextAccessor(); - services.AddLogging(); - services.AddSingleton(TimeProvider.System); - services.AddSingleton(new ConfigurationBuilder().Build()); - services.AddCoreAIServices(); - services.AddCoreEntityCoreDataStore(options => options.UseSqlite($"Data Source={databasePath}"), store => - { - store.TablePrefix = "CA_"; - configureStore?.Invoke(store); - }); - services.AddEntityCoreStores(); + var services = CreateEntityCoreServices(databasePath, configureStore); var provider = services.BuildServiceProvider(); await provider.InitializeEntityCoreSchemaAsync(); @@ -355,4 +659,25 @@ public async ValueTask DisposeAsync() } } } + + private static ServiceCollection CreateEntityCoreServices( + string databasePath, + Action configureStore = null) + { + var services = new ServiceCollection(); + + services.AddHttpContextAccessor(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddCoreAIServices(); + services.AddCoreEntityCoreDataStore(options => options.UseSqlite($"Data Source={databasePath}"), store => + { + store.TablePrefix = "CA_"; + configureStore?.Invoke(store); + }); + services.AddEntityCoreStores(); + + return services; + } } diff --git a/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs index 919c201f..05de01d4 100644 --- a/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; @@ -150,6 +151,7 @@ public async Task ProcessAsync_UsesTemplateForExtractionPrompt() var service = new DataExtractionService( clientFactory.Object, templateService.Object, + [new DefaultMarkdownTemplateParser()], TimeProvider.System, NullLogger.Instance, deploymentManager.Object); @@ -181,13 +183,443 @@ await service.ProcessAsync( Assert.Equal("What is your email?", promptArguments["lastAssistantMessage"]); } + [Fact] + public async Task ProcessAsync_WhenResponseIsMarkdownWrappedJson_ShouldExtractValues() + { + // Arrange + var clientFactory = new Mock(); + var templateService = new Mock(); + var deploymentManager = new Mock(); + var chatClient = new Mock(); + var profile = CreateProfile(settings => + { + settings.EnableDataExtraction = true; + settings.ExtractionCheckInterval = 1; + settings.DataExtractionEntries = [new DataExtractionEntry + { + Name = "zipCode", + Description = "The user's zip code.", + }, ]; + }); + profile.UtilityDeploymentName = "utility"; + + deploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(AIDeploymentType.Utility, "utility", null)) + .ReturnsAsync(new AIDeployment + { + ClientName = "OpenAI", + ConnectionName = "Default", + ModelName = "gpt-4.1", + }); + + clientFactory.Setup(factory => factory + .CreateChatClientAsync(It.IsAny())) + .ReturnsAsync(chatClient.Object); + + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtraction, It.IsAny>(), It.IsAny())) + .ReturnsAsync("system prompt"); + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>(), It.IsAny())) + .ReturnsAsync("rendered prompt"); + + chatClient.Setup(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """ + ```json + { + "fields": [ + { + "name": "zipCode", + "values": ["89118"], + "confidence": 0.99 + } + ], + "sessionEnded": false + } + ``` + """))); + + var service = CreateService(clientFactory, templateService, deploymentManager); + var session = new AIChatSession(); + + // Act + var result = await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your zip code?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "89118" }, + ], + TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Single(result.NewFields); + Assert.Equal("zipCode", result.NewFields[0].FieldName); + Assert.Equal("89118", result.NewFields[0].Value); + Assert.True(session.ExtractedData.TryGetValue("zipCode", out var state)); + Assert.Equal(["89118"], state.Values); + } + + [Fact] + public async Task ProcessAsync_WhenAssistantResponseUsesContentsText_ShouldExtractValues() + { + // Arrange + var clientFactory = new Mock(); + var templateService = new Mock(); + var deploymentManager = new Mock(); + var chatClient = new Mock(); + var profile = CreateProfile(settings => + { + settings.EnableDataExtraction = true; + settings.ExtractionCheckInterval = 1; + settings.DataExtractionEntries = [new DataExtractionEntry + { + Name = "zipCode", + Description = "The user's zip code.", + }, ]; + }); + profile.UtilityDeploymentName = "utility"; + + deploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(AIDeploymentType.Utility, "utility", null)) + .ReturnsAsync(new AIDeployment + { + ClientName = "OpenAI", + ConnectionName = "Default", + ModelName = "gpt-4.1", + }); + + clientFactory.Setup(factory => factory + .CreateChatClientAsync(It.IsAny())) + .ReturnsAsync(chatClient.Object); + + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtraction, It.IsAny>(), It.IsAny())) + .ReturnsAsync("system prompt"); + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>(), It.IsAny())) + .ReturnsAsync("rendered prompt"); + + var responseMessage = new ChatMessage + { + Role = ChatRole.Assistant, + Contents = [new TextContent("""{"fields":[{"name":"zipCode","values":["89118"],"confidence":0.99}],"sessionEnded":false}""")], + }; + + chatClient.Setup(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(responseMessage)); + + var service = CreateService(clientFactory, templateService, deploymentManager); + var session = new AIChatSession(); + + // Act + var result = await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your zip code?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "89118" }, + ], + TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Single(result.NewFields); + Assert.Equal("89118", result.NewFields[0].Value); + } + + [Fact] + public async Task ProcessAsync_WhenResponseFieldUsesCamelCaseAlias_ShouldMatchConfiguredSnakeCaseField() + { + // Arrange + var clientFactory = new Mock(); + var templateService = new Mock(); + var deploymentManager = new Mock(); + var chatClient = new Mock(); + var profile = CreateProfile(settings => + { + settings.EnableDataExtraction = true; + settings.ExtractionCheckInterval = 1; + settings.DataExtractionEntries = + [ + new DataExtractionEntry + { + Name = "first_name", + Description = "The customer's first name.", + }, + new DataExtractionEntry + { + Name = "last_name", + Description = "The customer's last name.", + }, + ]; + }); + profile.UtilityDeploymentName = "utility"; + + deploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(AIDeploymentType.Utility, "utility", null)) + .ReturnsAsync(new AIDeployment + { + ClientName = "OpenAI", + ConnectionName = "Default", + ModelName = "gpt-4.1", + }); + + clientFactory.Setup(factory => factory + .CreateChatClientAsync(It.IsAny())) + .ReturnsAsync(chatClient.Object); + + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtraction, It.IsAny>(), It.IsAny())) + .ReturnsAsync("system prompt"); + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>(), It.IsAny())) + .ReturnsAsync("rendered prompt"); + + chatClient.Setup(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """ + { + "fields": [ + { + "name": "firstName", + "values": ["Mike"], + "confidence": 0.99 + }, + { + "name": "lastName", + "values": ["Smith"], + "confidence": 0.99 + } + ], + "sessionEnded": false + } + """))); + + var service = CreateService(clientFactory, templateService, deploymentManager); + var session = new AIChatSession(); + + // Act + var result = await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your full name?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "Mike Smith" }, + ], + TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.NewFields.Count); + Assert.True(session.ExtractedData.TryGetValue("first_name", out var firstNameState)); + Assert.Equal(["Mike"], firstNameState.Values); + Assert.True(session.ExtractedData.TryGetValue("last_name", out var lastNameState)); + Assert.Equal(["Smith"], lastNameState.Values); + } + + [Fact] + public async Task ProcessAsync_WhenConfiguredFieldIsCustomerName_ShouldCombineFirstAndLastNameResponses() + { + // Arrange + var clientFactory = new Mock(); + var templateService = new Mock(); + var deploymentManager = new Mock(); + var chatClient = new Mock(); + var profile = CreateProfile(settings => + { + settings.EnableDataExtraction = true; + settings.ExtractionCheckInterval = 1; + settings.DataExtractionEntries = + [ + new DataExtractionEntry + { + Name = "customer_name", + Description = "The customer first, last or full name.", + IsUpdatable = true, + }, + ]; + }); + profile.UtilityDeploymentName = "utility"; + + deploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(AIDeploymentType.Utility, "utility", null)) + .ReturnsAsync(new AIDeployment + { + ClientName = "OpenAI", + ConnectionName = "Default", + ModelName = "gpt-4.1", + }); + + clientFactory.Setup(factory => factory + .CreateChatClientAsync(It.IsAny())) + .ReturnsAsync(chatClient.Object); + + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtraction, It.IsAny>(), It.IsAny())) + .ReturnsAsync("system prompt"); + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>(), It.IsAny())) + .ReturnsAsync("rendered prompt"); + + chatClient.SetupSequence(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """ + { + "fields": [ + { + "name": "firstName", + "values": ["Mike"], + "confidence": 0.99 + } + ], + "sessionEnded": false + } + """))) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """ + { + "fields": [ + { + "name": "last name", + "values": ["Smith"], + "confidence": 0.99 + } + ], + "sessionEnded": false + } + """))); + + var service = CreateService(clientFactory, templateService, deploymentManager); + var session = new AIChatSession(); + + // Act + await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your first name?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "Mike" }, + ], + TestContext.Current.CancellationToken); + + await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your first name?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "Mike" }, + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your last name?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "Smith" }, + ], + TestContext.Current.CancellationToken); + + // Assert + Assert.True(session.ExtractedData.TryGetValue("customer_name", out var state)); + Assert.Equal(["Mike Smith"], state.Values); + } + + [Fact] + public async Task ProcessAsync_WhenConfiguredFieldIsCustomerPhone_ShouldMatchPhoneNumberAlias() + { + // Arrange + var clientFactory = new Mock(); + var templateService = new Mock(); + var deploymentManager = new Mock(); + var chatClient = new Mock(); + var profile = CreateProfile(settings => + { + settings.EnableDataExtraction = true; + settings.ExtractionCheckInterval = 1; + settings.DataExtractionEntries = + [ + new DataExtractionEntry + { + Name = "customer_phone", + Description = "Customer phone number.", + IsUpdatable = true, + }, + ]; + }); + profile.UtilityDeploymentName = "utility"; + + deploymentManager.Setup(manager => manager + .ResolveOrDefaultAsync(AIDeploymentType.Utility, "utility", null)) + .ReturnsAsync(new AIDeployment + { + ClientName = "OpenAI", + ConnectionName = "Default", + ModelName = "gpt-4.1", + }); + + clientFactory.Setup(factory => factory + .CreateChatClientAsync(It.IsAny())) + .ReturnsAsync(chatClient.Object); + + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtraction, It.IsAny>(), It.IsAny())) + .ReturnsAsync("system prompt"); + templateService.Setup(service => service + .RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>(), It.IsAny())) + .ReturnsAsync("rendered prompt"); + + chatClient.Setup(client => client + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, """ + { + "fields": [ + { + "name": "phone number", + "values": ["7024993350"], + "confidence": 0.99 + } + ], + "sessionEnded": false + } + """))); + + var service = CreateService(clientFactory, templateService, deploymentManager); + var session = new AIChatSession(); + + // Act + var result = await service.ProcessAsync( + profile, + session, + [ + new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is the best phone number for the team to reach you?" }, + new AIChatSessionPrompt { Role = ChatRole.User, Content = "7024993350" }, + ], + TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(session.ExtractedData.TryGetValue("customer_phone", out var state)); + Assert.Equal(["7024993350"], state.Values); + } + private static DataExtractionService CreateService() { var clientFactory = new Mock(); var templateService = new Mock(); var deploymentManager = new Mock(); - return new DataExtractionService(clientFactory.Object, templateService.Object, TimeProvider.System, NullLogger.Instance, deploymentManager.Object); + return CreateService(clientFactory, templateService, deploymentManager); + } + + private static DataExtractionService CreateService( + Mock clientFactory, + Mock templateService, + Mock deploymentManager) + { + return new DataExtractionService( + clientFactory.Object, + templateService.Object, + [new DefaultMarkdownTemplateParser()], + TimeProvider.System, + NullLogger.Instance, + deploymentManager.Object); } private static AIProfile CreateProfile(Action configure) diff --git a/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionEventServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionEventServiceTests.cs new file mode 100644 index 00000000..f423c7bb --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionEventServiceTests.cs @@ -0,0 +1,63 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI.Models; +using Moq; + +namespace CrestApps.Core.Tests.Framework.AI; + +public sealed class DefaultAIChatSessionEventServiceTests +{ + [Fact] + public async Task RecordSessionStartedAsync_SavesInitialAnalyticsRecord() + { + var capturedEvent = default(AIChatSessionEvent); + var store = new Mock(MockBehavior.Strict); + store + .Setup(x => x.SaveAsync(It.IsAny(), TestContext.Current.CancellationToken)) + .Callback((chatSessionEvent, _) => capturedEvent = chatSessionEvent) + .Returns(Task.CompletedTask); + + var service = new DefaultAIChatSessionEventService(store.Object, TimeProvider.System); + + await service.RecordSessionStartedAsync(new AIChatSession + { + SessionId = "session-1", + ProfileId = "profile-1", + ClientId = "client-1", + }, TestContext.Current.CancellationToken); + + Assert.NotNull(capturedEvent); + Assert.Equal("session-1", capturedEvent.SessionId); + Assert.Equal("profile-1", capturedEvent.ProfileId); + Assert.Equal("client-1", capturedEvent.VisitorId); + Assert.Equal(0, capturedEvent.MessageCount); + store.VerifyAll(); + } + + [Fact] + public async Task RecordCompletionUsageAsync_UpdatesExistingTokenTotals() + { + var chatSessionEvent = new AIChatSessionEvent + { + SessionId = "session-1", + TotalInputTokens = 12, + TotalOutputTokens = 8, + }; + + var store = new Mock(MockBehavior.Strict); + store + .Setup(x => x.FindBySessionIdAsync("session-1", TestContext.Current.CancellationToken)) + .ReturnsAsync(chatSessionEvent); + store + .Setup(x => x.SaveAsync(chatSessionEvent, TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + + var service = new DefaultAIChatSessionEventService(store.Object, TimeProvider.System); + + await service.RecordCompletionUsageAsync("session-1", 5, 7, TestContext.Current.CancellationToken); + + Assert.Equal(17, chatSessionEvent.TotalInputTokens); + Assert.Equal(15, chatSessionEvent.TotalOutputTokens); + store.VerifyAll(); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionExtractedDataRecorderTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionExtractedDataRecorderTests.cs new file mode 100644 index 00000000..8424b6a2 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAIChatSessionExtractedDataRecorderTests.cs @@ -0,0 +1,78 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Chat.Services; +using CrestApps.Core.AI.Models; +using Moq; + +namespace CrestApps.Core.Tests.Framework.AI; + +public sealed class DefaultAIChatSessionExtractedDataRecorderTests +{ + [Fact] + public async Task RecordExtractedDataAsync_WithValues_SavesSnapshotRecord() + { + var store = new Mock(); + AIChatSessionExtractedDataRecord savedRecord = null; + store.Setup(x => x.SaveAsync(It.IsAny(), It.IsAny())) + .Callback((record, _) => savedRecord = record) + .Returns(Task.CompletedTask); + + var recorder = new DefaultAIChatSessionExtractedDataRecorder(store.Object, TimeProvider.System); + var profile = new AIProfile + { + ItemId = "profile-1", + }; + var session = new AIChatSession + { + SessionId = "session-1", + CreatedUtc = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc), + ClosedAtUtc = new DateTime(2026, 5, 1, 12, 5, 0, DateTimeKind.Utc), + ExtractedData = + { + ["customer_name"] = new ExtractedFieldState + { + Values = ["Mike Alhayek"], + }, + ["customer_phone"] = new ExtractedFieldState + { + Values = ["7024993350"], + }, + }, + }; + + await recorder.RecordExtractedDataAsync(profile, session, TestContext.Current.CancellationToken); + + Assert.NotNull(savedRecord); + Assert.Equal(session.SessionId, savedRecord.ItemId); + Assert.Equal(session.SessionId, savedRecord.SessionId); + Assert.Equal(profile.ItemId, savedRecord.ProfileId); + Assert.Equal(session.CreatedUtc, savedRecord.SessionStartedUtc); + Assert.Equal(session.ClosedAtUtc, savedRecord.SessionEndedUtc); + Assert.Equal("Mike Alhayek", Assert.Single(savedRecord.Values["customer_name"])); + Assert.Equal("7024993350", Assert.Single(savedRecord.Values["customer_phone"])); + store.Verify(x => x.SaveAsync(It.IsAny(), It.IsAny()), Times.Once); + store.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RecordExtractedDataAsync_WithoutValues_DeletesSnapshotRecord() + { + var store = new Mock(); + store.Setup(x => x.DeleteAsync("session-1", It.IsAny())) + .ReturnsAsync(true); + + var recorder = new DefaultAIChatSessionExtractedDataRecorder(store.Object, TimeProvider.System); + var profile = new AIProfile + { + ItemId = "profile-1", + }; + var session = new AIChatSession + { + SessionId = "session-1", + }; + + await recorder.RecordExtractedDataAsync(profile, session, TestContext.Current.CancellationToken); + + store.Verify(x => x.DeleteAsync("session-1", It.IsAny()), Times.Once); + store.Verify(x => x.SaveAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/AI/DefaultAICompletionUsageServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAICompletionUsageServiceTests.cs new file mode 100644 index 00000000..e82bec1b --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/AI/DefaultAICompletionUsageServiceTests.cs @@ -0,0 +1,115 @@ +using System.Security.Claims; +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Services; +using CrestApps.Core.Tests.Support; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace CrestApps.Core.Tests.Framework.AI; + +public sealed class DefaultAICompletionUsageServiceTests +{ + [Fact] + public async Task UsageRecordedAsync_SavesUsageAndForwardsSessionTokenTotals() + { + var record = new AICompletionUsageRecord + { + SessionId = "session-1", + InputTokenCount = 4, + OutputTokenCount = 9, + }; + + var usageStore = new Mock(MockBehavior.Strict); + usageStore + .Setup(x => x.SaveAsync(record, TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + + var chatSessionEventService = new Mock(MockBehavior.Strict); + chatSessionEventService + .Setup(x => x.RecordCompletionUsageAsync("session-1", 4, 9, TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + + var serviceProvider = new Mock(MockBehavior.Strict); + serviceProvider + .Setup(x => x.GetService(typeof(IAIChatSessionEventService))) + .Returns(chatSessionEventService.Object); + + var httpContextAccessor = new Mock(MockBehavior.Strict); + httpContextAccessor.SetupGet(x => x.HttpContext).Returns(new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "alice")], "test")), + }); + + var service = new DefaultAICompletionUsageService( + usageStore.Object, + serviceProvider.Object, + TimeProvider.System, + httpContextAccessor.Object, + new TestOptionsMonitor + { + CurrentValue = new GeneralAIOptions { EnableAIUsageTracking = true }, + }); + + await service.UsageRecordedAsync(record, TestContext.Current.CancellationToken); + + Assert.Equal("alice", record.UserName); + Assert.NotEqual(default, record.CreatedUtc); + usageStore.VerifyAll(); + chatSessionEventService.VerifyAll(); + serviceProvider.VerifyAll(); + } + + [Fact] + public async Task UsageRecordedAsync_DoesNotSaveWhenTrackingIsDisabled() + { + var usageStore = new Mock(MockBehavior.Strict); + var serviceProvider = new Mock(MockBehavior.Strict); + var httpContextAccessor = new Mock(MockBehavior.Strict); + + var service = new DefaultAICompletionUsageService( + usageStore.Object, + serviceProvider.Object, + TimeProvider.System, + httpContextAccessor.Object, + new TestOptionsMonitor + { + CurrentValue = new GeneralAIOptions { EnableAIUsageTracking = false }, + }); + + await service.UsageRecordedAsync(new AICompletionUsageRecord(), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task UsageRecordedAsync_UsesCurrentSettingsValueAtCallTime() + { + var record = new AICompletionUsageRecord(); + var optionsAccessor = new TestOptionsMonitor + { + CurrentValue = new GeneralAIOptions { EnableAIUsageTracking = false }, + }; + var usageStore = new Mock(MockBehavior.Strict); + var serviceProvider = new Mock(MockBehavior.Strict); + var httpContextAccessor = new Mock(MockBehavior.Strict); + + var service = new DefaultAICompletionUsageService( + usageStore.Object, + serviceProvider.Object, + TimeProvider.System, + httpContextAccessor.Object, + optionsAccessor); + + await service.UsageRecordedAsync(record, TestContext.Current.CancellationToken); + + optionsAccessor.CurrentValue = new GeneralAIOptions { EnableAIUsageTracking = true }; + usageStore + .Setup(x => x.SaveAsync(record, TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + httpContextAccessor.SetupGet(x => x.HttpContext).Returns((HttpContext)null!); + + await service.UsageRecordedAsync(record, TestContext.Current.CancellationToken); + + usageStore.VerifyAll(); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Blazor/AIConnectionViewModelTests.cs b/tests/CrestApps.Core.Tests/Framework/Blazor/AIConnectionViewModelTests.cs new file mode 100644 index 00000000..0fdb5dad --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Blazor/AIConnectionViewModelTests.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Blazor.Web.ViewModels; + +namespace CrestApps.Core.Tests.Framework.Blazor; + +public sealed class AIConnectionViewModelTests +{ + [Fact] + public void FromConnection_ShouldPreserveReadOnlyState() + { + var connection = new AIProviderConnection + { + ItemId = "config-openai", + Name = "config-openai", + DisplayText = "Config OpenAI", + Source = "AzureOpenAI", + IsReadOnly = true, + }; + + var model = AIConnectionViewModel.FromConnection(connection); + + Assert.True(model.IsReadOnly); + Assert.Equal("Azure", model.Source); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Blazor/AIDeploymentViewModelTests.cs b/tests/CrestApps.Core.Tests/Framework/Blazor/AIDeploymentViewModelTests.cs new file mode 100644 index 00000000..2383b079 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Blazor/AIDeploymentViewModelTests.cs @@ -0,0 +1,26 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Blazor.Web.ViewModels; + +namespace CrestApps.Core.Tests.Framework.Blazor; + +public sealed class AIDeploymentViewModelTests +{ + [Fact] + public void FromDeployment_ShouldPreserveReadOnlyState() + { + var deployment = new AIDeployment + { + ItemId = "config-gpt-4.1", + Name = "gpt-4.1", + ModelName = "gpt-4.1", + ClientName = "AzureOpenAI", + Type = AIDeploymentType.Chat, + IsReadOnly = true, + }; + + var model = AIDeploymentViewModel.FromDeployment(deployment); + + Assert.True(model.IsReadOnly); + Assert.Equal("Azure", model.ClientName); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatSessionCloseBackgroundServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatSessionCloseBackgroundServiceTests.cs index 34a98f2d..d2eaa325 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatSessionCloseBackgroundServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatSessionCloseBackgroundServiceTests.cs @@ -1,12 +1,36 @@ using System.Reflection; +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; -using CrestApps.Core.Mvc.Web.Areas.AIChat.BackgroundServices; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace CrestApps.Core.Tests.Framework.Mvc; public sealed class AIChatSessionCloseBackgroundServiceTests { + [Fact] + public void AddCoreAIChatSessionProcessing_RegistersHostedService() + { + var services = new ServiceCollection(); + + services.AddCoreAIChatSessionProcessing(); + + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(AIChatSessionCloseCycleService) + && descriptor.ImplementationType == typeof(AIChatSessionCloseCycleService) + && descriptor.Lifetime == ServiceLifetime.Singleton); + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(AIChatSessionCloseRunner) + && descriptor.ImplementationType == typeof(AIChatSessionCloseRunner) + && descriptor.Lifetime == ServiceLifetime.Singleton); + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(AIChatSessionCloseBackgroundService) + && descriptor.Lifetime == ServiceLifetime.Singleton); + } + [Fact] public void DetermineInactiveSessionStatus_WithUserPrompt_ReturnsClosed() { @@ -39,7 +63,7 @@ public void DetermineInactiveSessionStatus_WithoutUserPrompt_ReturnsAbandoned() private static ChatSessionStatus DetermineInactiveSessionStatus(IReadOnlyList prompts) { - var method = typeof(AIChatSessionCloseBackgroundService).GetMethod( + var method = typeof(AIChatSessionCloseCycleService).GetMethod( "DetermineInactiveSessionStatus", BindingFlags.NonPublic | BindingFlags.Static); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs index 2175c6e3..413bc90f 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs @@ -1,7 +1,9 @@ using CrestApps.Core.AI; +using CrestApps.Core.AI.AzureAIInference; using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Ollama; using CrestApps.Core.AI.OpenAI; using CrestApps.Core.AI.OpenAI.Azure; using CrestApps.Core.AI.OpenAI.Azure.Models; @@ -186,7 +188,7 @@ public async Task ConfigurationAIProviderConnectionStore_GetAllAsync_ShouldMerge var connections = await store.GetAllAsync(TestContext.Current.CancellationToken); - Assert.Contains(connections, connection => connection.Name == "config-primary" && AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)); + Assert.Contains(connections, connection => connection.Name == "config-primary" && connection.IsReadOnly); Assert.Contains(connections, connection => connection.Name == "ui-secondary" && connection.ItemId == "ui-connection"); } @@ -360,6 +362,7 @@ public async Task AIConnectionController_Index_ShouldIncludeMergedConnectionsAnd Name = "config-primary", DisplayText = "Config primary", Source = "OpenAI", + IsReadOnly = true, }, new AIProviderConnection { @@ -459,6 +462,7 @@ public async Task AIDeploymentController_Index_ShouldMarkConfiguredDeploymentsAs ModelName = "whisper", ClientName = "AzureSpeech", Type = AIDeploymentType.SpeechToText, + IsReadOnly = true, }, ]); @@ -492,17 +496,24 @@ public void AIConnectionViewModel_ApplyTo_ShouldNormalizeAzureOpenAIProviderName } [Fact] - public void AddAzureOpenAIProvider_ShouldRegisterAzureSpeechAsDeploymentProvider() + public void AddCoreAIProviders_ShouldRegisterDeploymentProvidersUsedByTheDeploymentCatalog() { var services = new ServiceCollection(); services.AddLogging(); services.AddCoreAIServices(); + services.AddCoreAIOpenAI(); services.AddCoreAIAzureOpenAI(); + services.AddCoreAIOllama(); + services.AddCoreAIAzureAIInference(); using var serviceProvider = services.BuildServiceProvider(); var options = serviceProvider.GetRequiredService>().Value; + Assert.True(options.Deployments.ContainsKey(OpenAIConstants.ClientName)); + Assert.True(options.Deployments.ContainsKey(AzureOpenAIConstants.ClientName)); Assert.True(options.Deployments.ContainsKey(AzureOpenAIConstants.AzureSpeechClientName)); + Assert.True(options.Deployments.ContainsKey(OllamaConstants.ClientName)); + Assert.True(options.Deployments.ContainsKey(AzureAIInferenceConstants.ClientName)); Assert.True(options.Deployments[AzureOpenAIConstants.AzureSpeechClientName].UseContainedConnection); } diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatExtractedDataControllerTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatExtractedDataControllerTests.cs index 6c9772fa..afed9a8e 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatExtractedDataControllerTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatExtractedDataControllerTests.cs @@ -1,7 +1,7 @@ +using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.ViewModels; using Microsoft.AspNetCore.Mvc; using Moq; @@ -28,7 +28,7 @@ public async Task IndexPost_WithoutProfileSelection_ShouldReturnValidationError( var controller = new ChatExtractedDataController( profileManager.Object, - new SampleAIChatSessionExtractedDataService(new Mock().Object, TimeProvider.System), + Mock.Of(), TimeProvider.System); var result = await controller.IndexPost(new ChatExtractedDataIndexViewModel()); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs index 7e2d9462..d573bb29 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs @@ -2,11 +2,11 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Chat.Handlers; using CrestApps.Core.AI.Chat.Hubs; +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs; -using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using Microsoft.AspNetCore.SignalR; @@ -181,7 +181,7 @@ public void GetFriendlyErrorMessage_WithInvalidChatModelSettings_ReturnsInteract Assert.Equal("The chat model settings are missing or invalid. Update the Chat model in this chat interaction, the linked AI Profile, or the global AI settings.", message); } - private static SampleCitationReferenceCollector CreateCitationCollector() + private static CitationReferenceCollector CreateCitationCollector() { return new(new CompositeAIReferenceLinkResolver(new ServiceCollection().BuildServiceProvider())); } diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs index 0578c6b3..0fd15736 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs @@ -1,8 +1,12 @@ using CrestApps.Core.AI; +using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.OpenAI; +using CrestApps.Core.AI.OpenAI.Azure; using CrestApps.Core.AI.Services; using CrestApps.Core.Services; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -102,7 +106,7 @@ public async Task GetAllAsync_ShouldReadProviderGroupedStandaloneDeployments() } [Fact] - public async Task GetAllAsync_ShouldPreserveConfiguredClientName() + public async Task GetAllAsync_ShouldNormalizeAzureOpenAIAliasToAzure() { // Arrange var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary @@ -116,14 +120,14 @@ public async Task GetAllAsync_ShouldPreserveConfiguredClientName() ["CrestApps:AI:Deployments:0:ApiKey"] = "secret", }).Build(); var aiOptions = new AIOptions(); - aiOptions.AddDeploymentProvider("AzureOpenAI"); + aiOptions.AddDeploymentProvider(AzureOpenAIConstants.ClientName); var store = CreateStore(configuration, aiOptions); // Act var deployment = Assert.Single(await store.GetAllAsync(TestContext.Current.CancellationToken)); // Assert - Assert.Equal("AzureOpenAI", deployment.ClientName); + Assert.Equal(AzureOpenAIConstants.ClientName, deployment.ClientName); Assert.Equal(AIDeploymentType.Embedding, deployment.Type); } @@ -155,6 +159,35 @@ public async Task GetAllAsync_ShouldLoadStandaloneDeploymentsForProvidersWithout Assert.Equal(AIDeploymentType.Chat, deployment.Type); } + [Fact] + public async Task AddCoreAIOpenAI_WhenDeploymentConfigured_ShouldExposeItInDeploymentStore() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Deployments:0:ClientName"] = "OpenAI", + ["CrestApps:AI:Deployments:0:ConnectionName"] = "shared-openai", + ["CrestApps:AI:Deployments:0:Name"] = "gpt-4.1", + ["CrestApps:AI:Deployments:0:ModelName"] = "gpt-4.1", + ["CrestApps:AI:Deployments:0:Type"] = "Chat", + }).Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddSingleton(TimeProvider.System); + services.AddLogging(); + services.AddCoreAIServices(); + services.AddCoreAIOpenAI(); + using var serviceProvider = services.BuildServiceProvider(); + + var deploymentStore = serviceProvider.GetRequiredService(); + var deployment = Assert.Single(await deploymentStore.GetAllAsync(TestContext.Current.CancellationToken)); + + Assert.Equal("gpt-4.1", deployment.Name); + Assert.Equal("OpenAI", deployment.ClientName); + Assert.Equal("shared-openai", deployment.ConnectionName); + Assert.True(deployment.IsReadOnly); + } + [Fact] public async Task GetAllAsync_ShouldReadEveryConfiguredDeploymentSectionAndPreserveConnectionNames() { diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcAIDocumentIndexingServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcAIDocumentIndexingServiceTests.cs index 936e4dc9..33a449f5 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcAIDocumentIndexingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcAIDocumentIndexingServiceTests.cs @@ -1,19 +1,19 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.Documents.Models; +using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.Azure.AISearch; using CrestApps.Core.Infrastructure; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; -using CrestApps.Core.Mvc.Web.Areas.Indexing.Services; +using CrestApps.Core.Tests.Support; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Moq; namespace CrestApps.Core.Tests.Framework.Mvc; -public sealed class MvcAIDocumentIndexingServiceTests +public sealed class DefaultAIDocumentIndexingServiceTests { [Fact] public async Task IndexAsync_WhenChunksDoNotContainEmbeddingsOrContent_SkipsIndexing() @@ -216,7 +216,7 @@ public async Task DeleteChunksAsync_WhenNoUsableIds_DoesNotLookupProfile() indexProfileStore.Verify(store => store.FindByNameAsync(It.IsAny(), It.IsAny()), Times.Never); } - private static SampleAIDocumentIndexingService CreateService( + private static DefaultAIDocumentIndexingService CreateService( ISearchIndexProfileStore indexProfileStore, ISearchIndexManager indexManager, ISearchDocumentManager documentManager) @@ -225,11 +225,11 @@ private static SampleAIDocumentIndexingService CreateService( services.AddKeyedSingleton(AISearchConstants.ProviderName, indexManager); services.AddKeyedSingleton(AISearchConstants.ProviderName, documentManager); - return new SampleAIDocumentIndexingService( - Options.Create(new InteractionDocumentOptions { IndexProfileName = "chat-documents" }), + return new DefaultAIDocumentIndexingService( + new TestOptionsMonitor { CurrentValue = new InteractionDocumentOptions { IndexProfileName = "chat-documents" } }, indexProfileStore, services.BuildServiceProvider(), - NullLogger.Instance); + NullLogger.Instance); } private static SearchIndexProfile CreateIndexProfile(string type = IndexProfileTypes.AIDocuments) diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs index dd31068b..f07f2e7b 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs @@ -1,9 +1,9 @@ +using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure.Indexing; -using CrestApps.Core.Mvc.Web.Services; using Microsoft.Extensions.DependencyInjection; namespace CrestApps.Core.Tests.Framework.Mvc; @@ -54,14 +54,14 @@ public void CollectToolReferences_ShouldResolveNewArticleLinks() Assert.Contains("article-2", contentItemIds); } - private static SampleCitationReferenceCollector CreateCollector() + private static CitationReferenceCollector CreateCollector() { var services = new ServiceCollection(); services.AddKeyedSingleton(IndexProfileTypes.Articles); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); - return new SampleCitationReferenceCollector(serviceProvider.GetRequiredService()); + return new CitationReferenceCollector(serviceProvider.GetRequiredService()); } private sealed class TestArticleResolver : IAIReferenceLinkResolver diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionEventStoreTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionEventStoreTests.cs new file mode 100644 index 00000000..3feef32a --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionEventStoreTests.cs @@ -0,0 +1,30 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Services; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Framework.Mvc; + +public sealed class YesSqlAIChatSessionEventStoreTests +{ + [Fact] + public async Task SaveAsync_WritesAnalyticsRecordToAiCollection() + { + var session = new Mock(MockBehavior.Strict); + session + .Setup(store => store.SaveAsync(It.IsAny(), false, "AI", TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + + var store = new YesSqlAIChatSessionEventStore( + session.Object, + Options.Create(new YesSqlStoreOptions())); + + await store.SaveAsync(new AIChatSessionEvent + { + SessionId = "session-1", + }, TestContext.Current.CancellationToken); + + session.VerifyAll(); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionExtractedDataStorePersistenceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionExtractedDataStorePersistenceTests.cs new file mode 100644 index 00000000..3e48eccc --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionExtractedDataStorePersistenceTests.cs @@ -0,0 +1,95 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using CrestApps.Core.Services; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using YesSql; +using YesSql.Provider.Sqlite; +using YesSql.Sql; + +namespace CrestApps.Core.Tests.Framework.Mvc; + +public sealed class YesSqlAIChatSessionExtractedDataStorePersistenceTests +{ + [Fact] + public async Task SaveAndCommitAsync_WritesAndReadsExtractedDataSnapshot() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = $"{nameof(YesSqlAIChatSessionExtractedDataStorePersistenceTests)}-{Guid.NewGuid():N}", + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + await using var rootConnection = new SqliteConnection(connectionString); + await rootConnection.OpenAsync(TestContext.Current.CancellationToken); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(TimeProvider.System); + services.AddCoreYesSqlDataStore(configuration => configuration.UseSqLite(connectionString)); + services.AddCoreAIChatSessionExtractedDataStoresYesSql(); + + await using var serviceProvider = services.BuildServiceProvider(); + await InitializeSchemaAsync(serviceProvider); + + await using (var scope = serviceProvider.CreateAsyncScope()) + { + var extractedDataStore = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + + await extractedDataStore.SaveAsync( + new AIChatSessionExtractedDataRecord + { + ItemId = "session-1", + SessionId = "session-1", + ProfileId = "profile-1", + SessionStartedUtc = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc), + SessionEndedUtc = new DateTime(2026, 5, 1, 12, 5, 0, DateTimeKind.Utc), + UpdatedUtc = new DateTime(2026, 5, 1, 12, 5, 0, DateTimeKind.Utc), + Values = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["customer_name"] = ["Mike Alhayek"], + ["customer_phone"] = ["7024993350"], + }, + }, + TestContext.Current.CancellationToken); + + await committer.CommitAsync(TestContext.Current.CancellationToken); + + var records = await extractedDataStore.GetAsync("profile-1", null, null, TestContext.Current.CancellationToken); + + Assert.Single(records); + Assert.Equal("session-1", records[0].SessionId); + Assert.Equal("Mike Alhayek", Assert.Single(records[0].Values["customer_name"])); + Assert.Equal("7024993350", Assert.Single(records[0].Values["customer_phone"])); + } + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM AI_AIChatSessionExtractedDataIndex;"; + + var count = Convert.ToInt32(await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, count); + } + + private static async Task InitializeSchemaAsync(IServiceProvider services) + { + var store = services.GetRequiredService(); + var options = services.GetRequiredService>().Value; + + await using var connection = store.Configuration.ConnectionFactory.CreateConnection(); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var transaction = await connection.BeginTransactionAsync(TestContext.Current.CancellationToken); + var schemaBuilder = new SchemaBuilder(store.Configuration, transaction); + + await schemaBuilder.CreateAIChatSessionExtractedDataIndexSchemaAsync(options); + await transaction.CommitAsync(TestContext.Current.CancellationToken); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionIndexPersistenceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionIndexPersistenceTests.cs new file mode 100644 index 00000000..90068683 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionIndexPersistenceTests.cs @@ -0,0 +1,155 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using CrestApps.Core.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using YesSql; +using YesSql.Provider.Sqlite; +using YesSql.Sql; + +namespace CrestApps.Core.Tests.Framework.Mvc; + +public sealed class YesSqlAIChatSessionIndexPersistenceTests +{ + [Fact] + public async Task SaveAndCommitAsync_WritesRowToAIChatSessionIndexTable() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = $"{nameof(YesSqlAIChatSessionIndexPersistenceTests)}-{Guid.NewGuid():N}", + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + await using var rootConnection = new SqliteConnection(connectionString); + await rootConnection.OpenAsync(TestContext.Current.CancellationToken); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(new HttpContextAccessor()); + services.AddSingleton(TimeProvider.System); + services.AddCoreYesSqlDataStore(configuration => configuration.UseSqLite(connectionString)); + services.AddCoreAIChatSessionBaseStoresYesSql(); + + await using var serviceProvider = services.BuildServiceProvider(); + await InitializeSchemaAsync(serviceProvider); + + await using (var scope = serviceProvider.CreateAsyncScope()) + { + var sessionManager = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + + var session = await sessionManager.NewAsync(new AIProfile + { + ItemId = "profile-1", + Type = AIProfileType.Chat, + }, new NewAIChatSessionContext(), TestContext.Current.CancellationToken); + + session.Title = "Support session"; + + await sessionManager.SaveAsync(session, TestContext.Current.CancellationToken); + await committer.CommitAsync(TestContext.Current.CancellationToken); + } + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM AI_AIChatSessionIndex;"; + + var count = Convert.ToInt32(await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, count); + } + + [Fact] + public async Task SaveAndCommitAsync_WhenExistingSessionIsSavedInNewScope_DoesNotCreateDuplicateDocuments() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = $"{nameof(YesSqlAIChatSessionIndexPersistenceTests)}-{Guid.NewGuid():N}", + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + await using var rootConnection = new SqliteConnection(connectionString); + await rootConnection.OpenAsync(TestContext.Current.CancellationToken); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(new HttpContextAccessor()); + services.AddSingleton(TimeProvider.System); + services.AddCoreYesSqlDataStore(configuration => configuration.UseSqLite(connectionString)); + services.AddCoreAIChatSessionBaseStoresYesSql(); + + await using var serviceProvider = services.BuildServiceProvider(); + await InitializeSchemaAsync(serviceProvider); + + string sessionId; + await using (var scope = serviceProvider.CreateAsyncScope()) + { + var sessionManager = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + + var session = await sessionManager.NewAsync(new AIProfile + { + ItemId = "profile-1", + Type = AIProfileType.Chat, + }, new NewAIChatSessionContext(), TestContext.Current.CancellationToken); + + session.Title = "Created"; + await sessionManager.SaveAsync(session, TestContext.Current.CancellationToken); + await committer.CommitAsync(TestContext.Current.CancellationToken); + sessionId = session.SessionId; + } + + await using (var scope = serviceProvider.CreateAsyncScope()) + { + var sessionManager = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + var session = await sessionManager.FindByIdAsync(sessionId, TestContext.Current.CancellationToken); + + Assert.NotNull(session); + + session.Title = "Updated"; + await sessionManager.SaveAsync(session, TestContext.Current.CancellationToken); + await committer.CommitAsync(TestContext.Current.CancellationToken); + } + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM AI_Document + WHERE Type = 'CrestApps.Core.AI.Models.AIChatSession, CrestApps.Core.AI.Abstractions' + AND Content LIKE '%' || $sessionId || '%'; + """; + command.Parameters.AddWithValue("$sessionId", sessionId); + + var count = Convert.ToInt32(await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, count); + } + + private static async Task InitializeSchemaAsync(IServiceProvider services) + { + var store = services.GetRequiredService(); + var options = services.GetRequiredService>().Value; + + await using var connection = store.Configuration.ConnectionFactory.CreateConnection(); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var transaction = await connection.BeginTransactionAsync(TestContext.Current.CancellationToken); + var schemaBuilder = new SchemaBuilder(store.Configuration, transaction); + + await schemaBuilder.CreateAIChatSessionIndexSchemaAsync(options); + await schemaBuilder.CreateAIChatSessionPromptIndexSchemaAsync(options); + + await transaction.CommitAsync(TestContext.Current.CancellationToken); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionManagerTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionManagerTests.cs index ca30b877..8a628d71 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionManagerTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAIChatSessionManagerTests.cs @@ -1,8 +1,10 @@ +using System.Linq.Expressions; using System.Security.Claims; using CrestApps.Core.AI; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; using CrestApps.Core.Data.YesSql.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; @@ -43,7 +45,7 @@ public async Task NewAsync_WithInitialPrompt_ShouldCreateAssistantPromptThatPart var manager = new YesSqlAIChatSessionManager( httpContextAccessor.Object, - new Mock().Object, + new Mock().Object, promptStore.Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -78,7 +80,7 @@ public async Task NewAsync_WhenIdentityHasNoNameIdentifier_UsesIdentityName() var manager = new YesSqlAIChatSessionManager( httpContextAccessor.Object, - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, promptStore.Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -116,7 +118,7 @@ public async Task NewAsync_WhenProfileIsNotChat_DoesNotCreatePromptOrAssignIniti var manager = new YesSqlAIChatSessionManager( Mock.Of(accessor => accessor.HttpContext == null), - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, promptStore.Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -149,7 +151,7 @@ public async Task NewAsync_WhenInitialPromptIsBlank_DoesNotCreateAssistantPrompt var manager = new YesSqlAIChatSessionManager( Mock.Of(accessor => accessor.HttpContext == null), - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, promptStore.Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -167,7 +169,22 @@ public async Task SaveAsync_UpdatesLastActivityAndPersistsSession() var timeProvider = new Mock(); timeProvider.Setup(provider => provider.GetUtcNow()).Returns(new DateTimeOffset(now)); - var sessionStore = new Mock(); + var sessionStore = new Mock(); + var query = new Mock(); + var typedQuery = new Mock>(); + var indexedQuery = new Mock>(); + sessionStore.Setup(session => session.Query("AI")).Returns(query.Object); + query.Setup(sessionQuery => sessionQuery.For(It.IsAny())).Returns(typedQuery.Object); + typedQuery.Setup(sessionQuery => sessionQuery.With()).Returns(indexedQuery.Object); + typedQuery + .Setup(sessionQuery => sessionQuery.With(It.IsAny>>())) + .Returns(indexedQuery.Object); + indexedQuery + .Setup(sessionQuery => sessionQuery.Where(It.IsAny>>())) + .Returns(indexedQuery.Object); + indexedQuery + .Setup(sessionQuery => sessionQuery.FirstOrDefaultAsync(It.IsAny())) + .ReturnsAsync((AIChatSession)null!); sessionStore .Setup(session => session.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); @@ -196,7 +213,7 @@ public async Task SaveAsync_WhenSessionIsNull_ThrowsArgumentNullException() { var sessionManager = new YesSqlAIChatSessionManager( new Mock(MockBehavior.Strict).Object, - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -209,7 +226,7 @@ public async Task FindByIdAsync_WhenIdIsEmpty_ThrowsArgumentException() { var sessionManager = new YesSqlAIChatSessionManager( new Mock(MockBehavior.Strict).Object, - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); @@ -222,7 +239,7 @@ public async Task DeleteAsync_WhenSessionIdIsEmpty_ThrowsArgumentException() { var sessionManager = new YesSqlAIChatSessionManager( new Mock(MockBehavior.Strict).Object, - new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, TimeProvider.System, Options.Create(new YesSqlStoreOptions())); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAICompletionUsageStoreTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAICompletionUsageStoreTests.cs new file mode 100644 index 00000000..9d2ab404 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlAICompletionUsageStoreTests.cs @@ -0,0 +1,27 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Services; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Framework.Mvc; + +public sealed class YesSqlAICompletionUsageStoreTests +{ + [Fact] + public async Task SaveAsync_WritesUsageRecordToAiCollection() + { + var session = new Mock(MockBehavior.Strict); + session + .Setup(store => store.SaveAsync(It.IsAny(), false, "AI", TestContext.Current.CancellationToken)) + .Returns(Task.CompletedTask); + + var store = new YesSqlAICompletionUsageStore( + session.Object, + Options.Create(new YesSqlStoreOptions())); + + await store.SaveAsync(new AICompletionUsageRecord(), TestContext.Current.CancellationToken); + + session.VerifyAll(); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlDefaultCollectionIndexPersistenceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlDefaultCollectionIndexPersistenceTests.cs new file mode 100644 index 00000000..a2e57c11 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/YesSqlDefaultCollectionIndexPersistenceTests.cs @@ -0,0 +1,84 @@ +using CrestApps.Core.Data.YesSql; +using CrestApps.Core.Data.YesSql.Indexes.Indexing; +using CrestApps.Core.Infrastructure.Indexing; +using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Services; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using YesSql; +using YesSql.Provider.Sqlite; +using YesSql.Sql; + +namespace CrestApps.Core.Tests.Framework.Mvc; + +public sealed class YesSqlDefaultCollectionIndexPersistenceTests +{ + [Fact] + public async Task SaveAndCommitAsync_WritesRowToConfiguredDefaultCollectionIndexTable() + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = $"{nameof(YesSqlDefaultCollectionIndexPersistenceTests)}-{Guid.NewGuid():N}", + Mode = SqliteOpenMode.Memory, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + await using var rootConnection = new SqliteConnection(connectionString); + await rootConnection.OpenAsync(TestContext.Current.CancellationToken); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(TimeProvider.System); + services.Configure(options => options.DefaultCollectionName = "Default"); + services.AddCoreYesSqlDataStore(configuration => configuration.UseSqLite(connectionString)); + services.AddCoreIndexingStoresYesSql(); + + await using var serviceProvider = services.BuildServiceProvider(); + await InitializeSchemaAsync(serviceProvider); + + await using (var scope = serviceProvider.CreateAsyncScope()) + { + var store = scope.ServiceProvider.GetRequiredService(); + var committer = scope.ServiceProvider.GetRequiredService(); + + await store.CreateAsync(new SearchIndexProfile + { + ItemId = "profile-1", + Name = "articles", + ProviderName = "Elasticsearch", + IndexName = "articles", + IndexFullName = "sample-articles", + Type = "articles", + CreatedUtc = new DateTime(2026, 05, 01, 0, 0, 0, DateTimeKind.Utc), + }, TestContext.Current.CancellationToken); + + await committer.CommitAsync(TestContext.Current.CancellationToken); + } + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM Default_SearchIndexProfileIndex;"; + + var count = Convert.ToInt32(await command.ExecuteScalarAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(1, count); + } + + private static async Task InitializeSchemaAsync(IServiceProvider services) + { + var store = services.GetRequiredService(); + var options = services.GetRequiredService>().Value; + + await using var connection = store.Configuration.ConnectionFactory.CreateConnection(); + await connection.OpenAsync(TestContext.Current.CancellationToken); + await using var transaction = await connection.BeginTransactionAsync(TestContext.Current.CancellationToken); + var schemaBuilder = new SchemaBuilder(store.Configuration, transaction); + + await schemaBuilder.CreateSearchIndexProfileIndexSchemaAsync(options); + + await transaction.CommitAsync(TestContext.Current.CancellationToken); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Startup/SharedWebApplicationBuilderExtensionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Startup/SharedWebApplicationBuilderExtensionsTests.cs new file mode 100644 index 00000000..84721ead --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/Startup/SharedWebApplicationBuilderExtensionsTests.cs @@ -0,0 +1,85 @@ +using CrestApps.Core.Startup.Shared.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CrestApps.Core.Tests.Framework.Startup; + +public sealed class SharedWebApplicationBuilderExtensionsTests +{ + [Fact] + public void AddSharedSampleHostDefaults_ShouldLoadProjectAndResolvedAppDataSettings() + { + var rootPath = Path.Combine(Path.GetTempPath(), "crestapps-tests", Guid.NewGuid().ToString("N")); + var contentRootPath = Path.Combine(rootPath, "content-root"); + var projectAppDataPath = Path.Combine(contentRootPath, "App_Data"); + var resolvedAppDataPath = Path.Combine(rootPath, "resolved-app-data"); + + Directory.CreateDirectory(projectAppDataPath); + Directory.CreateDirectory(resolvedAppDataPath); + + try + { + File.WriteAllText( + Path.Combine(projectAppDataPath, "appsettings.json"), + """ + { + "CrestApps": { + "AI": { + "Deployments": [ + { + "ClientName": "OpenAI", + "Name": "project-deployment", + "ModelName": "gpt-4.1", + "Type": "Chat" + } + ] + } + } + } + """); + + File.WriteAllText( + Path.Combine(resolvedAppDataPath, "appsettings.json"), + """ + { + "CrestApps": { + "AI": { + "Deployments": [ + { + "Name": "resolved-deployment" + } + ] + } + } + } + """); + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development, + }); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["CrestApps:AppDataPath"] = resolvedAppDataPath, + }); + + var appDataPath = builder.AddSharedSampleHostDefaults(); + + Assert.Equal(resolvedAppDataPath, appDataPath); + Assert.Equal("OpenAI", builder.Configuration["CrestApps:AI:Deployments:0:ClientName"]); + Assert.Equal("resolved-deployment", builder.Configuration["CrestApps:AI:Deployments:0:Name"]); + Assert.Equal("gpt-4.1", builder.Configuration["CrestApps:AI:Deployments:0:ModelName"]); + Assert.Equal("Chat", builder.Configuration["CrestApps:AI:Deployments:0:Type"]); + } + finally + { + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, recursive: true); + } + } + } +} diff --git a/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs index f732ff86..abb179f1 100644 --- a/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs @@ -4,9 +4,9 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.Templates.Models; using CrestApps.Core.Templates.Services; +using CrestApps.Core.Tests.Support; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using Moq; #pragma warning disable MEAI001 @@ -101,14 +101,20 @@ private static AIMemoryPreemptiveRagHandler CreateHandler( return new AIMemoryPreemptiveRagHandler( memorySearchService.Object, new FakeAITemplateService(), - Options.Create(new GeneralAIOptions + new TestOptionsMonitor { - EnablePreemptiveMemoryRetrieval = enablePreemptiveMemoryRetrieval, - }), - Options.Create(new ChatInteractionMemoryOptions + CurrentValue = new GeneralAIOptions + { + EnablePreemptiveMemoryRetrieval = enablePreemptiveMemoryRetrieval, + }, + }, + new TestOptionsMonitor { - EnableUserMemory = enableChatInteractionMemory, - }), + CurrentValue = new ChatInteractionMemoryOptions + { + EnableUserMemory = enableChatInteractionMemory, + }, + }, httpContextAccessor, NullLogger.Instance); } diff --git a/tests/CrestApps.Core.Tests/Support/TestOptionsMonitor.cs b/tests/CrestApps.Core.Tests/Support/TestOptionsMonitor.cs new file mode 100644 index 00000000..3c2d27f1 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Support/TestOptionsMonitor.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Tests.Support; + +internal sealed class TestOptionsMonitor : IOptionsMonitor +{ + public TOptions CurrentValue { get; set; } + + public TOptions Get(string name) + { + return CurrentValue; + } + + public IDisposable OnChange(Action listener) + { + return EmptyDisposable.Instance; + } + + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +}