- Learner links go through lessonUrl/courseUrl (src/web/urls.ts). - Generation, the studio course list and file listing require an authoring right and core canView, so a global author needs a grant for a restricted course. Contract requests 8 (global author in canView) and 9 (STC_AI_ALLOWED_BASE_URLS in AppConfig). Co-Authored-By: Claude Opus 5.5 <[email protected]>
82 lines
5.5 KiB
Markdown
82 lines
5.5 KiB
Markdown
# 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:
|
|
|
|
```ts
|
|
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.
|
|
|
|
## 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:
|
|
- `<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`.
|