Add specifications for gamification, generation, and R42 chat services
- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data. - Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling. - Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
This commit is contained in:
18
app/services/generation/src/index.ts
Normal file
18
app/services/generation/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
import Fastify from 'fastify';
|
||||
import { generateRoutes } from './routes/generate.js';
|
||||
import { publishRoutes } from './routes/publish.js';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(generateRoutes);
|
||||
await app.register(publishRoutes);
|
||||
|
||||
const port = parseInt(process.env['GENERATION_PORT'] ?? '3002', 10);
|
||||
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
168
app/services/generation/src/jobs/queue.ts
Normal file
168
app/services/generation/src/jobs/queue.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
import { generateMicroLearning } from '../pipeline/generate.js';
|
||||
import {
|
||||
MICRO_LEARNING_TYPES,
|
||||
type GenerationJob,
|
||||
type JobProgress,
|
||||
type TopicRecord,
|
||||
} from '../types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jobs = new Map<string, GenerationJob>();
|
||||
|
||||
const DEFAULT_PROGRESS: JobProgress = {
|
||||
topicsTotal: 0,
|
||||
topicsProcessed: 0,
|
||||
itemsTotal: 0,
|
||||
itemsGenerated: 0,
|
||||
itemsFailed: 0,
|
||||
};
|
||||
|
||||
export function createJob(themeId: string): GenerationJob {
|
||||
const job: GenerationJob = {
|
||||
id: uuid(),
|
||||
themeId,
|
||||
status: 'queued',
|
||||
progress: { ...DEFAULT_PROGRESS },
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
void runPipeline(job.id);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getJob(id: string): GenerationJob | undefined {
|
||||
return jobs.get(id);
|
||||
}
|
||||
|
||||
function updateJob(id: string, updates: Partial<Omit<GenerationJob, 'id' | 'createdAt'>>): void {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return;
|
||||
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
function mergeProgress(id: string, partial: Partial<JobProgress>): void {
|
||||
const job = jobs.get(id);
|
||||
if (!job) return;
|
||||
updateJob(id, { progress: { ...job.progress, ...partial } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runPipeline(jobId: string): Promise<void> {
|
||||
const job = jobs.get(jobId);
|
||||
if (!job) return;
|
||||
|
||||
updateJob(jobId, { status: 'running' });
|
||||
|
||||
let topics: TopicRecord[];
|
||||
try {
|
||||
topics = await fetchPublishedTopics(job.themeId);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
updateJob(jobId, { status: 'failed', error: `topic_fetch_failed: ${reason}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalItems = topics.length * MICRO_LEARNING_TYPES.length;
|
||||
mergeProgress(jobId, {
|
||||
topicsTotal: topics.length,
|
||||
itemsTotal: totalItems,
|
||||
});
|
||||
|
||||
// Pre-create all micro_learning records with status: queued
|
||||
const recordIds = await preCreateRecords(topics);
|
||||
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let ti = 0; ti < topics.length; ti++) {
|
||||
const topic = topics[ti];
|
||||
if (!topic) continue;
|
||||
|
||||
for (const type of MICRO_LEARNING_TYPES) {
|
||||
const recordId = recordIds.get(`${topic.id}:${type}`);
|
||||
if (!recordId) continue;
|
||||
|
||||
try {
|
||||
const content = await generateMicroLearning(topic, type);
|
||||
await updateMicroLearning(recordId, 'generated', content);
|
||||
generated++;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await updateMicroLearning(recordId, 'failed', null, reason);
|
||||
failed++;
|
||||
}
|
||||
|
||||
mergeProgress(jobId, { itemsGenerated: generated, itemsFailed: failed });
|
||||
}
|
||||
|
||||
mergeProgress(jobId, { topicsProcessed: ti + 1 });
|
||||
}
|
||||
|
||||
updateJob(jobId, { status: 'done' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PocketBase helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchPublishedTopics(themeId: string): Promise<TopicRecord[]> {
|
||||
const pb = await getPocketBase();
|
||||
const records = await pb.collection('topics').getFullList({
|
||||
filter: `theme = "${themeId}" && status = "published"`,
|
||||
});
|
||||
|
||||
return records.map(r => ({
|
||||
id: r['id'] as string,
|
||||
title: r['title'] as string,
|
||||
body: r['body'] as string,
|
||||
difficulty: r['difficulty'] as TopicRecord['difficulty'],
|
||||
key_terms: (r['key_terms'] as string[] | null) ?? [],
|
||||
status: r['status'] as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async function preCreateRecords(
|
||||
topics: TopicRecord[],
|
||||
): Promise<Map<string, string>> {
|
||||
const pb = await getPocketBase();
|
||||
const map = new Map<string, string>();
|
||||
|
||||
for (const topic of topics) {
|
||||
for (const type of MICRO_LEARNING_TYPES) {
|
||||
const record = await pb.collection('micro_learnings').create({
|
||||
topic: topic.id,
|
||||
type,
|
||||
content: null,
|
||||
status: 'queued',
|
||||
generation_model: 'claude-sonnet-4-20250514',
|
||||
});
|
||||
map.set(`${topic.id}:${type}`, record.id);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function updateMicroLearning(
|
||||
recordId: string,
|
||||
status: 'generated' | 'failed',
|
||||
content: unknown,
|
||||
_errorNote?: string,
|
||||
): Promise<void> {
|
||||
const pb = await getPocketBase();
|
||||
await pb.collection('micro_learnings').update(recordId, {
|
||||
status,
|
||||
content: content ?? null,
|
||||
generated_at: status === 'generated' ? new Date().toISOString() : null,
|
||||
});
|
||||
}
|
||||
9
app/services/generation/src/lib/anthropic.ts
Normal file
9
app/services/generation/src/lib/anthropic.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
export const anthropic = new Anthropic({
|
||||
apiKey: process.env['ANTHROPIC_API_KEY'],
|
||||
});
|
||||
|
||||
export const MODELS = {
|
||||
SONNET: 'claude-sonnet-4-20250514',
|
||||
} as const;
|
||||
14
app/services/generation/src/lib/pocketbase.ts
Normal file
14
app/services/generation/src/lib/pocketbase.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
|
||||
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
|
||||
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
export async function getPocketBase(): Promise<PocketBase> {
|
||||
if (!pb.authStore.isValid) {
|
||||
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
|
||||
}
|
||||
return pb;
|
||||
}
|
||||
163
app/services/generation/src/pipeline/generate.ts
Normal file
163
app/services/generation/src/pipeline/generate.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { anthropic, MODELS } from '../lib/anthropic.js';
|
||||
import {
|
||||
CONTENT_SCHEMAS,
|
||||
TYPE_LABELS,
|
||||
type MicroLearningType,
|
||||
type TopicRecord,
|
||||
} from '../types.js';
|
||||
|
||||
const SYSTEM_PROMPT = `You are a learning content designer. Your task is to generate structured learning content for a specific topic in an employee learning platform.
|
||||
|
||||
Output ONLY valid JSON matching the schema provided. No preamble, no explanation, no markdown fences.
|
||||
|
||||
The content should be accurate, practical, and appropriate for the stated difficulty level. Tone: professional but accessible.`;
|
||||
|
||||
function buildUserPrompt(
|
||||
topic: TopicRecord,
|
||||
type: MicroLearningType,
|
||||
schemaDescription: string,
|
||||
strict: boolean,
|
||||
): string {
|
||||
const base = `Topic: ${topic.title}
|
||||
Difficulty: ${topic.difficulty}
|
||||
Body:
|
||||
${topic.body}
|
||||
|
||||
Key terms: ${topic.key_terms.join(', ')}
|
||||
|
||||
Generate a ${TYPE_LABELS[type]} for this topic.
|
||||
|
||||
Output schema:
|
||||
${schemaDescription}`;
|
||||
|
||||
return strict ? base + '\n\nRespond with valid JSON only, no other text.' : base;
|
||||
}
|
||||
|
||||
const SCHEMA_DESCRIPTIONS: Record<MicroLearningType, string> = {
|
||||
concept_explainer: `{
|
||||
"paragraphs": ["2 to 3 paragraphs explaining the concept in plain language"],
|
||||
"example": "one concrete real-world example"
|
||||
}`,
|
||||
scenario_quiz: `{
|
||||
"scenario": "a realistic workplace scenario",
|
||||
"options": [
|
||||
{ "label": "A", "text": "answer text", "correct": false, "explanation": "why" },
|
||||
{ "label": "B", "text": "answer text", "correct": true, "explanation": "why" },
|
||||
{ "label": "C", "text": "answer text", "correct": false, "explanation": "why" },
|
||||
{ "label": "D", "text": "answer text", "correct": false, "explanation": "why" }
|
||||
]
|
||||
}
|
||||
Rules: exactly 4 options, exactly 1 marked correct: true.`,
|
||||
misconceptions: `{
|
||||
"items": [
|
||||
{ "misconception": "common wrong belief", "correction": "accurate explanation" }
|
||||
]
|
||||
}
|
||||
Rules: 3 to 5 items.`,
|
||||
how_to: `{
|
||||
"steps": [
|
||||
{ "number": 1, "instruction": "what to do" }
|
||||
]
|
||||
}
|
||||
Rules: 3 to 8 steps.`,
|
||||
comparison_card: `{
|
||||
"subject_a": "first concept or approach",
|
||||
"subject_b": "second concept or approach",
|
||||
"dimensions": [
|
||||
{ "label": "dimension name", "a": "how A differs", "b": "how B differs" }
|
||||
]
|
||||
}
|
||||
Rules: 3 to 6 dimensions.`,
|
||||
reflection_prompt: `{
|
||||
"prompt": "open-ended question for the employee to reflect on",
|
||||
"model_answer": "a thoughtful example answer the employee can compare against"
|
||||
}`,
|
||||
flashcard_set: `{
|
||||
"cards": [
|
||||
{ "question": "question text", "answer": "answer text" }
|
||||
]
|
||||
}
|
||||
Rules: 5 to 10 cards.`,
|
||||
case_study: `{
|
||||
"scenario": "a detailed real-world scenario (150+ words)",
|
||||
"questions": ["discussion question 1", "discussion question 2"]
|
||||
}
|
||||
Rules: 2 to 4 questions.`,
|
||||
glossary_anchor: `{
|
||||
"term": "the key term",
|
||||
"definition": "precise definition",
|
||||
"correct_use": "example sentence showing correct use",
|
||||
"misuse": "common incorrect usage to avoid"
|
||||
}`,
|
||||
myth_vs_evidence: `{
|
||||
"myth": "a commonly held misconception about this topic",
|
||||
"evidence": "the evidence-based counterpoint",
|
||||
"sources": ["source or reference if applicable — use empty array if none"]
|
||||
}`,
|
||||
};
|
||||
|
||||
async function callClaude(prompt: string): Promise<string> {
|
||||
const response = await anthropic.messages.create({
|
||||
model: MODELS.SONNET,
|
||||
max_tokens: 2000,
|
||||
temperature: 0,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const block = response.content[0];
|
||||
if (!block || block.type !== 'text') {
|
||||
throw new Error('unexpected response format from Claude');
|
||||
}
|
||||
return block.text;
|
||||
}
|
||||
|
||||
async function parseAndValidate(raw: string, type: MicroLearningType): Promise<unknown> {
|
||||
const schema = CONTENT_SCHEMAS[type];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return schema.parse(parsed);
|
||||
}
|
||||
|
||||
export async function generateMicroLearning(
|
||||
topic: TopicRecord,
|
||||
type: MicroLearningType,
|
||||
): Promise<unknown> {
|
||||
const schemaDesc = SCHEMA_DESCRIPTIONS[type];
|
||||
|
||||
// For glossary_anchor, hint Claude to use the first key term
|
||||
const topicWithHint: TopicRecord =
|
||||
type === 'glossary_anchor' && topic.key_terms.length > 0
|
||||
? { ...topic, body: topic.body + `\n\nAnchor term: ${topic.key_terms[0]}` }
|
||||
: topic;
|
||||
|
||||
const prompt = buildUserPrompt(topicWithHint, type, schemaDesc, false);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await callClaude(prompt);
|
||||
} catch (err) {
|
||||
throw new Error(`Claude API error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// First parse attempt
|
||||
try {
|
||||
return await parseAndValidate(raw, type);
|
||||
} catch {
|
||||
// Retry with strict prompt
|
||||
const strictPrompt = buildUserPrompt(topicWithHint, type, schemaDesc, true);
|
||||
let raw2: string;
|
||||
try {
|
||||
raw2 = await callClaude(strictPrompt);
|
||||
} catch (err) {
|
||||
throw new Error(`Claude API error on retry: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await parseAndValidate(raw2, type);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`validation failed after retry: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/services/generation/src/routes/generate.ts
Normal file
36
app/services/generation/src/routes/generate.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createJob, getJob } from '../jobs/queue.js';
|
||||
import { GenerateBodySchema } from '../types.js';
|
||||
|
||||
export async function generateRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/generate', async (request, reply) => {
|
||||
const parsed = GenerateBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { themeId } = parsed.data;
|
||||
const job = createJob(themeId);
|
||||
|
||||
return reply.status(202).send({
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
topicsFound: job.progress.topicsTotal,
|
||||
totalItems: job.progress.itemsTotal,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
|
||||
const job = getJob(request.params.jobId);
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: 'job not found' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
error: job.error,
|
||||
});
|
||||
});
|
||||
}
|
||||
44
app/services/generation/src/routes/publish.ts
Normal file
44
app/services/generation/src/routes/publish.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getPocketBase } from '../lib/pocketbase.js';
|
||||
import { PublishBodySchema } from '../types.js';
|
||||
|
||||
export async function publishRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.patch<{ Params: { id: string } }>('/micro-learnings/:id', async (request, reply) => {
|
||||
const parsed = PublishBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: 'invalid request', details: parsed.error.issues });
|
||||
}
|
||||
|
||||
const { id } = request.params;
|
||||
const { status: newStatus } = parsed.data;
|
||||
|
||||
const pb = await getPocketBase();
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = await pb.collection('micro_learnings').getOne(id) as Record<string, unknown>;
|
||||
} catch {
|
||||
return reply.status(404).send({ error: 'micro learning not found' });
|
||||
}
|
||||
|
||||
if (existing['status'] !== 'generated') {
|
||||
return reply.status(400).send({
|
||||
error: 'only generated records can be published or rejected',
|
||||
currentStatus: existing['status'],
|
||||
});
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { status: newStatus };
|
||||
if (newStatus === 'published') {
|
||||
updates['published_at'] = new Date().toISOString();
|
||||
}
|
||||
|
||||
const updated = await pb.collection('micro_learnings').update(id, updates);
|
||||
|
||||
return reply.send({
|
||||
id: updated.id,
|
||||
status: updated['status'],
|
||||
published_at: updated['published_at'] ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
201
app/services/generation/src/types.ts
Normal file
201
app/services/generation/src/types.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Micro learning types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MICRO_LEARNING_TYPES = [
|
||||
'concept_explainer',
|
||||
'scenario_quiz',
|
||||
'misconceptions',
|
||||
'how_to',
|
||||
'comparison_card',
|
||||
'reflection_prompt',
|
||||
'flashcard_set',
|
||||
'case_study',
|
||||
'glossary_anchor',
|
||||
'myth_vs_evidence',
|
||||
] as const;
|
||||
|
||||
export type MicroLearningType = (typeof MICRO_LEARNING_TYPES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content schemas — validated against AI output before PocketBase write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ConceptExplainerSchema = z.object({
|
||||
paragraphs: z.array(z.string().min(10)).min(2).max(3),
|
||||
example: z.string().min(20),
|
||||
});
|
||||
|
||||
export const ScenarioQuizSchema = z.object({
|
||||
scenario: z.string().min(30),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.enum(['A', 'B', 'C', 'D']),
|
||||
text: z.string().min(5),
|
||||
correct: z.boolean(),
|
||||
explanation: z.string().min(10),
|
||||
}),
|
||||
)
|
||||
.length(4)
|
||||
.refine(opts => opts.filter(o => o.correct).length === 1, {
|
||||
message: 'exactly one correct option required',
|
||||
}),
|
||||
});
|
||||
|
||||
export const MisconceptionsSchema = z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
misconception: z.string().min(10),
|
||||
correction: z.string().min(10),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(5),
|
||||
});
|
||||
|
||||
export const HowToSchema = z.object({
|
||||
steps: z
|
||||
.array(
|
||||
z.object({
|
||||
number: z.number().int().positive(),
|
||||
instruction: z.string().min(10),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(8),
|
||||
});
|
||||
|
||||
export const ComparisonCardSchema = z.object({
|
||||
subject_a: z.string().min(2),
|
||||
subject_b: z.string().min(2),
|
||||
dimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().min(2),
|
||||
a: z.string().min(5),
|
||||
b: z.string().min(5),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(6),
|
||||
});
|
||||
|
||||
export const ReflectionPromptSchema = z.object({
|
||||
prompt: z.string().min(20),
|
||||
model_answer: z.string().min(50),
|
||||
});
|
||||
|
||||
export const FlashcardSetSchema = z.object({
|
||||
cards: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string().min(5),
|
||||
answer: z.string().min(5),
|
||||
}),
|
||||
)
|
||||
.min(5)
|
||||
.max(10),
|
||||
});
|
||||
|
||||
export const CaseStudySchema = z.object({
|
||||
scenario: z.string().min(150),
|
||||
questions: z.array(z.string().min(10)).min(2).max(4),
|
||||
});
|
||||
|
||||
export const GlossaryAnchorSchema = z.object({
|
||||
term: z.string().min(2),
|
||||
definition: z.string().min(20),
|
||||
correct_use: z.string().min(20),
|
||||
misuse: z.string().min(20),
|
||||
});
|
||||
|
||||
export const MythVsEvidenceSchema = z.object({
|
||||
myth: z.string().min(20),
|
||||
evidence: z.string().min(30),
|
||||
sources: z.array(z.string()),
|
||||
});
|
||||
|
||||
// Map type → schema for lookup
|
||||
export const CONTENT_SCHEMAS: Record<MicroLearningType, z.ZodTypeAny> = {
|
||||
concept_explainer: ConceptExplainerSchema,
|
||||
scenario_quiz: ScenarioQuizSchema,
|
||||
misconceptions: MisconceptionsSchema,
|
||||
how_to: HowToSchema,
|
||||
comparison_card: ComparisonCardSchema,
|
||||
reflection_prompt: ReflectionPromptSchema,
|
||||
flashcard_set: FlashcardSetSchema,
|
||||
case_study: CaseStudySchema,
|
||||
glossary_anchor: GlossaryAnchorSchema,
|
||||
myth_vs_evidence: MythVsEvidenceSchema,
|
||||
};
|
||||
|
||||
// Map type → human-readable label for prompts
|
||||
export const TYPE_LABELS: Record<MicroLearningType, string> = {
|
||||
concept_explainer: 'Concept Explainer',
|
||||
scenario_quiz: 'Scenario Quiz',
|
||||
misconceptions: 'Misconceptions',
|
||||
how_to: 'How-To Guide',
|
||||
comparison_card: 'Comparison Card',
|
||||
reflection_prompt: 'Reflection Prompt',
|
||||
flashcard_set: 'Flashcard Set',
|
||||
case_study: 'Case Study',
|
||||
glossary_anchor: 'Glossary Anchor',
|
||||
myth_vs_evidence: 'Myth vs Evidence',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PocketBase: Topic (fetched from PB before generation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TopicRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
difficulty: 'introductory' | 'intermediate' | 'advanced';
|
||||
key_terms: string[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
|
||||
export interface JobProgress {
|
||||
topicsTotal: number;
|
||||
topicsProcessed: number;
|
||||
itemsTotal: number;
|
||||
itemsGenerated: number;
|
||||
itemsFailed: number;
|
||||
}
|
||||
|
||||
export interface GenerationJob {
|
||||
id: string;
|
||||
themeId: string;
|
||||
status: JobStatus;
|
||||
progress: JobProgress;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API request schemas (Zod — validates external input)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GenerateBodySchema = z.object({
|
||||
themeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type GenerateBody = z.infer<typeof GenerateBodySchema>;
|
||||
|
||||
export const PublishBodySchema = z.object({
|
||||
status: z.enum(['published', 'rejected']),
|
||||
});
|
||||
|
||||
export type PublishBody = z.infer<typeof PublishBodySchema>;
|
||||
Reference in New Issue
Block a user