Files
STCourses/docs/contract-requests/ai.md
T
2026-10-30 20:37:33 +01:00

6.4 KiB

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/<authorId>. The AI package's DraftSink has the same method and returns DraftWriteResult = { location?, branch?, commit? }, so the studio writer is assignable as is:

aiFeature(ctx, { draftSink: createDraftWriter(ctx, { identity: (id) => /* user name + email */ null }) })

generateDraft then reports location: "draft/<author> @ <short commit>" 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 <dataDir>/drafts/<courseId>/<run>/), 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.

Settled in v1.1 (feat/v11-ai): ChatMessage.toolCalls?: ToolCall[] and providerData?: unknown are in @stc/contracts, and providers may yield { providerData } once per turn. The assistant loop appends one assistant message per model turn. Anthropic returns { anthropic: <raw blocks> } and replays it verbatim (no in-memory map); OpenAI maps toolCalls to tool_calls.

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.

Settled in v1.1: table ai_mentor_prefs(user_id, course_id, off, updated_at) from packages/feature-ai/migrations/0002_ai_mentor_prefs.sql. upgradeAiData (src/upgrade.ts) copies v1 config_json.mentorOff toggles once (marker ai_data_v11 in schema_migrations).

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.

Settled in v1.1: column ai_messages.citations_json from 0001_ai_message_citations.sql, which also folds v1 sources rows into the assistant row before them. (MCP env/headers are now encrypted at rest too, masked in the admin API.)

6. Derived text cache layout (shared with WP-B)

Retrieval reads the importer's layout:

  • <dataDir>/cache/decks/<courseId>/<deckId>/text.json = [{ page, text }] (page images alongside);
  • <dataDir>/cache/transcripts/<courseId>/<lessonId>.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 <dataDir>/cache/<courseId>/** (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/<courseId>/<lessonId>. Every such link goes through lessonUrl(courseId, lessonId) / courseUrl(courseId) in packages/feature-ai/src/web/urls.ts (also exported from @stc/feature-ai/web), so a different WP-A route is a one-line change. Proposal: move that helper into @stc/contracts/web so WP-A owns the route shape.

8. A global author role in core's canView

Generation and the studio file listing now require an authoring right (owner, the author role or a course_authors row) and core's canView(user, course, acl, isAuthor). canView only knows per-course authors (isAuthor), so a user with the global author role cannot reach a restricted course without an ACL grant or a course_authors row. Spec section 6 allows author rights "on all courses or per course", and the studio branch (feature-studio server/feature.ts:82-85) currently grants the global role on every course. Proposal: core exports one canAuthor(user, course, acl, isAuthor) (or canView treats roles.includes("author") like isAuthor), and both the studio and the AI package call it. Until then the AI package follows core's canView (the stricter rule).

9. STC_AI_ALLOWED_BASE_URLS in AppConfig

Learners' own keys (BYOK) go only to https://api.anthropic.com, https://api.openai.com/v1 or an origin on the allowlist (SSRF guard). The allowlist is STC_AI_ALLOWED_BASE_URLS (comma-separated exact origins) plus the admin setting allowedBaseUrls in the server AI config. AppConfig is frozen, so the package reads the variable from process.env (injectable through aiFeature(ctx, { env })). Proposal: add aiAllowedBaseUrls: string[] to AppConfig and loadConfig.