51
apps/web/src/lib/ai/ai-normalize.test.ts
Executable file
51
apps/web/src/lib/ai/ai-normalize.test.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizeExplorationPayload,
|
||||
normalizeTeacherPayload,
|
||||
safeParseAiJson,
|
||||
} from "./ai-normalize";
|
||||
|
||||
describe("safeParseAiJson", () => {
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(safeParseAiJson("not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTeacherPayload", () => {
|
||||
it("coerces string quiz answers and alternate field names", () => {
|
||||
const normalized = normalizeTeacherPayload({
|
||||
flashcards: [{ front: "Q", back: "A" }],
|
||||
quiz: [{ question: "Pick one", options: ["a", "b"], answer: "1" }],
|
||||
homework: "Read for 20 minutes",
|
||||
});
|
||||
expect(normalized).toMatchObject({
|
||||
assignment: "Read for 20 minutes",
|
||||
quiz: [{ answer: 1 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeExplorationPayload", () => {
|
||||
it("maps description to hook", () => {
|
||||
const normalized = normalizeExplorationPayload({
|
||||
explorations: [
|
||||
{
|
||||
title: "Read poetry",
|
||||
description: "Spend time with a poem",
|
||||
category: "reading",
|
||||
duration: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(normalized).toEqual({
|
||||
explorations: [
|
||||
{
|
||||
title: "Read poetry",
|
||||
hook: "Spend time with a poem",
|
||||
category: "reading",
|
||||
minutes: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
124
apps/web/src/lib/ai/ai-normalize.ts
Executable file
124
apps/web/src/lib/ai/ai-normalize.ts
Executable file
@@ -0,0 +1,124 @@
|
||||
import { parseAiJson } from "./parse-json";
|
||||
|
||||
export function safeParseAiJson(raw: string): unknown | null {
|
||||
try {
|
||||
return parseAiJson(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTeacherPayload(data: unknown): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const o = data as Record<string, unknown>;
|
||||
|
||||
const flashcards = Array.isArray(o.flashcards)
|
||||
? o.flashcards
|
||||
.map((c) => {
|
||||
if (!c || typeof c !== "object") return null;
|
||||
const card = c as Record<string, unknown>;
|
||||
const front = String(card.front ?? "").trim();
|
||||
const back = String(card.back ?? "").trim();
|
||||
if (!front && !back) return null;
|
||||
return { front, back };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const quiz = Array.isArray(o.quiz)
|
||||
? o.quiz
|
||||
.map((q) => {
|
||||
if (!q || typeof q !== "object") return null;
|
||||
const item = q as Record<string, unknown>;
|
||||
const options = Array.isArray(item.options)
|
||||
? item.options.map((opt) => String(opt))
|
||||
: [];
|
||||
if (options.length === 0) return null;
|
||||
let answer = Number(item.answer ?? 0);
|
||||
if (!Number.isFinite(answer)) answer = 0;
|
||||
if (answer < 0 || answer >= options.length) answer = 0;
|
||||
return {
|
||||
question: String(item.question ?? "Question"),
|
||||
options,
|
||||
answer,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
title: o.title != null ? String(o.title) : undefined,
|
||||
introduction: o.introduction != null ? String(o.introduction) : undefined,
|
||||
objectives: Array.isArray(o.objectives)
|
||||
? o.objectives.map(String).filter(Boolean)
|
||||
: undefined,
|
||||
readingSteps: Array.isArray(o.readingSteps)
|
||||
? o.readingSteps.map(String).filter(Boolean)
|
||||
: undefined,
|
||||
reflectionPrompt:
|
||||
o.reflectionPrompt != null ? String(o.reflectionPrompt) : undefined,
|
||||
flashcards,
|
||||
quiz,
|
||||
assignment: String(o.assignment ?? o.homework ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExplorationPayload(data: unknown): unknown {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
const o = data as Record<string, unknown>;
|
||||
|
||||
const items = Array.isArray(o.explorations)
|
||||
? o.explorations
|
||||
: Array.isArray(o.quests)
|
||||
? o.quests
|
||||
: [];
|
||||
|
||||
return {
|
||||
explorations: items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const e = item as Record<string, unknown>;
|
||||
const title = String(e.title ?? "").trim();
|
||||
if (!title) return null;
|
||||
const minutes = Number(e.minutes ?? e.duration ?? 20);
|
||||
return {
|
||||
title,
|
||||
hook: String(e.hook ?? e.description ?? e.reason ?? title).trim(),
|
||||
category: String(e.category ?? "general").trim(),
|
||||
minutes: Number.isFinite(minutes) && minutes > 0 ? minutes : 20,
|
||||
};
|
||||
})
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function debugAiLog(
|
||||
location: string,
|
||||
message: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
console.error(`[adventureos-ai] ${location}: ${message}`, JSON.stringify(data));
|
||||
// #region agent log
|
||||
fetch("http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Debug-Session-Id": "04e12c",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: "04e12c",
|
||||
location,
|
||||
message,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
}
|
||||
|
||||
export function isAiTimeoutError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.name === "TimeoutError") return true;
|
||||
if (error.message.toLowerCase().includes("timeout")) return true;
|
||||
return (error as Error & { code?: number }).code === 23;
|
||||
}
|
||||
38
apps/web/src/lib/ai/model-resolve.test.ts
Executable file
38
apps/web/src/lib/ai/model-resolve.test.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isModelAvailable,
|
||||
modelNamesMatch,
|
||||
resolveModelName,
|
||||
} from "./model-resolve";
|
||||
|
||||
describe("resolveModelName", () => {
|
||||
const available = ["llama3.2:latest", "mistral:7b"];
|
||||
|
||||
it("returns exact match", () => {
|
||||
expect(resolveModelName("mistral:7b", available)).toBe("mistral:7b");
|
||||
});
|
||||
|
||||
it("returns partial tag match", () => {
|
||||
expect(resolveModelName("llama3.2:3b", available)).toBe("llama3.2:latest");
|
||||
});
|
||||
|
||||
it("falls back to first model", () => {
|
||||
expect(resolveModelName("unknown:model", available)).toBe("llama3.2:latest");
|
||||
});
|
||||
|
||||
it("returns null when no models", () => {
|
||||
expect(resolveModelName("llama3.2:3b", [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelAvailable", () => {
|
||||
it("detects partial matches", () => {
|
||||
expect(isModelAvailable("llama3.2:3b", ["llama3.2:latest"])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelNamesMatch", () => {
|
||||
it("matches same base with requested tag", () => {
|
||||
expect(modelNamesMatch("llama3.2:3b", "llama3.2:latest")).toBe(true);
|
||||
});
|
||||
});
|
||||
26
apps/web/src/lib/ai/model-resolve.ts
Executable file
26
apps/web/src/lib/ai/model-resolve.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
/** Match Ollama model names with or without tags (e.g. llama3.2:3b vs llama3.2:latest). */
|
||||
export function modelNamesMatch(requested: string, available: string): boolean {
|
||||
if (requested === available) return true;
|
||||
const reqBase = requested.split(":")[0];
|
||||
const availBase = available.split(":")[0];
|
||||
return reqBase === availBase && requested.includes(":");
|
||||
}
|
||||
|
||||
export function isModelAvailable(requested: string, availableModels: string[]): boolean {
|
||||
return availableModels.some((name) => modelNamesMatch(requested, name));
|
||||
}
|
||||
|
||||
export function resolveModelName(
|
||||
requested: string,
|
||||
availableModels: string[]
|
||||
): string | null {
|
||||
if (availableModels.length === 0) return null;
|
||||
|
||||
const exact = availableModels.find((name) => name === requested);
|
||||
if (exact) return exact;
|
||||
|
||||
const partial = availableModels.find((name) => modelNamesMatch(requested, name));
|
||||
if (partial) return partial;
|
||||
|
||||
return availableModels[0] ?? null;
|
||||
}
|
||||
12
apps/web/src/lib/ai/parse-json.test.ts
Executable file
12
apps/web/src/lib/ai/parse-json.test.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseAiJson } from "./parse-json";
|
||||
|
||||
describe("parseAiJson", () => {
|
||||
it("parses plain JSON", () => {
|
||||
expect(parseAiJson('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("strips markdown fences", () => {
|
||||
expect(parseAiJson('```json\n{"a":1}\n```')).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
8
apps/web/src/lib/ai/parse-json.ts
Executable file
8
apps/web/src/lib/ai/parse-json.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
export function parseAiJson(raw: string): unknown {
|
||||
let text = raw.trim();
|
||||
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
|
||||
if (fenced) {
|
||||
text = fenced[1].trim();
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
145
apps/web/src/lib/ai/prompts/defaults.ts
Executable file
145
apps/web/src/lib/ai/prompts/defaults.ts
Executable file
@@ -0,0 +1,145 @@
|
||||
export const PROMPT_DEFAULTS: Record<
|
||||
string,
|
||||
{ name: string; description: string; category: "template" | "system_prompt"; body: string }
|
||||
> = {
|
||||
quest_generation: {
|
||||
name: "Quest Generation",
|
||||
description: "Daily micro-quest suggestions",
|
||||
category: "template",
|
||||
body: `Based on this week's activity, suggest up to 3 small micro-quests for today.
|
||||
Context: {{context}}
|
||||
Return JSON: { "quests": [{ "title", "reason", "xp_hint", "category" }] }`,
|
||||
},
|
||||
exploration_generation: {
|
||||
name: "Exploration Generation",
|
||||
description: "Weekly curiosity quests",
|
||||
category: "template",
|
||||
body: `Suggest 3-5 gentle personal growth curiosity quests for this week in AdventureOS.
|
||||
Each quest should feel optional, meaningful, and achievable in 15-45 minutes — reading, learning, exercise, reflection, spiritual practice, or small technical exploration.
|
||||
Avoid duplicating active, completed, or recently denied quests listed in the context.
|
||||
Context (scores, recent activity, existing/denied quests): {{context}}
|
||||
Return JSON only: { "explorations": [{ "title", "hook", "category", "minutes" }] }`,
|
||||
},
|
||||
weekly_review: {
|
||||
name: "Weekly Review",
|
||||
description: "Mentor weekly review letter",
|
||||
category: "template",
|
||||
body: `Write a weekly mentor review. Identify gentle patterns, offer encouragement, suggest one focus.
|
||||
Context: {{context}}
|
||||
Return JSON: { "patterns": ["..."], "encouragement": "...", "focus_suggestion": "...", "letter": "150-250 word warm letter in second person" }`,
|
||||
},
|
||||
homework_generation: {
|
||||
name: "Homework Generation",
|
||||
description: "Teacher homework assignments",
|
||||
category: "template",
|
||||
body: `Create topic-specific learning materials about: {{topic}}
|
||||
Include a short introduction, learning objectives, reading/research steps, homework, reflection prompt, flashcards, and a quiz.
|
||||
Return JSON only:
|
||||
{
|
||||
"title": "...",
|
||||
"introduction": "...",
|
||||
"objectives": ["..."],
|
||||
"readingSteps": ["..."],
|
||||
"reflectionPrompt": "...",
|
||||
"flashcards": [{ "front", "back" }],
|
||||
"quiz": [{ "question", "options": ["a","b","c","d"], "answer": 0 }],
|
||||
"assignment": "..."
|
||||
}
|
||||
Use answer as a 0-based index number.`,
|
||||
},
|
||||
reflection_prompt: {
|
||||
name: "Reflection Prompt",
|
||||
description: "Daily reflection assistance",
|
||||
category: "template",
|
||||
body: `Help {{user_name}} reflect on their day.
|
||||
Week summary: {{week_summary}}
|
||||
Prayer summary: {{prayer_summary}}
|
||||
Offer 2-3 gentle reflection questions. Return JSON: { "questions": ["..."] }`,
|
||||
},
|
||||
mentor_summary: {
|
||||
name: "Mentor Summary",
|
||||
description: "Short mentor summary",
|
||||
category: "template",
|
||||
body: `Summarize {{user_name}} warmly in 150-300 words based on these verified memories.
|
||||
Memories: {{context}}
|
||||
Write in second person. Do not invent facts not in the memories. Plain text only.`,
|
||||
},
|
||||
learning_assignment: {
|
||||
name: "Learning Assignment",
|
||||
description: "Structured learning task",
|
||||
category: "template",
|
||||
body: `Create a gentle 20-minute learning assignment about {{learning_topics}}.
|
||||
Return JSON: { "assignment": "...", "steps": ["..."] }`,
|
||||
},
|
||||
reading_recommendation: {
|
||||
name: "Reading Recommendation",
|
||||
description: "Book or article suggestions",
|
||||
category: "template",
|
||||
body: `Based on reading progress: {{reading_progress}}
|
||||
Suggest 1-2 reading directions. Return JSON: { "recommendations": [{ "title", "reason" }] }`,
|
||||
},
|
||||
system_core: {
|
||||
name: "Core System Instructions",
|
||||
description: "Base persona for all AI roles",
|
||||
category: "system_prompt",
|
||||
body: `You are the Guide in AdventureOS — a wise, warm mentor helping someone build consistency over years.
|
||||
Never use guilt, shame, hustle culture, or comparison language.
|
||||
Be concise, encouraging, and practical. Output valid JSON only.`,
|
||||
},
|
||||
system_mentor: {
|
||||
name: "Mentor Instructions",
|
||||
description: "Weekly review mentor persona",
|
||||
category: "system_prompt",
|
||||
body: `You write as a patient mentor who has walked many roads. Speak in second person. Celebrate small wins.`,
|
||||
},
|
||||
system_quest: {
|
||||
name: "Quest Generation Instructions",
|
||||
description: "Quest giver persona",
|
||||
category: "system_prompt",
|
||||
body: `Suggest tiny, achievable quests. Never overwhelming. Each quest should feel optional and inviting.`,
|
||||
},
|
||||
system_homework: {
|
||||
name: "Homework Instructions",
|
||||
description: "Teacher homework persona",
|
||||
category: "system_prompt",
|
||||
body: `Create clear, engaging learning materials. Keep quizzes fair and assignments under 30 minutes.`,
|
||||
},
|
||||
system_weekly_review: {
|
||||
name: "Weekly Review Instructions",
|
||||
description: "Review generation persona",
|
||||
category: "system_prompt",
|
||||
body: `Identify patterns without judgment. The letter should feel like a handwritten note from a trusted friend.`,
|
||||
},
|
||||
system_teacher: {
|
||||
name: "Teacher Instructions",
|
||||
description: "Teacher content persona",
|
||||
category: "system_prompt",
|
||||
body: `Explain concepts simply. Use examples from everyday life. Encourage curiosity over perfection.`,
|
||||
},
|
||||
};
|
||||
|
||||
export const PLACEHOLDER_DOCS = [
|
||||
{ key: "user_name", description: "User display name" },
|
||||
{ key: "context", description: "JSON activity context" },
|
||||
{ key: "topic", description: "Learning topic" },
|
||||
{ key: "week_summary", description: "Summary of the week" },
|
||||
{ key: "reading_progress", description: "Reading stats" },
|
||||
{ key: "exercise_summary", description: "Exercise activity" },
|
||||
{ key: "prayer_summary", description: "Prayer/litany progress" },
|
||||
{ key: "learning_topics", description: "Current learning topics" },
|
||||
{ key: "chronicle_context", description: "Long-term journey context" },
|
||||
];
|
||||
|
||||
export const PERSONALITY_MODIFIERS: Record<string, string> = {
|
||||
supportive_mentor: "Be warm, encouraging, and patient. Focus on progress over perfection.",
|
||||
wise_teacher: "Be thoughtful and measured. Share wisdom through gentle observations.",
|
||||
quiet_observer: "Be brief and understated. Notice patterns without much commentary.",
|
||||
academic_tutor: "Be precise and educational. Explain the why behind suggestions.",
|
||||
friendly_coach: "Be energetic but never pushy. Use friendly, conversational language.",
|
||||
};
|
||||
|
||||
export const VERBOSITY_MODIFIERS: Record<string, string> = {
|
||||
minimal: "Keep responses very brief.",
|
||||
balanced: "Be concise but complete.",
|
||||
detailed: "Provide thorough, detailed responses.",
|
||||
};
|
||||
25
apps/web/src/lib/ai/prompts/render.test.ts
Executable file
25
apps/web/src/lib/ai/prompts/render.test.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderTemplate, findMissingPlaceholders } from "./render";
|
||||
|
||||
describe("renderTemplate", () => {
|
||||
it("substitutes placeholders", () => {
|
||||
const result = renderTemplate("Hello {{user_name}}, context: {{context}}", {
|
||||
user_name: "Traveler",
|
||||
context: { level: 5 },
|
||||
});
|
||||
expect(result).toContain("Hello Traveler");
|
||||
expect(result).toContain('"level":5');
|
||||
});
|
||||
|
||||
it("leaves missing placeholders intact", () => {
|
||||
const result = renderTemplate("Hello {{missing}}", {});
|
||||
expect(result).toBe("Hello {{missing}}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMissingPlaceholders", () => {
|
||||
it("finds missing keys", () => {
|
||||
const missing = findMissingPlaceholders("{{user_name}} {{topic}}", { user_name: "A" });
|
||||
expect(missing).toEqual(["topic"]);
|
||||
});
|
||||
});
|
||||
41
apps/web/src/lib/ai/prompts/render.ts
Executable file
41
apps/web/src/lib/ai/prompts/render.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;
|
||||
|
||||
export function renderTemplate(
|
||||
body: string,
|
||||
data: Record<string, string | number | unknown>
|
||||
): string {
|
||||
return body.replace(PLACEHOLDER_RE, (_, key: string) => {
|
||||
const val = data[key];
|
||||
if (val === undefined || val === null) return `{{${key}}}`;
|
||||
if (typeof val === "object") return JSON.stringify(val);
|
||||
return String(val);
|
||||
});
|
||||
}
|
||||
|
||||
export function findMissingPlaceholders(
|
||||
body: string,
|
||||
data: Record<string, unknown>
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
let match;
|
||||
const re = /\{\{(\w+)\}\}/g;
|
||||
while ((match = re.exec(body)) !== null) {
|
||||
const key = match[1];
|
||||
if (data[key] === undefined && !missing.includes(key)) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export const SAMPLE_PREVIEW_DATA: Record<string, unknown> = {
|
||||
user_name: "Traveler",
|
||||
context: { level: 5, recentReading: 42, exerciseDays: 3 },
|
||||
topic: "Stoic philosophy",
|
||||
week_summary: "You read 35 pages, prayed 4 days, and exercised twice.",
|
||||
reading_progress: "Currently reading page 120 of 300",
|
||||
exercise_summary: "2 exercise sessions this week",
|
||||
prayer_summary: "4 of 5 morning prayers completed",
|
||||
learning_topics: "Roman history, Latin roots",
|
||||
chronicle_context: "Chapter 3: The Long Road — 45 days on the journey",
|
||||
};
|
||||
38
apps/web/src/lib/ai/provider-registry.ts
Executable file
38
apps/web/src/lib/ai/provider-registry.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import type { AIProvider, AIProviderType } from "./types";
|
||||
import { createOllamaProvider } from "./providers/ollama";
|
||||
import {
|
||||
createOpenAICompatibleProvider,
|
||||
createLlamaCppProvider,
|
||||
} from "./providers/openai-compatible";
|
||||
|
||||
const providers: Record<AIProviderType, () => AIProvider> = {
|
||||
ollama: createOllamaProvider,
|
||||
openai_compatible: () => createOpenAICompatibleProvider("openai_compatible"),
|
||||
llamacpp: createLlamaCppProvider,
|
||||
};
|
||||
|
||||
export function getProvider(type: AIProviderType): AIProvider {
|
||||
const factory = providers[type];
|
||||
if (!factory) throw new Error(`Unknown provider: ${type}`);
|
||||
return factory();
|
||||
}
|
||||
|
||||
export function listProviderTypes(): {
|
||||
type: AIProviderType;
|
||||
name: string;
|
||||
envVars: string[];
|
||||
}[] {
|
||||
return [
|
||||
{ type: "ollama", name: "Ollama", envVars: [] },
|
||||
{
|
||||
type: "openai_compatible",
|
||||
name: "OpenAI Compatible",
|
||||
envVars: ["OPENAI_API_KEY"],
|
||||
},
|
||||
{
|
||||
type: "llamacpp",
|
||||
name: "llama.cpp Server",
|
||||
envVars: ["LLAMACPP_API_KEY"],
|
||||
},
|
||||
];
|
||||
}
|
||||
147
apps/web/src/lib/ai/providers/ollama.ts
Executable file
147
apps/web/src/lib/ai/providers/ollama.ts
Executable file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
AIProviderConfig,
|
||||
AIHealthResult,
|
||||
GenerateTextRequest,
|
||||
GenerateTextResponse,
|
||||
} from "../types";
|
||||
import { isModelAvailable, resolveModelName } from "../model-resolve";
|
||||
import { redactError, redactUrl } from "../types";
|
||||
|
||||
export function createOllamaProvider(): AIProvider {
|
||||
return {
|
||||
type: "ollama",
|
||||
async healthCheck(config: AIProviderConfig): Promise<AIHealthResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${config.baseUrl}/api/tags`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: "offline",
|
||||
provider: "ollama",
|
||||
model: config.model,
|
||||
latencyMs,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: new Date().toISOString(),
|
||||
lastError: redactError(`HTTP ${res.status}`),
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
const models = (data.models ?? []).map(
|
||||
(m: { name: string }) => m.name
|
||||
);
|
||||
const modelReady = isModelAvailable(config.model, models);
|
||||
const resolvedModel = resolveModelName(config.model, models);
|
||||
return {
|
||||
status:
|
||||
models.length === 0
|
||||
? "degraded"
|
||||
: modelReady || resolvedModel
|
||||
? "online"
|
||||
: "degraded",
|
||||
provider: "ollama",
|
||||
model: resolvedModel ?? config.model,
|
||||
latencyMs,
|
||||
lastSuccessAt: new Date().toISOString(),
|
||||
lastFailureAt: modelReady ? null : new Date().toISOString(),
|
||||
lastError: modelReady
|
||||
? null
|
||||
: models.length === 0
|
||||
? "No models installed in Ollama"
|
||||
: `Model "${config.model}" not found. Available: ${models.slice(0, 5).join(", ")}`,
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: models,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "offline",
|
||||
provider: "ollama",
|
||||
model: config.model,
|
||||
latencyMs: Date.now() - start,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: new Date().toISOString(),
|
||||
lastError: redactError(e instanceof Error ? e.message : "Connection failed"),
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
async listModels(config: AIProviderConfig) {
|
||||
const res = await fetch(`${config.baseUrl}/api/tags`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.models ?? []).map((m: { name: string }) => m.name);
|
||||
},
|
||||
async generateText(
|
||||
config: AIProviderConfig,
|
||||
req: GenerateTextRequest
|
||||
): Promise<GenerateTextResponse> {
|
||||
const start = Date.now();
|
||||
const requested = req.model || config.model;
|
||||
let model = requested;
|
||||
|
||||
const tagsRes = await fetch(`${config.baseUrl}/api/tags`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (tagsRes.ok) {
|
||||
const tagsData = await tagsRes.json();
|
||||
const models = (tagsData.models ?? []).map(
|
||||
(m: { name: string }) => m.name as string
|
||||
);
|
||||
const resolved = resolveModelName(requested, models);
|
||||
if (resolved) {
|
||||
model = resolved;
|
||||
} else if (models.length === 0) {
|
||||
throw new Error(`Ollama error: no models installed`);
|
||||
}
|
||||
if (model !== requested) {
|
||||
console.error(
|
||||
`[adventureos-ai] ollama: resolved model "${requested}" -> "${model}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.baseUrl}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt: req.prompt,
|
||||
system: req.system,
|
||||
stream: false,
|
||||
format: req.format === "json" ? "json" : undefined,
|
||||
options: {
|
||||
temperature: req.temperature ?? config.temperature,
|
||||
num_predict: req.maxTokens ?? config.maxTokens,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(req.timeoutMs ?? config.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Ollama error: HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ""}`
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
text: data.response ?? "",
|
||||
model,
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
127
apps/web/src/lib/ai/providers/openai-compatible.ts
Executable file
127
apps/web/src/lib/ai/providers/openai-compatible.ts
Executable file
@@ -0,0 +1,127 @@
|
||||
import type {
|
||||
AIProvider,
|
||||
AIProviderConfig,
|
||||
AIHealthResult,
|
||||
GenerateTextRequest,
|
||||
GenerateTextResponse,
|
||||
} from "../types";
|
||||
import { redactError, redactUrl } from "../types";
|
||||
import { getOpenAiCompatibleApiKey } from "@/lib/config";
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const key = getOpenAiCompatibleApiKey();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (key) headers.Authorization = `Bearer ${key}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function createOpenAICompatibleProvider(
|
||||
type: "openai_compatible" | "llamacpp" = "openai_compatible"
|
||||
): AIProvider {
|
||||
return {
|
||||
type,
|
||||
async healthCheck(config: AIProviderConfig): Promise<AIHealthResult> {
|
||||
const start = Date.now();
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
try {
|
||||
const res = await fetch(`${base}/v1/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: "offline",
|
||||
provider: type,
|
||||
model: config.model,
|
||||
latencyMs,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: new Date().toISOString(),
|
||||
lastError: redactError(`HTTP ${res.status}`),
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
const models = (data.data ?? []).map((m: { id: string }) => m.id);
|
||||
return {
|
||||
status: "online",
|
||||
provider: type,
|
||||
model: config.model,
|
||||
latencyMs,
|
||||
lastSuccessAt: new Date().toISOString(),
|
||||
lastFailureAt: null,
|
||||
lastError: null,
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: models,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "offline",
|
||||
provider: type,
|
||||
model: config.model,
|
||||
latencyMs: Date.now() - start,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: new Date().toISOString(),
|
||||
lastError: redactError(e instanceof Error ? e.message : "Connection failed"),
|
||||
baseUrlSafe: redactUrl(config.baseUrl),
|
||||
contextLength: null,
|
||||
memoryUsageMb: null,
|
||||
modelsAvailable: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
async listModels(config: AIProviderConfig) {
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const res = await fetch(`${base}/v1/models`, {
|
||||
headers: getAuthHeaders(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.data ?? []).map((m: { id: string }) => m.id);
|
||||
},
|
||||
async generateText(
|
||||
config: AIProviderConfig,
|
||||
req: GenerateTextRequest
|
||||
): Promise<GenerateTextResponse> {
|
||||
const start = Date.now();
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const messages = [];
|
||||
if (req.system) messages.push({ role: "system", content: req.system });
|
||||
messages.push({ role: "user", content: req.prompt });
|
||||
|
||||
const res = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: req.model || config.model,
|
||||
messages,
|
||||
temperature: req.temperature ?? config.temperature,
|
||||
max_tokens: req.maxTokens ?? config.maxTokens,
|
||||
response_format:
|
||||
req.format === "json" ? { type: "json_object" } : undefined,
|
||||
}),
|
||||
signal: AbortSignal.timeout(req.timeoutMs ?? config.timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(redactError(`API error ${res.status}: ${errText}`));
|
||||
}
|
||||
const data = await res.json();
|
||||
const text = data.choices?.[0]?.message?.content ?? "";
|
||||
return {
|
||||
text,
|
||||
model: req.model || config.model,
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const createLlamaCppProvider = () =>
|
||||
createOpenAICompatibleProvider("llamacpp");
|
||||
16
apps/web/src/lib/ai/types.test.ts
Executable file
16
apps/web/src/lib/ai/types.test.ts
Executable file
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { redactError, redactUrl } from "./types";
|
||||
|
||||
describe("redactError", () => {
|
||||
it("redacts API keys", () => {
|
||||
const result = redactError("Error with sk-abcdefghijklmnop token");
|
||||
expect(result).not.toContain("sk-abcdefghijklmnop");
|
||||
expect(result).toContain("sk-***");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactUrl", () => {
|
||||
it("returns origin and path", () => {
|
||||
expect(redactUrl("http://localhost:11434/api")).toBe("http://localhost:11434/api");
|
||||
});
|
||||
});
|
||||
108
apps/web/src/lib/ai/types.ts
Executable file
108
apps/web/src/lib/ai/types.ts
Executable file
@@ -0,0 +1,108 @@
|
||||
export type AIProviderType = "ollama" | "llamacpp" | "openai_compatible";
|
||||
|
||||
export type AIHealthStatus = "online" | "offline" | "degraded";
|
||||
|
||||
export interface AIError {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateTextRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
format?: "json" | "text";
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface GenerateTextResponse {
|
||||
text: string;
|
||||
model: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AIHealthResult {
|
||||
status: AIHealthStatus;
|
||||
provider: AIProviderType;
|
||||
model: string;
|
||||
latencyMs: number;
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
lastError: string | null;
|
||||
baseUrlSafe: string | null;
|
||||
contextLength: number | null;
|
||||
memoryUsageMb: number | null;
|
||||
modelsAvailable: string[];
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
readonly type: AIProviderType;
|
||||
healthCheck(config: AIProviderConfig): Promise<AIHealthResult>;
|
||||
listModels?(config: AIProviderConfig): Promise<string[]>;
|
||||
generateText(
|
||||
config: AIProviderConfig,
|
||||
req: GenerateTextRequest
|
||||
): Promise<GenerateTextResponse>;
|
||||
}
|
||||
|
||||
export interface AIProviderConfig {
|
||||
type: AIProviderType;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AIBehaviorConfig {
|
||||
personality: "supportive_mentor" | "wise_teacher" | "quiet_observer" | "academic_tutor" | "friendly_coach";
|
||||
verbosity: "minimal" | "balanced" | "detailed";
|
||||
frequency: "daily" | "weekly" | "monthly";
|
||||
creativity: number;
|
||||
enabled: boolean;
|
||||
strictMode: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_BEHAVIOR: AIBehaviorConfig = {
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
frequency: "weekly",
|
||||
creativity: 0.7,
|
||||
enabled: true,
|
||||
strictMode: false,
|
||||
};
|
||||
|
||||
import { env } from "@/lib/config";
|
||||
|
||||
export const DEFAULT_AI_PROVIDER: AIProviderConfig = {
|
||||
type: "ollama",
|
||||
baseUrl: env.ollamaUrl,
|
||||
model: env.ollamaModelFast,
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
timeoutMs: 60000,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function redactUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.password) u.password = "***";
|
||||
if (u.username && u.username !== "localhost") u.username = "***";
|
||||
return u.origin + u.pathname.replace(/\/+$/, "");
|
||||
} catch {
|
||||
return url.replace(/\/\/[^@]+@/, "//***@");
|
||||
}
|
||||
}
|
||||
|
||||
export function redactError(msg: string): string {
|
||||
return msg
|
||||
.replace(/sk-[a-zA-Z0-9]{10,}/g, "sk-***")
|
||||
.replace(/Bearer\s+[a-zA-Z0-9._-]+/gi, "Bearer ***")
|
||||
.replace(/api[_-]?key[=:]\s*\S+/gi, "api_key=***")
|
||||
.slice(0, 500);
|
||||
}
|
||||
32
apps/web/src/lib/api-client.ts
Executable file
32
apps/web/src/lib/api-client.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, init);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data && "error" in data
|
||||
? String((data as { error: string }).error)
|
||||
: "Request failed";
|
||||
throw new ApiClientError(message, res.status);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function jsonBody(body: unknown): RequestInit {
|
||||
return {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
24
apps/web/src/lib/api.ts
Executable file
24
apps/web/src/lib/api.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mapErrorToResponse } from "@/lib/errors";
|
||||
|
||||
export function jsonOk<T>(data: T, status = 200) {
|
||||
return NextResponse.json(data, { status });
|
||||
}
|
||||
|
||||
export function jsonError(message: string, status = 400) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
export async function handleApi<T>(
|
||||
fn: () => Promise<T>,
|
||||
unauthorized = false
|
||||
) {
|
||||
try {
|
||||
const data = await fn();
|
||||
return jsonOk(data);
|
||||
} catch (e) {
|
||||
const { message, status } = mapErrorToResponse(e);
|
||||
if (status >= 500) console.error(e);
|
||||
return jsonError(message, unauthorized ? 401 : status);
|
||||
}
|
||||
}
|
||||
37
apps/web/src/lib/auth.ts
Executable file
37
apps/web/src/lib/auth.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import { getIronSession, SessionOptions } from "iron-session";
|
||||
import { cookies } from "next/headers";
|
||||
import { env } from "@/lib/config";
|
||||
|
||||
export interface SessionData {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export const sessionOptions: SessionOptions = {
|
||||
password: env.sessionSecret,
|
||||
cookieName: "adventureos_session",
|
||||
cookieOptions: {
|
||||
secure: env.isProduction,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
},
|
||||
};
|
||||
|
||||
export async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions);
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string): boolean {
|
||||
return password === env.authPassword;
|
||||
}
|
||||
|
||||
export async function requireAuth() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyCronSecret(request: Request): boolean {
|
||||
const secret = request.headers.get("x-cron-secret");
|
||||
return secret === env.cronSecret;
|
||||
}
|
||||
48
apps/web/src/lib/config/constants.ts
Executable file
48
apps/web/src/lib/config/constants.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
/** Settings table keys persisted per user. */
|
||||
export const SETTINGS_KEYS = {
|
||||
theme: "theme",
|
||||
aiConfig: "ai_config",
|
||||
aiProvider: "ai_provider",
|
||||
aiHealthCache: "ai_health_cache",
|
||||
weeklyReadingGoal: "weekly_reading_goal",
|
||||
dayBoundaryHour: "day_boundary_hour",
|
||||
aiProfileSummary: "ai_profile_summary",
|
||||
aiMemoryLearning: "ai_memory_learning",
|
||||
} as const;
|
||||
|
||||
export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS];
|
||||
|
||||
/** Action event types recorded for undo. */
|
||||
export const ACTION_TYPES = {
|
||||
adventureItemUpdate: "adventure_item.update",
|
||||
adventureItemCreate: "adventure_item.create",
|
||||
adventureItemDelete: "adventure_item.delete",
|
||||
adventureWorkHoursUpdate: "adventure.work_hours.update",
|
||||
adventureRestDay: "adventure.rest_day",
|
||||
dailyTodoCreate: "daily_todo.create",
|
||||
dailyTodoUpdate: "daily_todo.update",
|
||||
dailyTodoDelete: "daily_todo.delete",
|
||||
readingLogPages: "reading.log_pages",
|
||||
reflectionSave: "reflection.save",
|
||||
explorationUpdate: "exploration.update",
|
||||
templateItemDelete: "template_item.delete",
|
||||
} as const;
|
||||
|
||||
export type ActionType = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
|
||||
|
||||
/** Entity types referenced by action events. */
|
||||
export const ENTITY_TYPES = {
|
||||
book: "book",
|
||||
readingProgress: "reading_progress",
|
||||
dailyAdventureItem: "daily_adventure_item",
|
||||
dailyTodo: "daily_todo",
|
||||
dailyAdventure: "daily_adventure",
|
||||
exploration: "exploration",
|
||||
adventureItem: "adventure_item",
|
||||
reflection: "reflection",
|
||||
} as const;
|
||||
|
||||
export type EntityType = (typeof ENTITY_TYPES)[keyof typeof ENTITY_TYPES];
|
||||
|
||||
/** Default theme when none is stored. */
|
||||
export const DEFAULT_THEME_ID = "minimal-dark";
|
||||
61
apps/web/src/lib/config/env.ts
Executable file
61
apps/web/src/lib/config/env.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Centralised environment configuration.
|
||||
* Preserves existing env variable names and default values.
|
||||
*/
|
||||
export const env = {
|
||||
get nodeEnv(): string {
|
||||
return process.env.NODE_ENV ?? "development";
|
||||
},
|
||||
|
||||
get isProduction(): boolean {
|
||||
return env.nodeEnv === "production";
|
||||
},
|
||||
|
||||
get sessionSecret(): string {
|
||||
return process.env.SESSION_SECRET ?? "complex_password_at_least_32_characters_long";
|
||||
},
|
||||
|
||||
get authPassword(): string {
|
||||
return process.env.AUTH_PASSWORD ?? "adventure";
|
||||
},
|
||||
|
||||
get cronSecret(): string {
|
||||
return process.env.CRON_SECRET ?? "change-me-cron-secret";
|
||||
},
|
||||
|
||||
get ollamaUrl(): string {
|
||||
return process.env.OLLAMA_URL ?? "http://localhost:11434";
|
||||
},
|
||||
|
||||
get ollamaModelFast(): string {
|
||||
return process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b";
|
||||
},
|
||||
|
||||
get ollamaModelProse(): string {
|
||||
return process.env.OLLAMA_MODEL_PROSE ?? process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b";
|
||||
},
|
||||
|
||||
get openAiApiKey(): string | undefined {
|
||||
return process.env.OPENAI_API_KEY;
|
||||
},
|
||||
|
||||
get llamaCppApiKey(): string | undefined {
|
||||
return process.env.LLAMACPP_API_KEY;
|
||||
},
|
||||
|
||||
get calibreLibraryPath(): string | undefined {
|
||||
return process.env.CALIBRE_LIBRARY_PATH;
|
||||
},
|
||||
|
||||
get calibreMetadataDbPath(): string | undefined {
|
||||
return process.env.CALIBRE_METADATA_DB_PATH;
|
||||
},
|
||||
|
||||
get calibreReadOnly(): boolean {
|
||||
return process.env.CALIBRE_READ_ONLY !== "false";
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getOpenAiCompatibleApiKey(): string | undefined {
|
||||
return env.openAiApiKey ?? env.llamaCppApiKey;
|
||||
}
|
||||
10
apps/web/src/lib/config/index.ts
Executable file
10
apps/web/src/lib/config/index.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
export { env, getOpenAiCompatibleApiKey } from "./env";
|
||||
export {
|
||||
SETTINGS_KEYS,
|
||||
ACTION_TYPES,
|
||||
ENTITY_TYPES,
|
||||
DEFAULT_THEME_ID,
|
||||
type SettingsKey,
|
||||
type ActionType,
|
||||
type EntityType,
|
||||
} from "./constants";
|
||||
25
apps/web/src/lib/dates-client.ts
Executable file
25
apps/web/src/lib/dates-client.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import {
|
||||
getLogicalToday,
|
||||
getLogicalYesterday,
|
||||
isWithinGraceWindow,
|
||||
DEFAULT_DAY_BOUNDARY_HOUR,
|
||||
} from "@adventureos/shared";
|
||||
|
||||
export function todayString(): string {
|
||||
return format(new Date(), "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export { getLogicalToday, getLogicalYesterday, isWithinGraceWindow, DEFAULT_DAY_BOUNDARY_HOUR };
|
||||
|
||||
export function formatDisplayDate(dateStr: string): string {
|
||||
return format(parseISO(dateStr), "EEEE, d MMM yyyy");
|
||||
}
|
||||
|
||||
export function weekStartString(date = new Date()): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return format(d, "yyyy-MM-dd");
|
||||
}
|
||||
45
apps/web/src/lib/dates.ts
Executable file
45
apps/web/src/lib/dates.ts
Executable file
@@ -0,0 +1,45 @@
|
||||
import { format, subDays, startOfWeek, parseISO } from "date-fns";
|
||||
import {
|
||||
getLogicalToday as sharedLogicalToday,
|
||||
getLogicalYesterday as sharedLogicalYesterday,
|
||||
isWithinGraceWindow as sharedGraceWindow,
|
||||
DEFAULT_DAY_BOUNDARY_HOUR,
|
||||
} from "@adventureos/shared";
|
||||
|
||||
export function todayString(): string {
|
||||
return format(new Date(), "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export function getLogicalToday(boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR, now = new Date()): string {
|
||||
return sharedLogicalToday(boundaryHour, now);
|
||||
}
|
||||
|
||||
export function getLogicalYesterday(boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR, now = new Date()): string {
|
||||
return sharedLogicalYesterday(boundaryHour, now);
|
||||
}
|
||||
|
||||
export function isWithinGraceWindow(
|
||||
boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR,
|
||||
graceHours = 4,
|
||||
now = new Date()
|
||||
): boolean {
|
||||
return sharedGraceWindow(boundaryHour, graceHours, now);
|
||||
}
|
||||
|
||||
export function formatDisplayDate(dateStr: string): string {
|
||||
return format(parseISO(dateStr), "EEEE, d MMM yyyy");
|
||||
}
|
||||
|
||||
export function weekStartString(date = new Date()): string {
|
||||
return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
export function lastNDays(n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
format(subDays(new Date(), i), "yyyy-MM-dd")
|
||||
);
|
||||
}
|
||||
|
||||
export function dayOfWeek(dateStr: string): number {
|
||||
return parseISO(dateStr).getDay();
|
||||
}
|
||||
2
apps/web/src/lib/db.ts
Executable file
2
apps/web/src/lib/db.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { db } from "@adventureos/db";
|
||||
export * from "@adventureos/db/schema";
|
||||
43
apps/web/src/lib/errors/index.ts
Executable file
43
apps/web/src/lib/errors/index.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 500
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = "Not found") {
|
||||
super(message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super(message, 401);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 503);
|
||||
}
|
||||
}
|
||||
|
||||
export function mapErrorToResponse(error: unknown): { message: string; status: number } {
|
||||
if (error instanceof AppError) {
|
||||
return { message: error.message, status: error.statusCode };
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
if (msg === "Unauthorized") return { message: msg, status: 401 };
|
||||
if (msg.includes("No user found")) return { message: msg, status: 503 };
|
||||
return { message: msg, status: 500 };
|
||||
}
|
||||
20
apps/web/src/lib/reflection-utils.ts
Executable file
20
apps/web/src/lib/reflection-utils.ts
Executable file
@@ -0,0 +1,20 @@
|
||||
export type ReflectionData = {
|
||||
wentWell: string;
|
||||
learned: string;
|
||||
improveTomorrow: string;
|
||||
};
|
||||
|
||||
export function hasMeaningfulReflectionContent(data: ReflectionData): boolean {
|
||||
return [data.wentWell, data.learned, data.improveTomorrow].some(
|
||||
(field) => field.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldAwardReflectionXp(
|
||||
existing: ReflectionData | null | undefined,
|
||||
data: ReflectionData
|
||||
): boolean {
|
||||
if (!hasMeaningfulReflectionContent(data)) return false;
|
||||
if (!existing) return true;
|
||||
return !hasMeaningfulReflectionContent(existing);
|
||||
}
|
||||
26
apps/web/src/lib/repositories/adventure-templates.repository.ts
Executable file
26
apps/web/src/lib/repositories/adventure-templates.repository.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||
import { db, adventureTemplates, adventureItems } from "@/lib/db";
|
||||
|
||||
export async function listTemplates(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)))
|
||||
.orderBy(desc(adventureTemplates.sortPriority));
|
||||
}
|
||||
|
||||
export async function findTemplateById(templateId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(eq(adventureTemplates.id, templateId));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function listTemplateItems(templateId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
}
|
||||
3
apps/web/src/lib/repositories/index.ts
Executable file
3
apps/web/src/lib/repositories/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export * from "./settings.repository";
|
||||
export * from "./teacher.repository";
|
||||
export * from "./adventure-templates.repository";
|
||||
29
apps/web/src/lib/repositories/settings.repository.ts
Executable file
29
apps/web/src/lib/repositories/settings.repository.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { db, settings } from "@/lib/db";
|
||||
import type { SettingsKey } from "@/lib/config";
|
||||
|
||||
export async function getSettingsForUser(userId: string) {
|
||||
return db.select().from(settings).where(eq(settings.userId, userId));
|
||||
}
|
||||
|
||||
export async function upsertSetting(
|
||||
userId: string,
|
||||
key: SettingsKey | string,
|
||||
value: unknown
|
||||
) {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key, value })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: sql`excluded.value` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSettingValue(userId: string, key: SettingsKey | string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.userId, userId), eq(settings.key, key)));
|
||||
return row?.value ?? null;
|
||||
}
|
||||
51
apps/web/src/lib/repositories/teacher.repository.ts
Executable file
51
apps/web/src/lib/repositories/teacher.repository.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db, teacherContent } from "@/lib/db";
|
||||
|
||||
export async function listTeacherContent(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(teacherContent)
|
||||
.where(eq(teacherContent.userId, userId))
|
||||
.orderBy(desc(teacherContent.createdAt));
|
||||
}
|
||||
|
||||
export async function findTeacherContentById(id: string) {
|
||||
const [row] = await db.select().from(teacherContent).where(eq(teacherContent.id, id));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function insertTeacherContent(data: {
|
||||
userId: string;
|
||||
topic: string;
|
||||
content: Record<string, unknown>;
|
||||
explorationId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const [row] = await db
|
||||
.insert(teacherContent)
|
||||
.values({
|
||||
userId: data.userId,
|
||||
explorationId: data.explorationId,
|
||||
topic: data.topic,
|
||||
content: data.content,
|
||||
status: data.status ?? "active",
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateTeacherContent(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
status: string;
|
||||
completedNote: string;
|
||||
completedAt: Date;
|
||||
}>
|
||||
) {
|
||||
const [row] = await db
|
||||
.update(teacherContent)
|
||||
.set(data)
|
||||
.where(eq(teacherContent.id, id))
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
93
apps/web/src/lib/services/achievements.ts
Executable file
93
apps/web/src/lib/services/achievements.ts
Executable file
@@ -0,0 +1,93 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getAchievement, ACHIEVEMENTS } from "@adventureos/shared";
|
||||
import { db, achievements, users, userProgress, explorations, weeklyReviews } from "../db";
|
||||
import { getBooksCompletedCount, getTotalPagesRead, getReadingStreak } from "./reading";
|
||||
import { refreshScores } from "./adventure";
|
||||
|
||||
export async function getAchievements(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(achievements)
|
||||
.where(eq(achievements.userId, userId));
|
||||
}
|
||||
|
||||
export async function unlockAchievement(
|
||||
userId: string,
|
||||
key: string,
|
||||
metadata?: Record<string, unknown>
|
||||
) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(achievements)
|
||||
.where(and(eq(achievements.userId, userId), eq(achievements.key, key)));
|
||||
|
||||
if (existing.length > 0) return null;
|
||||
|
||||
const [a] = await db
|
||||
.insert(achievements)
|
||||
.values({ userId, key, metadata })
|
||||
.returning();
|
||||
return a;
|
||||
}
|
||||
|
||||
export async function checkAchievements(userId: string) {
|
||||
const unlocked: string[] = [];
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
|
||||
if (!user || !progress) return unlocked;
|
||||
|
||||
const booksDone = await getBooksCompletedCount(userId);
|
||||
const totalPages = await getTotalPagesRead(userId);
|
||||
const streak = await getReadingStreak(userId);
|
||||
const explorationDone = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.status, "completed")));
|
||||
const reviews = await db
|
||||
.select()
|
||||
.from(weeklyReviews)
|
||||
.where(eq(weeklyReviews.userId, userId));
|
||||
|
||||
const checks: [string, boolean][] = [
|
||||
["first_book", booksDone >= 1],
|
||||
["books_5", booksDone >= 5],
|
||||
["books_10", booksDone >= 10],
|
||||
["pages_1000", totalPages >= 1000],
|
||||
["reading_streak_7", streak.best >= 7],
|
||||
["reading_streak_30", streak.best >= 30],
|
||||
["level_10", progress.level >= 10],
|
||||
["level_25", progress.level >= 25],
|
||||
["level_50", progress.level >= 50],
|
||||
["level_100", progress.level >= 100],
|
||||
["exploration_1", explorationDone.length >= 1],
|
||||
["exploration_10", explorationDone.length >= 10],
|
||||
["weekly_review_4", reviews.length >= 4],
|
||||
["consistency_60", progress.consistencyScore >= 60],
|
||||
[
|
||||
"year_one",
|
||||
user.createdAt &&
|
||||
Date.now() - new Date(user.createdAt).getTime() >= 365 * 86400000,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [key, condition] of checks) {
|
||||
if (condition) {
|
||||
const a = await unlockAchievement(userId, key);
|
||||
if (a) unlocked.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
export function getAllAchievementDefinitions() {
|
||||
return ACHIEVEMENTS;
|
||||
}
|
||||
|
||||
export function formatAchievement(key: string) {
|
||||
return getAchievement(key);
|
||||
}
|
||||
73
apps/web/src/lib/services/action-events.ts
Executable file
73
apps/web/src/lib/services/action-events.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import { db, actionEvents } from "../db";
|
||||
import { eq, desc, and, isNull, lt, sql } from "drizzle-orm";
|
||||
|
||||
export interface RecordActionInput {
|
||||
userId: string;
|
||||
actionType: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
summary: string;
|
||||
beforeState: Record<string, unknown>;
|
||||
afterState: Record<string, unknown>;
|
||||
inversePatch?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
undoable?: boolean;
|
||||
}
|
||||
|
||||
export async function recordAction(input: RecordActionInput) {
|
||||
const [event] = await db
|
||||
.insert(actionEvents)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
actionType: input.actionType,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: input.summary,
|
||||
beforeState: input.beforeState,
|
||||
afterState: input.afterState,
|
||||
inversePatch: input.inversePatch,
|
||||
metadata: input.metadata ?? {},
|
||||
undoable: input.undoable ?? true,
|
||||
})
|
||||
.returning();
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function getRecentActions(userId: string, limit = 20) {
|
||||
return db
|
||||
.select()
|
||||
.from(actionEvents)
|
||||
.where(and(eq(actionEvents.userId, userId), isNull(actionEvents.undoneAt)))
|
||||
.orderBy(desc(actionEvents.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getActionEvent(userId: string, actionEventId: string) {
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(actionEvents)
|
||||
.where(
|
||||
and(eq(actionEvents.id, actionEventId), eq(actionEvents.userId, userId))
|
||||
);
|
||||
return event ?? null;
|
||||
}
|
||||
|
||||
export async function markUndone(actionEventId: string) {
|
||||
await db
|
||||
.update(actionEvents)
|
||||
.set({ undoneAt: new Date() })
|
||||
.where(eq(actionEvents.id, actionEventId));
|
||||
}
|
||||
|
||||
export async function pruneOldActions(userId: string, daysOld = 90) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - daysOld);
|
||||
await db
|
||||
.delete(actionEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(actionEvents.userId, userId),
|
||||
lt(actionEvents.createdAt, cutoff)
|
||||
)
|
||||
);
|
||||
}
|
||||
4
apps/web/src/lib/services/adventure.ts
Executable file
4
apps/web/src/lib/services/adventure.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export * from "./adventure/materialization";
|
||||
export * from "./adventure/daily";
|
||||
export * from "./adventure/scoring";
|
||||
export * from "./adventure/templates";
|
||||
4
apps/web/src/lib/services/adventure/checklist-value.ts
Executable file
4
apps/web/src/lib/services/adventure/checklist-value.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export function initialChecklistValue(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const size = (config.checklistSize as number) ?? 5;
|
||||
return { checks: Array(size).fill(false) };
|
||||
}
|
||||
351
apps/web/src/lib/services/adventure/daily.ts
Executable file
351
apps/web/src/lib/services/adventure/daily.ts
Executable file
@@ -0,0 +1,351 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
xpForAdventureState,
|
||||
type AdventureItemState,
|
||||
type DaySnapshot,
|
||||
XP_AWARDS,
|
||||
computeAllScores,
|
||||
} from "@adventureos/shared";
|
||||
import {
|
||||
db,
|
||||
dailyAdventureItems,
|
||||
dailyAdventures,
|
||||
dailyTodos,
|
||||
userProgress,
|
||||
readingLogs,
|
||||
books,
|
||||
xpEvents,
|
||||
} from "@/lib/db";
|
||||
import { lastNDays } from "@/lib/dates";
|
||||
import { awardXp } from "@/lib/services/xp";
|
||||
import { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
import { deriveState } from "./derive-state";
|
||||
import { getDailyAdventure, materializeDay } from "./materialization";
|
||||
import { refreshScores } from "./scoring";
|
||||
|
||||
export async function updateAdventureItem(
|
||||
userId: string,
|
||||
date: string,
|
||||
itemId: string,
|
||||
updates: { value?: Record<string, unknown>; state?: AdventureItemState }
|
||||
) {
|
||||
const { items } = await getDailyAdventure(userId, date);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const newValue = { ...(item.value as Record<string, unknown>), ...updates.value };
|
||||
const newState =
|
||||
updates.state ?? deriveState(item.type, newValue, item.config as Record<string, unknown>);
|
||||
|
||||
const oldState = item.state as AdventureItemState;
|
||||
const beforeState = {
|
||||
value: item.value,
|
||||
state: item.state,
|
||||
completedAt: item.completedAt,
|
||||
};
|
||||
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({
|
||||
value: newValue,
|
||||
state: newState,
|
||||
completedAt: newState === "done" ? new Date() : null,
|
||||
})
|
||||
.where(eq(dailyAdventureItems.id, itemId));
|
||||
|
||||
const xpEventIds: string[] = [];
|
||||
|
||||
if (newState !== oldState && newState !== "blank") {
|
||||
const xp = xpForAdventureState(newState);
|
||||
if (xp > 0) {
|
||||
const result = await awardXp(userId, date, "adventure_item", xp, {
|
||||
itemId,
|
||||
state: newState,
|
||||
});
|
||||
if (result.xpEventId) xpEventIds.push(result.xpEventId);
|
||||
}
|
||||
if (item.type === "checkbox" && item.label.toLowerCase().includes("exercise") && newState === "done") {
|
||||
const ex = await awardXp(userId, date, "exercise", XP_AWARDS.exercise, { itemId });
|
||||
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
|
||||
}
|
||||
if (item.type === "checklist") {
|
||||
const oldChecks = ((item.value as Record<string, unknown>).checks as boolean[]) ?? [];
|
||||
const newChecks = (newValue.checks as boolean[]) ?? [];
|
||||
const added = newChecks.filter((c, i) => c && !oldChecks[i]).length;
|
||||
if (added > 0) {
|
||||
const sp = await awardXp(userId, date, "spiritual", added * XP_AWARDS.spiritual_per_check, {
|
||||
itemId,
|
||||
});
|
||||
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureItemUpdate,
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: itemId,
|
||||
summary: `Updated ${item.label}`,
|
||||
beforeState,
|
||||
afterState: { value: newValue, state: newState, completedAt: newState === "done" ? new Date() : null },
|
||||
metadata: { date, xpEventIds, itemLabel: item.label },
|
||||
});
|
||||
|
||||
await refreshScores(userId);
|
||||
return { state: newState, value: newValue, actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function setRestDay(userId: string, date: string) {
|
||||
const adventure = await materializeDay(userId, date);
|
||||
const before = { isRestDay: adventure.isRestDay };
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isRestDay: true })
|
||||
.where(eq(dailyAdventures.id, adventure.id));
|
||||
const xpResult = await awardXp(userId, date, "rest_day", XP_AWARDS.rest_day);
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureRestDay,
|
||||
entityType: "daily_adventure",
|
||||
entityId: adventure.id,
|
||||
summary: "Marked rest day",
|
||||
beforeState: before,
|
||||
afterState: { isRestDay: true },
|
||||
metadata: {
|
||||
date,
|
||||
adventureId: adventure.id,
|
||||
xpEventIds: xpResult.xpEventId ? [xpResult.xpEventId] : [],
|
||||
},
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function updateDaySettings(
|
||||
userId: string,
|
||||
date: string,
|
||||
updates: { workHoursTarget?: number; dayMode?: string }
|
||||
) {
|
||||
const { adventure } = await getDailyAdventure(userId, date);
|
||||
const before = {
|
||||
workHoursTarget: adventure.workHoursTarget,
|
||||
dayMode: adventure.dayMode,
|
||||
isRestDay: adventure.isRestDay,
|
||||
};
|
||||
|
||||
const setValues: Record<string, unknown> = {
|
||||
isCustomized: true,
|
||||
customizedAt: new Date(),
|
||||
};
|
||||
|
||||
if (updates.workHoursTarget !== undefined) {
|
||||
setValues.workHoursTarget = updates.workHoursTarget.toString();
|
||||
}
|
||||
|
||||
if (updates.dayMode !== undefined) {
|
||||
setValues.dayMode = updates.dayMode;
|
||||
if (updates.dayMode === "rest") {
|
||||
setValues.isRestDay = true;
|
||||
} else if (updates.dayMode === "normal" || updates.dayMode === "low_energy") {
|
||||
setValues.isRestDay = false;
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(dailyAdventures).set(setValues).where(eq(dailyAdventures.id, adventure.id));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureWorkHoursUpdate,
|
||||
entityType: "daily_adventure",
|
||||
entityId: adventure.id,
|
||||
summary: updates.dayMode
|
||||
? `Set day mode to ${updates.dayMode}`
|
||||
: `Set work hours target to ${updates.workHoursTarget}h`,
|
||||
beforeState: before,
|
||||
afterState: updates,
|
||||
metadata: { date },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function updateDailyItemMeta(
|
||||
userId: string,
|
||||
date: string,
|
||||
itemId: string,
|
||||
updates: {
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
const { adventure, items } = await getDailyAdventure(userId, date);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const beforeState = { label: item.label, enabled: item.enabled, config: item.config };
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({
|
||||
label: updates.label ?? item.label,
|
||||
enabled: updates.enabled ?? item.enabled,
|
||||
config: updates.config ? { ...(item.config as object), ...updates.config } : item.config,
|
||||
isCustom: item.isCustom || !!updates.label,
|
||||
})
|
||||
.where(eq(dailyAdventureItems.id, itemId));
|
||||
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isCustomized: true, customizedAt: new Date() })
|
||||
.where(eq(dailyAdventures.id, adventure.id));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureItemUpdate,
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: itemId,
|
||||
summary: `Updated ${item.label}`,
|
||||
beforeState,
|
||||
afterState: updates,
|
||||
metadata: { date },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function addCustomDailyItem(
|
||||
userId: string,
|
||||
date: string,
|
||||
data: { label: string; type?: string }
|
||||
) {
|
||||
const { adventure, items } = await getDailyAdventure(userId, date);
|
||||
const maxOrder = items.reduce((m, i) => Math.max(m, i.sortOrder), 0);
|
||||
const [created] = await db
|
||||
.insert(dailyAdventureItems)
|
||||
.values({
|
||||
dailyAdventureId: adventure.id,
|
||||
type: data.type ?? "checkbox",
|
||||
label: data.label,
|
||||
isCustom: true,
|
||||
sortOrder: maxOrder + 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isCustomized: true, customizedAt: new Date() })
|
||||
.where(eq(dailyAdventures.id, adventure.id));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureItemCreate,
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: created.id,
|
||||
summary: `Added ${data.label}`,
|
||||
beforeState: {},
|
||||
afterState: { label: data.label, type: data.type ?? "checkbox" },
|
||||
metadata: { date },
|
||||
});
|
||||
return { item: created, actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function deleteCustomDailyItem(userId: string, date: string, itemId: string) {
|
||||
const { adventure, items } = await getDailyAdventure(userId, date);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
if (!item.isCustom) throw new Error("Only custom items can be removed");
|
||||
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyAdventureItems.id, itemId));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.adventureItemDelete,
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: itemId,
|
||||
summary: `Removed ${item.label}`,
|
||||
beforeState: { deletedAt: null, label: item.label },
|
||||
afterState: { deletedAt: new Date() },
|
||||
metadata: { date, adventureId: adventure.id },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function createDailyTodo(userId: string, date: string, label: string) {
|
||||
const { adventure, todos } = await getDailyAdventure(userId, date);
|
||||
const maxOrder = todos.reduce((m, t) => Math.max(m, t.sortOrder), 0);
|
||||
const [todo] = await db
|
||||
.insert(dailyTodos)
|
||||
.values({ dailyAdventureId: adventure.id, label, sortOrder: maxOrder + 1 })
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isCustomized: true, customizedAt: new Date() })
|
||||
.where(eq(dailyAdventures.id, adventure.id));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.dailyTodoCreate,
|
||||
entityType: "daily_todo",
|
||||
entityId: todo.id,
|
||||
summary: `Added todo: ${label}`,
|
||||
beforeState: {},
|
||||
afterState: { label, done: false },
|
||||
metadata: { date },
|
||||
});
|
||||
return { todo, actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function updateDailyTodo(
|
||||
userId: string,
|
||||
date: string,
|
||||
todoId: string,
|
||||
updates: { label?: string; done?: boolean }
|
||||
) {
|
||||
const { todos } = await getDailyAdventure(userId, date);
|
||||
const todo = todos.find((t) => t.id === todoId);
|
||||
if (!todo) throw new Error("Todo not found");
|
||||
|
||||
const beforeState = { label: todo.label, done: todo.done };
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({
|
||||
label: updates.label ?? todo.label,
|
||||
done: updates.done ?? todo.done,
|
||||
})
|
||||
.where(eq(dailyTodos.id, todoId));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.dailyTodoUpdate,
|
||||
entityType: "daily_todo",
|
||||
entityId: todoId,
|
||||
summary: updates.done !== undefined ? `Todo: ${todo.label}` : `Updated todo: ${todo.label}`,
|
||||
beforeState,
|
||||
afterState: updates,
|
||||
metadata: { date },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function deleteDailyTodo(userId: string, date: string, todoId: string) {
|
||||
const { todos } = await getDailyAdventure(userId, date);
|
||||
const todo = todos.find((t) => t.id === todoId);
|
||||
if (!todo) throw new Error("Todo not found");
|
||||
|
||||
await db.update(dailyTodos).set({ deletedAt: new Date() }).where(eq(dailyTodos.id, todoId));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.dailyTodoDelete,
|
||||
entityType: "daily_todo",
|
||||
entityId: todoId,
|
||||
summary: `Removed todo: ${todo.label}`,
|
||||
beforeState: { label: todo.label, deletedAt: null },
|
||||
afterState: { deletedAt: new Date() },
|
||||
metadata: { date },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
37
apps/web/src/lib/services/adventure/derive-state.test.ts
Executable file
37
apps/web/src/lib/services/adventure/derive-state.test.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveState } from "./derive-state";
|
||||
|
||||
describe("deriveState", () => {
|
||||
it("checkbox: blank when not done", () => {
|
||||
expect(deriveState("checkbox", {}, {})).toBe("blank");
|
||||
});
|
||||
|
||||
it("checkbox: done when checked", () => {
|
||||
expect(deriveState("checkbox", { done: true }, {})).toBe("done");
|
||||
});
|
||||
|
||||
it("duration: done at target", () => {
|
||||
expect(deriveState("duration", { hours: 8 }, { targetHours: 8 })).toBe("done");
|
||||
});
|
||||
|
||||
it("duration: partial at half target", () => {
|
||||
expect(deriveState("duration", { hours: 4 }, { targetHours: 8 })).toBe("partial");
|
||||
});
|
||||
|
||||
it("duration: started below half", () => {
|
||||
expect(deriveState("duration", { hours: 2 }, { targetHours: 8 })).toBe("started");
|
||||
});
|
||||
|
||||
it("checklist: partial when some checked", () => {
|
||||
expect(deriveState("checklist", { checks: [true, false, false] }, {})).toBe("partial");
|
||||
});
|
||||
|
||||
it("checklist: done when all checked", () => {
|
||||
expect(deriveState("checklist", { checks: [true, true] }, {})).toBe("done");
|
||||
});
|
||||
|
||||
it("reading: done at 10+ pages", () => {
|
||||
expect(deriveState("reading", { pages: 10 }, {})).toBe("done");
|
||||
expect(deriveState("reading", { pages: 5 }, {})).toBe("partial");
|
||||
});
|
||||
});
|
||||
32
apps/web/src/lib/services/adventure/derive-state.ts
Executable file
32
apps/web/src/lib/services/adventure/derive-state.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import type { AdventureItemState } from "@adventureos/shared";
|
||||
|
||||
export function deriveState(
|
||||
type: string,
|
||||
value: Record<string, unknown>,
|
||||
config: Record<string, unknown>
|
||||
): AdventureItemState {
|
||||
if (type === "checkbox" || type === "timeblock") {
|
||||
return value.done ? "done" : "blank";
|
||||
}
|
||||
if (type === "duration") {
|
||||
const hours = (value.hours as number) ?? 0;
|
||||
const target = (config.targetHours as number) ?? 0;
|
||||
if (hours <= 0) return "blank";
|
||||
if (target > 0 && hours >= target) return "done";
|
||||
if (hours > 0) return hours >= target * 0.5 ? "partial" : "started";
|
||||
return "started";
|
||||
}
|
||||
if (type === "checklist") {
|
||||
const checks = (value.checks as boolean[]) ?? [];
|
||||
const done = checks.filter(Boolean).length;
|
||||
if (done === 0) return "blank";
|
||||
if (done === checks.length) return "done";
|
||||
return "partial";
|
||||
}
|
||||
if (type === "reading") {
|
||||
const pages = (value.pages as number) ?? 0;
|
||||
if (pages <= 0) return "blank";
|
||||
return pages >= 10 ? "done" : "partial";
|
||||
}
|
||||
return "blank";
|
||||
}
|
||||
4
apps/web/src/lib/services/adventure/index.ts
Executable file
4
apps/web/src/lib/services/adventure/index.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export * from "./materialization";
|
||||
export * from "./daily";
|
||||
export * from "./scoring";
|
||||
export * from "./templates";
|
||||
148
apps/web/src/lib/services/adventure/materialization.ts
Executable file
148
apps/web/src/lib/services/adventure/materialization.ts
Executable file
@@ -0,0 +1,148 @@
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
dailyAdventureItems,
|
||||
dailyAdventures,
|
||||
adventureTemplates,
|
||||
adventureItems,
|
||||
dailyTodos,
|
||||
} from "@/lib/db";
|
||||
import { dayOfWeek } from "@/lib/dates";
|
||||
import { initialChecklistValue } from "./checklist-value";
|
||||
import { pickTemplateForDay } from "./template-matching";
|
||||
|
||||
export async function findTemplateForDay(userId: string, date: string) {
|
||||
const dow = dayOfWeek(date);
|
||||
const templates = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
|
||||
|
||||
return pickTemplateForDay(templates, dow);
|
||||
}
|
||||
|
||||
export async function materializeDay(userId: string, date: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(dailyAdventures)
|
||||
.where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, date)));
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const template = await findTemplateForDay(userId, date);
|
||||
if (!template) {
|
||||
const [adventure] = await db
|
||||
.insert(dailyAdventures)
|
||||
.values({ userId, date })
|
||||
.returning();
|
||||
return adventure;
|
||||
}
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, template.id), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
|
||||
const [adventure] = await db
|
||||
.insert(dailyAdventures)
|
||||
.values({ userId, date, templateId: template.id })
|
||||
.returning();
|
||||
|
||||
for (const item of items) {
|
||||
const config = (item.config ?? {}) as Record<string, unknown>;
|
||||
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
|
||||
await db.insert(dailyAdventureItems).values({
|
||||
dailyAdventureId: adventure.id,
|
||||
sourceItemId: item.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config,
|
||||
value,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
return adventure;
|
||||
}
|
||||
|
||||
export async function getDailyAdventure(userId: string, date: string) {
|
||||
const adventure = await materializeDay(userId, date);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(dailyAdventureItems)
|
||||
.where(
|
||||
and(
|
||||
eq(dailyAdventureItems.dailyAdventureId, adventure.id),
|
||||
isNull(dailyAdventureItems.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(dailyAdventureItems.sortOrder);
|
||||
|
||||
const todos = await db
|
||||
.select()
|
||||
.from(dailyTodos)
|
||||
.where(
|
||||
and(eq(dailyTodos.dailyAdventureId, adventure.id), isNull(dailyTodos.deletedAt))
|
||||
)
|
||||
.orderBy(dailyTodos.sortOrder);
|
||||
|
||||
return { adventure, items, todos };
|
||||
}
|
||||
|
||||
export async function applyTemplateToDate(
|
||||
userId: string,
|
||||
date: string,
|
||||
templateId: string,
|
||||
force = false
|
||||
) {
|
||||
const { adventure } = await getDailyAdventure(userId, date);
|
||||
if (adventure.isCustomized && !force) {
|
||||
throw new Error("Day is customized — confirm overwrite to apply template");
|
||||
}
|
||||
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
|
||||
if (!template) throw new Error("Template not found");
|
||||
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyAdventureItems.dailyAdventureId, adventure.id));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
|
||||
for (const item of items) {
|
||||
const config = (item.config ?? {}) as Record<string, unknown>;
|
||||
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
|
||||
await db.insert(dailyAdventureItems).values({
|
||||
dailyAdventureId: adventure.id,
|
||||
sourceItemId: item.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config,
|
||||
value,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({
|
||||
templateId,
|
||||
isCustomized: false,
|
||||
customizedAt: null,
|
||||
workHoursTarget: null,
|
||||
})
|
||||
.where(eq(dailyAdventures.id, adventure.id));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
11
apps/web/src/lib/services/adventure/scoring-helpers.ts
Executable file
11
apps/web/src/lib/services/adventure/scoring-helpers.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, settings } from "@/lib/db";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
|
||||
export async function getWeeklyReadingGoal(userId: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal)));
|
||||
return (row?.value as number) ?? 50;
|
||||
}
|
||||
100
apps/web/src/lib/services/adventure/scoring.ts
Executable file
100
apps/web/src/lib/services/adventure/scoring.ts
Executable file
@@ -0,0 +1,100 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { computeAllScores, type DaySnapshot } from "@adventureos/shared";
|
||||
import { db, userProgress, readingLogs, books, xpEvents } from "@/lib/db";
|
||||
import { lastNDays } from "@/lib/dates";
|
||||
import { getDailyAdventure } from "./materialization";
|
||||
import { getWeeklyReadingGoal } from "./scoring-helpers";
|
||||
|
||||
export async function buildDaySnapshot(userId: string, date: string): Promise<DaySnapshot> {
|
||||
const { adventure, items } = await getDailyAdventure(userId, date);
|
||||
const scorable = items.filter((i) => i.type !== "note" && i.enabled);
|
||||
const touched = scorable.filter((i) => i.state !== "blank").length;
|
||||
|
||||
const exerciseItem = items.find(
|
||||
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise")
|
||||
);
|
||||
const workItem = items.find((i) => i.type === "duration");
|
||||
const teachingItem = items.find(
|
||||
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("teaching")
|
||||
);
|
||||
const classItems = items.filter(
|
||||
(i) => i.type === "timeblock" || i.label.toLowerCase().includes("class")
|
||||
);
|
||||
|
||||
const prayerItem = items.find((i) => i.label === "Prayer");
|
||||
const litanyItem = items.find((i) => i.label === "Litanies");
|
||||
|
||||
const prayerChecks =
|
||||
((prayerItem?.value as Record<string, unknown>)?.checks as boolean[])?.filter(Boolean)
|
||||
.length ?? 0;
|
||||
const prayerTotal =
|
||||
((prayerItem?.value as Record<string, unknown>)?.checks as boolean[])?.length ?? 5;
|
||||
const litanyChecks =
|
||||
((litanyItem?.value as Record<string, unknown>)?.checks as boolean[])?.filter(Boolean)
|
||||
.length ?? 0;
|
||||
const litanyTotal =
|
||||
((litanyItem?.value as Record<string, unknown>)?.checks as boolean[])?.length ?? 6;
|
||||
|
||||
const dayLogs = await db
|
||||
.select({ pages: readingLogs.pagesRead })
|
||||
.from(readingLogs)
|
||||
.innerJoin(books, eq(readingLogs.bookId, books.id))
|
||||
.where(and(eq(books.userId, userId), eq(readingLogs.date, date)));
|
||||
|
||||
const pagesRead = dayLogs.reduce((s, l) => s + l.pages, 0);
|
||||
const weeklyGoal = await getWeeklyReadingGoal(userId);
|
||||
|
||||
const workTarget =
|
||||
adventure.workHoursTarget != null
|
||||
? Number(adventure.workHoursTarget)
|
||||
: ((workItem?.config as Record<string, unknown>)?.targetHours as number) ?? 7.5;
|
||||
|
||||
const completedExplorationXp = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(xpEvents.userId, userId),
|
||||
eq(xpEvents.date, date),
|
||||
eq(xpEvents.source, "exploration")
|
||||
)
|
||||
);
|
||||
const explorationsDone = completedExplorationXp.filter(
|
||||
(e) => !(e.metadata as Record<string, unknown>)?.revoked
|
||||
).length;
|
||||
|
||||
return {
|
||||
date,
|
||||
adventureSlots: adventure.isRestDay ? 0 : scorable.length,
|
||||
adventureTouched: adventure.isRestDay ? scorable.length : touched,
|
||||
exerciseDone: exerciseItem?.state === "done",
|
||||
workHours: ((workItem?.value as Record<string, unknown>)?.hours as number) ?? 0,
|
||||
workTarget,
|
||||
sleepLogged: false,
|
||||
classesDone: classItems.filter((i) => i.state === "done").length,
|
||||
teachingDone: teachingItem?.state === "done",
|
||||
explorationsDone,
|
||||
prayerChecks,
|
||||
prayerTotal,
|
||||
litanyChecks,
|
||||
litanyTotal,
|
||||
pagesRead,
|
||||
readingGoalWeekly: weeklyGoal,
|
||||
hadSpiritualEver: prayerChecks + litanyChecks > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshScores(userId: string) {
|
||||
const days: DaySnapshot[] = [];
|
||||
for (const date of lastNDays(30)) {
|
||||
days.push(await buildDaySnapshot(userId, date));
|
||||
}
|
||||
|
||||
const weeklyGoal = await getWeeklyReadingGoal(userId);
|
||||
const scores = computeAllScores(days, weeklyGoal);
|
||||
|
||||
await db
|
||||
.update(userProgress)
|
||||
.set({ ...scores, scoresUpdatedAt: new Date() })
|
||||
.where(eq(userProgress.userId, userId));
|
||||
}
|
||||
49
apps/web/src/lib/services/adventure/template-matching.test.ts
Executable file
49
apps/web/src/lib/services/adventure/template-matching.test.ts
Executable file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickTemplateForDay } from "./template-matching";
|
||||
|
||||
describe("pickTemplateForDay", () => {
|
||||
const templates = [
|
||||
{
|
||||
id: "weekday",
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
sortPriority: 0,
|
||||
isDefault: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
id: "tuesday-thursday",
|
||||
daysOfWeek: [2, 4],
|
||||
sortPriority: 10,
|
||||
isDefault: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
id: "deleted",
|
||||
daysOfWeek: [2],
|
||||
sortPriority: 100,
|
||||
isDefault: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers higher sortPriority on overlapping days", () => {
|
||||
expect(pickTemplateForDay(templates, 2)?.id).toBe("tuesday-thursday");
|
||||
});
|
||||
|
||||
it("breaks ties by fewer daysOfWeek entries", () => {
|
||||
const tied = [
|
||||
{ id: "broad", daysOfWeek: [1, 2, 3, 4, 5], sortPriority: 5, isDefault: false, deletedAt: null },
|
||||
{ id: "narrow", daysOfWeek: [2], sortPriority: 5, isDefault: false, deletedAt: null },
|
||||
];
|
||||
expect(pickTemplateForDay(tied, 2)?.id).toBe("narrow");
|
||||
});
|
||||
|
||||
it("falls back to default when no day match", () => {
|
||||
expect(pickTemplateForDay(templates, 0)?.id).toBe("weekday");
|
||||
});
|
||||
|
||||
it("ignores deleted templates", () => {
|
||||
const onlyDeleted = templates.filter((t) => t.id === "deleted");
|
||||
expect(pickTemplateForDay(onlyDeleted, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
23
apps/web/src/lib/services/adventure/template-matching.ts
Executable file
23
apps/web/src/lib/services/adventure/template-matching.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
export type TemplateCandidate = {
|
||||
id: string;
|
||||
daysOfWeek: number[];
|
||||
sortPriority: number;
|
||||
isDefault: boolean;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
export function pickTemplateForDay(
|
||||
templates: TemplateCandidate[],
|
||||
dayOfWeek: number
|
||||
): TemplateCandidate | null {
|
||||
const active = templates.filter((t) => t.deletedAt === null);
|
||||
const matching = active
|
||||
.filter((t) => t.daysOfWeek.includes(dayOfWeek))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.sortPriority - a.sortPriority || a.daysOfWeek.length - b.daysOfWeek.length
|
||||
);
|
||||
|
||||
if (matching.length > 0) return matching[0];
|
||||
return active.find((t) => t.isDefault) ?? active[0] ?? null;
|
||||
}
|
||||
192
apps/web/src/lib/services/adventure/templates.ts
Executable file
192
apps/web/src/lib/services/adventure/templates.ts
Executable file
@@ -0,0 +1,192 @@
|
||||
import { and, eq, isNull, inArray } from "drizzle-orm";
|
||||
import { db, adventureTemplates, adventureItems } from "@/lib/db";
|
||||
import { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
|
||||
export async function updateTemplate(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
daysOfWeek: number[];
|
||||
isDefault: boolean;
|
||||
sortPriority: number;
|
||||
}>
|
||||
) {
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
|
||||
if (!template) throw new Error("Template not found");
|
||||
|
||||
await db.update(adventureTemplates).set(data).where(eq(adventureTemplates.id, templateId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function getTemplates(userId: string) {
|
||||
const templates = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
|
||||
|
||||
const result = [];
|
||||
for (const t of templates) {
|
||||
const items = await db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(and(eq(adventureItems.templateId, t.id), isNull(adventureItems.deletedAt)))
|
||||
.orderBy(adventureItems.sortOrder);
|
||||
result.push({ ...t, items });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function duplicateTemplate(userId: string, templateId: string) {
|
||||
const templates = await getTemplates(userId);
|
||||
const source = templates.find((t) => t.id === templateId);
|
||||
if (!source) throw new Error("Template not found");
|
||||
|
||||
const [copy] = await db
|
||||
.insert(adventureTemplates)
|
||||
.values({
|
||||
userId,
|
||||
name: `${source.name} (copy)`,
|
||||
daysOfWeek: [],
|
||||
isDefault: false,
|
||||
isSystem: false,
|
||||
sortPriority: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
for (const item of source.items) {
|
||||
await db.insert(adventureItems).values({
|
||||
templateId: copy.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: item.config,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled ?? true,
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(userId: string, templateId: string) {
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
|
||||
if (!template) throw new Error("Template not found");
|
||||
if (template.isSystem) throw new Error("System templates cannot be deleted");
|
||||
|
||||
await db
|
||||
.update(adventureTemplates)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(adventureTemplates.id, templateId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyTemplateOwnership(userId: string, templateId: string) {
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(adventureTemplates.id, templateId),
|
||||
eq(adventureTemplates.userId, userId),
|
||||
isNull(adventureTemplates.deletedAt)
|
||||
)
|
||||
);
|
||||
if (!template) throw new Error("Template not found");
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function createTemplate(
|
||||
userId: string,
|
||||
data: { name: string; daysOfWeek?: number[]; isDefault?: boolean }
|
||||
) {
|
||||
const [template] = await db
|
||||
.insert(adventureTemplates)
|
||||
.values({
|
||||
userId,
|
||||
name: data.name,
|
||||
daysOfWeek: data.daysOfWeek ?? [],
|
||||
isDefault: data.isDefault ?? false,
|
||||
})
|
||||
.returning();
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function upsertTemplateItem(
|
||||
templateId: string,
|
||||
item: {
|
||||
id?: string;
|
||||
type: string;
|
||||
label: string;
|
||||
config?: Record<string, unknown>;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
) {
|
||||
if (item.id) {
|
||||
await db
|
||||
.update(adventureItems)
|
||||
.set({
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: item.config ?? {},
|
||||
sortOrder: item.sortOrder ?? 0,
|
||||
enabled: item.enabled ?? true,
|
||||
})
|
||||
.where(eq(adventureItems.id, item.id));
|
||||
return item.id;
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(adventureItems)
|
||||
.values({
|
||||
templateId,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: item.config ?? {},
|
||||
sortOrder: item.sortOrder ?? 0,
|
||||
enabled: item.enabled ?? true,
|
||||
})
|
||||
.returning();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function deleteTemplateItem(itemId: string, userId: string) {
|
||||
const templates = await db
|
||||
.select()
|
||||
.from(adventureTemplates)
|
||||
.where(eq(adventureTemplates.userId, userId));
|
||||
const templateIds = templates.map((t) => t.id);
|
||||
|
||||
if (templateIds.length === 0) throw new Error("Item not found");
|
||||
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(adventureItems)
|
||||
.where(
|
||||
and(eq(adventureItems.id, itemId), inArray(adventureItems.templateId, templateIds))
|
||||
);
|
||||
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const beforeState = { deletedAt: null, label: item.label };
|
||||
await db
|
||||
.update(adventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(adventureItems.id, itemId));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.templateItemDelete,
|
||||
entityType: "adventure_item",
|
||||
entityId: itemId,
|
||||
summary: `Deleted template item: ${item.label}`,
|
||||
beforeState,
|
||||
afterState: { deletedAt: new Date() },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
154
apps/web/src/lib/services/ai-chat.ts
Normal file
154
apps/web/src/lib/services/ai-chat.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { and, eq, desc, isNull } from "drizzle-orm";
|
||||
import { db, aiChatSessions, aiChatMessages } from "../db";
|
||||
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
||||
import { generateChatReply, getAiAvailability } from "./ai";
|
||||
import { listMemories, getProfileSummary } from "./ai-memory";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
|
||||
export async function listChatSessions(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(aiChatSessions)
|
||||
.where(and(eq(aiChatSessions.userId, userId), isNull(aiChatSessions.archivedAt)))
|
||||
.orderBy(desc(aiChatSessions.updatedAt))
|
||||
.limit(20);
|
||||
}
|
||||
|
||||
export async function createChatSession(
|
||||
userId: string,
|
||||
title?: string,
|
||||
featureContext?: Record<string, unknown>
|
||||
) {
|
||||
const [session] = await db
|
||||
.insert(aiChatSessions)
|
||||
.values({
|
||||
userId,
|
||||
title: title ?? "New conversation",
|
||||
featureContext: featureContext ?? {},
|
||||
})
|
||||
.returning();
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getChatSession(userId: string, sessionId: string) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(aiChatSessions)
|
||||
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
|
||||
if (!session) return null;
|
||||
|
||||
const messages = await db
|
||||
.select()
|
||||
.from(aiChatMessages)
|
||||
.where(eq(aiChatMessages.sessionId, sessionId))
|
||||
.orderBy(aiChatMessages.createdAt);
|
||||
|
||||
return { session, messages };
|
||||
}
|
||||
|
||||
export async function sendChatMessage(userId: string, sessionId: string, content: string) {
|
||||
const sessionData = await getChatSession(userId, sessionId);
|
||||
if (!sessionData) throw new Error("Session not found");
|
||||
|
||||
const { session, messages } = sessionData;
|
||||
|
||||
await db.insert(aiChatMessages).values({
|
||||
sessionId,
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
});
|
||||
|
||||
const availability = await getAiAvailability(userId);
|
||||
const ctx = await buildMentorContext(userId, {
|
||||
userMessage: content,
|
||||
feature: "mentor",
|
||||
activeDate: (session.featureContext as Record<string, unknown>)?.date as string | undefined,
|
||||
logContext: true,
|
||||
});
|
||||
|
||||
let reply: string;
|
||||
let offline = false;
|
||||
let memoryIdsUsed = ctx.memoryIds;
|
||||
|
||||
if (!availability.canUse || !availability.online) {
|
||||
offline = true;
|
||||
reply = buildOfflineReply(content, ctx);
|
||||
} else {
|
||||
const history = messages
|
||||
.slice(-6)
|
||||
.map((m) => `${m.role}: ${m.content}`)
|
||||
.join("\n");
|
||||
|
||||
const generated = await generateChatReply(userId, {
|
||||
userMessage: content,
|
||||
contextBlock: formatContextForPrompt(ctx),
|
||||
history,
|
||||
});
|
||||
|
||||
reply =
|
||||
generated ??
|
||||
"I'm having trouble responding right now. Your memories are still saved — try again when the AI is online.";
|
||||
if (!generated) offline = true;
|
||||
}
|
||||
|
||||
const [assistantMsg] = await db
|
||||
.insert(aiChatMessages)
|
||||
.values({
|
||||
sessionId,
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
metadata: { memoryIdsUsed, offline },
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(aiChatSessions)
|
||||
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
|
||||
.where(eq(aiChatSessions.id, sessionId));
|
||||
|
||||
return { message: assistantMsg, reply, memoryIdsUsed, offline };
|
||||
}
|
||||
|
||||
function truncateTitle(text: string): string {
|
||||
const t = text.trim();
|
||||
return t.length > 40 ? `${t.slice(0, 37)}…` : t;
|
||||
}
|
||||
|
||||
function buildOfflineReply(message: string, ctx: Awaited<ReturnType<typeof buildMentorContext>>): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("what do you know") || lower.includes("about me")) {
|
||||
const memLines = ctx.memories.map((m) => `• ${m.title}: ${m.content}`).join("\n");
|
||||
return [
|
||||
"The AI is offline, but here is what I have saved locally:",
|
||||
ctx.profileSummary ? `\nSummary: ${ctx.profileSummary}` : "",
|
||||
memLines ? `\nMemories:\n${memLines}` : "\nNo memories saved yet.",
|
||||
"\nYou can edit these in Settings → AI Memory.",
|
||||
].join("");
|
||||
}
|
||||
return "The mentor is offline right now. Your adventure data is still here — try again when AI is online, or check Settings → AI Memory.";
|
||||
}
|
||||
|
||||
export async function archiveChatSession(userId: string, sessionId: string) {
|
||||
await db
|
||||
.update(aiChatSessions)
|
||||
.set({ archivedAt: new Date() })
|
||||
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
|
||||
}
|
||||
|
||||
export async function getWhatAiKnows(userId: string) {
|
||||
const summary = await getProfileSummary(userId);
|
||||
const memories = await listMemories(userId, { enabled: true });
|
||||
const grouped: Record<string, typeof memories> = {};
|
||||
for (const m of memories) {
|
||||
if (!grouped[m.category]) grouped[m.category] = [];
|
||||
grouped[m.category].push(m);
|
||||
}
|
||||
return {
|
||||
summary: summary?.summary ?? "",
|
||||
grouped: Object.entries(grouped).map(([category, items]) => ({
|
||||
category,
|
||||
label: MEMORY_CATEGORIES[category as MemoryCategory] ?? category,
|
||||
items,
|
||||
})),
|
||||
};
|
||||
}
|
||||
116
apps/web/src/lib/services/ai-config.ts
Executable file
116
apps/web/src/lib/services/ai-config.ts
Executable file
@@ -0,0 +1,116 @@
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { db, settings, aiHealthLog } from "../db";
|
||||
import {
|
||||
DEFAULT_AI_BEHAVIOR,
|
||||
DEFAULT_AI_PROVIDER,
|
||||
type AIBehaviorConfig,
|
||||
type AIProviderConfig,
|
||||
type AIHealthResult,
|
||||
} from "../ai/types";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
import { PERSONALITY_MODIFIERS, VERBOSITY_MODIFIERS } from "../ai/prompts/defaults";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
|
||||
export async function getAiBehaviorConfig(userId: string): Promise<AIBehaviorConfig> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiConfig)));
|
||||
if (!row?.value) return DEFAULT_AI_BEHAVIOR;
|
||||
return { ...DEFAULT_AI_BEHAVIOR, ...(row.value as Partial<AIBehaviorConfig>) };
|
||||
}
|
||||
|
||||
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiProvider)));
|
||||
if (!row?.value) return DEFAULT_AI_PROVIDER;
|
||||
return { ...DEFAULT_AI_PROVIDER, ...(row.value as Partial<AIProviderConfig>) };
|
||||
}
|
||||
|
||||
export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBehaviorConfig>) {
|
||||
const current = await getAiBehaviorConfig(userId);
|
||||
const merged = { ...current, ...config };
|
||||
const { sql } = await import("drizzle-orm");
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: SETTINGS_KEYS.aiConfig, value: merged })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: sql`excluded.value` },
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
|
||||
const current = await getAiProviderConfig(userId);
|
||||
const merged = { ...current, ...config };
|
||||
const { sql } = await import("drizzle-orm");
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: SETTINGS_KEYS.aiProvider, value: merged })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: sql`excluded.value` },
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getCachedHealth(userId: string): Promise<AIHealthResult | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiHealthCache)));
|
||||
return (row?.value as AIHealthResult) ?? null;
|
||||
}
|
||||
|
||||
export async function runHealthCheck(userId: string): Promise<AIHealthResult> {
|
||||
const config = await getAiProviderConfig(userId);
|
||||
const provider = getProvider(config.type);
|
||||
const result = await provider.healthCheck(config);
|
||||
|
||||
const { sql } = await import("drizzle-orm");
|
||||
await db.insert(aiHealthLog).values({
|
||||
userId,
|
||||
providerType: config.type,
|
||||
status: result.status,
|
||||
latencyMs: result.latencyMs,
|
||||
model: config.model,
|
||||
contextLength: result.contextLength,
|
||||
memoryUsageMb: result.memoryUsageMb,
|
||||
baseUrlSafe: result.baseUrlSafe,
|
||||
errorMessage: result.lastError,
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: SETTINGS_KEYS.aiHealthCache, value: result })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: sql`excluded.value` },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLastHealthLogs(userId: string, limit = 10) {
|
||||
return db
|
||||
.select()
|
||||
.from(aiHealthLog)
|
||||
.where(eq(aiHealthLog.userId, userId))
|
||||
.orderBy(desc(aiHealthLog.checkedAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(
|
||||
behavior: AIBehaviorConfig,
|
||||
corePrompt: string,
|
||||
rolePrompt?: string
|
||||
): string {
|
||||
const parts = [corePrompt];
|
||||
if (rolePrompt) parts.push(rolePrompt);
|
||||
parts.push(PERSONALITY_MODIFIERS[behavior.personality] ?? "");
|
||||
parts.push(VERBOSITY_MODIFIERS[behavior.verbosity] ?? "");
|
||||
return parts.filter(Boolean).join("\n\n");
|
||||
}
|
||||
33
apps/web/src/lib/services/ai-context.test.ts
Normal file
33
apps/web/src/lib/services/ai-context.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatContextForPrompt } from "./ai-context";
|
||||
|
||||
describe("formatContextForPrompt", () => {
|
||||
it("includes low-energy day note", () => {
|
||||
const text = formatContextForPrompt({
|
||||
profileSummary: "A reader.",
|
||||
memories: [],
|
||||
memoryIds: [],
|
||||
activeDay: { dayMode: "low_energy", isBackfilled: false },
|
||||
recentActivity: "recent",
|
||||
featureSlice: "",
|
||||
tokenEstimate: 100,
|
||||
layers: { memories: "" },
|
||||
});
|
||||
expect(text).toContain("low_energy");
|
||||
expect(text).toContain("gentle");
|
||||
});
|
||||
|
||||
it("includes backfill note", () => {
|
||||
const text = formatContextForPrompt({
|
||||
profileSummary: "",
|
||||
memories: [{ id: "1", category: "likes", title: "T", content: "C" }],
|
||||
memoryIds: ["1"],
|
||||
activeDay: { isBackfilled: true, dayMode: "normal" },
|
||||
recentActivity: "",
|
||||
featureSlice: "",
|
||||
tokenEstimate: 50,
|
||||
layers: { memories: "[likes] T: C" },
|
||||
});
|
||||
expect(text).toContain("backfilled");
|
||||
});
|
||||
});
|
||||
274
apps/web/src/lib/services/ai-context.ts
Normal file
274
apps/web/src/lib/services/ai-context.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { format, subDays } from "date-fns";
|
||||
import type { MemoryCategory, DayMode } from "@adventureos/shared";
|
||||
import { MEMORY_CATEGORIES } from "@adventureos/shared";
|
||||
import { requireUser } from "./user";
|
||||
import { getDailyAdventure, buildDaySnapshot } from "./adventure";
|
||||
import { getReflection } from "./reflection";
|
||||
import { getProfileSummary, listMemories, markMemoriesUsed } from "./ai-memory";
|
||||
import { getLogicalToday } from "../dates";
|
||||
import { getDayBoundaryHour } from "./day-boundary";
|
||||
import { db, explorations, weeklyReviews, aiContextLogs } from "../db";
|
||||
import { and, eq, desc } from "drizzle-orm";
|
||||
import { weekStartString } from "../dates";
|
||||
|
||||
const ALWAYS_INCLUDE: MemoryCategory[] = ["ai_tone", "boundaries", "current_goals"];
|
||||
|
||||
const KEYWORD_CATEGORY_MAP: Record<string, MemoryCategory[]> = {
|
||||
read: ["reading_preferences", "likes"],
|
||||
book: ["reading_preferences"],
|
||||
worry: ["worries"],
|
||||
anxious: ["worries"],
|
||||
goal: ["current_goals", "long_term_goals"],
|
||||
learn: ["learning_interests", "likes"],
|
||||
exercise: ["exercise_preferences"],
|
||||
work: ["work_study", "daily_routines"],
|
||||
pray: ["spiritual_practices"],
|
||||
spiritual: ["spiritual_practices"],
|
||||
tired: ["discouragers", "motivators"],
|
||||
focus: ["current_goals"],
|
||||
};
|
||||
|
||||
export type MentorContextOptions = {
|
||||
userMessage?: string;
|
||||
feature?: string;
|
||||
activeDate?: string;
|
||||
topic?: string;
|
||||
maxMemories?: number;
|
||||
logContext?: boolean;
|
||||
};
|
||||
|
||||
export type BuiltContext = {
|
||||
profileSummary: string;
|
||||
memories: { id: string; category: string; title: string; content: string }[];
|
||||
memoryIds: string[];
|
||||
activeDay: Record<string, unknown> | null;
|
||||
recentActivity: string;
|
||||
featureSlice: string;
|
||||
tokenEstimate: number;
|
||||
layers: Record<string, string>;
|
||||
};
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function scoreMemory(
|
||||
m: { category: string; title: string; content: string; tags: string[] | null },
|
||||
categories: Set<string>,
|
||||
message: string
|
||||
): number {
|
||||
let score = 0;
|
||||
if (categories.has(m.category)) score += 3;
|
||||
if (ALWAYS_INCLUDE.includes(m.category as MemoryCategory)) score += 5;
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes(m.title.toLowerCase())) score += 2;
|
||||
for (const tag of m.tags ?? []) {
|
||||
if (lower.includes(tag.toLowerCase())) score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function categoriesFromMessage(message: string): Set<string> {
|
||||
const cats = new Set<string>(ALWAYS_INCLUDE);
|
||||
const lower = message.toLowerCase();
|
||||
for (const [kw, list] of Object.entries(KEYWORD_CATEGORY_MAP)) {
|
||||
if (lower.includes(kw)) list.forEach((c) => cats.add(c));
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
export async function buildMentorContext(
|
||||
userId: string,
|
||||
opts: MentorContextOptions = {}
|
||||
): Promise<BuiltContext> {
|
||||
const { user, progress } = await requireUser();
|
||||
const boundaryHour = await getDayBoundaryHour(userId);
|
||||
const activeDate = opts.activeDate ?? getLogicalToday(boundaryHour);
|
||||
const message = opts.userMessage ?? "";
|
||||
|
||||
const summaryRow = await getProfileSummary(userId);
|
||||
const profileSummary = truncate(summaryRow?.summary ?? "", 1200);
|
||||
|
||||
const allMemories = await listMemories(userId, { enabled: true });
|
||||
const targetCats = categoriesFromMessage(message);
|
||||
const scored = allMemories
|
||||
.map((m) => ({ m, score: scoreMemory(m, targetCats, message) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const maxMem = opts.maxMemories ?? 12;
|
||||
const selected = scored.slice(0, maxMem).map((s) => s.m);
|
||||
const memoryIds = selected.map((m) => m.id);
|
||||
|
||||
const memories = selected.map((m) => ({
|
||||
id: m.id,
|
||||
category: m.category,
|
||||
title: m.title,
|
||||
content: truncate(m.content, 120),
|
||||
}));
|
||||
|
||||
let activeDay: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const { adventure, items } = await getDailyAdventure(userId, activeDate);
|
||||
activeDay = {
|
||||
date: activeDate,
|
||||
dayMode: adventure.dayMode ?? "normal",
|
||||
isRestDay: adventure.isRestDay,
|
||||
isBackfilled: adventure.isBackfilled,
|
||||
itemsLogged: items.filter((i) => i.state !== "blank").length,
|
||||
itemCount: items.filter((i) => i.enabled && i.type !== "note").length,
|
||||
};
|
||||
} catch {
|
||||
activeDay = { date: activeDate };
|
||||
}
|
||||
|
||||
const digestParts: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const d = format(subDays(new Date(activeDate), i), "yyyy-MM-dd");
|
||||
const snap = await buildDaySnapshot(userId, d);
|
||||
const ref = await getReflection(userId, d);
|
||||
digestParts.push(
|
||||
`${d}: touched ${snap.adventureTouched}/${snap.adventureSlots}, work ${snap.workHours}h, read ${snap.pagesRead}p` +
|
||||
(ref?.learned ? `, learned: ${truncate(ref.learned, 60)}` : "")
|
||||
);
|
||||
}
|
||||
const recentActivity = truncate(digestParts.join("\n"), 800);
|
||||
|
||||
let featureSlice = "";
|
||||
if (opts.feature === "teacher" && opts.topic) {
|
||||
featureSlice = `Teacher topic: ${opts.topic}`;
|
||||
} else if (opts.feature === "review") {
|
||||
featureSlice = `Weekly review for user level ${progress?.level}`;
|
||||
} else if (opts.feature === "quest") {
|
||||
featureSlice = "Generating daily quests";
|
||||
} else if (opts.feature === "cartographer") {
|
||||
featureSlice = "Cartographer weekly explorations";
|
||||
}
|
||||
|
||||
const layers: Record<string, string> = {
|
||||
userName: user.displayName,
|
||||
profileSummary,
|
||||
memories: memories.map((m) => `[${m.category}] ${m.title}: ${m.content}`).join("\n"),
|
||||
activeDay: JSON.stringify(activeDay),
|
||||
recentActivity,
|
||||
featureSlice,
|
||||
};
|
||||
|
||||
const tokenEstimate = Object.values(layers).reduce((s, v) => s + estimateTokens(v), 0);
|
||||
|
||||
if (memoryIds.length > 0) {
|
||||
await markMemoriesUsed(userId, memoryIds);
|
||||
}
|
||||
|
||||
if (opts.logContext) {
|
||||
await db.insert(aiContextLogs).values({
|
||||
userId,
|
||||
feature: opts.feature ?? "mentor",
|
||||
memoryIds,
|
||||
tokenEstimate,
|
||||
layers,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profileSummary,
|
||||
memories,
|
||||
memoryIds,
|
||||
activeDay,
|
||||
recentActivity,
|
||||
featureSlice,
|
||||
tokenEstimate,
|
||||
layers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy context for quest/review generation — enriched with memory. */
|
||||
export async function buildAiContext(userId: string) {
|
||||
const { progress } = await requireUser();
|
||||
const ctx = await buildMentorContext(userId, { feature: "quest" });
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
||||
days.push(await buildDaySnapshot(userId, d));
|
||||
}
|
||||
|
||||
const reflections = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
||||
const r = await getReflection(userId, d);
|
||||
if (r) reflections.push({ date: d, learned: r.learned });
|
||||
}
|
||||
|
||||
const lastReview = await db
|
||||
.select()
|
||||
.from(weeklyReviews)
|
||||
.where(eq(weeklyReviews.userId, userId))
|
||||
.orderBy(desc(weeklyReviews.weekStart))
|
||||
.limit(1);
|
||||
|
||||
const weekOf = weekStartString();
|
||||
const thisWeekExplorations = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf)));
|
||||
|
||||
const priorWeekOf = format(subDays(new Date(weekOf), 7), "yyyy-MM-dd");
|
||||
const priorWeekExplorations = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, priorWeekOf)));
|
||||
|
||||
const recentlyDenied = [...thisWeekExplorations, ...priorWeekExplorations]
|
||||
.filter((e) => e.status === "dismissed")
|
||||
.map((e) => e.title);
|
||||
|
||||
return {
|
||||
level: progress?.level,
|
||||
chapter: progress?.currentChapter,
|
||||
scores: {
|
||||
consistency: progress?.consistencyScore,
|
||||
reading: progress?.readingScore,
|
||||
},
|
||||
last7Days: days,
|
||||
reflections,
|
||||
lastIntention: lastReview[0]?.userIntention,
|
||||
explorations: {
|
||||
thisWeek: thisWeekExplorations.map((e) => ({
|
||||
title: e.title,
|
||||
status: e.status,
|
||||
})),
|
||||
recentlyDenied,
|
||||
},
|
||||
personalMemory: {
|
||||
profileSummary: ctx.profileSummary,
|
||||
memories: ctx.memories,
|
||||
},
|
||||
dayModes: days.map((d) => ({ date: d.date })),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatContextForPrompt(ctx: BuiltContext): string {
|
||||
const dayNote =
|
||||
ctx.activeDay?.dayMode && ctx.activeDay.dayMode !== "normal"
|
||||
? `Note: User marked this as a ${ctx.activeDay.dayMode} day. Be gentle, not judgmental.`
|
||||
: ctx.activeDay?.isBackfilled
|
||||
? "Note: User logged this day later (backfilled). Acknowledge continuity, not failure."
|
||||
: "";
|
||||
|
||||
return [
|
||||
`Profile: ${ctx.profileSummary}`,
|
||||
ctx.memories.length ? `Memories:\n${ctx.layers.memories}` : "",
|
||||
`Today: ${ctx.layers.activeDay}`,
|
||||
dayNote,
|
||||
`Recent:\n${ctx.recentActivity}`,
|
||||
ctx.featureSlice,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export { MEMORY_CATEGORIES };
|
||||
13
apps/web/src/lib/services/ai-memory.test.ts
Normal file
13
apps/web/src/lib/services/ai-memory.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MEMORY_CATEGORIES, DEFAULT_AI_MEMORY_LEARNING } from "@adventureos/shared";
|
||||
|
||||
describe("memory constants", () => {
|
||||
it("has 20 memory categories", () => {
|
||||
expect(Object.keys(MEMORY_CATEGORIES)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("defaults learning to opt-in", () => {
|
||||
expect(DEFAULT_AI_MEMORY_LEARNING.learningEnabled).toBe(false);
|
||||
expect(DEFAULT_AI_MEMORY_LEARNING.requireApproval).toBe(true);
|
||||
});
|
||||
});
|
||||
321
apps/web/src/lib/services/ai-memory.ts
Normal file
321
apps/web/src/lib/services/ai-memory.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { and, eq, isNull, desc, ilike, or, inArray } from "drizzle-orm";
|
||||
import {
|
||||
type MemoryCategory,
|
||||
type MemorySourceType,
|
||||
type MemorySensitivity,
|
||||
type AiProfileSummary,
|
||||
type AiMemoryLearningSettings,
|
||||
DEFAULT_AI_MEMORY_LEARNING,
|
||||
MEMORY_CATEGORIES,
|
||||
} from "@adventureos/shared";
|
||||
import { db, aiMemories, aiMemorySuggestions } from "../db";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
import { getSettingValue, upsertSetting } from "@/lib/repositories/settings.repository";
|
||||
|
||||
export type MemoryRow = typeof aiMemories.$inferSelect;
|
||||
|
||||
export type MemoryInput = {
|
||||
category: MemoryCategory;
|
||||
title: string;
|
||||
content: string;
|
||||
sourceType?: MemorySourceType;
|
||||
sourceRef?: { type: string; id?: string; date?: string };
|
||||
confidence?: number;
|
||||
userVerified?: boolean;
|
||||
enabled?: boolean;
|
||||
sensitivity?: MemorySensitivity;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export async function listMemories(
|
||||
userId: string,
|
||||
opts: {
|
||||
category?: string;
|
||||
q?: string;
|
||||
enabled?: boolean;
|
||||
includeArchived?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const conditions = [eq(aiMemories.userId, userId)];
|
||||
if (!opts.includeArchived) conditions.push(isNull(aiMemories.archivedAt));
|
||||
if (opts.category) conditions.push(eq(aiMemories.category, opts.category));
|
||||
if (opts.enabled !== undefined) conditions.push(eq(aiMemories.enabled, opts.enabled));
|
||||
if (opts.q?.trim()) {
|
||||
const pattern = `%${opts.q.trim()}%`;
|
||||
conditions.push(or(ilike(aiMemories.title, pattern), ilike(aiMemories.content, pattern))!);
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(aiMemories)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(aiMemories.updatedAt));
|
||||
}
|
||||
|
||||
export async function getMemory(userId: string, id: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(aiMemories)
|
||||
.where(and(eq(aiMemories.userId, userId), eq(aiMemories.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function createMemory(userId: string, input: MemoryInput) {
|
||||
if (!(input.category in MEMORY_CATEGORIES)) {
|
||||
throw new Error("Invalid memory category");
|
||||
}
|
||||
const [row] = await db
|
||||
.insert(aiMemories)
|
||||
.values({
|
||||
userId,
|
||||
category: input.category,
|
||||
title: input.title.trim(),
|
||||
content: input.content.trim(),
|
||||
sourceType: input.sourceType ?? "manual",
|
||||
sourceRef: input.sourceRef,
|
||||
confidence: String(input.confidence ?? 1),
|
||||
userVerified: input.userVerified ?? true,
|
||||
enabled: input.enabled ?? true,
|
||||
sensitivity: input.sensitivity ?? "normal",
|
||||
tags: input.tags ?? [],
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateMemory(
|
||||
userId: string,
|
||||
id: string,
|
||||
updates: Partial<MemoryInput> & { markWrong?: boolean }
|
||||
) {
|
||||
const existing = await getMemory(userId, id);
|
||||
if (!existing) throw new Error("Memory not found");
|
||||
|
||||
if (updates.markWrong) {
|
||||
await db
|
||||
.update(aiMemories)
|
||||
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
|
||||
.where(eq(aiMemories.id, id));
|
||||
return { archived: true };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (updates.title !== undefined) set.title = updates.title.trim();
|
||||
if (updates.content !== undefined) set.content = updates.content.trim();
|
||||
if (updates.category !== undefined) set.category = updates.category;
|
||||
if (updates.enabled !== undefined) set.enabled = updates.enabled;
|
||||
if (updates.tags !== undefined) set.tags = updates.tags;
|
||||
if (updates.sensitivity !== undefined) set.sensitivity = updates.sensitivity;
|
||||
if (updates.userVerified !== undefined) set.userVerified = updates.userVerified;
|
||||
|
||||
await db.update(aiMemories).set(set).where(eq(aiMemories.id, id));
|
||||
return getMemory(userId, id);
|
||||
}
|
||||
|
||||
export async function archiveMemory(userId: string, id: string) {
|
||||
await db
|
||||
.update(aiMemories)
|
||||
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
|
||||
.where(and(eq(aiMemories.userId, userId), eq(aiMemories.id, id)));
|
||||
}
|
||||
|
||||
export async function resetAllMemories(userId: string) {
|
||||
await db
|
||||
.update(aiMemories)
|
||||
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
|
||||
.where(and(eq(aiMemories.userId, userId), isNull(aiMemories.archivedAt)));
|
||||
await db
|
||||
.update(aiMemorySuggestions)
|
||||
.set({ status: "ignored", reviewedAt: new Date() })
|
||||
.where(and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.status, "pending")));
|
||||
await upsertSetting(userId, SETTINGS_KEYS.aiProfileSummary, {
|
||||
summary: "",
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceMemoryCount: 0,
|
||||
version: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProfileSummary(userId: string): Promise<AiProfileSummary | null> {
|
||||
const value = (await getSettingValue(userId, SETTINGS_KEYS.aiProfileSummary)) as AiProfileSummary | null;
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
export async function saveProfileSummary(userId: string, summary: string) {
|
||||
const existing = (await getProfileSummary(userId)) ?? {
|
||||
summary: "",
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceMemoryCount: 0,
|
||||
version: 0,
|
||||
};
|
||||
const next: AiProfileSummary = {
|
||||
...existing,
|
||||
summary: summary.trim(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
version: existing.version + 1,
|
||||
};
|
||||
await upsertSetting(userId, SETTINGS_KEYS.aiProfileSummary, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function getMemoryLearningSettings(userId: string): Promise<AiMemoryLearningSettings> {
|
||||
const value = (await getSettingValue(userId, SETTINGS_KEYS.aiMemoryLearning)) as
|
||||
| Partial<AiMemoryLearningSettings>
|
||||
| null;
|
||||
return { ...DEFAULT_AI_MEMORY_LEARNING, ...value };
|
||||
}
|
||||
|
||||
export async function saveMemoryLearningSettings(
|
||||
userId: string,
|
||||
settings: Partial<AiMemoryLearningSettings>
|
||||
) {
|
||||
const current = await getMemoryLearningSettings(userId);
|
||||
const next = { ...current, ...settings };
|
||||
await upsertSetting(userId, SETTINGS_KEYS.aiMemoryLearning, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function listSuggestions(userId: string, status = "pending") {
|
||||
return db
|
||||
.select()
|
||||
.from(aiMemorySuggestions)
|
||||
.where(and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.status, status)))
|
||||
.orderBy(desc(aiMemorySuggestions.createdAt));
|
||||
}
|
||||
|
||||
export async function acceptSuggestion(
|
||||
userId: string,
|
||||
suggestionId: string,
|
||||
edits?: Partial<MemoryInput>
|
||||
) {
|
||||
const [suggestion] = await db
|
||||
.select()
|
||||
.from(aiMemorySuggestions)
|
||||
.where(
|
||||
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
|
||||
);
|
||||
if (!suggestion) throw new Error("Suggestion not found");
|
||||
|
||||
const memory = await createMemory(userId, {
|
||||
category: (edits?.category ?? suggestion.category) as MemoryCategory,
|
||||
title: edits?.title ?? suggestion.title,
|
||||
content: edits?.content ?? suggestion.content,
|
||||
sourceType: suggestion.sourceType as MemorySourceType,
|
||||
sourceRef: suggestion.sourceRef ?? undefined,
|
||||
confidence: Number(suggestion.confidence),
|
||||
userVerified: true,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(aiMemorySuggestions)
|
||||
.set({ status: "accepted", reviewedAt: new Date() })
|
||||
.where(eq(aiMemorySuggestions.id, suggestionId));
|
||||
|
||||
return memory;
|
||||
}
|
||||
|
||||
export async function rejectSuggestion(userId: string, suggestionId: string) {
|
||||
await db
|
||||
.update(aiMemorySuggestions)
|
||||
.set({ status: "rejected", reviewedAt: new Date() })
|
||||
.where(
|
||||
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
|
||||
);
|
||||
}
|
||||
|
||||
export async function ignoreSuggestion(userId: string, suggestionId: string) {
|
||||
await db
|
||||
.update(aiMemorySuggestions)
|
||||
.set({ status: "ignored", reviewedAt: new Date() })
|
||||
.where(
|
||||
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSuggestion(
|
||||
userId: string,
|
||||
input: {
|
||||
category: MemoryCategory;
|
||||
title: string;
|
||||
content: string;
|
||||
sourceType: MemorySourceType;
|
||||
sourceRef?: { type: string; id?: string; date?: string };
|
||||
confidence?: number;
|
||||
}
|
||||
) {
|
||||
const settings = await getMemoryLearningSettings(userId);
|
||||
if (!settings.learningEnabled) return null;
|
||||
|
||||
const pending = await listSuggestions(userId, "pending");
|
||||
if (pending.length >= settings.maxPendingSuggestions) return null;
|
||||
|
||||
const dup = await db
|
||||
.select()
|
||||
.from(aiMemories)
|
||||
.where(
|
||||
and(
|
||||
eq(aiMemories.userId, userId),
|
||||
isNull(aiMemories.archivedAt),
|
||||
ilike(aiMemories.content, `%${input.content.slice(0, 40)}%`)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (dup.length > 0) return null;
|
||||
|
||||
const [row] = await db
|
||||
.insert(aiMemorySuggestions)
|
||||
.values({
|
||||
userId,
|
||||
category: input.category,
|
||||
title: input.title.trim(),
|
||||
content: input.content.trim(),
|
||||
sourceType: input.sourceType,
|
||||
sourceRef: input.sourceRef,
|
||||
confidence: String(input.confidence ?? 0.7),
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function markMemoriesUsed(userId: string, memoryIds: string[]) {
|
||||
if (memoryIds.length === 0) return;
|
||||
await db
|
||||
.update(aiMemories)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(and(eq(aiMemories.userId, userId), inArray(aiMemories.id, memoryIds)));
|
||||
}
|
||||
|
||||
export async function exportMemories(userId: string) {
|
||||
const memories = await listMemories(userId, { includeArchived: false });
|
||||
const summary = await getProfileSummary(userId);
|
||||
const learning = await getMemoryLearningSettings(userId);
|
||||
return { memories, summary, learning };
|
||||
}
|
||||
|
||||
export async function importMemories(
|
||||
userId: string,
|
||||
data: { memories: MemoryInput[] },
|
||||
overwrite = false
|
||||
) {
|
||||
let imported = 0;
|
||||
for (const m of data.memories) {
|
||||
if (!overwrite) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(aiMemories)
|
||||
.where(
|
||||
and(
|
||||
eq(aiMemories.userId, userId),
|
||||
eq(aiMemories.title, m.title),
|
||||
isNull(aiMemories.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue;
|
||||
}
|
||||
await createMemory(userId, { ...m, sourceType: "import" });
|
||||
imported++;
|
||||
}
|
||||
return { imported };
|
||||
}
|
||||
163
apps/web/src/lib/services/ai-teacher.test.ts
Executable file
163
apps/web/src/lib/services/ai-teacher.test.ts
Executable file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { teacherSchema } from "./ai";
|
||||
|
||||
vi.mock("./ai-config", () => ({
|
||||
getAiBehaviorConfig: vi.fn(),
|
||||
getAiProviderConfig: vi.fn(),
|
||||
buildSystemPrompt: vi.fn(() => "system"),
|
||||
}));
|
||||
|
||||
vi.mock("./ai-templates", () => ({
|
||||
getTemplateBody: vi.fn(async () => "topic: {{topic}}"),
|
||||
}));
|
||||
|
||||
vi.mock("../ai/provider-registry", () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./user", () => ({
|
||||
requireUser: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
}));
|
||||
|
||||
import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
|
||||
describe("teacherSchema", () => {
|
||||
it("coerces string quiz answers to numbers", () => {
|
||||
const parsed = teacherSchema.parse({
|
||||
flashcards: [{ front: "Q", back: "A" }],
|
||||
quiz: [{ question: "Q?", options: ["a", "b"], answer: "0" }],
|
||||
assignment: "Read",
|
||||
});
|
||||
expect(parsed.quiz[0].answer).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts optional structured fields", () => {
|
||||
const parsed = teacherSchema.parse({
|
||||
title: "Roman Roads",
|
||||
introduction: "A brief intro",
|
||||
objectives: ["Learn basics"],
|
||||
readingSteps: ["Find a source"],
|
||||
reflectionPrompt: "What surprised you?",
|
||||
flashcards: [{ front: "Q", back: "A" }],
|
||||
quiz: [{ question: "Q?", options: ["a", "b"], answer: 0 }],
|
||||
assignment: "Research",
|
||||
});
|
||||
expect(parsed.title).toBe("Roman Roads");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTeacherContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws for empty topic", async () => {
|
||||
const { generateTeacherContent } = await import("./ai");
|
||||
await expect(generateTeacherContent(" ", "user-1")).rejects.toThrow(
|
||||
"Topic is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns AI content when provider succeeds", async () => {
|
||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
creativity: 0.7,
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
strictMode: false,
|
||||
});
|
||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
type: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
model: "test",
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
listModels: vi.fn(async () => []),
|
||||
generateText: vi.fn(async () => ({
|
||||
text: JSON.stringify({
|
||||
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
|
||||
quiz: [
|
||||
{
|
||||
question: "Rust is?",
|
||||
options: ["Fast", "Slow"],
|
||||
answer: 0,
|
||||
},
|
||||
],
|
||||
assignment: "Read chapter 1",
|
||||
}),
|
||||
model: "test",
|
||||
latencyMs: 10,
|
||||
})),
|
||||
});
|
||||
|
||||
const { generateTeacherContent } = await import("./ai");
|
||||
const result = await generateTeacherContent("Rust ownership", "user-1");
|
||||
expect(result.source).toBe("ai");
|
||||
expect(result.content.flashcards[0].back).toContain("systems language");
|
||||
});
|
||||
|
||||
it("returns fallback when AI is disabled", async () => {
|
||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||
enabled: false,
|
||||
creativity: 0.7,
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
strictMode: false,
|
||||
});
|
||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
type: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
model: "test",
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const { generateTeacherContent } = await import("./ai");
|
||||
const result = await generateTeacherContent("Stoicism", "user-1");
|
||||
expect(result.source).toBe("fallback");
|
||||
expect(result.content.assignment).toContain("Stoicism");
|
||||
});
|
||||
|
||||
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
|
||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
creativity: 0.7,
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
strictMode: false,
|
||||
});
|
||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
type: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
model: "test",
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
listModels: vi.fn(async () => []),
|
||||
generateText: vi.fn(async () => ({
|
||||
text: "not valid json",
|
||||
model: "test",
|
||||
latencyMs: 10,
|
||||
})),
|
||||
});
|
||||
|
||||
const { generateTeacherContent } = await import("./ai");
|
||||
const result = await generateTeacherContent("Stoicism", "user-1");
|
||||
expect(result.source).toBe("fallback");
|
||||
});
|
||||
});
|
||||
162
apps/web/src/lib/services/ai-templates.ts
Executable file
162
apps/web/src/lib/services/ai-templates.ts
Executable file
@@ -0,0 +1,162 @@
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { db, aiPromptTemplates, aiPromptVersions } from "../db";
|
||||
import { PROMPT_DEFAULTS } from "../ai/prompts/defaults";
|
||||
import { renderTemplate, SAMPLE_PREVIEW_DATA } from "../ai/prompts/render";
|
||||
|
||||
export async function seedPromptTemplatesForUser(userId: string) {
|
||||
for (const [key, def] of Object.entries(PROMPT_DEFAULTS)) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(aiPromptTemplates)
|
||||
.where(and(eq(aiPromptTemplates.userId, userId), eq(aiPromptTemplates.key, key)));
|
||||
if (existing) continue;
|
||||
|
||||
const [template] = await db
|
||||
.insert(aiPromptTemplates)
|
||||
.values({
|
||||
userId,
|
||||
key,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
category: def.category,
|
||||
body: def.body,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
isDefaultOverride: false,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await db.insert(aiPromptVersions).values({
|
||||
templateId: template.id,
|
||||
version: 1,
|
||||
body: def.body,
|
||||
createdBy: "seed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPromptTemplates(userId: string) {
|
||||
await seedPromptTemplatesForUser(userId);
|
||||
return db
|
||||
.select()
|
||||
.from(aiPromptTemplates)
|
||||
.where(eq(aiPromptTemplates.userId, userId))
|
||||
.orderBy(aiPromptTemplates.category, aiPromptTemplates.name);
|
||||
}
|
||||
|
||||
export async function getPromptTemplate(userId: string, key: string) {
|
||||
await seedPromptTemplatesForUser(userId);
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(aiPromptTemplates)
|
||||
.where(and(eq(aiPromptTemplates.userId, userId), eq(aiPromptTemplates.key, key)));
|
||||
return template ?? null;
|
||||
}
|
||||
|
||||
export async function getTemplateBody(userId: string, key: string): Promise<string> {
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
if (template?.enabled) return template.body;
|
||||
return PROMPT_DEFAULTS[key]?.body ?? "";
|
||||
}
|
||||
|
||||
export async function updatePromptTemplate(
|
||||
userId: string,
|
||||
key: string,
|
||||
updates: { body?: string; enabled?: boolean; name?: string; description?: string }
|
||||
) {
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
if (!template) throw new Error("Template not found");
|
||||
|
||||
const newVersion = template.version + 1;
|
||||
const newBody = updates.body ?? template.body;
|
||||
|
||||
await db
|
||||
.update(aiPromptTemplates)
|
||||
.set({
|
||||
body: newBody,
|
||||
enabled: updates.enabled ?? template.enabled,
|
||||
name: updates.name ?? template.name,
|
||||
description: updates.description ?? template.description,
|
||||
version: updates.body ? newVersion : template.version,
|
||||
isDefaultOverride: updates.body ? true : template.isDefaultOverride,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(aiPromptTemplates.id, template.id));
|
||||
|
||||
if (updates.body) {
|
||||
await db.insert(aiPromptVersions).values({
|
||||
templateId: template.id,
|
||||
version: newVersion,
|
||||
body: newBody,
|
||||
createdBy: "user",
|
||||
});
|
||||
}
|
||||
|
||||
return getPromptTemplate(userId, key);
|
||||
}
|
||||
|
||||
export async function resetPromptTemplate(userId: string, key: string) {
|
||||
const def = PROMPT_DEFAULTS[key];
|
||||
if (!def) throw new Error("Unknown template key");
|
||||
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
if (!template) throw new Error("Template not found");
|
||||
|
||||
const newVersion = template.version + 1;
|
||||
await db
|
||||
.update(aiPromptTemplates)
|
||||
.set({
|
||||
body: def.body,
|
||||
version: newVersion,
|
||||
isDefaultOverride: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(aiPromptTemplates.id, template.id));
|
||||
|
||||
await db.insert(aiPromptVersions).values({
|
||||
templateId: template.id,
|
||||
version: newVersion,
|
||||
body: def.body,
|
||||
createdBy: "reset",
|
||||
});
|
||||
|
||||
return getPromptTemplate(userId, key);
|
||||
}
|
||||
|
||||
export async function getTemplateVersions(userId: string, key: string) {
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
if (!template) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(aiPromptVersions)
|
||||
.where(eq(aiPromptVersions.templateId, template.id))
|
||||
.orderBy(desc(aiPromptVersions.version));
|
||||
}
|
||||
|
||||
export async function previewTemplate(userId: string, key: string, body?: string) {
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
const text = body ?? template?.body ?? PROMPT_DEFAULTS[key]?.body ?? "";
|
||||
return renderTemplate(text, SAMPLE_PREVIEW_DATA);
|
||||
}
|
||||
|
||||
export async function restoreTemplateVersion(
|
||||
userId: string,
|
||||
key: string,
|
||||
version: number
|
||||
) {
|
||||
const template = await getPromptTemplate(userId, key);
|
||||
if (!template) throw new Error("Template not found");
|
||||
|
||||
const [ver] = await db
|
||||
.select()
|
||||
.from(aiPromptVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(aiPromptVersions.templateId, template.id),
|
||||
eq(aiPromptVersions.version, version)
|
||||
)
|
||||
);
|
||||
if (!ver) throw new Error("Version not found");
|
||||
|
||||
return updatePromptTemplate(userId, key, { body: ver.body });
|
||||
}
|
||||
427
apps/web/src/lib/services/ai.ts
Executable file
427
apps/web/src/lib/services/ai.ts
Executable file
@@ -0,0 +1,427 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pickRandomExplorations,
|
||||
pickRandomQuests,
|
||||
} from "@adventureos/shared";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
import {
|
||||
debugAiLog,
|
||||
isAiTimeoutError,
|
||||
normalizeExplorationPayload,
|
||||
normalizeTeacherPayload,
|
||||
safeParseAiJson,
|
||||
} from "../ai/ai-normalize";
|
||||
import { parseAiJson } from "../ai/parse-json";
|
||||
import {
|
||||
getAiBehaviorConfig,
|
||||
getAiProviderConfig,
|
||||
buildSystemPrompt,
|
||||
} from "./ai-config";
|
||||
import { getTemplateBody } from "./ai-templates";
|
||||
import { renderTemplate } from "../ai/prompts/render";
|
||||
import { requireUser } from "./user";
|
||||
import { env } from "@/lib/config";
|
||||
|
||||
const questSchema = z.object({
|
||||
quests: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
reason: z.string(),
|
||||
xp_hint: z.string(),
|
||||
category: z.string(),
|
||||
})
|
||||
)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
const explorationSchema = z.object({
|
||||
explorations: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
hook: z.string(),
|
||||
category: z.string(),
|
||||
minutes: z.coerce.number(),
|
||||
})
|
||||
)
|
||||
.max(5),
|
||||
});
|
||||
|
||||
const mentorSchema = z.object({
|
||||
patterns: z.array(z.string()),
|
||||
encouragement: z.string(),
|
||||
focus_suggestion: z.string(),
|
||||
letter: z.string(),
|
||||
});
|
||||
|
||||
export const teacherSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
introduction: z.string().optional(),
|
||||
objectives: z.array(z.string()).optional(),
|
||||
readingSteps: z.array(z.string()).optional(),
|
||||
reflectionPrompt: z.string().optional(),
|
||||
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
||||
quiz: z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
options: z.array(z.string()),
|
||||
answer: z.coerce.number(),
|
||||
})
|
||||
),
|
||||
assignment: z.string(),
|
||||
});
|
||||
|
||||
export type TeacherContent = z.infer<typeof teacherSchema>;
|
||||
export type AiContentSource = "ai" | "fallback";
|
||||
export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
|
||||
|
||||
export type TeacherGenerationResult = {
|
||||
content: TeacherContent;
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
|
||||
export type ExplorationGenerationResult = {
|
||||
explorations: z.infer<typeof explorationSchema>["explorations"];
|
||||
source: AiContentSource;
|
||||
fallbackReason?: AiFallbackReason;
|
||||
};
|
||||
|
||||
async function getUserId(): Promise<string> {
|
||||
const { user } = await requireUser();
|
||||
return user.id;
|
||||
}
|
||||
|
||||
let lastAiFallbackReason: AiFallbackReason | null = null;
|
||||
|
||||
export function getLastAiFallbackReason(): AiFallbackReason | null {
|
||||
return lastAiFallbackReason;
|
||||
}
|
||||
|
||||
export async function getAiAvailability(userId: string) {
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
if (!behavior.enabled) return { canUse: false, online: false };
|
||||
|
||||
const providerConfig = await getAiProviderConfig(userId);
|
||||
if (!providerConfig.enabled) return { canUse: false, online: false };
|
||||
|
||||
try {
|
||||
const provider = getProvider(providerConfig.type);
|
||||
const health = await provider.healthCheck(providerConfig);
|
||||
return { canUse: true, online: health.status === "online" };
|
||||
} catch {
|
||||
return { canUse: true, online: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function aiGenerate(
|
||||
userId: string,
|
||||
templateKey: string,
|
||||
systemKey: string,
|
||||
roleKey: string | undefined,
|
||||
data: Record<string, unknown>,
|
||||
modelOverride?: string,
|
||||
format: "json" | "text" = "json"
|
||||
): Promise<string | null> {
|
||||
lastAiFallbackReason = null;
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
if (!behavior.enabled) return null;
|
||||
|
||||
const providerConfig = await getAiProviderConfig(userId);
|
||||
if (!providerConfig.enabled) return null;
|
||||
|
||||
const templateBody = await getTemplateBody(userId, templateKey);
|
||||
const coreSystem = await getTemplateBody(userId, systemKey);
|
||||
const roleSystem = roleKey ? await getTemplateBody(userId, roleKey) : undefined;
|
||||
const system = buildSystemPrompt(behavior, coreSystem, roleSystem);
|
||||
const { user } = await requireUser();
|
||||
const prompt = renderTemplate(templateBody, {
|
||||
...data,
|
||||
user_name: user.displayName,
|
||||
context: data.context ?? data,
|
||||
});
|
||||
|
||||
try {
|
||||
const provider = getProvider(providerConfig.type);
|
||||
const model =
|
||||
modelOverride ??
|
||||
(templateKey === "weekly_review"
|
||||
? env.ollamaModelProse
|
||||
: providerConfig.model);
|
||||
|
||||
const res = await provider.generateText(providerConfig, {
|
||||
model,
|
||||
prompt,
|
||||
system,
|
||||
temperature: behavior.creativity,
|
||||
maxTokens: providerConfig.maxTokens,
|
||||
format,
|
||||
timeoutMs: providerConfig.timeoutMs,
|
||||
});
|
||||
return res.text;
|
||||
} catch (e) {
|
||||
if (behavior.strictMode) throw e;
|
||||
lastAiFallbackReason = isAiTimeoutError(e) ? "timeout" : "generation_failed";
|
||||
debugAiLog("ai.ts:aiGenerate", "generation failed", {
|
||||
templateKey,
|
||||
timedOut: lastAiFallbackReason === "timeout",
|
||||
error: e instanceof Error ? e.message : "unknown",
|
||||
});
|
||||
console.error("AI generation failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasMeaningfulTeacherContent(content: TeacherContent): boolean {
|
||||
return (
|
||||
content.assignment.trim().length > 0 ||
|
||||
content.flashcards.length > 0 ||
|
||||
content.quiz.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function parseTeacherResponse(raw: string): TeacherContent | null {
|
||||
const parsed = safeParseAiJson(raw);
|
||||
if (!parsed) return null;
|
||||
const normalized = normalizeTeacherPayload(parsed);
|
||||
const result = teacherSchema.safeParse(normalized);
|
||||
if (!result.success) {
|
||||
debugAiLog("ai.ts:parseTeacherResponse", "schema failed", {
|
||||
issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!hasMeaningfulTeacherContent(result.data)) return null;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function parseExplorationResponse(raw: string) {
|
||||
const parsed = safeParseAiJson(raw);
|
||||
if (!parsed) return null;
|
||||
const normalized = normalizeExplorationPayload(parsed);
|
||||
const result = explorationSchema.safeParse(normalized);
|
||||
if (!result.success) {
|
||||
debugAiLog("ai.ts:parseExplorationResponse", "schema failed", {
|
||||
issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (result.data.explorations.length === 0) return null;
|
||||
return result.data.explorations;
|
||||
}
|
||||
|
||||
function teacherFallback(topic: string): TeacherContent {
|
||||
return {
|
||||
flashcards: [
|
||||
{
|
||||
front: `What is ${topic}?`,
|
||||
back: "Explore this topic through reading and observation.",
|
||||
},
|
||||
],
|
||||
quiz: [
|
||||
{
|
||||
question: `Which approach helps you learn about ${topic}?`,
|
||||
options: ["Curious reading", "Giving up", "Ignoring it", "Rushing"],
|
||||
answer: 0,
|
||||
},
|
||||
],
|
||||
assignment: `Spend 20 minutes researching ${topic}. Write one sentence about what surprised you.`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuests(context: Record<string, unknown>, userId?: string) {
|
||||
const uid = userId ?? (await getUserId());
|
||||
const raw = await aiGenerate(
|
||||
uid,
|
||||
"quest_generation",
|
||||
"system_core",
|
||||
"system_quest",
|
||||
{ context: JSON.stringify(context) }
|
||||
);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = questSchema.parse(parseAiJson(raw));
|
||||
return parsed.quests;
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
}
|
||||
return pickRandomQuests(3);
|
||||
}
|
||||
|
||||
export async function generateExplorations(
|
||||
context: Record<string, unknown>,
|
||||
userId?: string
|
||||
): Promise<ExplorationGenerationResult> {
|
||||
const uid = userId ?? (await getUserId());
|
||||
const behavior = await getAiBehaviorConfig(uid);
|
||||
const availability = await getAiAvailability(uid);
|
||||
|
||||
const raw = await aiGenerate(
|
||||
uid,
|
||||
"exploration_generation",
|
||||
"system_core",
|
||||
"system_quest",
|
||||
{ context: JSON.stringify(context) }
|
||||
);
|
||||
|
||||
if (raw) {
|
||||
const explorations = parseExplorationResponse(raw);
|
||||
if (explorations) {
|
||||
return { explorations, source: "ai" };
|
||||
}
|
||||
debugAiLog("ai.ts:generateExplorations", "parse failed", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
rawLen: raw.length,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
throw new Error("AI returned invalid exploration data. Try again.");
|
||||
}
|
||||
} else {
|
||||
debugAiLog("ai.ts:generateExplorations", "no raw response", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
throw new Error("AI generation failed");
|
||||
}
|
||||
}
|
||||
|
||||
return { explorations: pickRandomExplorations(4), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
|
||||
}
|
||||
|
||||
export async function generateMentorReview(context: Record<string, unknown>, userId?: string) {
|
||||
const uid = userId ?? (await getUserId());
|
||||
const raw = await aiGenerate(
|
||||
uid,
|
||||
"weekly_review",
|
||||
"system_core",
|
||||
"system_weekly_review",
|
||||
{ context: JSON.stringify(context) },
|
||||
env.ollamaModelProse
|
||||
);
|
||||
if (raw) {
|
||||
try {
|
||||
return mentorSchema.parse(parseAiJson(raw));
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
patterns: ["You showed up when you could — that matters."],
|
||||
encouragement: "Consistency is built in small moments, not perfect weeks.",
|
||||
focus_suggestion: "Pick one habit to nurture gently next week.",
|
||||
letter: `Dear Traveler,\n\nAnother week on the long road. Some days you moved forward with purpose; others you rested or tended to what life asked of you. Both belong in a life well lived.\n\nWhat matters is that you are still here, still building, still curious. Next week, choose one small anchor — reading, movement, or prayer — and let everything else orbit around it gently.\n\nThe adventure continues.\n\n— Your Guide`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateTeacherContent(
|
||||
topic: string,
|
||||
userId?: string,
|
||||
personalContext?: string
|
||||
): Promise<TeacherGenerationResult> {
|
||||
const trimmed = topic.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Topic is required");
|
||||
}
|
||||
|
||||
const uid = userId ?? (await getUserId());
|
||||
const behavior = await getAiBehaviorConfig(uid);
|
||||
const availability = await getAiAvailability(uid);
|
||||
|
||||
const raw = await aiGenerate(
|
||||
uid,
|
||||
"homework_generation",
|
||||
"system_core",
|
||||
"system_teacher",
|
||||
{ topic: trimmed, context: personalContext ?? "" }
|
||||
);
|
||||
|
||||
if (raw) {
|
||||
const content = parseTeacherResponse(raw);
|
||||
if (content) {
|
||||
return { content, source: "ai" };
|
||||
}
|
||||
debugAiLog("ai.ts:generateTeacherContent", "parse failed", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
rawLen: raw.length,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
throw new Error("AI returned invalid lesson content. Try again.");
|
||||
}
|
||||
} else {
|
||||
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
throw new Error("AI generation failed");
|
||||
}
|
||||
}
|
||||
|
||||
return { content: teacherFallback(trimmed), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
|
||||
}
|
||||
|
||||
export async function isOllamaAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const userId = await getUserId();
|
||||
const availability = await getAiAvailability(userId);
|
||||
return availability.online;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAiAvailable(): Promise<boolean> {
|
||||
return isOllamaAvailable();
|
||||
}
|
||||
|
||||
export async function generateChatReply(
|
||||
userId: string,
|
||||
input: { userMessage: string; contextBlock: string; history?: string }
|
||||
): Promise<string | null> {
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
if (!behavior.enabled) return null;
|
||||
|
||||
const providerConfig = await getAiProviderConfig(userId);
|
||||
if (!providerConfig.enabled) return null;
|
||||
|
||||
try {
|
||||
const coreSystem = await getTemplateBody(userId, "system_core");
|
||||
let roleSystem: string;
|
||||
try {
|
||||
roleSystem = await getTemplateBody(userId, "system_mentor");
|
||||
} catch {
|
||||
roleSystem = "You are a calm mentor who remembers the user's journey. Never shame. Never pretend to know what you don't. Be grounded and encouraging.";
|
||||
}
|
||||
const system = `${buildSystemPrompt(behavior, coreSystem, roleSystem)}\n\nPersonal context (use naturally, do not dump):\n${input.contextBlock}`;
|
||||
|
||||
const prompt = [
|
||||
input.history ? `Recent conversation:\n${input.history}\n` : "",
|
||||
`User: ${input.userMessage}`,
|
||||
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const provider = getProvider(providerConfig.type);
|
||||
const res = await provider.generateText(providerConfig, {
|
||||
model: providerConfig.model,
|
||||
prompt,
|
||||
system,
|
||||
format: "text",
|
||||
temperature: behavior.creativity,
|
||||
maxTokens: Math.min(providerConfig.maxTokens, 800),
|
||||
timeoutMs: providerConfig.timeoutMs,
|
||||
});
|
||||
return res.text.trim() || null;
|
||||
} catch (e) {
|
||||
if (behavior.strictMode) throw e;
|
||||
console.error("Chat generation failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
103
apps/web/src/lib/services/calibre-reading.ts
Executable file
103
apps/web/src/lib/services/calibre-reading.ts
Executable file
@@ -0,0 +1,103 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, readingProgress, readingLogs } from "../db";
|
||||
import { todayString } from "../dates";
|
||||
import { recordReadingLogAction } from "./reading/log-pages";
|
||||
|
||||
export async function getReadingProgress(userId: string) {
|
||||
return db.select().from(readingProgress).where(eq(readingProgress.userId, userId));
|
||||
}
|
||||
|
||||
export async function getProgressForBook(userId: string, calibreBookId: number) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(readingProgress)
|
||||
.where(
|
||||
and(eq(readingProgress.userId, userId), eq(readingProgress.calibreBookId, calibreBookId))
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function upsertReadingProgress(
|
||||
userId: string,
|
||||
calibreBookId: number,
|
||||
data: {
|
||||
calibreUuid?: string;
|
||||
currentPage?: number;
|
||||
totalPages?: number;
|
||||
status?: string;
|
||||
}
|
||||
) {
|
||||
const existing = await getProgressForBook(userId, calibreBookId);
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(readingProgress)
|
||||
.set({
|
||||
currentPage: data.currentPage ?? existing.currentPage,
|
||||
totalPages: data.totalPages ?? existing.totalPages,
|
||||
status: data.status ?? existing.status,
|
||||
lastReadDate: todayString(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(readingProgress.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(readingProgress)
|
||||
.values({
|
||||
userId,
|
||||
calibreBookId,
|
||||
calibreUuid: data.calibreUuid,
|
||||
currentPage: data.currentPage ?? 0,
|
||||
totalPages: data.totalPages ?? 300,
|
||||
status: data.status ?? "reading",
|
||||
lastReadDate: todayString(),
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function logCalibrePages(
|
||||
userId: string,
|
||||
calibreBookId: number,
|
||||
pages: number,
|
||||
date = todayString()
|
||||
) {
|
||||
const progress = await getProgressForBook(userId, calibreBookId);
|
||||
const beforePage = progress?.currentPage ?? 0;
|
||||
const totalPages = progress?.totalPages ?? 300;
|
||||
const newPage = Math.min(totalPages, beforePage + pages);
|
||||
const finished = newPage >= totalPages;
|
||||
|
||||
const updated = await upsertReadingProgress(userId, calibreBookId, {
|
||||
currentPage: newPage,
|
||||
status: finished ? "finished" : "reading",
|
||||
});
|
||||
|
||||
await db.insert(readingLogs).values({
|
||||
date,
|
||||
pagesRead: pages,
|
||||
calibreBookId,
|
||||
});
|
||||
|
||||
const { actionEventId } = await recordReadingLogAction({
|
||||
userId,
|
||||
date,
|
||||
pages,
|
||||
summary: `Logged ${pages} pages (Calibre #${calibreBookId})`,
|
||||
entityType: "reading_progress",
|
||||
entityId: updated.id,
|
||||
beforeState: { currentPage: beforePage, status: progress?.status ?? "reading" },
|
||||
afterState: { currentPage: newPage, status: finished ? "finished" : "reading" },
|
||||
metadata: { date, calibreBookId },
|
||||
xpMetadata: { calibreBookId, pages },
|
||||
});
|
||||
|
||||
return {
|
||||
currentPage: newPage,
|
||||
totalPages,
|
||||
status: finished ? "finished" : "reading",
|
||||
actionEventId,
|
||||
};
|
||||
}
|
||||
268
apps/web/src/lib/services/calibre.ts
Executable file
268
apps/web/src/lib/services/calibre.ts
Executable file
@@ -0,0 +1,268 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { env } from "@/lib/config";
|
||||
|
||||
export type CalibreStatus = {
|
||||
configured: boolean;
|
||||
online: boolean;
|
||||
bookCount: number;
|
||||
error?: string;
|
||||
lastCheckAt: string;
|
||||
};
|
||||
|
||||
export type CalibreBook = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
title: string;
|
||||
authors: string[];
|
||||
tags: string[];
|
||||
comment: string | null;
|
||||
series: string | null;
|
||||
seriesIndex: number | null;
|
||||
rating: number | null;
|
||||
formats: string[];
|
||||
hasCover: boolean;
|
||||
};
|
||||
|
||||
function getMetadataDbPath(): string | null {
|
||||
const libraryPath = env.calibreLibraryPath;
|
||||
if (!libraryPath) return null;
|
||||
const explicit = env.calibreMetadataDbPath;
|
||||
if (explicit) return explicit;
|
||||
return path.join(libraryPath, "metadata.db");
|
||||
}
|
||||
|
||||
function assertReadOnly() {
|
||||
if (!env.calibreReadOnly) {
|
||||
throw new Error("Calibre write access is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
async function openDb() {
|
||||
assertReadOnly();
|
||||
const dbPath = getMetadataDbPath();
|
||||
if (!dbPath) return null;
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error("Calibre metadata.db not found");
|
||||
}
|
||||
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 3000 });
|
||||
}
|
||||
|
||||
export async function getCalibreStatus(): Promise<CalibreStatus> {
|
||||
const now = new Date().toISOString();
|
||||
const dbPath = getMetadataDbPath();
|
||||
if (!dbPath) {
|
||||
return { configured: false, online: false, bookCount: 0, lastCheckAt: now };
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db) {
|
||||
return { configured: false, online: false, bookCount: 0, lastCheckAt: now };
|
||||
}
|
||||
const row = db.prepare("SELECT COUNT(*) as c FROM books WHERE series_index IS NOT NULL OR 1=1").get() as {
|
||||
c: number;
|
||||
};
|
||||
db.close();
|
||||
return { configured: true, online: true, bookCount: row.c, lastCheckAt: now };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Calibre unavailable";
|
||||
const locked = msg.toLowerCase().includes("locked") || msg.toLowerCase().includes("busy");
|
||||
return {
|
||||
configured: true,
|
||||
online: false,
|
||||
bookCount: 0,
|
||||
error: locked ? "Calibre is using the database — try again shortly" : msg,
|
||||
lastCheckAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCalibreBooks(options?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<CalibreBook[]> {
|
||||
const db = await openDb();
|
||||
if (!db) return [];
|
||||
|
||||
const limit = options?.limit ?? 200;
|
||||
const offset = options?.offset ?? 0;
|
||||
const search = options?.search?.trim();
|
||||
|
||||
let query = `
|
||||
SELECT b.id, b.uuid, b.title, b.series_index, b.has_cover, c.text as comment,
|
||||
sr.name as series_name, r.rating
|
||||
FROM books b
|
||||
LEFT JOIN comments c ON c.book = b.id
|
||||
LEFT JOIN books_series_link bsl ON bsl.book = b.id
|
||||
LEFT JOIN series sr ON sr.id = bsl.series
|
||||
LEFT JOIN books_ratings_link brl ON brl.book = b.id
|
||||
LEFT JOIN ratings r ON r.id = brl.rating
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
if (search) {
|
||||
query += ` WHERE b.title LIKE ? OR b.id IN (
|
||||
SELECT bal.book FROM books_authors_link bal
|
||||
JOIN authors a ON a.id = bal.author
|
||||
WHERE a.name LIKE ?
|
||||
)`;
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
query += ` ORDER BY b.timestamp DESC LIMIT ? OFFSET ?`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const rows = db.prepare(query).all(...params) as Array<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
title: string;
|
||||
series_index: number | null;
|
||||
has_cover: number;
|
||||
comment: string | null;
|
||||
series_name: string | null;
|
||||
rating: number | null;
|
||||
}>;
|
||||
|
||||
const books: CalibreBook[] = [];
|
||||
for (const row of rows) {
|
||||
const authors = db
|
||||
.prepare(
|
||||
`SELECT a.name FROM authors a
|
||||
JOIN books_authors_link bal ON bal.author = a.id
|
||||
WHERE bal.book = ?`
|
||||
)
|
||||
.all(row.id) as { name: string }[];
|
||||
|
||||
const tags = db
|
||||
.prepare(
|
||||
`SELECT t.name FROM tags t
|
||||
JOIN books_tags_link btl ON btl.tag = t.id
|
||||
WHERE btl.book = ?`
|
||||
)
|
||||
.all(row.id) as { name: string }[];
|
||||
|
||||
const formats = db
|
||||
.prepare(`SELECT format FROM data WHERE book = ?`)
|
||||
.all(row.id) as { format: string }[];
|
||||
|
||||
books.push({
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
title: row.title,
|
||||
authors: authors.map((a) => a.name),
|
||||
tags: tags.map((t) => t.name),
|
||||
comment: row.comment,
|
||||
series: row.series_name,
|
||||
seriesIndex: row.series_index,
|
||||
rating: row.rating,
|
||||
formats: formats.map((f) => f.format),
|
||||
hasCover: !!row.has_cover,
|
||||
});
|
||||
}
|
||||
|
||||
db.close();
|
||||
return books;
|
||||
}
|
||||
|
||||
export async function getCalibreBook(id: number): Promise<CalibreBook | null> {
|
||||
const books = await listCalibreBooks({ limit: 1, offset: 0 });
|
||||
void books;
|
||||
const db = await openDb();
|
||||
if (!db) return null;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT b.id, b.uuid, b.title, b.series_index, b.has_cover, c.text as comment,
|
||||
sr.name as series_name, r.rating
|
||||
FROM books b
|
||||
LEFT JOIN comments c ON c.book = b.id
|
||||
LEFT JOIN books_series_link bsl ON bsl.book = b.id
|
||||
LEFT JOIN series sr ON sr.id = bsl.series
|
||||
LEFT JOIN books_ratings_link brl ON brl.book = b.id
|
||||
LEFT JOIN ratings r ON r.id = brl.rating
|
||||
WHERE b.id = ?`
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
id: number;
|
||||
uuid: string;
|
||||
title: string;
|
||||
series_index: number | null;
|
||||
has_cover: number;
|
||||
comment: string | null;
|
||||
series_name: string | null;
|
||||
rating: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
const authors = db
|
||||
.prepare(
|
||||
`SELECT a.name FROM authors a
|
||||
JOIN books_authors_link bal ON bal.author = a.id
|
||||
WHERE bal.book = ?`
|
||||
)
|
||||
.all(id) as { name: string }[];
|
||||
|
||||
const tags = db
|
||||
.prepare(
|
||||
`SELECT t.name FROM tags t
|
||||
JOIN books_tags_link btl ON btl.tag = t.id
|
||||
WHERE btl.book = ?`
|
||||
)
|
||||
.all(id) as { name: string }[];
|
||||
|
||||
const formats = db
|
||||
.prepare(`SELECT format FROM data WHERE book = ?`)
|
||||
.all(id) as { format: string }[];
|
||||
|
||||
db.close();
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
title: row.title,
|
||||
authors: authors.map((a) => a.name),
|
||||
tags: tags.map((t) => t.name),
|
||||
comment: row.comment,
|
||||
series: row.series_name,
|
||||
seriesIndex: row.series_index,
|
||||
rating: row.rating,
|
||||
formats: formats.map((f) => f.format),
|
||||
hasCover: !!row.has_cover,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCoverPath(calibreBookId: number): string | null {
|
||||
const libraryPath = env.calibreLibraryPath;
|
||||
if (!libraryPath) return null;
|
||||
|
||||
const dbPath = getMetadataDbPath();
|
||||
if (!dbPath || !fs.existsSync(dbPath)) return null;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require("better-sqlite3");
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 3000 });
|
||||
const row = db
|
||||
.prepare(`SELECT path FROM books WHERE id = ?`)
|
||||
.get(calibreBookId) as { path: string } | undefined;
|
||||
db.close();
|
||||
if (!row) return null;
|
||||
|
||||
const coverPath = path.join(libraryPath, row.path, "cover.jpg");
|
||||
const resolved = path.resolve(coverPath);
|
||||
const libraryRoot = path.resolve(libraryPath);
|
||||
if (!resolved.startsWith(libraryRoot)) return null;
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
43
apps/web/src/lib/services/cartographer.ts
Executable file
43
apps/web/src/lib/services/cartographer.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db, userProgress, weeklyReviews } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
import { getExplorations } from "./explorations";
|
||||
|
||||
export async function getCartographerDesk(userId: string) {
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
|
||||
const weekOf = weekStartString();
|
||||
|
||||
const [review] = await db
|
||||
.select()
|
||||
.from(weeklyReviews)
|
||||
.where(eq(weeklyReviews.userId, userId))
|
||||
.orderBy(desc(weeklyReviews.weekStart))
|
||||
.limit(1);
|
||||
|
||||
const weekExplorations = await getExplorations(userId, weekOf);
|
||||
|
||||
const domains = [
|
||||
{ key: "consistency", label: "Consistency", score: progress?.consistencyScore ?? 0 },
|
||||
{ key: "discipline", label: "Discipline", score: progress?.disciplineScore ?? 0 },
|
||||
{ key: "learning", label: "Learning", score: progress?.learningScore ?? 0 },
|
||||
{ key: "spiritual", label: "Spiritual", score: progress?.spiritualScore ?? 0 },
|
||||
{ key: "health", label: "Health", score: progress?.healthScore ?? 0 },
|
||||
{ key: "reading", label: "Reading", score: progress?.readingScore ?? 0 },
|
||||
];
|
||||
|
||||
return {
|
||||
domains,
|
||||
chapter: progress?.currentChapter ?? "Prologue: Awakening",
|
||||
level: progress?.level ?? 1,
|
||||
horizon: {
|
||||
intention: review?.userIntention ?? null,
|
||||
mentorLetter: review?.mentorLetter ?? null,
|
||||
},
|
||||
activePaths: weekExplorations.filter((e) => e.status === "active"),
|
||||
suggested: weekExplorations.filter((e) => e.status === "suggested"),
|
||||
};
|
||||
}
|
||||
61
apps/web/src/lib/services/catch-up.ts
Normal file
61
apps/web/src/lib/services/catch-up.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { format, subDays } from "date-fns";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, dailyAdventures, dailyAdventureItems } from "../db";
|
||||
import { getDailyAdventure } from "./adventure/materialization";
|
||||
import { getReflection } from "./reflection";
|
||||
import { getLogicalToday } from "../dates";
|
||||
import { getDayBoundaryHour } from "./day-boundary";
|
||||
|
||||
export type CatchUpGap = {
|
||||
date: string;
|
||||
reason: "empty" | "incomplete" | "no_reflection";
|
||||
};
|
||||
|
||||
export async function detectCatchUpGaps(userId: string, days = 7): Promise<CatchUpGap[]> {
|
||||
const boundaryHour = await getDayBoundaryHour(userId);
|
||||
const logicalToday = getLogicalToday(boundaryHour);
|
||||
const gaps: CatchUpGap[] = [];
|
||||
|
||||
for (let i = 1; i <= days; i++) {
|
||||
const d = format(subDays(new Date(logicalToday), i), "yyyy-MM-dd");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(dailyAdventures)
|
||||
.where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, d)));
|
||||
|
||||
if (!row) {
|
||||
gaps.push({ date: d, reason: "empty" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(dailyAdventureItems)
|
||||
.where(
|
||||
and(
|
||||
eq(dailyAdventureItems.dailyAdventureId, row.id),
|
||||
eq(dailyAdventureItems.enabled, true)
|
||||
)
|
||||
);
|
||||
|
||||
const scorable = items.filter((i) => i.type !== "note" && !i.deletedAt);
|
||||
const touched = scorable.filter((i) => i.state !== "blank").length;
|
||||
|
||||
if (!row.isRestDay && scorable.length > 0 && touched === 0) {
|
||||
gaps.push({ date: d, reason: "incomplete" });
|
||||
}
|
||||
}
|
||||
|
||||
return gaps.slice(0, 3);
|
||||
}
|
||||
|
||||
export async function markDayLogged(userId: string, date: string, isBackfilled = false) {
|
||||
const { adventure } = await getDailyAdventure(userId, date);
|
||||
const logicalToday = getLogicalToday(await getDayBoundaryHour(userId));
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (!adventure.loggedAt) updates.loggedAt = new Date();
|
||||
if (isBackfilled || date !== logicalToday) updates.isBackfilled = true;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.update(dailyAdventures).set(updates).where(eq(dailyAdventures.id, adventure.id));
|
||||
}
|
||||
}
|
||||
233
apps/web/src/lib/services/dashboard.ts
Executable file
233
apps/web/src/lib/services/dashboard.ts
Executable file
@@ -0,0 +1,233 @@
|
||||
import { format, subDays, startOfMonth } from "date-fns";
|
||||
import { journeyDay } from "@adventureos/shared";
|
||||
import { requireUser } from "./user";
|
||||
import { getDailyAdventure, buildDaySnapshot, refreshScores } from "./adventure";
|
||||
import { getReflection } from "./reflection";
|
||||
import { getBooks, getReadingStreak, getWeeklyPages, getBooksCompletedCount, getTotalPagesRead } from "./reading";
|
||||
import { getSuggestions } from "./explorations";
|
||||
import { awardDailyVisit } from "./xp";
|
||||
import { getAchievements } from "./achievements";
|
||||
import { todayString, getLogicalToday, getLogicalYesterday, isWithinGraceWindow } from "../dates";
|
||||
import { db, settings } from "../db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
import { getXpBySource, getXpInRange } from "./xp";
|
||||
import { getDayBoundaryHour } from "./day-boundary";
|
||||
import { detectCatchUpGaps } from "./catch-up";
|
||||
|
||||
export async function getDashboard(date?: string) {
|
||||
const { user, progress } = await requireUser();
|
||||
const boundaryHour = await getDayBoundaryHour(user.id);
|
||||
const logicalToday = getLogicalToday(boundaryHour);
|
||||
const activeDate = date ?? logicalToday;
|
||||
await materializeAndVisit(user.id, activeDate);
|
||||
|
||||
const { adventure, items, todos } = await getDailyAdventure(user.id, activeDate);
|
||||
const reflection = await getReflection(user.id, activeDate);
|
||||
const suggestions = await getSuggestions(user.id, "quest_giver");
|
||||
const bookList = await getBooks(user.id);
|
||||
const streak = await getReadingStreak(user.id);
|
||||
const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd");
|
||||
const weeklyPages = await getWeeklyPages(user.id, weekStart, activeDate);
|
||||
const catchUpGaps = await detectCatchUpGaps(user.id);
|
||||
|
||||
const [goalRow] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(
|
||||
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
|
||||
);
|
||||
|
||||
const activeBooks = bookList
|
||||
.filter((b) => b.status === "reading")
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
author: b.author,
|
||||
totalPages: b.totalPages,
|
||||
currentPage: b.currentPage,
|
||||
status: b.status as "reading" | "paused" | "finished",
|
||||
progressPercent: Math.round((b.currentPage / b.totalPages) * 100),
|
||||
}));
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
portraitConfig: user.portraitConfig,
|
||||
currentTitle: user.currentTitle,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
progress: {
|
||||
totalXp: progress?.totalXp ?? 0,
|
||||
level: progress?.level ?? 1,
|
||||
currentChapter: progress?.currentChapter ?? "Prologue: Awakening",
|
||||
graceDaysRemaining: progress?.graceDaysRemaining ?? 2,
|
||||
consistencyScore: progress?.consistencyScore ?? 0,
|
||||
disciplineScore: progress?.disciplineScore ?? 0,
|
||||
learningScore: progress?.learningScore ?? 0,
|
||||
spiritualScore: progress?.spiritualScore ?? 0,
|
||||
healthScore: progress?.healthScore ?? 0,
|
||||
readingScore: progress?.readingScore ?? 0,
|
||||
journeyDay: journeyDay(user.createdAt),
|
||||
},
|
||||
today: {
|
||||
id: adventure.id,
|
||||
date: adventure.date,
|
||||
isRestDay: adventure.isRestDay,
|
||||
isCustomized: adventure.isCustomized,
|
||||
workHoursTarget:
|
||||
adventure.workHoursTarget != null ? Number(adventure.workHoursTarget) : null,
|
||||
dayMode: (adventure.dayMode ?? "normal") as import("@adventureos/shared").DayMode,
|
||||
isBackfilled: adventure.isBackfilled ?? false,
|
||||
loggedAt: adventure.loggedAt?.toISOString() ?? null,
|
||||
items: items
|
||||
.filter((i) => i.enabled)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
type: i.type,
|
||||
label: i.label,
|
||||
state: i.state,
|
||||
value: i.value,
|
||||
config: i.config,
|
||||
sortOrder: i.sortOrder,
|
||||
enabled: i.enabled,
|
||||
isCustom: i.isCustom,
|
||||
})),
|
||||
todos: todos.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
done: t.done,
|
||||
sortOrder: t.sortOrder,
|
||||
})),
|
||||
},
|
||||
reflection: reflection
|
||||
? {
|
||||
wentWell: reflection.wentWell,
|
||||
learned: reflection.learned,
|
||||
improveTomorrow: reflection.improveTomorrow,
|
||||
}
|
||||
: null,
|
||||
suggestions: suggestions.map((s) => ({
|
||||
id: s.id,
|
||||
role: s.role,
|
||||
content: s.content as Record<string, unknown>,
|
||||
generatedAt: s.generatedAt.toISOString(),
|
||||
})),
|
||||
reading: {
|
||||
activeBooks,
|
||||
streak,
|
||||
weeklyPages,
|
||||
weeklyGoal: (goalRow?.value as number) ?? 50,
|
||||
},
|
||||
dateContext: {
|
||||
logicalToday,
|
||||
logicalYesterday: getLogicalYesterday(boundaryHour),
|
||||
calendarToday: todayString(),
|
||||
boundaryHour,
|
||||
isGraceWindow: isWithinGraceWindow(boundaryHour),
|
||||
activeDate,
|
||||
isViewingYesterday: activeDate === getLogicalYesterday(boundaryHour),
|
||||
},
|
||||
catchUpGaps,
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeAndVisit(userId: string, date: string) {
|
||||
await getDailyAdventure(userId, date);
|
||||
await awardDailyVisit(userId, date);
|
||||
await refreshScores(userId);
|
||||
const existing = await getAchievements(userId);
|
||||
if (existing.length === 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_visit");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatsOverview() {
|
||||
const { user, progress } = await requireUser();
|
||||
const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd");
|
||||
const today = todayString();
|
||||
const thirtyAgo = format(subDays(new Date(), 29), "yyyy-MM-dd");
|
||||
|
||||
const xpMonth = await getXpInRange(user.id, monthStart, today);
|
||||
const xpByCategory = await getXpBySource(user.id, thirtyAgo, today);
|
||||
|
||||
const trend = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
||||
const snap = await buildDaySnapshot(user.id, d);
|
||||
const rate =
|
||||
snap.adventureSlots > 0
|
||||
? Math.round((snap.adventureTouched / snap.adventureSlots) * 100)
|
||||
: 100;
|
||||
trend.push({ date: d, value: rate });
|
||||
}
|
||||
|
||||
return {
|
||||
totalXp: progress?.totalXp ?? 0,
|
||||
xpThisMonth: xpMonth.reduce((s, r) => s + r.amount, 0),
|
||||
booksCompleted: await getBooksCompletedCount(user.id),
|
||||
pagesThisMonth: await getWeeklyPages(user.id, monthStart, today),
|
||||
totalPagesRead: await getTotalPagesRead(user.id),
|
||||
exerciseSessions: 0,
|
||||
consistencyTrend: trend,
|
||||
xpByCategory,
|
||||
scores: {
|
||||
consistency: progress?.consistencyScore ?? 0,
|
||||
discipline: progress?.disciplineScore ?? 0,
|
||||
learning: progress?.learningScore ?? 0,
|
||||
spiritual: progress?.spiritualScore ?? 0,
|
||||
health: progress?.healthScore ?? 0,
|
||||
reading: progress?.readingScore ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStatsDomain(domain: string, range = 30) {
|
||||
const { user } = await requireUser();
|
||||
const days = [];
|
||||
for (let i = range - 1; i >= 0; i--) {
|
||||
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
|
||||
const snap = await buildDaySnapshot(user.id, d);
|
||||
days.push({ ...snap });
|
||||
}
|
||||
|
||||
switch (domain) {
|
||||
case "reading": {
|
||||
const books = await getBooks(user.id);
|
||||
const streak = await getReadingStreak(user.id);
|
||||
return { books, streak, days: days.map((d) => ({ date: d.date, pages: d.pagesRead })) };
|
||||
}
|
||||
case "work":
|
||||
return {
|
||||
days: days.map((d) => ({
|
||||
date: d.date,
|
||||
hours: d.workHours,
|
||||
target: d.workTarget,
|
||||
})),
|
||||
};
|
||||
case "exercise":
|
||||
return {
|
||||
days: days.map((d) => ({ date: d.date, done: d.exerciseDone })),
|
||||
};
|
||||
case "spiritual":
|
||||
return {
|
||||
days: days.map((d) => ({
|
||||
date: d.date,
|
||||
prayer: d.prayerChecks,
|
||||
litanies: d.litanyChecks,
|
||||
})),
|
||||
};
|
||||
case "learning":
|
||||
return {
|
||||
days: days.map((d) => ({
|
||||
date: d.date,
|
||||
classes: d.classesDone,
|
||||
teaching: d.teachingDone,
|
||||
})),
|
||||
};
|
||||
default:
|
||||
return { days };
|
||||
}
|
||||
}
|
||||
15
apps/web/src/lib/services/day-boundary.ts
Normal file
15
apps/web/src/lib/services/day-boundary.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { DEFAULT_DAY_BOUNDARY_HOUR } from "@adventureos/shared";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
import { getSettingValue, upsertSetting } from "@/lib/repositories/settings.repository";
|
||||
|
||||
export async function getDayBoundaryHour(userId: string): Promise<number> {
|
||||
const value = (await getSettingValue(userId, SETTINGS_KEYS.dayBoundaryHour)) as number | null;
|
||||
if (value == null || value < 0 || value > 5) return DEFAULT_DAY_BOUNDARY_HOUR;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function setDayBoundaryHour(userId: string, hour: number): Promise<number> {
|
||||
const clamped = Math.max(0, Math.min(5, Math.round(hour)));
|
||||
await upsertSetting(userId, SETTINGS_KEYS.dayBoundaryHour, clamped);
|
||||
return clamped;
|
||||
}
|
||||
76
apps/web/src/lib/services/explorations.test.ts
Executable file
76
apps/web/src/lib/services/explorations.test.ts
Executable file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
filterDuplicateExplorations,
|
||||
normalizeQuestTitle,
|
||||
} from "./explorations";
|
||||
|
||||
describe("normalizeQuestTitle", () => {
|
||||
it("lowercases and trims titles", () => {
|
||||
expect(normalizeQuestTitle(" How Traceroute Works ")).toBe(
|
||||
"how traceroute works"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterDuplicateExplorations", () => {
|
||||
it("removes items matching existing titles", () => {
|
||||
const items = [
|
||||
{ title: "How traceroute works", hook: "a", category: "tech", minutes: 20 },
|
||||
{ title: "New quest", hook: "b", category: "reading", minutes: 15 },
|
||||
];
|
||||
const filtered = filterDuplicateExplorations(items, ["How Traceroute Works"]);
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].title).toBe("New quest");
|
||||
});
|
||||
|
||||
it("deduplicates within generated batch", () => {
|
||||
const items = [
|
||||
{ title: "Read poetry", hook: "a", category: "reading", minutes: 20 },
|
||||
{ title: "Read Poetry", hook: "b", category: "reading", minutes: 15 },
|
||||
];
|
||||
const filtered = filterDuplicateExplorations(items, []);
|
||||
expect(filtered).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exploration regenerate guard", () => {
|
||||
it("allows regeneration when only dismissed explorations exist", () => {
|
||||
const existing = [{ status: "dismissed" }, { status: "dismissed" }];
|
||||
const suggested = existing.filter((e) => e.status === "suggested");
|
||||
expect(suggested.length).toBe(0);
|
||||
});
|
||||
|
||||
it("allows regeneration when only active quests remain", () => {
|
||||
const existing = [{ status: "active" }, { status: "dismissed" }];
|
||||
const suggested = existing.filter((e) => e.status === "suggested");
|
||||
expect(suggested.length).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks regeneration when suggested quests remain", () => {
|
||||
const existing = [{ status: "suggested" }, { status: "active" }];
|
||||
const suggested = existing.filter((e) => e.status === "suggested");
|
||||
expect(suggested.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("excludes denied quests from active suggestions", () => {
|
||||
const explorations = [
|
||||
{ id: "1", status: "suggested" },
|
||||
{ id: "2", status: "dismissed" },
|
||||
{ id: "3", status: "active" },
|
||||
];
|
||||
const suggested = explorations.filter((e) => e.status === "suggested");
|
||||
const active = explorations.filter((e) => e.status === "active");
|
||||
expect(suggested).toHaveLength(1);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(
|
||||
explorations.filter((e) => e.status === "dismissed")
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("only suggested quests can be denied", () => {
|
||||
const canDeny = (status: string) => status === "suggested";
|
||||
expect(canDeny("suggested")).toBe(true);
|
||||
expect(canDeny("active")).toBe(false);
|
||||
expect(canDeny("completed")).toBe(false);
|
||||
});
|
||||
});
|
||||
297
apps/web/src/lib/services/explorations.ts
Executable file
297
apps/web/src/lib/services/explorations.ts
Executable file
@@ -0,0 +1,297 @@
|
||||
import { and, eq, desc } from "drizzle-orm";
|
||||
import { subDays, format } from "date-fns";
|
||||
import { XP_AWARDS } from "@adventureos/shared";
|
||||
import { db, explorations, aiSuggestions, weeklyReviews } from "../db";
|
||||
import { weekStartString } from "../dates";
|
||||
import { generateExplorations, generateMentorReview, generateQuests } from "./ai";
|
||||
import { awardXp, getXpInRange } from "./xp";
|
||||
import { buildDaySnapshot, refreshScores } from "./adventure";
|
||||
import { buildAiContext } from "./ai-context";
|
||||
import { requireUser } from "./user";
|
||||
import { recordAction } from "./action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
|
||||
export function normalizeQuestTitle(title: string): string {
|
||||
return title.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function filterDuplicateExplorations<
|
||||
T extends { title: string },
|
||||
>(items: T[], existingTitles: string[]): T[] {
|
||||
const seen = new Set(existingTitles.map(normalizeQuestTitle));
|
||||
return items.filter((item) => {
|
||||
const key = normalizeQuestTitle(item.title);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSuggestions(userId: string, role?: string) {
|
||||
const conditions = [
|
||||
eq(aiSuggestions.userId, userId),
|
||||
eq(aiSuggestions.dismissed, false),
|
||||
];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(aiSuggestions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(aiSuggestions.generatedAt))
|
||||
.limit(10);
|
||||
|
||||
if (role) return rows.filter((r) => r.role === role);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function dismissSuggestion(id: string, userId: string) {
|
||||
await db
|
||||
.update(aiSuggestions)
|
||||
.set({ dismissed: true })
|
||||
.where(and(eq(aiSuggestions.id, id), eq(aiSuggestions.userId, userId)));
|
||||
}
|
||||
|
||||
export async function generateDailyQuests(userId: string) {
|
||||
const context = await buildAiContext(userId);
|
||||
const quests = await generateQuests(context, userId);
|
||||
|
||||
await db.insert(aiSuggestions).values({
|
||||
userId,
|
||||
role: "quest_giver",
|
||||
content: { quests },
|
||||
});
|
||||
|
||||
return quests;
|
||||
}
|
||||
|
||||
export async function generateWeeklyExplorations(userId: string) {
|
||||
const weekOf = weekStartString();
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf)));
|
||||
|
||||
const suggested = existing.filter((e) => e.status === "suggested");
|
||||
const statusCounts = existing.reduce<Record<string, number>>((acc, e) => {
|
||||
acc[e.status] = (acc[e.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate guard check',data:{weekOf,statusCounts,suggestedCount:suggested.length},timestamp:Date.now(),hypothesisId:'C1'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
if (suggested.length > 0) {
|
||||
const visible = existing.filter((e) => e.status !== "dismissed");
|
||||
return { explorations: visible, source: "existing" as const };
|
||||
}
|
||||
|
||||
const context = await buildAiContext(userId);
|
||||
const { explorations: generated, source, fallbackReason } = await generateExplorations(
|
||||
context,
|
||||
userId
|
||||
);
|
||||
|
||||
const existingTitles = existing.map((e) => e.title);
|
||||
const items = filterDuplicateExplorations(generated, existingTitles);
|
||||
|
||||
const created = [];
|
||||
for (const item of items) {
|
||||
const [row] = await db
|
||||
.insert(explorations)
|
||||
.values({
|
||||
userId,
|
||||
title: item.title,
|
||||
description: item.hook,
|
||||
category: item.category,
|
||||
weekOf,
|
||||
status: "suggested",
|
||||
minutes: item.minutes ?? null,
|
||||
})
|
||||
.returning();
|
||||
created.push(row);
|
||||
}
|
||||
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate complete',data:{source,generatedCount:generated.length,createdCount:created.length,filteredCount:items.length},timestamp:Date.now(),hypothesisId:'C3'})}).catch(()=>{});
|
||||
// #endregion
|
||||
|
||||
const visible = [
|
||||
...existing.filter((e) => e.status !== "dismissed"),
|
||||
...created,
|
||||
];
|
||||
return {
|
||||
explorations: visible,
|
||||
source,
|
||||
fallbackReason: source === "fallback" ? fallbackReason : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getExplorations(userId: string, weekOf?: string) {
|
||||
const week = weekOf ?? weekStartString();
|
||||
return db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, week)))
|
||||
.orderBy(desc(explorations.acceptedAt));
|
||||
}
|
||||
|
||||
export async function acceptExploration(id: string, userId: string) {
|
||||
const [before] = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
if (!before) throw new Error("Exploration not found");
|
||||
|
||||
await db
|
||||
.update(explorations)
|
||||
.set({ status: "active", acceptedAt: new Date() })
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.explorationUpdate,
|
||||
entityType: "exploration",
|
||||
entityId: id,
|
||||
summary: `Accepted exploration: ${before.title}`,
|
||||
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
|
||||
afterState: { status: "active", acceptedAt: new Date(), completedNote: before.completedNote },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function completeExploration(
|
||||
id: string,
|
||||
userId: string,
|
||||
note: string,
|
||||
date: string
|
||||
) {
|
||||
const [before] = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
if (!before) throw new Error("Exploration not found");
|
||||
|
||||
await db
|
||||
.update(explorations)
|
||||
.set({ status: "completed", completedNote: note })
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
const xpResult = await awardXp(userId, date, "exploration", XP_AWARDS.exploration, {
|
||||
explorationId: id,
|
||||
});
|
||||
const { checkAchievements } = await import("./achievements");
|
||||
await checkAchievements(userId);
|
||||
await refreshScores(userId);
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.explorationUpdate,
|
||||
entityType: "exploration",
|
||||
entityId: id,
|
||||
summary: `Completed exploration: ${before.title}`,
|
||||
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
|
||||
afterState: { status: "completed", acceptedAt: before.acceptedAt, completedNote: note },
|
||||
metadata: { xpEventIds: xpResult.xpEventId ? [xpResult.xpEventId] : [] },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function dismissExploration(id: string, userId: string) {
|
||||
const [before] = await db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
if (!before) throw new Error("Exploration not found");
|
||||
if (before.status !== "suggested") {
|
||||
throw new Error("Only suggested quests can be denied");
|
||||
}
|
||||
|
||||
await db
|
||||
.update(explorations)
|
||||
.set({ status: "dismissed" })
|
||||
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.explorationUpdate,
|
||||
entityType: "exploration",
|
||||
entityId: id,
|
||||
summary: `Dismissed exploration: ${before.title}`,
|
||||
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
|
||||
afterState: { status: "dismissed", acceptedAt: before.acceptedAt, completedNote: before.completedNote },
|
||||
});
|
||||
return { actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function generateWeeklyReview(userId: string, weekStart: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(weeklyReviews)
|
||||
.where(
|
||||
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
|
||||
);
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const context = await buildAiContext(userId);
|
||||
const mentor = await generateMentorReview(context, userId);
|
||||
|
||||
const weekEnd = format(
|
||||
new Date(new Date(weekStart).getTime() + 6 * 86400000),
|
||||
"yyyy-MM-dd"
|
||||
);
|
||||
const xpRows = await getXpInRange(userId, weekStart, weekEnd);
|
||||
const xpEarned = xpRows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
const [review] = await db
|
||||
.insert(weeklyReviews)
|
||||
.values({
|
||||
userId,
|
||||
weekStart,
|
||||
content: {
|
||||
patterns: mentor.patterns,
|
||||
encouragement: mentor.encouragement,
|
||||
focus_suggestion: mentor.focus_suggestion,
|
||||
xpEarned,
|
||||
},
|
||||
mentorLetter: mentor.letter,
|
||||
xpEarned,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
export async function getWeeklyReview(userId: string, weekStart: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(weeklyReviews)
|
||||
.where(
|
||||
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function setReviewIntention(
|
||||
userId: string,
|
||||
weekStart: string,
|
||||
intention: string
|
||||
) {
|
||||
await db
|
||||
.update(weeklyReviews)
|
||||
.set({ userIntention: intention })
|
||||
.where(
|
||||
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
|
||||
);
|
||||
await awardXp(userId, weekStart, "weekly_review", XP_AWARDS.weekly_review);
|
||||
}
|
||||
|
||||
export async function getExplorationHistory(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(explorations)
|
||||
.where(
|
||||
and(eq(explorations.userId, userId), eq(explorations.status, "completed"))
|
||||
)
|
||||
.orderBy(desc(explorations.acceptedAt))
|
||||
.limit(50);
|
||||
}
|
||||
89
apps/web/src/lib/services/memory-extraction.ts
Normal file
89
apps/web/src/lib/services/memory-extraction.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
|
||||
import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
|
||||
import { parseAiJson } from "@/lib/ai/parse-json";
|
||||
import { getProvider } from "@/lib/ai/provider-registry";
|
||||
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "./ai-config";
|
||||
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
|
||||
|
||||
const candidateSchema = z.object({
|
||||
candidates: z
|
||||
.array(
|
||||
z.object({
|
||||
category: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
confidence: z.coerce.number(),
|
||||
sensitivity: z.enum(["normal", "private", "sensitive"]).optional(),
|
||||
})
|
||||
)
|
||||
.max(2),
|
||||
});
|
||||
|
||||
export async function extractMemoryCandidates(
|
||||
userId: string,
|
||||
sourceType: MemorySourceType,
|
||||
sourceText: string,
|
||||
sourceRef?: { type: string; id?: string; date?: string }
|
||||
) {
|
||||
const settings = await getMemoryLearningSettings(userId);
|
||||
if (!settings.learningEnabled || !settings.autoSuggestEnabled) return [];
|
||||
|
||||
const behavior = await getAiBehaviorConfig(userId);
|
||||
const providerConfig = await getAiProviderConfig(userId);
|
||||
if (!behavior.enabled || !providerConfig.enabled) return [];
|
||||
|
||||
const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses).
|
||||
User text:
|
||||
"""
|
||||
${sourceText.slice(0, 1500)}
|
||||
"""
|
||||
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
|
||||
If nothing clear, return { "candidates": [] }.`;
|
||||
|
||||
try {
|
||||
const coreSystem = await getTemplateBody(userId);
|
||||
const provider = getProvider(providerConfig.type);
|
||||
const res = await provider.generateText(providerConfig, {
|
||||
model: providerConfig.model,
|
||||
prompt,
|
||||
system: buildSystemPrompt(behavior, coreSystem),
|
||||
format: "json",
|
||||
temperature: 0.3,
|
||||
maxTokens: 400,
|
||||
timeoutMs: providerConfig.timeoutMs,
|
||||
});
|
||||
|
||||
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
|
||||
if (!parsed.success) return [];
|
||||
|
||||
const results = [];
|
||||
for (const c of parsed.data.candidates) {
|
||||
if (c.confidence < 0.6) continue;
|
||||
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
|
||||
if (
|
||||
SENSITIVE_MEMORY_CATEGORIES.includes(c.category as MemoryCategory) &&
|
||||
!settings.allowSensitiveCategories
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const row = await createSuggestion(userId, {
|
||||
category: c.category as MemoryCategory,
|
||||
title: c.title,
|
||||
content: c.content,
|
||||
sourceType,
|
||||
sourceRef,
|
||||
confidence: c.confidence,
|
||||
});
|
||||
if (row) results.push(row);
|
||||
}
|
||||
return results;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getTemplateBody(userId: string) {
|
||||
const { getTemplateBody: getTpl } = await import("./ai-templates");
|
||||
return getTpl(userId, "system_core");
|
||||
}
|
||||
78
apps/web/src/lib/services/quick-log.ts
Normal file
78
apps/web/src/lib/services/quick-log.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { XP_AWARDS } from "@adventureos/shared";
|
||||
import { getDailyAdventure } from "./adventure/materialization";
|
||||
import { updateAdventureItem } from "./adventure/daily";
|
||||
import { saveReflection } from "./reflection";
|
||||
import { markDayLogged } from "./catch-up";
|
||||
import { getLogicalToday } from "../dates";
|
||||
import { getDayBoundaryHour } from "./day-boundary";
|
||||
|
||||
export type QuickLogInput = {
|
||||
workMinutes?: number;
|
||||
readingPages?: number;
|
||||
exercise?: boolean;
|
||||
prayerChecks?: number;
|
||||
reflection?: string;
|
||||
};
|
||||
|
||||
export async function quickLog(userId: string, date: string, input: QuickLogInput) {
|
||||
const { items } = await getDailyAdventure(userId, date);
|
||||
const results: string[] = [];
|
||||
|
||||
if (input.workMinutes != null && input.workMinutes > 0) {
|
||||
const workItem = items.find((i) => i.type === "duration");
|
||||
if (workItem) {
|
||||
const hours = input.workMinutes / 60;
|
||||
await updateAdventureItem(userId, date, workItem.id, {
|
||||
value: { hours },
|
||||
});
|
||||
results.push(`work: ${hours.toFixed(1)}h`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.readingPages != null && input.readingPages > 0) {
|
||||
const readingItem = items.find((i) => i.type === "reading");
|
||||
if (readingItem) {
|
||||
await updateAdventureItem(userId, date, readingItem.id, {
|
||||
value: { pages: input.readingPages },
|
||||
});
|
||||
results.push(`reading: ${input.readingPages} pages`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.exercise === true) {
|
||||
const exerciseItem = items.find(
|
||||
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise")
|
||||
);
|
||||
if (exerciseItem) {
|
||||
await updateAdventureItem(userId, date, exerciseItem.id, { state: "done" });
|
||||
results.push("exercise: done");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.prayerChecks != null && input.prayerChecks > 0) {
|
||||
const prayerItem = items.find((i) => i.label === "Prayer");
|
||||
if (prayerItem) {
|
||||
const checks =
|
||||
((prayerItem.value as Record<string, unknown>)?.checks as boolean[]) ?? [];
|
||||
const updated = checks.map((_, i) => i < input.prayerChecks!);
|
||||
await updateAdventureItem(userId, date, prayerItem.id, {
|
||||
value: { checks: updated },
|
||||
});
|
||||
results.push(`prayer: ${input.prayerChecks} checks`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.reflection?.trim()) {
|
||||
await saveReflection(userId, date, {
|
||||
wentWell: "",
|
||||
learned: input.reflection.trim(),
|
||||
improveTomorrow: "",
|
||||
});
|
||||
results.push("reflection saved");
|
||||
}
|
||||
|
||||
const logicalToday = getLogicalToday(await getDayBoundaryHour(userId));
|
||||
await markDayLogged(userId, date, date !== logicalToday);
|
||||
|
||||
return { saved: results, xpNote: XP_AWARDS.reflection };
|
||||
}
|
||||
210
apps/web/src/lib/services/reading.ts
Executable file
210
apps/web/src/lib/services/reading.ts
Executable file
@@ -0,0 +1,210 @@
|
||||
import { and, eq, desc, sql, sum } from "drizzle-orm";
|
||||
import {
|
||||
XP_AWARDS,
|
||||
xpForReadingPages,
|
||||
} from "@adventureos/shared";
|
||||
import { db, books, readingLogs } from "../db";
|
||||
import { todayString } from "../dates";
|
||||
import { awardXp } from "./xp";
|
||||
import { checkAchievements } from "./achievements";
|
||||
import { recordAction } from "./action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
|
||||
export async function getBooks(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(eq(books.userId, userId))
|
||||
.orderBy(desc(books.startedAt));
|
||||
}
|
||||
|
||||
export async function createBook(
|
||||
userId: string,
|
||||
data: { title: string; author?: string; totalPages: number }
|
||||
) {
|
||||
const [book] = await db
|
||||
.insert(books)
|
||||
.values({
|
||||
userId,
|
||||
title: data.title,
|
||||
author: data.author,
|
||||
totalPages: data.totalPages,
|
||||
})
|
||||
.returning();
|
||||
return book;
|
||||
}
|
||||
|
||||
export async function updateBook(
|
||||
bookId: string,
|
||||
userId: string,
|
||||
data: Partial<{
|
||||
title: string;
|
||||
author: string;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
status: string;
|
||||
notes: string;
|
||||
}>
|
||||
) {
|
||||
const [book] = await db
|
||||
.update(books)
|
||||
.set(data)
|
||||
.where(and(eq(books.id, bookId), eq(books.userId, userId)))
|
||||
.returning();
|
||||
return book;
|
||||
}
|
||||
|
||||
export async function logPages(
|
||||
userId: string,
|
||||
bookId: string,
|
||||
pages: number,
|
||||
date = todayString(),
|
||||
note?: string
|
||||
) {
|
||||
const [book] = await db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(and(eq(books.id, bookId), eq(books.userId, userId)));
|
||||
|
||||
if (!book) throw new Error("Book not found");
|
||||
|
||||
const newPage = Math.min(book.totalPages, book.currentPage + pages);
|
||||
const wasFinished = book.status === "finished";
|
||||
|
||||
const bookBefore = {
|
||||
currentPage: book.currentPage,
|
||||
status: book.status,
|
||||
finishedAt: book.finishedAt,
|
||||
};
|
||||
|
||||
const [log] = await db.insert(readingLogs).values({
|
||||
bookId,
|
||||
date,
|
||||
pagesRead: pages,
|
||||
note,
|
||||
}).returning();
|
||||
|
||||
const status =
|
||||
newPage >= book.totalPages ? "finished" : book.status === "paused" ? "paused" : "reading";
|
||||
|
||||
await db
|
||||
.update(books)
|
||||
.set({
|
||||
currentPage: newPage,
|
||||
status,
|
||||
finishedAt: status === "finished" ? new Date() : book.finishedAt,
|
||||
})
|
||||
.where(eq(books.id, bookId));
|
||||
|
||||
const xpEventIds: string[] = [];
|
||||
const xp = xpForReadingPages(pages);
|
||||
if (xp > 0) {
|
||||
const xpResult = await awardXp(userId, date, "reading", xp, { bookId, pages });
|
||||
if (xpResult.xpEventId) xpEventIds.push(xpResult.xpEventId);
|
||||
}
|
||||
|
||||
if (status === "finished" && !wasFinished) {
|
||||
const completeXp = await awardXp(userId, date, "book_complete", XP_AWARDS.book_complete, { bookId });
|
||||
if (completeXp.xpEventId) xpEventIds.push(completeXp.xpEventId);
|
||||
await checkAchievements(userId);
|
||||
}
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.readingLogPages,
|
||||
entityType: "book",
|
||||
entityId: bookId,
|
||||
summary: `Logged ${pages} pages in ${book.title}`,
|
||||
beforeState: { book: bookBefore },
|
||||
afterState: { book: { currentPage: newPage, status, finishedAt: status === "finished" ? new Date() : book.finishedAt } },
|
||||
metadata: { readingLogId: log.id, pages, date, xpEventIds },
|
||||
});
|
||||
|
||||
return { currentPage: newPage, status, actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
export async function getReadingStreak(userId: string) {
|
||||
const logs = await db
|
||||
.select({ date: readingLogs.date, pages: readingLogs.pagesRead })
|
||||
.from(readingLogs)
|
||||
.innerJoin(books, eq(readingLogs.bookId, books.id))
|
||||
.where(eq(books.userId, userId))
|
||||
.orderBy(desc(readingLogs.date));
|
||||
|
||||
const byDate = new Map<string, number>();
|
||||
for (const log of logs) {
|
||||
byDate.set(log.date, (byDate.get(log.date) ?? 0) + log.pages);
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
let best = 0;
|
||||
let isPaused = false;
|
||||
const today = todayString();
|
||||
const dates = Array.from(byDate.keys()).sort().reverse();
|
||||
|
||||
if (dates.length === 0) {
|
||||
return { current: 0, best: 0, isPaused: false };
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const ds = d.toISOString().slice(0, 10);
|
||||
const pages = byDate.get(ds) ?? 0;
|
||||
if (pages > 0) {
|
||||
current++;
|
||||
} else if (i === 0 && ds === today) {
|
||||
// today not read yet — don't break streak
|
||||
} else if (i === 1 && !byDate.has(today) && current > 0) {
|
||||
isPaused = true;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
d.setDate(d.getDate() - 1);
|
||||
}
|
||||
|
||||
let run = 0;
|
||||
const allDates = Array.from(byDate.keys()).sort();
|
||||
for (const date of allDates) {
|
||||
if ((byDate.get(date) ?? 0) > 0) {
|
||||
run++;
|
||||
best = Math.max(best, run);
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { current, best, isPaused };
|
||||
}
|
||||
|
||||
export async function getWeeklyPages(userId: string, from: string, to: string) {
|
||||
const [row] = await db
|
||||
.select({ total: sum(readingLogs.pagesRead) })
|
||||
.from(readingLogs)
|
||||
.innerJoin(books, eq(readingLogs.bookId, books.id))
|
||||
.where(
|
||||
and(
|
||||
eq(books.userId, userId),
|
||||
sql`${readingLogs.date} >= ${from}`,
|
||||
sql`${readingLogs.date} <= ${to}`
|
||||
)
|
||||
);
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
export async function getTotalPagesRead(userId: string) {
|
||||
const [row] = await db
|
||||
.select({ total: sum(readingLogs.pagesRead) })
|
||||
.from(readingLogs)
|
||||
.innerJoin(books, eq(readingLogs.bookId, books.id))
|
||||
.where(eq(books.userId, userId));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
export async function getBooksCompletedCount(userId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(and(eq(books.userId, userId), eq(books.status, "finished")));
|
||||
return rows.length;
|
||||
}
|
||||
42
apps/web/src/lib/services/reading/log-pages.ts
Executable file
42
apps/web/src/lib/services/reading/log-pages.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import { awardXp } from "@/lib/services/xp";
|
||||
import { xpForReadingPages } from "@adventureos/shared";
|
||||
import { recordAction } from "@/lib/services/action-events";
|
||||
import { ACTION_TYPES } from "@/lib/config";
|
||||
|
||||
export type ReadingLogActionInput = {
|
||||
userId: string;
|
||||
date: string;
|
||||
pages: number;
|
||||
summary: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
beforeState: Record<string, unknown>;
|
||||
afterState: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
xpMetadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function recordReadingLogAction(input: ReadingLogActionInput) {
|
||||
const xp = xpForReadingPages(input.pages);
|
||||
let xpEventId: string | undefined;
|
||||
if (xp > 0) {
|
||||
const xpResult = await awardXp(input.userId, input.date, "reading", xp, input.xpMetadata);
|
||||
xpEventId = xpResult.xpEventId;
|
||||
}
|
||||
|
||||
const actionEvent = await recordAction({
|
||||
userId: input.userId,
|
||||
actionType: ACTION_TYPES.readingLogPages,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: input.summary,
|
||||
beforeState: input.beforeState,
|
||||
afterState: input.afterState,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
xpEventIds: xpEventId ? [xpEventId] : [],
|
||||
},
|
||||
});
|
||||
|
||||
return { xpEventId, actionEventId: actionEvent.id };
|
||||
}
|
||||
74
apps/web/src/lib/services/reflection.test.ts
Executable file
74
apps/web/src/lib/services/reflection.test.ts
Executable file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
hasMeaningfulReflectionContent,
|
||||
shouldAwardReflectionXp,
|
||||
} from "../reflection-utils";
|
||||
|
||||
describe("hasMeaningfulReflectionContent", () => {
|
||||
it("returns false for all empty fields", () => {
|
||||
expect(
|
||||
hasMeaningfulReflectionContent({
|
||||
wentWell: "",
|
||||
learned: "",
|
||||
improveTomorrow: "",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for whitespace-only fields", () => {
|
||||
expect(
|
||||
hasMeaningfulReflectionContent({
|
||||
wentWell: " ",
|
||||
learned: "\t",
|
||||
improveTomorrow: " \n ",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when any field has content", () => {
|
||||
expect(
|
||||
hasMeaningfulReflectionContent({
|
||||
wentWell: "",
|
||||
learned: "Read for 20 minutes",
|
||||
improveTomorrow: "",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAwardReflectionXp", () => {
|
||||
const empty = { wentWell: "", learned: "", improveTomorrow: "" };
|
||||
const filled = {
|
||||
wentWell: "Good day",
|
||||
learned: "Something new",
|
||||
improveTomorrow: "Sleep earlier",
|
||||
};
|
||||
|
||||
it("does not award for empty content", () => {
|
||||
expect(shouldAwardReflectionXp(null, empty)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not award for whitespace-only content", () => {
|
||||
expect(
|
||||
shouldAwardReflectionXp(null, {
|
||||
wentWell: " ",
|
||||
learned: "",
|
||||
improveTomorrow: "",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("awards on first meaningful save", () => {
|
||||
expect(shouldAwardReflectionXp(null, filled)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not award when updating already meaningful reflection", () => {
|
||||
expect(shouldAwardReflectionXp(filled, { ...filled, learned: "More" })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("awards when empty row exists and user adds meaningful content", () => {
|
||||
expect(shouldAwardReflectionXp(empty, filled)).toBe(true);
|
||||
});
|
||||
});
|
||||
126
apps/web/src/lib/services/reflection.ts
Executable file
126
apps/web/src/lib/services/reflection.ts
Executable file
@@ -0,0 +1,126 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { XP_AWARDS } from "@adventureos/shared";
|
||||
import { db, reflections } from "../db";
|
||||
import {
|
||||
hasMeaningfulReflectionContent,
|
||||
shouldAwardReflectionXp,
|
||||
type ReflectionData,
|
||||
} from "../reflection-utils";
|
||||
import { awardXp } from "./xp";
|
||||
import { recordAction } from "./action-events";
|
||||
import { ACTION_TYPES, ENTITY_TYPES } from "@/lib/config";
|
||||
|
||||
export type { ReflectionData } from "../reflection-utils";
|
||||
export {
|
||||
hasMeaningfulReflectionContent,
|
||||
shouldAwardReflectionXp,
|
||||
} from "../reflection-utils";
|
||||
|
||||
export async function getReflection(userId: string, date: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(reflections)
|
||||
.where(and(eq(reflections.userId, userId), eq(reflections.date, date)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function saveReflection(
|
||||
userId: string,
|
||||
date: string,
|
||||
data: ReflectionData
|
||||
) {
|
||||
const existing = await getReflection(userId, date);
|
||||
const meaningful = hasMeaningfulReflectionContent(data);
|
||||
|
||||
if (!meaningful && !existing) {
|
||||
return { ...data, xpAwarded: 0 };
|
||||
}
|
||||
|
||||
let xpAwarded = 0;
|
||||
let reflectionId: string;
|
||||
const beforeState = existing
|
||||
? {
|
||||
existed: true,
|
||||
wentWell: existing.wentWell,
|
||||
learned: existing.learned,
|
||||
improveTomorrow: existing.improveTomorrow,
|
||||
}
|
||||
: { existed: false, wentWell: "", learned: "", improveTomorrow: "" };
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(reflections)
|
||||
.set(data)
|
||||
.where(eq(reflections.id, existing.id));
|
||||
reflectionId = existing.id;
|
||||
|
||||
if (shouldAwardReflectionXp(existing, data)) {
|
||||
const result = await awardXp(
|
||||
userId,
|
||||
date,
|
||||
"reflection",
|
||||
XP_AWARDS.reflection
|
||||
);
|
||||
xpAwarded = result.awarded;
|
||||
if (xpAwarded > 0) {
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_reflection");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [inserted] = await db
|
||||
.insert(reflections)
|
||||
.values({ userId, date, ...data })
|
||||
.returning({ id: reflections.id });
|
||||
reflectionId = inserted.id;
|
||||
const result = await awardXp(
|
||||
userId,
|
||||
date,
|
||||
"reflection",
|
||||
XP_AWARDS.reflection
|
||||
);
|
||||
xpAwarded = result.awarded;
|
||||
const { unlockAchievement } = await import("./achievements");
|
||||
await unlockAchievement(userId, "first_reflection");
|
||||
}
|
||||
|
||||
await recordAction({
|
||||
userId,
|
||||
actionType: ACTION_TYPES.reflectionSave,
|
||||
entityType: ENTITY_TYPES.reflection,
|
||||
entityId: reflectionId,
|
||||
summary: `Saved reflection for ${date}`,
|
||||
beforeState,
|
||||
afterState: { ...data, existed: true },
|
||||
metadata: { date, xpAwarded },
|
||||
});
|
||||
|
||||
if (meaningful) {
|
||||
const { extractMemoryCandidates } = await import("./memory-extraction");
|
||||
const { getMemoryLearningSettings } = await import("./ai-memory");
|
||||
const learning = await getMemoryLearningSettings(userId);
|
||||
if (learning.suggestAfterReflection) {
|
||||
const text = [data.wentWell, data.learned, data.improveTomorrow].filter(Boolean).join("\n");
|
||||
await extractMemoryCandidates(userId, "reflection", text, { type: "reflection", date });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...data, xpAwarded };
|
||||
}
|
||||
|
||||
export async function getReflectionsInRange(
|
||||
userId: string,
|
||||
from: string,
|
||||
to: string
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(reflections)
|
||||
.where(
|
||||
and(
|
||||
eq(reflections.userId, userId),
|
||||
sql`${reflections.date} >= ${from}`,
|
||||
sql`${reflections.date} <= ${to}`
|
||||
)
|
||||
);
|
||||
}
|
||||
57
apps/web/src/lib/services/teacher.ts
Executable file
57
apps/web/src/lib/services/teacher.ts
Executable file
@@ -0,0 +1,57 @@
|
||||
import { generateTeacherContent } from "./ai";
|
||||
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
||||
import {
|
||||
listTeacherContent,
|
||||
findTeacherContentById,
|
||||
insertTeacherContent,
|
||||
updateTeacherContent,
|
||||
} from "@/lib/repositories/teacher.repository";
|
||||
import type { TeacherCreateBody } from "@/lib/validation/schemas";
|
||||
|
||||
export async function getTeacherHistory(userId: string) {
|
||||
return listTeacherContent(userId);
|
||||
}
|
||||
|
||||
export async function getTeacherLesson(userId: string, id: string) {
|
||||
const row = await findTeacherContentById(id);
|
||||
if (!row || row.userId !== userId) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function createTeacherLesson(
|
||||
userId: string,
|
||||
input: TeacherCreateBody
|
||||
) {
|
||||
const topic = input.topic.trim();
|
||||
if (!topic) {
|
||||
throw new Error("Topic is required");
|
||||
}
|
||||
|
||||
const ctx = await buildMentorContext(userId, { feature: "teacher", topic, userMessage: topic });
|
||||
const { content, source, fallbackReason } = await generateTeacherContent(topic, userId, formatContextForPrompt(ctx));
|
||||
const row = await insertTeacherContent({
|
||||
userId,
|
||||
topic,
|
||||
explorationId: input.explorationId,
|
||||
content,
|
||||
status: "active",
|
||||
});
|
||||
return { ...row, source, fallbackReason };
|
||||
}
|
||||
|
||||
export async function completeTeacherLesson(
|
||||
userId: string,
|
||||
id: string,
|
||||
completedNote: string
|
||||
) {
|
||||
const row = await findTeacherContentById(id);
|
||||
if (!row || row.userId !== userId) throw new Error("Lesson not found");
|
||||
|
||||
const updated = await updateTeacherContent(id, {
|
||||
status: "completed",
|
||||
completedNote,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
if (!updated) throw new Error("Lesson not found");
|
||||
return updated;
|
||||
}
|
||||
30
apps/web/src/lib/services/theme-migration.ts
Executable file
30
apps/web/src/lib/services/theme-migration.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import { migrateThemeId } from "@/themes/registry";
|
||||
import { SETTINGS_KEYS } from "@/lib/config";
|
||||
|
||||
export function normalizeSettingsTheme(
|
||||
settings: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
if (settings.theme !== undefined) {
|
||||
return { ...settings, theme: migrateThemeId(settings.theme as string) };
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function migrateUserThemeInDb(
|
||||
userId: string,
|
||||
currentTheme: string | undefined
|
||||
): Promise<string> {
|
||||
const migrated = migrateThemeId(currentTheme);
|
||||
if (currentTheme && currentTheme !== migrated) {
|
||||
const { db, settings } = await import("../db");
|
||||
const { eq, and, sql } = await import("drizzle-orm");
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ userId, key: SETTINGS_KEYS.theme, value: migrated })
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId, settings.key],
|
||||
set: { value: sql`excluded.value` },
|
||||
});
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
41
apps/web/src/lib/services/undo.test.ts
Executable file
41
apps/web/src/lib/services/undo.test.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Characterization: Calibre reading logs use actionType "reading.log_pages"
|
||||
* but store beforeState as { currentPage, status } on reading_progress,
|
||||
* while undo.ts expects before.book for manual books table.
|
||||
*/
|
||||
describe("undo reading.log_pages characterization", () => {
|
||||
const manualBeforeState = {
|
||||
book: { currentPage: 50, status: "reading", finishedAt: null },
|
||||
};
|
||||
|
||||
const calibreBeforeState = {
|
||||
currentPage: 50,
|
||||
status: "reading",
|
||||
};
|
||||
|
||||
it("manual path has book wrapper expected by undo handler", () => {
|
||||
expect(manualBeforeState.book).toBeDefined();
|
||||
expect(manualBeforeState.book.currentPage).toBe(50);
|
||||
});
|
||||
|
||||
it("calibre path lacks book wrapper — undo handler mismatch", () => {
|
||||
expect("book" in calibreBeforeState).toBe(false);
|
||||
expect(calibreBeforeState.currentPage).toBe(50);
|
||||
});
|
||||
|
||||
it("documents entity types", () => {
|
||||
const manualEntity = { entityType: "book", actionType: "reading.log_pages" };
|
||||
const calibreEntity = { entityType: "reading_progress", actionType: "reading.log_pages" };
|
||||
expect(manualEntity.entityType).not.toBe(calibreEntity.entityType);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exploration regenerate guard", () => {
|
||||
it("allows regeneration when only dismissed explorations exist", () => {
|
||||
const existing = [{ status: "dismissed" }, { status: "dismissed" }];
|
||||
const actionable = existing.filter((e) => e.status !== "dismissed");
|
||||
expect(actionable.length).toBe(0);
|
||||
});
|
||||
});
|
||||
235
apps/web/src/lib/services/undo.ts
Executable file
235
apps/web/src/lib/services/undo.ts
Executable file
@@ -0,0 +1,235 @@
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
dailyAdventureItems,
|
||||
dailyAdventures,
|
||||
dailyTodos,
|
||||
books,
|
||||
readingLogs,
|
||||
reflections,
|
||||
explorations,
|
||||
adventureItems,
|
||||
userProgress,
|
||||
xpEvents,
|
||||
} from "../db";
|
||||
import {
|
||||
getActionEvent,
|
||||
markUndone,
|
||||
type RecordActionInput,
|
||||
} from "./action-events";
|
||||
import { refreshScores } from "./adventure";
|
||||
import { levelFromXp, chapterForLevel } from "@adventureos/shared";
|
||||
|
||||
export type UndoResult = {
|
||||
ok: boolean;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function recalculateProgress(userId: string) {
|
||||
const events = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(eq(xpEvents.userId, userId));
|
||||
|
||||
const active = events.filter(
|
||||
(e) => !(e.metadata as Record<string, unknown>)?.revoked
|
||||
);
|
||||
const totalXp = active.reduce((s, e) => s + e.amount, 0);
|
||||
const newLevel = levelFromXp(totalXp);
|
||||
const chapter = chapterForLevel(newLevel);
|
||||
|
||||
await db
|
||||
.update(userProgress)
|
||||
.set({ totalXp, level: newLevel, currentChapter: chapter.name })
|
||||
.where(eq(userProgress.userId, userId));
|
||||
}
|
||||
|
||||
async function revokeXpEvents(userId: string, xpEventIds: string[]) {
|
||||
if (xpEventIds.length === 0) return;
|
||||
const events = await db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds))
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
await db.insert(xpEvents).values({
|
||||
userId,
|
||||
date: event.date,
|
||||
source: event.source,
|
||||
amount: -event.amount,
|
||||
metadata: {
|
||||
revokedEventId: event.id,
|
||||
actionUndo: true,
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(xpEvents)
|
||||
.set({
|
||||
metadata: {
|
||||
...(event.metadata as Record<string, unknown>),
|
||||
revoked: true,
|
||||
},
|
||||
})
|
||||
.where(eq(xpEvents.id, event.id));
|
||||
}
|
||||
await recalculateProgress(userId);
|
||||
}
|
||||
|
||||
export async function undoAction(
|
||||
userId: string,
|
||||
actionEventId: string
|
||||
): Promise<UndoResult> {
|
||||
const event = await getActionEvent(userId, actionEventId);
|
||||
if (!event) return { ok: false, error: "Action not found" };
|
||||
if (event.undoneAt) return { ok: false, error: "Already undone" };
|
||||
if (!event.undoable) return { ok: false, error: "Action cannot be undone" };
|
||||
|
||||
const before = event.beforeState as Record<string, unknown>;
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const xpEventIds = (meta.xpEventIds as string[]) ?? [];
|
||||
|
||||
try {
|
||||
switch (event.actionType) {
|
||||
case "adventure_item.update": {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if ("value" in before) updates.value = before.value as Record<string, unknown>;
|
||||
if ("state" in before) updates.state = before.state as string;
|
||||
if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null;
|
||||
if ("label" in before) updates.label = before.label as string;
|
||||
if ("enabled" in before) updates.enabled = before.enabled as boolean;
|
||||
if ("config" in before) updates.config = before.config as Record<string, unknown>;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set(updates)
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
}
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "adventure_item.create": {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "adventure_item.delete": {
|
||||
await db
|
||||
.update(dailyAdventureItems)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(dailyAdventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "adventure.work_hours.update": {
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null })
|
||||
.where(eq(dailyAdventures.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.create": {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.update": {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({
|
||||
label: before.label as string,
|
||||
done: before.done as boolean,
|
||||
})
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "daily_todo.delete": {
|
||||
await db
|
||||
.update(dailyTodos)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(dailyTodos.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
case "reading.log_pages": {
|
||||
const bookBefore = before.book as Record<string, unknown>;
|
||||
const logId = meta.readingLogId as string;
|
||||
if (logId) {
|
||||
await db.delete(readingLogs).where(eq(readingLogs.id, logId));
|
||||
}
|
||||
await db
|
||||
.update(books)
|
||||
.set({
|
||||
currentPage: bookBefore.currentPage as number,
|
||||
status: bookBefore.status as string,
|
||||
finishedAt: (bookBefore.finishedAt as Date | null) ?? null,
|
||||
})
|
||||
.where(eq(books.id, event.entityId));
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
break;
|
||||
}
|
||||
case "adventure.rest_day": {
|
||||
const adventureId = meta.adventureId as string;
|
||||
if (adventureId) {
|
||||
await db
|
||||
.update(dailyAdventures)
|
||||
.set({ isRestDay: false })
|
||||
.where(eq(dailyAdventures.id, adventureId));
|
||||
}
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "reflection.save": {
|
||||
if (before.existed) {
|
||||
await db
|
||||
.update(reflections)
|
||||
.set({
|
||||
wentWell: before.wentWell as string,
|
||||
learned: before.learned as string,
|
||||
improveTomorrow: before.improveTomorrow as string,
|
||||
})
|
||||
.where(eq(reflections.id, event.entityId));
|
||||
} else {
|
||||
await db.delete(reflections).where(eq(reflections.id, event.entityId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "exploration.update": {
|
||||
await db
|
||||
.update(explorations)
|
||||
.set({
|
||||
status: before.status as string,
|
||||
acceptedAt: (before.acceptedAt as Date | null) ?? null,
|
||||
completedNote: (before.completedNote as string | null) ?? null,
|
||||
})
|
||||
.where(eq(explorations.id, event.entityId));
|
||||
await revokeXpEvents(userId, xpEventIds);
|
||||
await refreshScores(userId);
|
||||
break;
|
||||
}
|
||||
case "template_item.delete": {
|
||||
await db
|
||||
.update(adventureItems)
|
||||
.set({ deletedAt: null })
|
||||
.where(eq(adventureItems.id, event.entityId));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return { ok: false, error: `Unknown action type: ${event.actionType}` };
|
||||
}
|
||||
|
||||
await markUndone(actionEventId);
|
||||
return { ok: true, summary: event.summary };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Undo failed";
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export { type RecordActionInput };
|
||||
44
apps/web/src/lib/services/user.ts
Executable file
44
apps/web/src/lib/services/user.ts
Executable file
@@ -0,0 +1,44 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, users, userProgress } from "../db";
|
||||
|
||||
export async function getUser() {
|
||||
const [user] = await db.select().from(users).limit(1);
|
||||
if (!user) return null;
|
||||
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, user.id));
|
||||
|
||||
return { user, progress: progress ?? null };
|
||||
}
|
||||
|
||||
export async function requireUser() {
|
||||
const result = await getUser();
|
||||
if (!result?.user) {
|
||||
throw new Error("No user found. Run db:seed first.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function updateUserProfile(
|
||||
userId: string,
|
||||
data: {
|
||||
displayName?: string;
|
||||
portraitConfig?: {
|
||||
skinTone: string;
|
||||
hairColor: string;
|
||||
clothingColor: string;
|
||||
};
|
||||
currentTitle?: string;
|
||||
}
|
||||
) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
...(data.displayName && { displayName: data.displayName }),
|
||||
...(data.portraitConfig && { portraitConfig: data.portraitConfig }),
|
||||
...(data.currentTitle !== undefined && { currentTitle: data.currentTitle }),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
147
apps/web/src/lib/services/xp.ts
Executable file
147
apps/web/src/lib/services/xp.ts
Executable file
@@ -0,0 +1,147 @@
|
||||
import { eq, and, gte, lte, desc, sql, sum } from "drizzle-orm";
|
||||
import {
|
||||
applyDiminishingReturns,
|
||||
chapterForLevel,
|
||||
DAILY_SOFT_CAPS,
|
||||
levelFromXp,
|
||||
XP_AWARDS,
|
||||
type XpSource,
|
||||
} from "@adventureos/shared";
|
||||
import { db, userProgress, xpEvents } from "../db";
|
||||
|
||||
export async function getDailyXpEarned(
|
||||
userId: string,
|
||||
date: string,
|
||||
source?: XpSource
|
||||
): Promise<number> {
|
||||
const conditions = [
|
||||
eq(xpEvents.userId, userId),
|
||||
eq(xpEvents.date, date),
|
||||
];
|
||||
if (source) {
|
||||
conditions.push(eq(xpEvents.source, source));
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ total: sum(xpEvents.amount) })
|
||||
.from(xpEvents)
|
||||
.where(and(...conditions));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
export async function awardXp(
|
||||
userId: string,
|
||||
date: string,
|
||||
source: XpSource,
|
||||
amount: number,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<{ awarded: number; levelUp: boolean; newLevel: number; xpEventId?: string }> {
|
||||
if (amount <= 0) {
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
return { awarded: 0, levelUp: false, newLevel: progress?.level ?? 1 };
|
||||
}
|
||||
|
||||
const softCap = DAILY_SOFT_CAPS[source];
|
||||
let finalAmount = amount;
|
||||
if (softCap) {
|
||||
const earned = await getDailyXpEarned(userId, date, source);
|
||||
finalAmount = applyDiminishingReturns(amount, earned, softCap);
|
||||
}
|
||||
|
||||
if (finalAmount <= 0) {
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
return { awarded: 0, levelUp: false, newLevel: progress?.level ?? 1 };
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(xpEvents)
|
||||
.values({
|
||||
userId,
|
||||
date,
|
||||
source,
|
||||
amount: finalAmount,
|
||||
metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [progress] = await db
|
||||
.select()
|
||||
.from(userProgress)
|
||||
.where(eq(userProgress.userId, userId));
|
||||
|
||||
const oldLevel = progress.level;
|
||||
const newTotal = progress.totalXp + finalAmount;
|
||||
const newLevel = levelFromXp(newTotal);
|
||||
const chapter = chapterForLevel(newLevel);
|
||||
|
||||
await db
|
||||
.update(userProgress)
|
||||
.set({
|
||||
totalXp: newTotal,
|
||||
level: newLevel,
|
||||
currentChapter: chapter.name,
|
||||
})
|
||||
.where(eq(userProgress.userId, userId));
|
||||
|
||||
return {
|
||||
awarded: finalAmount,
|
||||
levelUp: newLevel > oldLevel,
|
||||
newLevel,
|
||||
xpEventId: inserted?.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function awardDailyVisit(userId: string, date: string) {
|
||||
const earned = await getDailyXpEarned(userId, date, "daily_visit");
|
||||
if (earned > 0) return null;
|
||||
return awardXp(userId, date, "daily_visit", XP_AWARDS.daily_visit);
|
||||
}
|
||||
|
||||
export async function getXpInRange(
|
||||
userId: string,
|
||||
from: string,
|
||||
to: string
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(xpEvents.userId, userId),
|
||||
gte(xpEvents.date, from),
|
||||
lte(xpEvents.date, to)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(xpEvents.date));
|
||||
}
|
||||
|
||||
export async function getXpBySource(
|
||||
userId: string,
|
||||
from: string,
|
||||
to: string
|
||||
) {
|
||||
const rows = await db
|
||||
.select({
|
||||
source: xpEvents.source,
|
||||
total: sum(xpEvents.amount),
|
||||
})
|
||||
.from(xpEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(xpEvents.userId, userId),
|
||||
gte(xpEvents.date, from),
|
||||
lte(xpEvents.date, to)
|
||||
)
|
||||
)
|
||||
.groupBy(xpEvents.source);
|
||||
|
||||
return rows.map((r) => ({
|
||||
category: r.source,
|
||||
amount: Number(r.total ?? 0),
|
||||
}));
|
||||
}
|
||||
38
apps/web/src/lib/types/jsonb.ts
Executable file
38
apps/web/src/lib/types/jsonb.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
/** Typed shapes stored in JSONB columns and action event payloads. */
|
||||
|
||||
export type ActionBeforeState = Record<string, unknown>;
|
||||
|
||||
export type BookBeforeState = {
|
||||
currentPage: number;
|
||||
status: string;
|
||||
finishedAt: Date | null;
|
||||
};
|
||||
|
||||
export type ReadingLogBeforeState =
|
||||
| { book: BookBeforeState }
|
||||
| { currentPage: number; status: string };
|
||||
|
||||
export type AdventureItemValueJson = {
|
||||
hours?: number;
|
||||
note?: string;
|
||||
checks?: boolean[];
|
||||
pages?: number;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
export type AdventureItemConfigJson = {
|
||||
targetHours?: number;
|
||||
checklistSize?: number;
|
||||
bookId?: string;
|
||||
scheduledTime?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type AiConfigJson = {
|
||||
personality?: string;
|
||||
verbosity?: string;
|
||||
frequency?: string;
|
||||
creativity?: number;
|
||||
enabled?: boolean;
|
||||
strictMode?: boolean;
|
||||
};
|
||||
2
apps/web/src/lib/validation/index.ts
Executable file
2
apps/web/src/lib/validation/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { parseJsonBody } from "./parse";
|
||||
export * from "./schemas";
|
||||
20
apps/web/src/lib/validation/parse.ts
Executable file
20
apps/web/src/lib/validation/parse.ts
Executable file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { ValidationError } from "@/lib/errors";
|
||||
|
||||
export async function parseJsonBody<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>
|
||||
): Promise<T> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
throw new ValidationError("Invalid JSON body");
|
||||
}
|
||||
const result = schema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
const msg = result.error.issues.map((i) => i.message).join("; ");
|
||||
throw new ValidationError(msg || "Validation failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
39
apps/web/src/lib/validation/schemas.test.ts
Executable file
39
apps/web/src/lib/validation/schemas.test.ts
Executable file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ValidationError, mapErrorToResponse } from "@/lib/errors";
|
||||
import { teacherCreateSchema, normalizeTeacherCreateBody } from "@/lib/validation/schemas";
|
||||
|
||||
describe("teacherCreateSchema", () => {
|
||||
it("requires topic", () => {
|
||||
const result = teacherCreateSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts valid topic", () => {
|
||||
const result = teacherCreateSchema.safeParse({ topic: "Rust ownership" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts null explorationId", () => {
|
||||
const result = teacherCreateSchema.safeParse({
|
||||
topic: "Rust ownership",
|
||||
explorationId: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(normalizeTeacherCreateBody(result.data).explorationId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapErrorToResponse", () => {
|
||||
it("maps ValidationError to 400", () => {
|
||||
const mapped = mapErrorToResponse(new ValidationError("bad input"));
|
||||
expect(mapped.status).toBe(400);
|
||||
expect(mapped.message).toBe("bad input");
|
||||
});
|
||||
|
||||
it("maps Unauthorized to 401", () => {
|
||||
const mapped = mapErrorToResponse(new Error("Unauthorized"));
|
||||
expect(mapped.status).toBe(401);
|
||||
});
|
||||
});
|
||||
81
apps/web/src/lib/validation/schemas.ts
Executable file
81
apps/web/src/lib/validation/schemas.ts
Executable file
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const teacherCreateSchema = z.object({
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
explorationId: z.union([z.string().uuid(), z.null()]).optional(),
|
||||
});
|
||||
|
||||
export type TeacherCreateBody = {
|
||||
topic: string;
|
||||
explorationId?: string;
|
||||
};
|
||||
|
||||
export function normalizeTeacherCreateBody(
|
||||
body: z.infer<typeof teacherCreateSchema>
|
||||
): TeacherCreateBody {
|
||||
return {
|
||||
topic: body.topic,
|
||||
explorationId: body.explorationId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const templateCreateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
sortPriority: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const templateUpdateSchema = templateCreateSchema.partial();
|
||||
|
||||
export const templateItemSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const settingsPatchSchema = z.object({
|
||||
profile: z
|
||||
.object({
|
||||
displayName: z.string().optional(),
|
||||
currentTitle: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
settings: z.record(z.string(), z.unknown()).optional(),
|
||||
spiritual: z
|
||||
.object({
|
||||
prayerLabels: z.array(z.string()).optional(),
|
||||
litanyLabels: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const adventureItemUpdateSchema = z.object({
|
||||
value: z.record(z.string(), z.unknown()).optional(),
|
||||
state: z.enum(["blank", "started", "partial", "done"]).optional(),
|
||||
label: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const dailyTodoCreateSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
});
|
||||
|
||||
export const dailyTodoUpdateSchema = z.object({
|
||||
label: z.string().optional(),
|
||||
done: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const bookCreateSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
author: z.string().optional(),
|
||||
totalPages: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const logPagesSchema = z.object({
|
||||
pages: z.number().int().positive(),
|
||||
date: z.string().optional(),
|
||||
});
|
||||
Reference in New Issue
Block a user