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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Blocked by
None - can start immediately.
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_balanceorout_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
LlmSettingsclass (BaseUrl,RouterModel,SynthesisModel,SqlModel,RequestTimeoutSeconds,MaxHistoryTurns,SqlRowLimit,SqlStatementTimeoutMs) tobackend/splitzy-dotnet/Extensions/SplitzyConfig.cs; addLlmSettings Llm { get; }toISplitzyConfigandSplitzyConfig. Bind viaservices.Configure<LlmSettings>(_config.GetSection("Llm"))inbackend/splitzy-dotnet/Application/Startup.cs::ConfigureServices, following the existing pattern used forEmailSettings/MessagingSettings.[HITL]: realBaseUrland model names for the self-hosted Ollama/vLLM host must be supplied per environment — placeholder values are fine for this slice.backend/splitzy-dotnet/Services/Interfaces/ILlmClient.csandbackend/splitzy-dotnet/Services/LlmClient.cswithTask<string> CompleteAsync(string prompt, string model, CancellationToken ct)(used by the router) andIAsyncEnumerable<string> StreamAsync(IReadOnlyList<ChatTurn> messages, string model, CancellationToken ct)(used by synthesis). Register viaservices.AddHttpClient<ILlmClient, LlmClient>()inStartup.cs.backend/splitzy-dotnet/Services/Chat/RouterResult.cs(intent enum withMyBalance,OutOfScopefor now — extended in later slices — plus a params object) and a router prompt template underbackend/splitzy-dotnet/Services/Chat/Prompts/. Parse the model's JSON output strictly; any parse failure or low-confidence output must map toOutOfScope, never to a fabricated answer.backend/splitzy-dotnet/Services/Chat/IChatQueryService.csandChatQueryService.cswithTask<...> MyBalance(int userId), reusing the existing balance/settlement aggregation logic already inbackend/splitzy-dotnet/Controllers/DashboardController.cs::GetDashboard(GroupBalances+ExpenseSimplifier). Register as scoped inStartup.cs.backend/splitzy-dotnet/Services/Chat/IChatOrchestrator.cs/ChatOrchestrator.cswithIAsyncEnumerable<string> AskAsync(int userId, string newMessage, CancellationToken ct): call the router, dispatchMyBalancetoChatQueryService, dispatchOutOfScopeto a fixed refusal string, then stream the synthesis. Createbackend/splitzy-dotnet/DTO/ChatDTO.cswithChatRequest { string NewMessage }andChatTurn { string Role; string Content }. Createbackend/splitzy-dotnet/Controllers/ChatController.cs([Authorize],[Route("api/[controller]")]) withPOST /api/chat/streamwriting SSE frames (Content-Type: text/event-stream) from the orchestrator'sIAsyncEnumerable<string>; resolveuserIdviaHttpContext.GetCurrentUserId()(Extensions/HttpContextExtensions.cs), never from the request body.chatfixed-window limiter policy (PermitLimit: 10,Window: 1 minute) to the rate limiter block inStartup.cs::ConfigureServices(alongside the existingglobal/login/per-userpolicies) and apply[EnableRateLimiting("chat")]onChatController.backend/spllitzy-dotnet-tests/ChatControllerTests.cs(NUnit + Moq, followingDashboardControllerTests.csconventions andTestHelper.CreateTestContext()): router classification test (balance question →MyBalance, unrelated question →OutOfScope),ChatQueryService.MyBalancecorrectness against seeded fixture data, and a cross-user isolation test proving user A's request cannot surface user B's balance.Agent Routing
Technical Context Snapshot
Current stack in scope
userIdis resolved server-side viaHttpContext.GetCurrentUserId()— this is the established pattern for every existing controller and must be followed here too.Dependencies in scope
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.IAsyncEnumerablestreaming andHttpClientcover the SSE + LLM-call needs without new NuGet packages.Architecture alignment
Services/Interfaces/I*.cs+Services/*.cs+services.AddScoped<...>registration pattern used throughoutStartup.cs(e.g.IEmailService/EMailService,IJWTService/JWTService).[Authorize],[EnableRateLimiting(...)],[ApiController],[Route("api/[controller]")]as seen inDashboardController.cs.create-git-issueprovides routing hints only; it must not assign concrete agent/model names.run-with-itremains the final runtime routing authority.Integration touchpoints
POST /api/chat/stream(SSE). No existing API contract is changed.GroupBalances).Llmadded toappsettings*.json; must not break existing config binding.Acceptance criteria
POST /api/chat/streamwith 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/streamwith an unrelated/off-topic question returns the fixed refusal without any database or LLM synthesis call.Blocked by
None - can start immediately.