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 atranscriptfile in the course, which the job writes too);- plus plain
.txt/.md/.vtt/.srtunder<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.