Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
193
lib/schemas.ts
Normal file
193
lib/schemas.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schemas shared by the API routes (server) and the UI (client).
|
||||
* Everything the model returns is validated against these.
|
||||
*/
|
||||
|
||||
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
||||
export type Difficulty = z.infer<typeof difficultySchema>;
|
||||
|
||||
const baseActivity = {
|
||||
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
||||
topic: z.string().min(1).max(120).optional(),
|
||||
explanation: z.string().min(1).max(600),
|
||||
};
|
||||
|
||||
export const flashcardSchema = z.object({
|
||||
type: z.literal("flashcard"),
|
||||
front: z.string().min(1).max(400),
|
||||
back: z.string().min(1).max(1200),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const multipleChoiceSchema = z.object({
|
||||
type: z.literal("multiple_choice"),
|
||||
question: z.string().min(1).max(600),
|
||||
options: z.array(z.string().min(1).max(300)).length(4),
|
||||
correctIndex: z.number().int().min(0).max(3),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const trueFalseSchema = z.object({
|
||||
type: z.literal("true_false"),
|
||||
statement: z.string().min(1).max(600),
|
||||
answer: z.boolean(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const fillBlankSchema = z.object({
|
||||
type: z.literal("fill_blank"),
|
||||
/** Must contain a `____` placeholder. */
|
||||
sentence: z.string().min(1).max(600),
|
||||
answer: z.string().min(1).max(200),
|
||||
/** Optional word bank; when present it is shown as tappable choices. */
|
||||
options: z.array(z.string().min(1).max(200)).min(2).max(6).optional(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const matchingSchema = z.object({
|
||||
type: z.literal("matching"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
pairs: z
|
||||
.array(
|
||||
z.object({
|
||||
term: z.string().min(1).max(200),
|
||||
definition: z.string().min(1).max(300),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const orderingSchema = z.object({
|
||||
type: z.literal("ordering"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
/** Items in the CORRECT order; the UI shuffles them. */
|
||||
items: z.array(z.string().min(1).max(300)).min(3).max(7),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const shortAnswerSchema = z.object({
|
||||
type: z.literal("short_answer"),
|
||||
question: z.string().min(1).max(600),
|
||||
referenceAnswer: z.string().min(1).max(1200),
|
||||
/** Ideas the grader should look for. */
|
||||
keyPoints: z.array(z.string().min(1).max(200)).min(1).max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Activities as produced by the model (no id / xp yet - the server adds them).
|
||||
*/
|
||||
export const generatedActivitySchema = z.discriminatedUnion("type", [
|
||||
flashcardSchema,
|
||||
multipleChoiceSchema,
|
||||
trueFalseSchema,
|
||||
fillBlankSchema,
|
||||
matchingSchema,
|
||||
orderingSchema,
|
||||
shortAnswerSchema,
|
||||
]);
|
||||
export type GeneratedActivity = z.infer<typeof generatedActivitySchema>;
|
||||
|
||||
export const generatedDeckSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/** Language of the generated content, e.g. "Czech" or "cs". */
|
||||
language: z.string().max(60).optional(),
|
||||
activities: z.array(generatedActivitySchema).min(4).max(20),
|
||||
});
|
||||
export type GeneratedDeck = z.infer<typeof generatedDeckSchema>;
|
||||
|
||||
/** What the vision step must return. */
|
||||
export const extractedNotesSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/**
|
||||
* Language of the notes (English name or BCP-47 code). Everything the app
|
||||
* generates afterwards must be written in this language.
|
||||
*/
|
||||
language: z.string().min(2).max(60).default("the same language as the notes"),
|
||||
/** Clean, structured transcription of everything relevant to study. */
|
||||
notes: z.string().min(1),
|
||||
keyConcepts: z.array(z.string().min(1).max(300)).min(1).max(40),
|
||||
definitions: z
|
||||
.array(z.object({ term: z.string().min(1).max(200), definition: z.string().min(1).max(600) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
formulas: z
|
||||
.array(z.object({ name: z.string().min(1).max(200), formula: z.string().min(1).max(400) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
facts: z.array(z.string().min(1).max(400)).max(40).default([]),
|
||||
});
|
||||
export type ExtractedNotes = z.infer<typeof extractedNotesSchema>;
|
||||
|
||||
/** Grading response for short answers. */
|
||||
export const gradeResultSchema = z.object({
|
||||
correct: z.boolean(),
|
||||
score: z.number().min(0).max(100),
|
||||
feedback: z.string().min(1).max(600),
|
||||
missing: z.array(z.string().max(200)).max(6).default([]),
|
||||
});export type GradeResult = z.infer<typeof gradeResultSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side deck types (activity + id/xp, plus storage metadata) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type Activity = GeneratedActivity & { id: string; xp: number };
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export const folderSchema: z.ZodType<Folder> = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
name: z.string().min(1).max(60),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
/** Virtual folder that holds every deck without a folder of its own. */
|
||||
export const UNORGANIZED_ID = "unorganized";
|
||||
|
||||
export type Deck = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
/** Language the deck is written in (kept for grading, regenerate, add-more). */
|
||||
language?: string;
|
||||
/** Folder this deck lives in; undefined means "Unorganized". */
|
||||
folderId?: string;
|
||||
/** Text extracted from the user's notes - reused by regenerate/add-more. */
|
||||
sourceText: string;
|
||||
difficulty: Difficulty;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
activities: Activity[];
|
||||
attempts: number;
|
||||
/** Best result as a 0-100 percentage. */
|
||||
bestScore?: number;
|
||||
};
|
||||
|
||||
export const deckSchema: z.ZodType<Deck> = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
subject: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
folderId: z.string().optional(),
|
||||
sourceText: z.string(),
|
||||
difficulty: difficultySchema,
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
activities: z.array(generatedActivitySchema.and(z.object({ id: z.string(), xp: z.number() }))).min(1),
|
||||
attempts: z.number(),
|
||||
bestScore: z.number().optional(),
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue