import { z } from "zod"; import { canonicalJson, type JsonObject } from "../core/json.js"; import type { ThoughtEvent } from "../events/types.js"; import { eventIdFor, type JazzThoughtStore } from "../jazz/store.js"; import { POST_TRAINING_COURSE, POST_TRAINING_COURSE_ID, postTrainingLesson, postTrainingLessonContext, } from "./post-training.js"; export const COURSE_QUESTION_EVENT_TYPE = "stream.thought.source.course.question"; export const COURSE_QUESTION_SCHEMA_VERSION = 1; export const COURSE_QUESTION_SOURCE = `web-course:${POST_TRAINING_COURSE_ID}`; export const COURSE_TUTOR_AGENT_ID = "post-training-course-tutor"; export const COURSE_QUESTION_MAX_CHARS = 2_000; const requestIdSchema = z.string().min(1).max(100).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/); export const courseQuestionRequestSchema = z.object({ requestId: requestIdSchema, courseRevision: z.string().regex(/^[a-f0-9]{64}$/), lessonId: z.string().min(1).max(100), sectionId: z.string().min(1).max(100).optional(), question: z.string().trim().min(1).max(COURSE_QUESTION_MAX_CHARS), }).strict(); export const courseQuestionPayloadSchema = z.object({ requestId: requestIdSchema, courseId: z.literal(POST_TRAINING_COURSE_ID), courseRevision: z.string().regex(/^[a-f0-9]{64}$/), lessonId: z.string().min(1).max(100), lessonTitle: z.string().min(1).max(200), sectionId: z.string().min(1).max(100).optional(), sectionTitle: z.string().min(1).max(200).optional(), question: z.string().min(1).max(COURSE_QUESTION_MAX_CHARS), lessonContext: z.string().min(1).max(48_000), interface: z.literal("thought-stream-inspector"), }).strict(); export type CourseQuestionRequest = z.infer; export type CourseQuestionPayload = z.infer; export async function appendPostTrainingCourseQuestion( store: JazzThoughtStore, input: CourseQuestionRequest, options: { occurredAt?: string | undefined; actor?: string | undefined } = {}, ) { const request = courseQuestionRequestSchema.parse(input); if (request.courseRevision !== POST_TRAINING_COURSE.revision) { throw new CourseRevisionConflictError(); } const lesson = postTrainingLesson(request.lessonId); if (!lesson) throw new Error("Course lesson is unknown"); const section = request.sectionId ? lesson.sections.find((candidate) => candidate.id === request.sectionId) : undefined; if (request.sectionId && !section) throw new Error("Course section is unknown"); const payload = courseQuestionPayloadSchema.parse({ requestId: request.requestId, courseId: POST_TRAINING_COURSE_ID, courseRevision: POST_TRAINING_COURSE.revision, lessonId: lesson.id, lessonTitle: lesson.title, ...(section ? { sectionId: section.id, sectionTitle: section.title } : {}), question: request.question, lessonContext: postTrainingLessonContext(lesson.id, section?.id), interface: "thought-stream-inspector", }); const idempotencyKey = `course-question-v1:${payload.requestId}`; const eventId = eventIdFor(COURSE_QUESTION_SOURCE, idempotencyKey); const existing = await store.getEvent(eventId); if (existing) { const actual = parseCourseQuestionEvent(existing); if (!actual || canonicalJson(actual as unknown as JsonObject) !== canonicalJson(payload as unknown as JsonObject)) { throw new CourseQuestionConflictError(); } return { event: existing, inserted: false }; } const appended = await store.appendEvent({ type: COURSE_QUESTION_EVENT_TYPE, schemaVersion: COURSE_QUESTION_SCHEMA_VERSION, source: COURSE_QUESTION_SOURCE, sourceKind: "web", externalId: payload.requestId, idempotencyKey, occurredAt: options.occurredAt ?? new Date().toISOString(), actor: options.actor ?? "operator:cameron", correlationId: payload.requestId, privacy: "sensitive", payload: payload as unknown as JsonObject, }); const actual = parseCourseQuestionEvent(appended.event); if (!actual || canonicalJson(actual as unknown as JsonObject) !== canonicalJson(payload as unknown as JsonObject)) { throw new Error("Course question receipt is inconsistent"); } return appended; } export function parseCourseQuestionEvent(event: ThoughtEvent): CourseQuestionPayload | undefined { if (event.type !== COURSE_QUESTION_EVENT_TYPE || event.schemaVersion !== COURSE_QUESTION_SCHEMA_VERSION || event.source !== COURSE_QUESTION_SOURCE || event.sourceKind !== "web" || event.privacy !== "sensitive" || event.externalId !== event.correlationId || event.rootEventId !== event.id) return undefined; const parsed = courseQuestionPayloadSchema.safeParse(event.payload); if (!parsed.success || parsed.data.requestId !== event.externalId) return undefined; return parsed.data; } export class CourseRevisionConflictError extends Error { constructor() { super("Course revision changed"); this.name = "CourseRevisionConflictError"; } } export class CourseQuestionConflictError extends Error { constructor() { super("Course question identity changed"); this.name = "CourseQuestionConflictError"; } }