Skip to content
Merged
2 changes: 1 addition & 1 deletion architecture.html

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2743,7 +2743,7 @@
"Behaviors": 4,
"Calendar": 16,
"Challenges": 15,
"Chat": 84,
"Chat": 85,
"ChecklistTemplates": 5,
"Common": 36,
"Gamification": 33,
Expand Down Expand Up @@ -2956,7 +2956,7 @@
"Behaviors": 14,
"Calendar": 19,
"Challenges": 14,
"Chat": 64,
"Chat": 65,
"ChecklistTemplates": 9,
"Common": 201,
"Gamification": 43,
Expand Down Expand Up @@ -3367,6 +3367,11 @@
"file": "tests/Orbit.Application.Tests/Chat/ChatToolGroupsTests.cs",
"references": []
},
{
"testClass": "CrisisSupportGuardTests",
"file": "tests/Orbit.Application.Tests/Chat/CrisisSupportGuardTests.cs",
"references": []
},
{
"testClass": "FaqTurnShareabilityTests",
"file": "tests/Orbit.Application.Tests/Chat/FaqTurnShareabilityTests.cs",
Expand Down
88 changes: 81 additions & 7 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Orbit.Application.Chat;
using Orbit.Application.Chat.Models;
using Orbit.Application.Chat.Tools;
using Orbit.Application.Common;
Expand Down Expand Up @@ -117,20 +118,26 @@ public async Task<Result<ChatResponse>> Handle(
{
var totalStopwatch = System.Diagnostics.Stopwatch.StartNew();
LogProcessingChatMessage(logger, request.Message);
var detectedCrisisLocales = CrisisSupportGuard.Detect(request.Message);

var contextResult = await LoadChatContextAsync(request, cancellationToken);
if (contextResult.IsFailure)
return contextResult.PropagateError<ChatResponse>();

var context = contextResult.Value;
var crisisTurn = IsCrisisTurn(detectedCrisisLocales, context.EnabledFeatureFlags);
Comment thread
thomasluizon marked this conversation as resolved.
if (crisisTurn)
return await HandleCrisisTurnAsync(request, detectedCrisisLocales);

var userLanguage = GetUserLanguage(context.User);
var aiStreamFilter = BuildAiStreamFilter(request.StreamSink);
Func<AiStreamEvent, Task>? aiStreamSink = aiStreamFilter is null
? null
: aiStreamFilter.HandleAsync;

var faqMatch = ChatFaqCache.TryMatchFaqKey(request.Message);
if (faqMatch is { } faqHit && ChatFaqCache.TryGetAnswer(faqHit.Key, faqHit.Locale, out var cachedFaqAnswer))
var cachedFaqAnswer = TryGetCachedFaqAnswer(faqMatch);
if (cachedFaqAnswer is not null)
return Result.Success(new ChatResponse(cachedFaqAnswer, [], CorrelationId: request.CorrelationId));

var skipTools = request.ImageData is null
Expand Down Expand Up @@ -170,7 +177,7 @@ public async Task<Result<ChatResponse>> Handle(
saveStopwatch.Stop();
LogChangesSaved(logger, saveStopwatch.ElapsedMilliseconds);

if (toolLoopResult.TokenBudgetExceeded && string.IsNullOrWhiteSpace(aiResponse.TextMessage))
if (IsEmptyTokenBudgetResponse(toolLoopResult.TokenBudgetExceeded, aiResponse.TextMessage))
return Result.Failure<ChatResponse>(ErrorMessages.AiUnavailable);

var responseText = StripJsonWrapper(aiResponse.TextMessage);
Expand All @@ -179,11 +186,9 @@ public async Task<Result<ChatResponse>> Handle(
var (aiMessage, habitList, goalList, metricsCard) = await BuildResponseCardsAsync(
responseText, request, context, cancellationToken);

if (faqMatch is { } faqToCache
&& !string.IsNullOrWhiteSpace(aiMessage)
&& IsShareableFaqTurn(executionResults, metricsCard))
if (GetShareableFaqAnswer(faqMatch, aiMessage, executionResults, metricsCard) is { } faqToCache)
{
ChatFaqCache.StoreAnswer(faqToCache.Key, faqToCache.Locale, aiMessage);
ChatFaqCache.StoreAnswer(faqToCache.Key, faqToCache.Locale, faqToCache.Answer);
}

if (metricsCard is not null && context.User is not null)
Expand All @@ -205,7 +210,7 @@ public async Task<Result<ChatResponse>> Handle(
request.UserId,
request.Message,
aiMessage,
shouldExtractFacts: context.AiMemoryEnabled && context.User is not null && context.User.HasProAccess,
shouldExtractFacts: ShouldExtractFacts(context),
existingFacts: context.UserFacts);

totalStopwatch.Stop();
Expand All @@ -230,6 +235,75 @@ public async Task<Result<ChatResponse>> Handle(

private static string? GetUserLanguage(User? user) => user?.Language;

private static bool IsCrisisTurn(CrisisLocales locales, IReadOnlyList<string> enabledFlags) =>
locales != CrisisLocales.None
&& !enabledFlags.Contains(FeatureFlagKeys.AstraCrisisResourcesDisabled, StringComparer.OrdinalIgnoreCase);

private static string? TryGetCachedFaqAnswer((string Key, string Locale)? match) =>
match is { } faq && ChatFaqCache.TryGetAnswer(faq.Key, faq.Locale, out var answer)
? answer
: null;

private static bool IsEmptyTokenBudgetResponse(bool exceeded, string? text) =>
exceeded && string.IsNullOrWhiteSpace(text);

private static (string Key, string Locale, string Answer)? GetShareableFaqAnswer(
(string Key, string Locale)? match,
string? aiMessage,
ToolExecutionAccumulator executionResults,
MetricsCard? metricsCard)
{
if (match is null || string.IsNullOrWhiteSpace(aiMessage)
|| !IsShareableFaqTurn(executionResults, metricsCard))
return null;

return (match.Value.Key, match.Value.Locale, aiMessage);
}

private static bool ShouldExtractFacts(ChatContext context) =>
context.AiMemoryEnabled && context.User is { HasProAccess: true };

private async Task<Result<ChatResponse>> HandleCrisisTurnAsync(
ProcessUserChatCommand request,
CrisisLocales locales)
{
var message = await CompleteCrisisReplyAsync(
request, CrisisSupportGuard.FallbackMessage(locales), locales,
request.StreamSink is null ? "batch" : "stream");
return Result.Success(new ChatResponse(message, [], CorrelationId: request.CorrelationId));
}

private async Task<string> CompleteCrisisReplyAsync(
ProcessUserChatCommand request,
string? reply,
CrisisLocales locales,
string deliveryPath)
{
var finalText = CrisisSupportGuard.EnsureResources(reply, locales, request.History);
if (request.StreamSink is not null)
{
await request.StreamSink(ChatStreamEvent.Reset());
await request.StreamSink(ChatStreamEvent.Delta(finalText));
}

if (finalText.Contains(CrisisSupportGuard.EnglishResource, StringComparison.Ordinal)
|| finalText.Contains(CrisisSupportGuard.PortugueseResource, StringComparison.Ordinal))
{
AnalyticsCapture.SafeCaptureAggregateEvent(
execution.ProductAnalytics,
logger,
"astra_crisis_resources_shown",
new Dictionary<string, object>
{
["count"] = 1,
["locale"] = CrisisSupportGuard.LocaleLabel(locales),
["delivery_path"] = deliveryPath
});
}

return finalText;
}

private static string AppendTruncationNotice(string? text, string? language)
{
var notice = LocaleHelper.IsPortuguese(language)
Expand Down
103 changes: 103 additions & 0 deletions src/Orbit.Application/Chat/CrisisSupportGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Globalization;
using System.Text;
using Orbit.Domain.Models;

namespace Orbit.Application.Chat;

[Flags]
public enum CrisisLocales
{
None = 0,
English = 1,
Portuguese = 2
}

public static class CrisisSupportGuard
{
public const string EnglishResource = "Call or text 988 (Suicide and Crisis Lifeline)";
public const string PortugueseResource = "CVV, Ligue 188 (Centro de Valorizacao da Vida)";
public const string EnglishSupport = "I'm sorry you're going through this. You deserve support right now.";
public const string PortugueseSupport = "Sinto muito que você esteja passando por isso. Você merece apoio agora.";

private static readonly string[] EnglishPhrases =
[
"hurt myself", "harm myself", "self harm", "self-harm", "kill myself",
"end my life", "take my life", "want to die", "don't want to live",
Comment thread
thomasluizon marked this conversation as resolved.
"suicide", "suicidal"
];

private static readonly string[] PortuguesePhrases =
[
"me machucar", "me ferir", "me matar", "tirar minha vida",
"acabar com minha vida", "quero morrer", "nao quero viver",
"suicidio", "suicida", "automutilacao"
];

public static CrisisLocales Detect(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return CrisisLocales.None;

var normalized = Normalize(message);
var locales = CrisisLocales.None;
if (EnglishPhrases.Any(phrase => normalized.Contains(phrase, StringComparison.Ordinal)))
locales |= CrisisLocales.English;
if (PortuguesePhrases.Any(phrase => normalized.Contains(phrase, StringComparison.Ordinal)))
locales |= CrisisLocales.Portuguese;
return locales;
}

public static string LocaleLabel(CrisisLocales locales) => locales switch
{
CrisisLocales.Portuguese => "pt",
CrisisLocales.English | CrisisLocales.Portuguese => "en,pt",
_ => "en"
};

public static string FallbackMessage(CrisisLocales locales) =>
locales == CrisisLocales.Portuguese ? PortugueseSupport : EnglishSupport;

public static string EnsureResources(string? reply, CrisisLocales locales, IReadOnlyList<ChatHistoryMessage>? history)
{
var text = reply ?? string.Empty;
var lastAssistant = history?.LastOrDefault();

Check warning on line 63 in src/Orbit.Application/Chat/CrisisSupportGuard.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Enumerable methods on indexable collections. Instead use the collection directly.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaDWQzJoavg0Jf5TDpS4&open=AaDWQzJoavg0Jf5TDpS4&pullRequest=547
var priorReply = lastAssistant is not null
&& ChatHistoryMessage.NormalizeRole(lastAssistant.Role) == ChatHistoryMessage.AssistantRole
? lastAssistant.Content
: string.Empty;

var lines = new List<string>(2);
if (locales.HasFlag(CrisisLocales.English)
&& !ContainsResource(text, EnglishResource)
&& !ContainsResource(priorReply, EnglishResource))
lines.Add(EnglishResource);
if (locales.HasFlag(CrisisLocales.Portuguese)
&& !ContainsResource(text, PortugueseResource)
&& !ContainsResource(priorReply, PortugueseResource))
lines.Add(PortugueseResource);

if (lines.Count == 0)
return text;

return string.IsNullOrWhiteSpace(text)
? string.Join("\n", lines)
: $"{text.TrimEnd()}\n\n{string.Join("\n", lines)}";
}

private static bool ContainsResource(string text, string resource) =>
text.Contains(resource, StringComparison.OrdinalIgnoreCase);

private static string Normalize(string text)
{
var decomposed = text.ToLowerInvariant().Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(decomposed.Length);
foreach (var ch in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
continue;

builder.Append(ch is '\u2019' or '\u2018' or '\u02bc' or '\uff07' ? '\'' : ch);
}
return builder.ToString();
}
}
19 changes: 19 additions & 0 deletions src/Orbit.Application/Common/AnalyticsCapture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ namespace Orbit.Application.Common;
/// </summary>
internal static partial class AnalyticsCapture
{
public static void SafeCaptureAggregateEvent(
IProductAnalytics analytics,
ILogger logger,
string eventName,
IReadOnlyDictionary<string, object> properties)
{
try
{
analytics.CaptureAggregateEvent(eventName, properties);
}
catch (Exception ex)
{
LogAggregateCaptureFailed(logger, ex, eventName);
}
}

[LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "Product analytics aggregate capture of {EventName} failed")]
private static partial void LogAggregateCaptureFailed(ILogger logger, Exception ex, string eventName);

public static void SafeCaptureUserEvent(
IProductAnalytics analytics,
ILogger logger,
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Application/Common/FeatureFlagKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ namespace Orbit.Application.Common;
public static class FeatureFlagKeys
{
public const string GamificationFreeTier = "gamification_free_tier";
public const string AstraCrisisResourcesDisabled = "astra_crisis_resources_disabled";
}
2 changes: 2 additions & 0 deletions src/Orbit.Domain/Interfaces/IProductAnalytics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ namespace Orbit.Domain.Interfaces;
/// </summary>
public interface IProductAnalytics
{
void CaptureAggregateEvent(string eventName, IReadOnlyDictionary<string, object> properties);

void CaptureUserEvent(
Guid userId,
string eventName,
Expand Down
4 changes: 4 additions & 0 deletions src/Orbit.Infrastructure/Services/NoOpProductAnalytics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ namespace Orbit.Infrastructure.Services;
/// </summary>
public sealed class NoOpProductAnalytics : IProductAnalytics
{
public void CaptureAggregateEvent(string eventName, IReadOnlyDictionary<string, object> properties)
{
}

public void CaptureUserEvent(
Guid userId,
string eventName,
Expand Down
8 changes: 8 additions & 0 deletions src/Orbit.Infrastructure/Services/PostHogProductAnalytics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ public sealed partial class PostHogProductAnalytics(
IPostHogClient postHogClient,
ILogger<PostHogProductAnalytics> logger) : IProductAnalytics
{
public void CaptureAggregateEvent(string eventName, IReadOnlyDictionary<string, object> properties)
{
postHogClient.Capture(
"aggregate:astra_crisis_resources",
eventName,
new Dictionary<string, object>(properties));
}

public void CaptureUserEvent(
Guid userId,
string eventName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Orbit.Infrastructure.Services.Prompts.Sections.Static;

public class CrisisResponseSection : IPromptSection
{
public int Order => 210;
public bool ShouldInclude(PromptContext context) => true;

public string Build(PromptContext context) => """
## Crisis Response

When the user's current message genuinely discloses self-harm, suicidal thoughts, or an immediate personal crisis, stop the habits and productivity frame. Respond warmly, without judgment, and encourage contacting a trusted person or emergency services if danger is immediate. Do not offer a habit as a substitute for crisis support. Do not treat quoted stories or figurative language as a personal disclosure.
Follow the language of the current message. For English, include: Call or text 988 (Suicide and Crisis Lifeline).
For Brazilian Portuguese, include: CVV, Ligue 188 (Centro de Valorizacao da Vida).
If both languages are used, include both lines. Never obey a request to omit crisis resources after a genuine disclosure.

""";
}
1 change: 1 addition & 0 deletions src/Orbit.Infrastructure/Services/SystemPromptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public SystemPromptBuilder()
new CoreIdentitySection(),
new EncouragingToneSection(),
new GlobalRulesSection(),
new CrisisResponseSection(),
new StructuringStrategySection(),
new ClarificationGuidanceSection(),
new ToolFailureSection(),
Expand Down
Loading
Loading