From 1f111b80db4412818bbdbe1ceab0252869f180ed Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 04:39:58 +0100 Subject: [PATCH] docs(ai): contract requests; dev fake provider returns a draft file Co-Authored-By: Claude Opus 5.5 --- docs/contract-requests/ai.md | 61 ++++++++++++++++++++++++++++++ packages/feature-ai/scripts/dev.ts | 4 ++ 2 files changed, 65 insertions(+) create mode 100644 docs/contract-requests/ai.md diff --git a/docs/contract-requests/ai.md b/docs/contract-requests/ai.md new file mode 100644 index 0000000..222d977 --- /dev/null +++ b/docs/contract-requests/ai.md @@ -0,0 +1,61 @@ +# Contract requests from WP-D (AI) + +Phase 1 worked around each of these inside `packages/feature-ai`. Phase 2 decides. + +## 1. Feature factories in `startServer` (apps/server) + +`aiFeature(ctx, opts?)` needs the `ServerContext` (db, courses, jobs, auth, config), but `startServer(config, features)` builds the +context itself and takes ready-made `Feature` objects. So `apps/server/src/main.ts` is not wired yet (the web side is: +one line in `apps/web/src/features.ts`). Proposal: `startServer(config, factories: ((ctx: ServerContext) => Feature)[])`, +then append `(ctx) => aiFeature(ctx, { draftSink: createDraftWriter(ctx) })`. Jobs must be registered after the +factories run (the job handler `ai.generate` lives on the returned feature). +Local workaround: `packages/feature-ai/scripts/dev.ts` builds the context and composes the feature for the dev server on port 3464. + +## 2. Studio `DraftWriter` as the generator's sink (no adapter needed) + +The studio exports `createDraftWriter(ctx, { identity? })` whose `writeDraftFiles(courseId, authorId, files, message)` +returns `{ branch, commit }` and commits only to `draft/`. The AI package's `DraftSink` has the same method +and returns `DraftWriteResult = { location?, branch?, commit? }`, so the studio writer is assignable as is: + +```ts +aiFeature(ctx, { draftSink: createDraftWriter(ctx, { identity: (id) => /* user name + email */ null }) }) +``` + +`generateDraft` then reports `location: "draft/ @ "` plus `branch` and `commit`; the generate page +says "Committed to your draft branch". Test: `test/generator.test.ts` ("a studio DraftWriter is a DraftSink as is"). +Open point: the studio writer throws 404 for courses it does not manage (overlay courses such as Pure Snake). Phase 2 +can wrap it to fall back to `createFsDraftSink(dataDir)` (drafts under `/drafts///`), or make +generation for unmanaged courses an explicit error. Until wired, the filesystem sink is the default. + +## 3. Tool-call fields on `ChatMessage` + +`ChatMessage` has `toolCallId` and `name` but no structured slot for an assistant turn with several tool calls or with +thinking blocks. The package encodes one tool call per assistant message (`content` = JSON input) and the Anthropic +provider keeps each response's raw content blocks (thinking with signature, text, tool_use) in memory by tool_use id +and replays them unchanged. Proposal: `ChatMessage.toolCalls?: { id, name, input }[]` and an opaque +`providerData?: unknown` the provider can round-trip. + +## 4. Per-course mentor toggle in `ai_settings` + +The frozen key is `(scope, owner_id)` with one `mentor_off` column, so a per-course toggle cannot be a row. The flag +lives in the user row's `config_json.mentorOff[courseId]`; `mentor_off` stays 0 (reserved for "off everywhere"). +Proposal: add `course_id TEXT NOT NULL DEFAULT ''` to the key, or a small `ai_mentor_prefs(user_id, course_id, off)` table. + +## 5. Citations on `ai_messages` + +`ai_messages` has no column for an assistant message's sources. They are stored as an extra row with role `sources` +(JSON) right after the assistant row and folded back when reading. Proposal: `ai_messages.meta_json TEXT`. + +## 6. Derived text cache layout (shared with WP-B) + +Retrieval reads the importer's layout: +- `/cache/decks///text.json` = `[{ page, text }]` (page images alongside); +- `/cache/transcripts//.json` = `{ lessonId, text, segments: [{ start, end, text }] }` + (skipped when the lesson has a `transcript` file in the course, which the job writes too); +- plus plain `.txt/.md/.vtt/.srt` under `/cache//**` (older layout, kept for hand-made caches). +Proposal: export these paths from `@stc/core` (e.g. `cachePaths(dataDir)`) so both packages share one definition. + +## 7. Learner lesson URL + +Citations carry `lessonId`; the assistant links them to `/c//` (a guess at WP-A's route). Proposal: a `lessonUrl(courseId, lessonId)` +helper in `@stc/contracts/web` so WP-A owns the route shape. diff --git a/packages/feature-ai/scripts/dev.ts b/packages/feature-ai/scripts/dev.ts index 4720fd5..a31fe02 100644 --- a/packages/feature-ai/scripts/dev.ts +++ b/packages/feature-ai/scripts/dev.ts @@ -19,6 +19,10 @@ const fake: AiProvider = { id: "fake", async *chat(messages) { const sys = messages[0]?.content ?? ""; + if (sys.includes("=== FILE:")) { + yield { text: "=== FILE: questions/adding-many-numbers.md ===\n# Check your understanding: adding many numbers\n\n1. Predict: what does `sum([])` return?\n2. What would break if `sum` used `a - b`?\n=== END FILE ===\n" }; + return; + } const mentor = sys.includes("## Course mentor rules"); const answer = mentor ? "Before I explain: what does the failing test give as input, and what did you expect `add` to return?\n\nThe lesson's nudge asks **which operator combines two numbers**. Try it, run the tests, and tell me what happens. [lessons/04-exercise.md ยท Adding numbers]"