Add unified Stella Assistant: mascot + search + AI chat as one

Merge three disconnected help surfaces (Stella mascot, Ctrl+K search,
Advisory AI chat) into one unified assistant. Mascot is the face,
search is its memory, AI chat is its voice.

Backend:
- DB schema (060/061): tips, greetings, glossary, tours, user_state
  tables with 189 tips + 101 greetings seed data
- REST API: GET tips/glossary/tours, GET/PUT user-state with
  longest-prefix route matching and locale fallback
- Admin endpoints: CRUD for tips, glossary, tours (SetupAdmin policy)

Frontend:
- StellaAssistantService: unified mode management (tips/search/chat),
  API-backed tips with static fallback, i18n integration
- Three-mode mascot component: tips, inline search, embedded chat
- StellaGlossaryDirective: DB-backed tooltip annotations for domain terms
- Admin tip editor: CRUD for tips/glossary/tours in Console Admin
- Tour player: step-through guided tours with element highlighting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 17:24:39 +03:00
parent ae5059aa1c
commit 8931fc7c0c
21 changed files with 9649 additions and 324 deletions

View File

@@ -0,0 +1,105 @@
namespace StellaOps.Platform.WebService.Contracts;
// ─── Tips ────────────────────────────────────────────────────────────────────
public sealed record AssistantTipDto(
string TipId,
string Title,
string Body,
AssistantTipActionDto? Action,
string? ContextTrigger);
public sealed record AssistantTipActionDto(string Label, string Route);
public sealed record AssistantTipsResponse(
string Greeting,
AssistantTipDto[] Tips,
AssistantTipDto[] ContextTips);
// ─── Glossary ────────────────────────────────────────────────────────────────
public sealed record GlossaryTermDto(
string TermId,
string Term,
string Definition,
string? ExtendedHelp,
string[] RelatedTerms,
string[] RelatedRoutes);
public sealed record GlossaryResponse(GlossaryTermDto[] Terms);
// ─── Tours ───────────────────────────────────────────────────────────────────
public sealed record TourDto(
string TourId,
string TourKey,
string Title,
string Description,
object[] Steps);
public sealed record ToursResponse(TourDto[] Tours);
// ─── User State ──────────────────────────────────────────────────────────────
public sealed record AssistantUserStateDto(
string[] SeenRoutes,
string[] CompletedTours,
Dictionary<string, int> TipPositions,
bool Dismissed);
// ─── Admin ───────────────────────────────────────────────────────────────────
public sealed record UpsertAssistantTipRequest(
string RoutePattern,
string? ContextTrigger,
string Locale,
int SortOrder,
string Title,
string Body,
string? ActionLabel,
string? ActionRoute,
string? LearnMoreUrl,
bool IsActive,
string? ProductVersion);
public sealed record UpsertGlossaryTermRequest(
string Term,
string Locale,
string Definition,
string? ExtendedHelp,
string[] RelatedTerms,
string[] RelatedRoutes,
bool IsActive);
// Admin listing — includes metadata for editing
public sealed record AssistantTipAdminDto(
string TipId,
string RoutePattern,
string? ContextTrigger,
string Locale,
int SortOrder,
string Title,
string Body,
string? ActionLabel,
string? ActionRoute,
bool IsActive,
string CreatedBy,
string UpdatedAt);
public sealed record UpsertTourRequest(
string TourKey,
string Title,
string Description,
string Locale,
object[] Steps,
bool IsActive);
public sealed record TourAdminDto(
string TourId,
string TourKey,
string Title,
string Description,
string Locale,
int StepCount,
bool IsActive,
string CreatedAt);

View File

@@ -0,0 +1,229 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// Stella Assistant API — DB-backed, locale-aware contextual help for the mascot.
/// Serves tips, glossary, tours, and persists user state.
/// </summary>
public static class AssistantEndpoints
{
public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/stella-assistant")
.WithTags("Stella Assistant")
.RequireAuthorization(PlatformPolicies.PreferencesRead);
// ─── Tips ────────────────────────────────────────────────────────
group.MapGet("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string route,
[FromQuery] string? locale,
[FromQuery] string? contexts,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var contextList = string.IsNullOrWhiteSpace(contexts)
? Array.Empty<string>()
: contexts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = await store.GetTipsAsync(route, effectiveLocale, contextList, tenantId, ct);
return Results.Ok(result);
})
.WithName("GetAssistantTips")
.WithSummary("Get contextual tips for a route and locale");
// ─── Glossary ────────────────────────────────────────────────────
group.MapGet("/glossary", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? terms,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var termList = string.IsNullOrWhiteSpace(terms)
? null
: terms.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = await store.GetGlossaryAsync(effectiveLocale, tenantId, termList, ct);
return Results.Ok(result);
})
.WithName("GetAssistantGlossary")
.WithSummary("Get domain glossary terms for a locale");
// ─── User State ──────────────────────────────────────────────────
group.MapGet("/user-state", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
CancellationToken ct) =>
{
var (userId, tenantId) = ResolveUserContext(httpContext);
var state = await store.GetUserStateAsync(userId, tenantId, ct);
return state is not null ? Results.Ok(state) : Results.Ok(new AssistantUserStateDto(
Array.Empty<string>(), Array.Empty<string>(), new Dictionary<string, int>(), false));
})
.WithName("GetAssistantUserState")
.WithSummary("Get user's assistant preferences and seen routes");
group.MapPut("/user-state", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
AssistantUserStateDto state,
CancellationToken ct) =>
{
var (userId, tenantId) = ResolveUserContext(httpContext);
await store.UpsertUserStateAsync(userId, tenantId, state, ct);
return Results.Ok();
})
.WithName("UpdateAssistantUserState")
.WithSummary("Persist user's assistant preferences")
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
// ─── Tours ───────────────────────────────────────────────────────
group.MapGet("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? tourKey,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var result = await store.GetToursAsync(effectiveLocale, tenantId, tourKey, ct);
return Results.Ok(result);
})
.WithName("GetAssistantTours")
.WithSummary("Get guided tour definitions");
// ─── Admin CRUD ──────────────────────────────────────────────────
var admin = app.MapGroup("/api/v1/stella-assistant/admin")
.WithTags("Stella Assistant Admin")
.RequireAuthorization(PlatformPolicies.SetupAdmin);
admin.MapPost("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertAssistantTipRequest request,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var actor = ResolveUserId(httpContext);
var id = await store.UpsertTipAsync(tenantId, request, actor, ct);
return Results.Ok(new { tipId = id });
})
.WithName("UpsertAssistantTip")
.WithSummary("Create or update a tip");
admin.MapDelete("/tips/{tipId}", async Task<IResult>(
string tipId,
PostgresAssistantStore store,
CancellationToken ct) =>
{
await store.DeactivateTipAsync(tipId, ct);
return Results.Ok();
})
.WithName("DeactivateAssistantTip")
.WithSummary("Deactivate a tip");
admin.MapGet("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? route,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var result = await store.ListAllTipsAsync(tenantId, locale ?? "en-US", route, ct);
return Results.Ok(result);
})
.WithName("ListAllAssistantTips")
.WithSummary("List all tips for admin editing");
admin.MapGet("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var result = await store.ListAllToursAsync(tenantId, locale ?? "en-US", ct);
return Results.Ok(result);
})
.WithName("ListAllAssistantTours")
.WithSummary("List all tours for admin editing");
admin.MapPost("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertTourRequest request,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var id = await store.UpsertTourAsync(tenantId, request, ct);
return Results.Ok(new { tourId = id });
})
.WithName("UpsertAssistantTour")
.WithSummary("Create or update a guided tour");
admin.MapGet("/tours/{tourKey}", async Task<IResult>(
string tourKey,
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var tour = await store.GetTourByKeyAsync(tenantId, tourKey, locale ?? "en-US", ct);
return tour is not null ? Results.Ok(tour) : Results.NotFound();
})
.WithName("GetAssistantTourByKey")
.WithSummary("Get a single tour by key for editing");
admin.MapPost("/glossary", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertGlossaryTermRequest request,
CancellationToken ct) =>
{
var tenantId = ResolveTenantId(httpContext);
var id = await store.UpsertGlossaryTermAsync(tenantId, request, ct);
return Results.Ok(new { termId = id });
})
.WithName("UpsertGlossaryTerm")
.WithSummary("Create or update a glossary term");
return app;
}
private static string ResolveUserId(HttpContext ctx)
=> ctx.User.FindFirst("sub")?.Value
?? ctx.User.FindFirst("stellaops:user_id")?.Value
?? "anonymous";
private static string ResolveTenantId(HttpContext ctx)
=> ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
private static (string UserId, string TenantId) ResolveUserContext(HttpContext ctx)
{
var userId = ctx.User.FindFirst("sub")?.Value
?? ctx.User.FindFirst("stellaops:user_id")?.Value
?? "anonymous";
var tenantId = ResolveTenantId(ctx);
return (userId, tenantId);
}
}

View File

@@ -244,6 +244,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
builder.Services.AddSingleton<ITranslationStore, PostgresTranslationStore>();
builder.Services.AddSingleton<PostgresAssistantStore>();
// Auto-migrate platform schemas on startup
builder.Services.AddStartupMigrations<PlatformServiceOptions>(
@@ -337,6 +338,7 @@ app.Use(async (context, next) =>
await app.LoadTranslationsAsync();
app.MapLocalizationEndpoints();
app.MapAssistantEndpoints();
app.MapEnvironmentSettingsEndpoints();
app.MapEnvironmentSettingsAdminEndpoints();
app.MapContextEndpoints();

View File

@@ -0,0 +1,575 @@
using System.Text.Json;
using Npgsql;
using StellaOps.Platform.WebService.Contracts;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// PostgreSQL store for the Stella Assistant (tips, greetings, glossary, tours, user state).
/// </summary>
public sealed class PostgresAssistantStore
{
private readonly NpgsqlDataSource _ds;
public PostgresAssistantStore(NpgsqlDataSource dataSource)
=> _ds = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
// ─── Tips ────────────────────────────────────────────────────────────
/// <summary>
/// Resolve tips for a given route, locale, and optional context triggers.
/// Uses longest-prefix matching: /ops/policy/vex/consensus matches before /ops/policy/vex.
/// Falls back to en-US if no tips exist for the requested locale.
/// </summary>
public async Task<AssistantTipsResponse> GetTipsAsync(
string route, string locale, string[] contexts, string tenantId, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
// Greeting
var greeting = await GetGreetingAsync(conn, route, locale, tenantId, ct);
// Page tips (longest prefix match)
var tips = await GetPageTipsAsync(conn, route, locale, tenantId, ct);
// Context-triggered tips
var contextTips = contexts.Length > 0
? await GetContextTipsAsync(conn, contexts, locale, tenantId, ct)
: Array.Empty<AssistantTipDto>();
return new AssistantTipsResponse(greeting, tips, contextTips);
}
private async Task<string> GetGreetingAsync(
NpgsqlConnection conn, string route, string locale, string tenantId, CancellationToken ct)
{
// Try exact match, then progressively shorter prefixes
var prefixes = BuildPrefixes(route);
const string sql = @"
SELECT greeting_text FROM platform.assistant_greetings
WHERE route_pattern = ANY(@routes)
AND locale = @locale
AND (tenant_id = @tenantId OR tenant_id = '_system')
AND is_active
ORDER BY length(route_pattern) DESC, tenant_id DESC
LIMIT 1";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@routes", prefixes);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tenantId", tenantId);
var result = await cmd.ExecuteScalarAsync(ct) as string;
// Fallback to en-US if not found
if (result is null && locale != "en-US")
{
cmd.Parameters["@locale"].Value = "en-US";
result = await cmd.ExecuteScalarAsync(ct) as string;
}
return result ?? "Hi! I'm Stella, your DevOps guide.";
}
private async Task<AssistantTipDto[]> GetPageTipsAsync(
NpgsqlConnection conn, string route, string locale, string tenantId, CancellationToken ct)
{
var prefixes = BuildPrefixes(route);
const string sql = @"
WITH matched AS (
SELECT t.*, length(t.route_pattern) AS specificity,
ROW_NUMBER() OVER (
PARTITION BY t.sort_order
ORDER BY length(t.route_pattern) DESC, t.tenant_id DESC
) AS rn
FROM platform.assistant_tips t
WHERE t.route_pattern = ANY(@routes)
AND t.locale = @locale
AND (t.tenant_id = @tenantId OR t.tenant_id = '_system')
AND t.is_active
AND t.context_trigger IS NULL
)
SELECT tip_id, title, body, action_label, action_route, context_trigger
FROM matched WHERE rn = 1
ORDER BY sort_order";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@routes", prefixes);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tenantId", tenantId);
var tips = new List<AssistantTipDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
tips.Add(MapTip(reader));
// Fallback to en-US
if (tips.Count == 0 && locale != "en-US")
{
await reader.DisposeAsync();
cmd.Parameters["@locale"].Value = "en-US";
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
while (await reader2.ReadAsync(ct))
tips.Add(MapTip(reader2));
}
return tips.ToArray();
}
private async Task<AssistantTipDto[]> GetContextTipsAsync(
NpgsqlConnection conn, string[] contexts, string locale, string tenantId, CancellationToken ct)
{
const string sql = @"
SELECT tip_id, title, body, action_label, action_route, context_trigger
FROM platform.assistant_tips
WHERE context_trigger = ANY(@contexts)
AND locale = @locale
AND (tenant_id = @tenantId OR tenant_id = '_system')
AND is_active
ORDER BY sort_order";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@contexts", contexts);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tenantId", tenantId);
var tips = new List<AssistantTipDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
tips.Add(MapTip(reader));
if (tips.Count == 0 && locale != "en-US")
{
await reader.DisposeAsync();
cmd.Parameters["@locale"].Value = "en-US";
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
while (await reader2.ReadAsync(ct))
tips.Add(MapTip(reader2));
}
return tips.ToArray();
}
// ─── Glossary ────────────────────────────────────────────────────────
public async Task<GlossaryResponse> GetGlossaryAsync(
string locale, string? tenantId, string[]? terms, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
var tid = tenantId ?? "_system";
var sql = @"
SELECT term_id, term, definition, extended_help, related_terms, related_routes
FROM platform.assistant_glossary
WHERE locale = @locale
AND (tenant_id = @tid OR tenant_id = '_system')
AND is_active";
if (terms is { Length: > 0 })
sql += " AND term = ANY(@terms)";
sql += " ORDER BY term";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tid", tid);
if (terms is { Length: > 0 })
cmd.Parameters.AddWithValue("@terms", terms);
var result = new List<GlossaryTermDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
result.Add(new GlossaryTermDto(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.IsDBNull(3) ? null : reader.GetString(3),
reader.GetFieldValue<string[]>(4),
reader.GetFieldValue<string[]>(5)));
}
// Fallback to en-US
if (result.Count == 0 && locale != "en-US")
{
await reader.DisposeAsync();
cmd.Parameters["@locale"].Value = "en-US";
await using var reader2 = await cmd.ExecuteReaderAsync(ct);
while (await reader2.ReadAsync(ct))
{
result.Add(new GlossaryTermDto(
reader2.GetString(0),
reader2.GetString(1),
reader2.GetString(2),
reader2.IsDBNull(3) ? null : reader2.GetString(3),
reader2.GetFieldValue<string[]>(4),
reader2.GetFieldValue<string[]>(5)));
}
}
return new GlossaryResponse(result.ToArray());
}
// ─── User State ──────────────────────────────────────────────────────
public async Task<AssistantUserStateDto?> GetUserStateAsync(
string userId, string tenantId, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
SELECT seen_routes, completed_tours, tip_positions, dismissed
FROM platform.assistant_user_state
WHERE user_id = @userId AND tenant_id = @tenantId";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@tenantId", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
var tipPositions = reader.IsDBNull(2)
? new Dictionary<string, int>()
: JsonSerializer.Deserialize<Dictionary<string, int>>(reader.GetString(2))
?? new Dictionary<string, int>();
return new AssistantUserStateDto(
reader.GetFieldValue<string[]>(0),
reader.GetFieldValue<string[]>(1),
tipPositions,
reader.GetBoolean(3));
}
public async Task UpsertUserStateAsync(
string userId, string tenantId, AssistantUserStateDto state, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
INSERT INTO platform.assistant_user_state (user_id, tenant_id, seen_routes, completed_tours, tip_positions, dismissed, last_seen_at)
VALUES (@userId, @tenantId, @seenRoutes, @completedTours, @tipPositions::jsonb, @dismissed, now())
ON CONFLICT (user_id, tenant_id) DO UPDATE SET
seen_routes = EXCLUDED.seen_routes,
completed_tours = EXCLUDED.completed_tours,
tip_positions = EXCLUDED.tip_positions,
dismissed = EXCLUDED.dismissed,
last_seen_at = now()";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@userId", userId);
cmd.Parameters.AddWithValue("@tenantId", tenantId);
cmd.Parameters.AddWithValue("@seenRoutes", state.SeenRoutes);
cmd.Parameters.AddWithValue("@completedTours", state.CompletedTours);
cmd.Parameters.AddWithValue("@tipPositions", JsonSerializer.Serialize(state.TipPositions));
cmd.Parameters.AddWithValue("@dismissed", state.Dismissed);
await cmd.ExecuteNonQueryAsync(ct);
}
// ─── Helpers ─────────────────────────────────────────────────────────
// ─── Tours ───────────────────────────────────────────────────────────
public async Task<ToursResponse> GetToursAsync(
string locale, string tenantId, string? tourKey, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
var sql = @"
SELECT tour_id, tour_key, title, description, steps
FROM platform.assistant_tours
WHERE locale = @locale
AND (tenant_id = @tid OR tenant_id = '_system')
AND is_active";
if (tourKey is not null)
sql += " AND tour_key = @tourKey";
sql += " ORDER BY tour_key";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tid", tenantId);
if (tourKey is not null)
cmd.Parameters.AddWithValue("@tourKey", tourKey);
var tours = new List<TourDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var stepsJson = reader.GetString(4);
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
tours.Add(new TourDto(
reader.GetGuid(0).ToString(),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
steps));
}
// Fallback to en-US
if (tours.Count == 0 && locale != "en-US")
{
await reader.DisposeAsync();
cmd.Parameters["@locale"].Value = "en-US";
await using var r2 = await cmd.ExecuteReaderAsync(ct);
while (await r2.ReadAsync(ct))
{
var stepsJson = r2.GetString(4);
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
tours.Add(new TourDto(r2.GetGuid(0).ToString(), r2.GetString(1), r2.GetString(2), r2.GetString(3), steps));
}
}
return new ToursResponse(tours.ToArray());
}
// ─── Admin CRUD ──────────────────────────────────────────────────────
public async Task<string> UpsertTipAsync(
string tenantId, UpsertAssistantTipRequest req, string actor, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
INSERT INTO platform.assistant_tips
(route_pattern, context_trigger, locale, sort_order, title, body,
action_label, action_route, learn_more_url, is_active, product_version,
tenant_id, created_by, updated_at)
VALUES (@route, @ctx, @locale, @sort, @title, @body,
@actionLabel, @actionRoute, @learnMore, @active, @version,
@tid, @actor, now())
ON CONFLICT ON CONSTRAINT ux_assistant_tips_route_ctx_locale_order
DO UPDATE SET
title = EXCLUDED.title,
body = EXCLUDED.body,
action_label = EXCLUDED.action_label,
action_route = EXCLUDED.action_route,
learn_more_url = EXCLUDED.learn_more_url,
is_active = EXCLUDED.is_active,
product_version = EXCLUDED.product_version,
updated_at = now()
RETURNING tip_id::text";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@route", req.RoutePattern);
cmd.Parameters.AddWithValue("@ctx", (object?)req.ContextTrigger ?? DBNull.Value);
cmd.Parameters.AddWithValue("@locale", req.Locale);
cmd.Parameters.AddWithValue("@sort", req.SortOrder);
cmd.Parameters.AddWithValue("@title", req.Title);
cmd.Parameters.AddWithValue("@body", req.Body);
cmd.Parameters.AddWithValue("@actionLabel", (object?)req.ActionLabel ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actionRoute", (object?)req.ActionRoute ?? DBNull.Value);
cmd.Parameters.AddWithValue("@learnMore", (object?)req.LearnMoreUrl ?? DBNull.Value);
cmd.Parameters.AddWithValue("@active", req.IsActive);
cmd.Parameters.AddWithValue("@version", (object?)req.ProductVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@tid", tenantId);
cmd.Parameters.AddWithValue("@actor", actor);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString() ?? "";
}
public async Task DeactivateTipAsync(string tipId, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = "UPDATE platform.assistant_tips SET is_active = FALSE, updated_at = now() WHERE tip_id = @id::uuid";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@id", tipId);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<AssistantTipAdminDto[]> ListAllTipsAsync(
string tenantId, string locale, string? route, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
var sql = @"
SELECT tip_id, route_pattern, context_trigger, locale, sort_order,
title, body, action_label, action_route, is_active,
created_by, updated_at::text
FROM platform.assistant_tips
WHERE (tenant_id = @tid OR tenant_id = '_system')
AND locale = @locale";
if (route is not null)
sql += " AND route_pattern = @route";
sql += " ORDER BY route_pattern, sort_order";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@tid", tenantId);
cmd.Parameters.AddWithValue("@locale", locale);
if (route is not null)
cmd.Parameters.AddWithValue("@route", route);
var tips = new List<AssistantTipAdminDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
tips.Add(new AssistantTipAdminDto(
reader.GetGuid(0).ToString(),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
reader.GetString(3),
reader.GetInt32(4),
reader.GetString(5),
reader.GetString(6),
reader.IsDBNull(7) ? null : reader.GetString(7),
reader.IsDBNull(8) ? null : reader.GetString(8),
reader.GetBoolean(9),
reader.GetString(10),
reader.GetString(11)));
}
return tips.ToArray();
}
public async Task<string> UpsertGlossaryTermAsync(
string tenantId, UpsertGlossaryTermRequest req, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
INSERT INTO platform.assistant_glossary
(term, locale, definition, extended_help, related_terms, related_routes, is_active, tenant_id)
VALUES (@term, @locale, @def, @ext, @relTerms, @relRoutes, @active, @tid)
ON CONFLICT ON CONSTRAINT ux_assistant_glossary_term_locale
DO UPDATE SET
definition = EXCLUDED.definition,
extended_help = EXCLUDED.extended_help,
related_terms = EXCLUDED.related_terms,
related_routes = EXCLUDED.related_routes,
is_active = EXCLUDED.is_active
RETURNING term_id::text";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@term", req.Term);
cmd.Parameters.AddWithValue("@locale", req.Locale);
cmd.Parameters.AddWithValue("@def", req.Definition);
cmd.Parameters.AddWithValue("@ext", (object?)req.ExtendedHelp ?? DBNull.Value);
cmd.Parameters.AddWithValue("@relTerms", req.RelatedTerms);
cmd.Parameters.AddWithValue("@relRoutes", req.RelatedRoutes);
cmd.Parameters.AddWithValue("@active", req.IsActive);
cmd.Parameters.AddWithValue("@tid", tenantId);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString() ?? "";
}
public async Task<TourAdminDto[]> ListAllToursAsync(
string tenantId, string locale, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
SELECT tour_id, tour_key, title, description, locale,
jsonb_array_length(steps), is_active, created_at::text
FROM platform.assistant_tours
WHERE (tenant_id = @tid OR tenant_id = '_system')
AND locale = @locale
ORDER BY tour_key";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@tid", tenantId);
cmd.Parameters.AddWithValue("@locale", locale);
var tours = new List<TourAdminDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
tours.Add(new TourAdminDto(
reader.GetGuid(0).ToString(),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
reader.GetString(4),
reader.GetInt32(5),
reader.GetBoolean(6),
reader.GetString(7)));
}
return tours.ToArray();
}
public async Task<string> UpsertTourAsync(
string tenantId, UpsertTourRequest req, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
INSERT INTO platform.assistant_tours
(tour_key, locale, title, description, steps, is_active, tenant_id)
VALUES (@key, @locale, @title, @desc, @steps::jsonb, @active, @tid)
ON CONFLICT ON CONSTRAINT ux_assistant_tours_key_locale
DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
steps = EXCLUDED.steps,
is_active = EXCLUDED.is_active
RETURNING tour_id::text";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@key", req.TourKey);
cmd.Parameters.AddWithValue("@locale", req.Locale);
cmd.Parameters.AddWithValue("@title", req.Title);
cmd.Parameters.AddWithValue("@desc", req.Description);
cmd.Parameters.AddWithValue("@steps", JsonSerializer.Serialize(req.Steps));
cmd.Parameters.AddWithValue("@active", req.IsActive);
cmd.Parameters.AddWithValue("@tid", tenantId);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString() ?? "";
}
public async Task<TourDto?> GetTourByKeyAsync(
string tenantId, string tourKey, string locale, CancellationToken ct = default)
{
await using var conn = await _ds.OpenConnectionAsync(ct);
const string sql = @"
SELECT tour_id, tour_key, title, description, steps
FROM platform.assistant_tours
WHERE tour_key = @key AND locale = @locale
AND (tenant_id = @tid OR tenant_id = '_system')
LIMIT 1";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@key", tourKey);
cmd.Parameters.AddWithValue("@locale", locale);
cmd.Parameters.AddWithValue("@tid", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
var stepsJson = reader.GetString(4);
var steps = JsonSerializer.Deserialize<object[]>(stepsJson) ?? Array.Empty<object>();
return new TourDto(
reader.GetGuid(0).ToString(),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
steps);
}
/// <summary>Build all route prefixes for longest-prefix matching.</summary>
private static string[] BuildPrefixes(string route)
{
// /ops/policy/vex/consensus → ["/ops/policy/vex/consensus", "/ops/policy/vex", "/ops/policy", "/ops", "/"]
var prefixes = new List<string> { route };
var path = route;
while (path.Length > 1)
{
var lastSlash = path.LastIndexOf('/');
if (lastSlash <= 0) break;
path = path[..lastSlash];
prefixes.Add(path);
}
prefixes.Add("/");
return prefixes.ToArray();
}
private static AssistantTipDto MapTip(NpgsqlDataReader reader)
{
var actionLabel = reader.IsDBNull(3) ? null : reader.GetString(3);
var actionRoute = reader.IsDBNull(4) ? null : reader.GetString(4);
var action = actionLabel is not null && actionRoute is not null
? new AssistantTipActionDto(actionLabel, actionRoute)
: null;
return new AssistantTipDto(
reader.GetGuid(0).ToString(),
reader.GetString(1),
reader.GetString(2),
action,
reader.IsDBNull(5) ? null : reader.GetString(5));
}
}

View File

@@ -0,0 +1,105 @@
-- SPRINT_20260329_007 / Unified Stella Assistant
-- DB-backed contextual tips, glossary, tours, and user state for the Stella mascot.
-- Replaces hardcoded English-only tips with locale-aware, admin-editable content.
CREATE SCHEMA IF NOT EXISTS platform;
-- ─── Tips: page/tab-level contextual help ───────────────────────────────────
CREATE TABLE IF NOT EXISTS platform.assistant_tips (
tip_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_pattern TEXT NOT NULL,
context_trigger TEXT,
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
sort_order INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
body TEXT NOT NULL,
action_label TEXT,
action_route TEXT,
learn_more_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
product_version VARCHAR(32),
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(256) NOT NULL DEFAULT 'system',
CONSTRAINT ux_assistant_tips_route_ctx_locale_order
UNIQUE (tenant_id, route_pattern, context_trigger, locale, sort_order)
);
CREATE INDEX IF NOT EXISTS ix_assistant_tips_route_locale
ON platform.assistant_tips (route_pattern, locale)
WHERE is_active;
CREATE INDEX IF NOT EXISTS ix_assistant_tips_context
ON platform.assistant_tips (context_trigger)
WHERE context_trigger IS NOT NULL AND is_active;
-- ─── Greetings: per-page greeting text ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS platform.assistant_greetings (
greeting_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_pattern TEXT NOT NULL,
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
greeting_text TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ux_assistant_greetings_route_locale
UNIQUE (tenant_id, route_pattern, locale)
);
-- ─── Glossary: domain term definitions ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS platform.assistant_glossary (
term_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
term VARCHAR(128) NOT NULL,
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
definition TEXT NOT NULL,
extended_help TEXT,
related_terms TEXT[] NOT NULL DEFAULT '{}',
related_routes TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ux_assistant_glossary_term_locale
UNIQUE (tenant_id, term, locale)
);
CREATE INDEX IF NOT EXISTS ix_assistant_glossary_locale
ON platform.assistant_glossary (locale)
WHERE is_active;
-- ─── Tours: guided walkthroughs ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS platform.assistant_tours (
tour_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tour_key VARCHAR(128) NOT NULL,
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
title TEXT NOT NULL,
description TEXT NOT NULL,
steps JSONB NOT NULL DEFAULT '[]',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ux_assistant_tours_key_locale
UNIQUE (tenant_id, tour_key, locale)
);
-- ─── User state: per-user mascot preferences ───────────────────────────────
CREATE TABLE IF NOT EXISTS platform.assistant_user_state (
user_id VARCHAR(256) NOT NULL,
tenant_id VARCHAR(128) NOT NULL,
seen_routes TEXT[] NOT NULL DEFAULT '{}',
completed_tours TEXT[] NOT NULL DEFAULT '{}',
tip_positions JSONB NOT NULL DEFAULT '{}',
dismissed BOOLEAN NOT NULL DEFAULT FALSE,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, tenant_id)
);