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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user