Skip to content

feat(api): conversation HTTP endpoints + server-side scenario seeding (spec 004)#206

Open
davidortinau wants to merge 6 commits into
mainfrom
davidortinau/conversation-http-endpoints
Open

feat(api): conversation HTTP endpoints + server-side scenario seeding (spec 004)#206
davidortinau wants to merge 6 commits into
mainfrom
davidortinau/conversation-http-endpoints

Conversation

@davidortinau
Copy link
Copy Markdown
Owner

Implements spec 004 to expose MAUI's `IConversationAgentService` behavior as HTTP endpoints so a Flutter client can drive the Conversation activity. Also moves predefined scenario seeding to the API server so the Flutter client (which has no local DB) sees the canned set.

Endpoints (all under `/api/v1/conversation`, all `RequireAuthorization`)

  • `GET /scenarios` — list predefined + user scenarios (PascalCase JSON for spec parity)
  • `POST /start` — start a turn (scenario optional; persona/situation/type may override)
  • `POST /continue` — continue with full client-held history; returns grader output

Endpoints return 503 when the API has no `IChatClient` configured (matches the `/api/v1/ai/chat` pattern).

Server-side scenario seeding

  • New `ConversationScenarioSeedData` (Shared/Data) — single source of truth for the 5 predefined scenarios. Consumed by both API and MAUI; cannot drift.
  • New `ConversationScenarioSeeder` (API/Conversation) — runs at startup alongside `NumberContentSeeder`. Idempotent upsert keyed on `(Name, IsPredefined=true)`. Wraps writes in a Postgres advisory lock + `IExecutionStrategy.ExecuteAsync` block so it is safe under ACA replica scale-out and Npgsql's retrying execution strategy.
  • MAUI `ScenarioService.SeedPredefinedScenariosAsync` now reads from the shared seed data — call site and offline-first-launch UX preserved.
  • `ConversationScenario` is intentionally not added to CoreSync (no `UserProfileId` column yet, and locally-seeded predefined rows would collide with server-assigned ids).

DI cleanup (side fix surfaced by ValidateOnBuild)

Moved `AiService`, `TranscriptFormattingService`, `VideoImportPipelineService` into the `if (openAiApiKey)` conditional block, and `IVoiceDiscoveryService` into the `if (elevenLabsKey)` block. These services require `IChatClient` / `ElevenLabsClient` (themselves conditional), so unconditional registration crashed host startup whenever the keys were absent. Endpoints that depend on these services already accept them as nullable `[FromServices]` and return 503 when null.

Tests

  • `ConversationEndpointsTests` — 9 endpoint tests + 8-case `NormalizeComprehensionScore` theory
  • `ConversationScenarioSeederTests` — 5 tests covering empty / idempotent / stale-update / partial-state / user-row-untouched

Verification

  • ✅ `dotnet build src/SentenceStudio.Api` → 0 errors
  • ✅ `dotnet test tests/SentenceStudio.Api.Tests` → 187/188 (1 pre-existing unrelated failure)
  • ✅ `aspire run --detach --isolated` → AppHost up, all resources Ready
  • ✅ `azd deploy` → SUCCESS
  • ✅ Production logs confirm seeder ran on revision 0000102: `ConversationScenario seed: inserted 5, updated 0 predefined rows` (after `SELECT pg_advisory_xact_lock(42424242);`)
  • ✅ Live endpoint `GET https://api.livelyforest-b32e7d63.centralus.azurecontainerapps.io/api/v1/conversation/scenarios\` returns 401 unauthenticated (correct — auth-gated)

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

davidortinau and others added 6 commits May 19, 2026 21:35
Implements specs/004-conversation-http-endpoints/spec.md — exposes the
Conversation activity to the Flutter client over HTTP.

Endpoints (all under /api/v1/conversation, RequireAuthorization):
  - GET  /scenarios  → PascalCase scenarios list (matches MAUI EF shape)
  - POST /start      → opening assistant message for a scenario
  - POST /continue   → next assistant turn + grading (comprehension score
                       + grammar corrections + vocabulary analysis)

Server-side service is stateless: the client passes full history every
turn. Why a new server-side IServerConversationService rather than
reusing the MAUI IConversationAgentService:
  - AppLib is UseMaui=true / net11; the API is net10.0 and cannot
    reference it.
  - The MAUI impl is stateful (holds _conversationAgent on the instance);
    wrong model for HTTP.
  - The MAUI impl loads prompts via MAUI's OpenAppPackageFileAsync; the
    API's IFileSystemService stub throws on that method.

ServerConversationService inlines the 4 scriban prompt templates as
C# string interpolation and runs partner + grader IChatClient calls in
parallel on /continue. Defensive score normalization handles both 0–1
and 0–100 grader scales (Math.Round, clamp to [0,100]).

DI hardening in Program.cs (fixes pre-existing test breakage):
The unconditional Singleton registrations of AiService,
TranscriptFormattingService, VideoImportPipelineService, and
IVoiceDiscoveryService were causing ValidateOnBuild to fail any test
host that didn't configure AI:OpenAI:ApiKey or ElevenLabsKey, because
those services transitively require IChatClient / ElevenLabsClient
(which are themselves registered conditionally). Moved each into the
conditional block alongside its underlying dependency. The new
IServerConversationService follows the same pattern; endpoints return
503 when it isn't registered (parity with /api/v1/ai/chat).

Wire-contract enforcement:
  - /scenarios DTO uses explicit [JsonPropertyName] PascalCase keys
    because minimal API defaults to camelCase.
  - /start and /continue use default camelCase.
  - Tests assert both directions (keys present and not-leaking).

Tests: 17 new (9 endpoint integration + 8 score-normalization theory
cases). All pass. Full API.Tests suite: 182/183 — the one remaining
failure (API_PlansEndpointWorksWithValidToken returning 401) is a
pre-existing latent test issue that was previously masked by the DI
validation failure and is unrelated to this work.

Out of scope per spec: sessionized memory, per-bubble TTS, plan timer,
vocabulary scoring write-back. vocabularyAnalysis is emitted as an
empty array for v1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…a with MAUI

The 5 predefined Conversation scenarios were previously seeded only by the
MAUI client into each device's local SQLite. The Flutter client has no
local DB and reaches the API directly, so GET /api/v1/conversation/scenarios
returned an empty list against Postgres.

Move the seed data to a single shared source (Shared/Data/ConversationScenarioSeedData.cs)
consumed by both:

- Api: new ConversationScenarioSeeder runs from Program.cs startup alongside
  NumberContentSeeder. Idempotent upsert keyed on (Name, IsPredefined=true).
  Updates existing predefined rows when seed copy changes; leaves
  user-created rows alone.
- AppLib: ScenarioService.SeedPredefinedScenariosAsync now consumes the
  shared data. Same call site (SentenceStudioAppBuilder), same idempotent
  HasPredefinedScenariosAsync gate — preserves offline-first-launch UX.

Both heads seed independently (mirrors the NumberContentSeeder precedent);
ConversationScenario is intentionally NOT added to CoreSync — model has no
UserProfileId yet and existing locally-seeded rows would collide on id.

Tests: 5 new ConversationScenarioSeederTests cover empty / idempotent /
stale-update / partial-state / user-row-untouched scenarios.

Verified:
- dotnet build src/SentenceStudio.Api → 0 errors
- dotnet test tests/SentenceStudio.Api.Tests → 187/188 (1 pre-existing
  unrelated failure: API_PlansEndpointWorksWithValidToken)
- aspire run --detach --isolated → AppHost up, API resource Ready, no
  startup errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Npgsql's retrying execution strategy disallows user-initiated transactions
unless they run inside IExecutionStrategy.ExecuteAsync. Production deploy
crashed the API container on startup with:

  InvalidOperationException: The configured execution strategy
  'NpgsqlRetryingExecutionStrategy' does not support user-initiated
  transactions.

Wrap the BeginTransaction + pg_advisory_xact_lock + SeedCoreAsync sequence
in a strategy delegate so it's retriable as a unit. SeedCoreAsync now also
clears the change tracker at entry so a retry runs against a clean state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tory

Production /api/v1/plans/today (and other plans endpoints) returned 500 with:

  UnauthorizedAccessException: Access to the path '/app/sentencestudio' is denied
   ---> IOException: Permission denied
    at ApiFileSystemService..ctor(String appDataDirectory)
    at TenantContextMiddleware.InvokeAsync(...)

ACA's /app filesystem is read-only for the runtime user, so
Environment.SpecialFolder.LocalApplicationData (→ /app/sentencestudio)
threw on Directory.CreateDirectory. The singleton constructs lazily on
first transitive resolution; plans pulls it in via PlanService →
LearningResourceRepository, so the first plans request blew the ctor.
Conversation doesn't depend on it transitively, which is why that
endpoint worked.

The API doesn't actually persist anything in AppDataDirectory — the
dependency is structural (the abstraction exists for MAUI). Default
to Path.GetTempPath() (/tmp in ACA, always writable), with an
AppDataDirectory config override for ops flexibility.

Also harden ApiFileSystemService.ctor to swallow UnauthorizedAccessException
and IOException on the CreateDirectory call so DI resolution can never
crash the request pipeline if a future deployment lands on a read-only
mount.

Credit: diagnosis from SentenceStudioFlutter session (Azure Log Analytics
trace + repro).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Implements GET /api/v1/activity-log per Flutter handoff spec
(activity-log-api-spec.md). Extracts the per-user activity rollup
logic out of ProgressService into a new IActivityLogService so the
API and MAUI share one code path; ProgressService.GetActivityLogAsync
now delegates to it.

Endpoint contract:
- Auth: Bearer JWT required (401 otherwise).
- Query: fromUtc, toUtc (required, ISO-8601), filter=All|Input|Output (optional).
- Validation: 400 on missing dates, fromUtc>toUtc, range >90 days, bad filter.
- Body: [] when no activity; otherwise Monday-anchored 7-day weeks
  newest-first with camelCase keys (weekStart, weekEnd, hasActivity,
  inputMinutes, outputMinutes, completedAtUtc, etc.) and PascalCase
  enum values (Input/Output, Reading/Writing/...).
- Ad-hoc plans (PlanItemId prefixed "adhoc-") surface as isAdhoc=true
  with displayName="Ad-hoc"; generated plans within a day are numbered
  "Plan 1", "Plan 2", ...

Registers IActivityLogService scoped on the API side (consumes the
request-scoped IUserScopeProvider via HttpUserScopeProvider) and
singleton on the MAUI side (DeviceUserScopeProvider is a singleton).

Tests: 10 new endpoint tests cover auth, validation, empty result,
rollup wire shape, ad-hoc labeling, filter behavior, and per-user
isolation. Full API suite 197/198 (the 1 pre-existing PlansEndpoint
failure is unrelated and was red on main before this work).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Round 2 of the Flutter-unblock contract. No MAUI breakage.

Activity Log DTO widened to match the spec:
- Week: totalMinutes, inputMinutes, outputMinutes, activityCount,
  plansCompleted, plansTotal.
- Entry: planItemId, estimatedMinutes, isCompleted, resourceTitle
  (nullable), skillName (nullable), title, description. title and
  description currently pass TitleKey/DescriptionKey through unchanged
  (e.g. "PlanItemReadingTitle") until server-side localization is
  wired up — the spec explicitly permits this as temporary.

New endpoint: POST /api/v1/plans/adhoc/start
- Auth: Bearer JWT, user resolved via IUserScopeProvider.
- Idempotent via deterministic planItemId = "adhoc-{clientSessionId}".
  No schema change; the (UserProfileId, Date, PlanItemId) lookup is
  the uniqueness contract.
- 201 Created on first call; 200 OK on idempotent replay (same id).
- 400 problem+json for missing/invalid clientSessionId, unknown
  activityType, non-positive estimatedMinutes.
- estimatedMinutes defaults to 10 when omitted; clamps at 24h max.
- Returned planItemId is accepted by the existing
  POST /api/v1/plans/{date}/items/{id}/progress endpoint (covered
  by a test).

Tests: 10 new ad-hoc + 1 widened activity-log test. Full API suite
207/208 (1 pre-existing unrelated JwtBearer failure on main).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant