Skip to content

Chat foundation: streaming balance intent + router skeleton #146

Description

@rghvgrv

Parent

#145 — Ask Splitzy: personal spending chatbot (self-hosted LLM)

What to build

The chat foundation: a self-hosted LLM client, config plumbing, an AI router that classifies a question as either my_balance or out_of_scope, a balance-lookup query reusing the existing dashboard balance logic, and a streaming (SSE) chat endpoint with its own rate limit. This is the walking skeleton every later slice builds on — demoable via Swagger/curl: ask about your balance and get a real streamed answer; ask something unrelated and get a canned refusal, with no DB or synthesis call made.

Implementation Steps

  1. LLM config — Add LlmSettings class (BaseUrl, RouterModel, SynthesisModel, SqlModel, RequestTimeoutSeconds, MaxHistoryTurns, SqlRowLimit, SqlStatementTimeoutMs) to backend/splitzy-dotnet/Extensions/SplitzyConfig.cs; add LlmSettings Llm { get; } to ISplitzyConfig and SplitzyConfig. Bind via services.Configure<LlmSettings>(_config.GetSection("Llm")) in backend/splitzy-dotnet/Application/Startup.cs::ConfigureServices, following the existing pattern used for EmailSettings/MessagingSettings. [HITL]: real BaseUrl and model names for the self-hosted Ollama/vLLM host must be supplied per environment — placeholder values are fine for this slice.
  2. LLM client — Create backend/splitzy-dotnet/Services/Interfaces/ILlmClient.cs and backend/splitzy-dotnet/Services/LlmClient.cs with Task<string> CompleteAsync(string prompt, string model, CancellationToken ct) (used by the router) and IAsyncEnumerable<string> StreamAsync(IReadOnlyList<ChatTurn> messages, string model, CancellationToken ct) (used by synthesis). Register via services.AddHttpClient<ILlmClient, LlmClient>() in Startup.cs.
  3. Router — Create backend/splitzy-dotnet/Services/Chat/RouterResult.cs (intent enum with MyBalance, OutOfScope for now — extended in later slices — plus a params object) and a router prompt template under backend/splitzy-dotnet/Services/Chat/Prompts/. Parse the model's JSON output strictly; any parse failure or low-confidence output must map to OutOfScope, never to a fabricated answer.
  4. Balance query — Create backend/splitzy-dotnet/Services/Chat/IChatQueryService.cs and ChatQueryService.cs with Task<...> MyBalance(int userId), reusing the existing balance/settlement aggregation logic already in backend/splitzy-dotnet/Controllers/DashboardController.cs::GetDashboard (GroupBalances + ExpenseSimplifier). Register as scoped in Startup.cs.
  5. Orchestrator + endpoint — Create backend/splitzy-dotnet/Services/Chat/IChatOrchestrator.cs / ChatOrchestrator.cs with IAsyncEnumerable<string> AskAsync(int userId, string newMessage, CancellationToken ct): call the router, dispatch MyBalance to ChatQueryService, dispatch OutOfScope to a fixed refusal string, then stream the synthesis. Create backend/splitzy-dotnet/DTO/ChatDTO.cs with ChatRequest { string NewMessage } and ChatTurn { string Role; string Content }. Create backend/splitzy-dotnet/Controllers/ChatController.cs ([Authorize], [Route("api/[controller]")]) with POST /api/chat/stream writing SSE frames (Content-Type: text/event-stream) from the orchestrator's IAsyncEnumerable<string>; resolve userId via HttpContext.GetCurrentUserId() (Extensions/HttpContextExtensions.cs), never from the request body.
  6. Rate limiting — Add a chat fixed-window limiter policy (PermitLimit: 10, Window: 1 minute) to the rate limiter block in Startup.cs::ConfigureServices (alongside the existing global/login/per-user policies) and apply [EnableRateLimiting("chat")] on ChatController.
  7. Tests — Add backend/spllitzy-dotnet-tests/ChatControllerTests.cs (NUnit + Moq, following DashboardControllerTests.cs conventions and TestHelper.CreateTestContext()): router classification test (balance question → MyBalance, unrelated question → OutOfScope), ChatQueryService.MyBalance correctness against seeded fixture data, and a cross-user isolation test proving user A's request cannot surface user B's balance.

Agent Routing

agent_routing:
  complexity_hint: medium-hard
  required_capability: advanced
  parallel_safe: false
  cost_preference: balanced
  speed_preference: balanced
  ownership_scope:
    - backend/splitzy-dotnet/Extensions/SplitzyConfig.cs
    - backend/splitzy-dotnet/Services/
    - backend/splitzy-dotnet/Controllers/ChatController.cs
    - backend/splitzy-dotnet/DTO/ChatDTO.cs
    - backend/splitzy-dotnet/Application/Startup.cs
    - backend/spllitzy-dotnet-tests/ChatControllerTests.cs
  verification:
    - dotnet test backend/spllitzy-dotnet-tests
    - Manual Swagger call to POST /api/chat/stream for a balance question and an out-of-scope question

Technical Context Snapshot

Current stack in scope

  • Backend: ASP.NET Core 8.0 Web API, EF Core 8 + Npgsql (PostgreSQL), JWT bearer auth, Serilog request logging, System.Threading.RateLimiting, Swashbuckle/Swagger.
  • No LLM client exists today; this slice introduces the first one.
  • Auth: userId is resolved server-side via HttpContext.GetCurrentUserId() — this is the established pattern for every existing controller and must be followed here too.

Dependencies in scope

  • Reuse: Microsoft.Extensions.Http (AddHttpClient) for the LLM client — already an implicit ASP.NET Core dependency, no new package needed for HTTP calls to the self-hosted model endpoint.
  • New dependency additions allowed for this slice: no. IAsyncEnumerable streaming and HttpClient cover the SSE + LLM-call needs without new NuGet packages.

Architecture alignment

  • Follow the existing Services/Interfaces/I*.cs + Services/*.cs + services.AddScoped<...> registration pattern used throughout Startup.cs (e.g. IEmailService/EMailService, IJWTService/JWTService).
  • Follow the existing controller pattern: [Authorize], [EnableRateLimiting(...)], [ApiController], [Route("api/[controller]")] as seen in DashboardController.cs.
  • create-git-issue provides routing hints only; it must not assign concrete agent/model names.
  • run-with-it remains the final runtime routing authority.

Integration touchpoints

  • New endpoint: POST /api/chat/stream (SSE). No existing API contract is changed.
  • No schema/migration changes in this slice (balance data already exists via GroupBalances).
  • New config section Llm added to appsettings*.json; must not break existing config binding.

Acceptance criteria

  • POST /api/chat/stream with a balance question streams back a correct, natural-language answer for the authenticated user, reusing the same balance figures the dashboard shows.
  • POST /api/chat/stream with an unrelated/off-topic question returns the fixed refusal without any database or LLM synthesis call.
  • Cross-user isolation test passes: user A can never receive user B's balance data through this endpoint.

Blocked by

None - can start immediately.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestready-for-agentImplementation-ready slice for an AFK/agent to pick up

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions