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,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);
}
}