DocDoeAI / src /lib /api /studio.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
23.6 kB
import type { StudioToolId } from "@/components/dashboard/studioTools";
import {
generateBrainAnswerCorrection,
generateBrainExamAnswer,
generateBrainFlashcards,
generateBrainLastNightPlan,
generateBrainNotes,
generateBrainQuiz,
generateBrainVideoPlan,
generateStudyPath,
} from "@/lib/api/brain";
import { getStudyPath, getWhatToStudy } from "@/lib/api/intelligence";
import {
normalizeExamAnswer,
normalizeFlashcards,
normalizeNotes,
normalizePYQAnalysis,
normalizeQuiz,
normalizeStudyPlan,
normalizeVideoPlan,
} from "@/lib/api/normalizers";
import { analyzeStructuredPreviousPapers, listPreviousPapers } from "@/lib/api/previous-papers";
import { createVideoScenePlan } from "@/lib/api/video";
import type {
BrainAnswerCorrectionOutput,
BrainGeneratePayload,
ExamModeAnswer,
ExamModeFourMarkAnswer,
ExamModeTwoMarkAnswer,
StudentLevel,
StudyPathPlanType,
VideoScene,
} from "@/lib/api/types";
type SourceValidationError = {
valid: false;
error: string;
};
type SourceValidationResult = { valid: true } | SourceValidationError;
type SourceWithStatus = {
id: string;
status?: "uploaded" | "processing" | "ready" | "failed";
};
export type AnswerCorrectionInput = {
question: string;
studentAnswer: string;
marks: "1" | "2" | "3" | "4" | "5" | "6" | "not_sure";
subject?: string;
chapter?: string;
};
function validateSourceForTool(documents: SourceWithStatus[], sourceIds: string[]): SourceValidationResult {
if (sourceIds.length === 0) {
return { valid: false, error: "Choose a source first so I can make this from your material." };
}
const selectedDocuments = documents.filter((doc) => sourceIds.includes(doc.id));
if (selectedDocuments.length === 0) {
return { valid: false, error: "Selected source not found. Please choose a valid source." };
}
const processingSources = selectedDocuments.filter((doc) => doc.status === "processing" || doc.status === "uploaded");
if (processingSources.length > 0) {
return { valid: false, error: "This source is still being read. I'll unlock source-based tools when it's ready." };
}
const failedSources = selectedDocuments.filter((doc) => doc.status === "failed");
if (failedSources.length > 0) {
return { valid: false, error: "I couldn't read this file properly. Try retrying extraction or upload a clearer file." };
}
// If any source has no status, treat it as potentially not ready
const unknownStatusSources = selectedDocuments.filter((doc) => !doc.status);
if (unknownStatusSources.length > 0 && unknownStatusSources.length === selectedDocuments.length) {
return { valid: false, error: "Source status unknown. Please wait for the source to finish processing." };
}
return { valid: true };
}
export type StudioRunSection = {
title: string;
items: string[];
variant?: "default" | "caution" | "action" | "keywords";
};
export type StudioRunResult = {
toolId: StudioToolId;
title: string;
summary: string;
sections: StudioRunSection[];
status: "ready" | "empty" | "fallback";
generationTimeMs?: number | null;
cacheHit?: boolean;
raw?: unknown;
evidenceLabel?: string | null;
};
type RunStudioToolInput = {
toolId: StudioToolId;
documentId: string;
sourceIds?: string[];
sourceTitle: string;
language?: string;
studentLevel?: StudentLevel;
weakAreas?: string[];
goal?: string;
timeLeft?: string;
topic?: string;
studyProfileId?: string | null;
quizQuestionCount?: number;
documents?: SourceWithStatus[] | undefined;
answerCorrection?: AnswerCorrectionInput;
};
const toolTitles: Record<StudioToolId, string> = {
"smart-notes": "Teach this syllabus topic",
"pyq-intelligence": "Show exam pattern from uploaded PYQs",
"ai-quiz": "Test me on this topic",
flashcards: "Lock exam keywords",
"study-videos": "Teach with a visual lesson",
"exam-answer": "Make board-answer version",
"answer-correction": "Correct my answer",
"last-night-prep": "Tell me what to study tonight",
"study-plan": "Build my chapter roadmap",
"what-to-study": "What To Study First",
"check-level": "Check My Level",
"exam-war-mode": "Exam War Mode",
};
function baseResult(input: RunStudioToolInput, summary: string, sections: StudioRunSection[], raw?: unknown, evidenceLabel?: string | null): StudioRunResult {
return {
toolId: input.toolId,
title: `${toolTitles[input.toolId]}: ${input.sourceTitle}`,
summary,
sections,
status: "ready",
raw,
evidenceLabel: evidenceLabel ?? null,
};
}
function answerToLine(answer: ExamModeAnswer | ExamModeTwoMarkAnswer | ExamModeFourMarkAnswer | { question?: string; answer?: string }) {
if ("question" in answer && "answer" in answer) return `${answer.question}: ${answer.answer}`;
return "Mark-wise answer outline";
}
function planTypeForTool(toolId: StudioToolId): StudyPathPlanType {
if (toolId === "last-night-prep") return "1h";
if (toolId === "exam-war-mode") return "5h";
return "7d";
}
function evidenceLabelFromRaw(raw: unknown): string | null {
if (raw && typeof raw === "object" && "evidence_label" in raw) {
const label = (raw as { evidence_label?: unknown }).evidence_label;
return typeof label === "string" ? label : null;
}
return null;
}
// The title must describe what the analysis actually did, never a span the data
// does not establish. The analyzer is upload-gated: it compares only the user's
// uploaded and verified papers and refuses pattern analysis below 2 years
// (backend/app/services/pyq_pattern_analyzer.py). "10-year" must not appear
// unless a genuine verified evidence label (8+ distinct years) is present.
function pyqTitleFromEvidence(label: string | null, paperCount: number) {
const text = label?.toLowerCase() ?? "";
const hasVerifiedEvidence =
text.includes("verified") && !text.includes("not verified") && !text.includes("unverified");
if (hasVerifiedEvidence) return "Verified multi-year PYQ signals";
if (paperCount === 1) return "Signals from this paper";
return "Signals across uploaded papers";
}
function uploadedPaperEvidenceLabel(paperCount: number) {
return `Based on ${paperCount} uploaded question paper${paperCount !== 1 ? "s" : ""}`;
}
export async function runStudioTool(input: RunStudioToolInput): Promise<StudioRunResult> {
const language = input.language ?? "English";
const sourceIds = input.sourceIds?.length ? input.sourceIds : [input.documentId];
// Validate sources if documents are provided
if (input.documents && input.documents.length > 0) {
const validation = validateSourceForTool(input.documents, sourceIds);
if (!validation.valid) {
return {
toolId: input.toolId,
title: `${toolTitles[input.toolId]}: ${input.sourceTitle}`,
summary: validation.error,
sections: [{ title: "Error", items: [validation.error], variant: "caution" as const }],
status: "empty",
};
}
}
const topic = input.topic || input.sourceTitle.replace(/^\d+\s+selected\s+sources$/i, "selected sources");
const brainPayload: BrainGeneratePayload = {
topic,
subject: undefined,
source_id: input.documentId,
source_ids: sourceIds,
study_profile_id: input.studyProfileId ?? null,
level: input.studentLevel ?? "intermediate",
goal: input.goal ?? "good marks",
language_preference: language,
options: { tool: input.toolId, weak_areas: input.weakAreas ?? [] },
};
if (input.toolId === "smart-notes") {
const result = await generateBrainNotes({ ...brainPayload, options: { mode: "smart_notes" } });
const notes = normalizeNotes(result.output);
const sections: StudioRunSection[] = [
{ title: "Key points", items: notes.key_points },
{ title: "Exam focus", items: notes.exam_focus ?? notes.exam_keywords ?? [] },
...(notes.scoring_keywords?.length ? [{ title: "Scoring keywords", items: notes.scoring_keywords, variant: "keywords" as const }] : []),
...(notes.common_mistakes?.length ? [{ title: "Common mistakes", items: notes.common_mistakes }] : []),
{ title: "Last-minute revision", items: notes.last_minute_revision ?? [] },
...(notes.ten_minute_revision?.length ? [{ title: "10-min revision", items: notes.ten_minute_revision }] : []),
...(notes.caution_note ? [{ title: "Caution", items: [notes.caution_note], variant: "caution" as const }] : []),
...(notes.next_study_action ? [{ title: "Next move", items: [notes.next_study_action], variant: "action" as const }] : []),
];
return baseResult(
input,
notes.source_summary ?? notes.summary ?? notes.student_level_summary ?? "DocDoe created source-based revision notes.",
sections,
result,
result.evidence_label,
);
}
if (input.toolId === "ai-quiz") {
const questionCount = input.quizQuestionCount ?? 5;
const result = await generateBrainQuiz({
...brainPayload,
options: {
...(brainPayload.options ?? {}),
difficulty: "exam",
num_questions: questionCount,
question_count: questionCount,
},
});
const questions = normalizeQuiz(result.output);
const traps = questions.map((q) => q.common_trap).filter((t): t is string => Boolean(t));
const quizSections: StudioRunSection[] = [
{ title: "Practice questions", items: questions.map((question) => question.question) },
...(traps.length ? [{ title: "Common traps", items: traps, variant: "caution" as const }] : []),
];
const quizResult = baseResult(
input,
questions.length > 0
? result.cache_hit
? "DocDoe reused your recent quiz instantly."
: questionCount >= 10
? `DocDoe created a ${questionCount}-question practice set from your selected source.`
: "DocDoe created a short practice quiz from the active source."
: "DocDoe could not create quiz questions from this source yet.",
quizSections,
result,
result.evidence_label,
);
return {
...quizResult,
generationTimeMs: result.generation_time_ms ?? null,
cacheHit: Boolean(result.cache_hit),
};
}
if (input.toolId === "flashcards") {
const result = await generateBrainFlashcards({ ...brainPayload, options: { card_count: 8 } });
const cards = normalizeFlashcards(result.output);
const hooks = cards.map((c) => c.memory_hook).filter((h): h is string => Boolean(h));
const examUses = cards.map((c) => c.exam_use).filter((u): u is string => Boolean(u));
const flashSections: StudioRunSection[] = [
{ title: "Cards", items: cards.map((card) => `${card.front} — ${card.back}`) },
...(hooks.length ? [{ title: "Memory hooks", items: hooks }] : []),
...(examUses.length ? [{ title: "Exam use", items: examUses }] : []),
];
return baseResult(
input,
cards.length > 0
? "DocDoe created active-recall cards for quick revision."
: "DocDoe could not create flashcards from this source yet.",
flashSections,
result,
result.evidence_label,
);
}
if (input.toolId === "exam-answer") {
const result = await generateBrainExamAnswer(brainPayload);
const output = normalizeExamAnswer(result.output);
const answers = [
...(output?.one_mark ?? []),
...(output?.two_mark ?? []),
...(output?.four_mark ?? []),
...result.output.six_mark,
...(output?.one_mark_answers ?? []),
...(output?.two_mark_answers ?? []),
...(output?.four_mark_answers ?? []),
];
const examSections: StudioRunSection[] = [
{ title: "Mark-wise answers", items: answers.map(answerToLine).slice(0, 6) },
{ title: "Keywords", items: output?.keywords_to_use ?? output?.important_keywords ?? result.output.keywords_to_underline ?? [] },
...(result.output.scoring_keywords?.length ? [{ title: "Scoring keywords", items: result.output.scoring_keywords, variant: "keywords" as const }] : []),
{ title: "Common mistakes", items: output?.mistakes_to_avoid ?? [result.output.common_mistake] },
{ title: "Examiner tip", items: [result.output.examiner_tip, ...(result.output.answer_writing_formula ?? [])] },
...(result.output.caution_note ? [{ title: "Caution", items: [result.output.caution_note], variant: "caution" as const }] : []),
...(result.output.next_study_action ? [{ title: "Next move", items: [result.output.next_study_action], variant: "action" as const }] : []),
];
return baseResult(
input,
"DocDoe shaped this into mark-wise exam answers.",
examSections,
result,
result.evidence_label,
);
}
if (input.toolId === "answer-correction") {
const correction = input.answerCorrection;
if (!correction?.question.trim() || !correction.studentAnswer.trim()) {
return {
toolId: input.toolId,
title: `${toolTitles[input.toolId]}: ${input.sourceTitle}`,
summary: "Paste the question and your answer before correction.",
sections: [{ title: "Missing input", items: ["Paste the question and your answer before correction."], variant: "caution" as const }],
status: "empty",
};
}
const result = await generateBrainAnswerCorrection({
...brainPayload,
topic: correction.question,
subject: correction.subject || brainPayload.subject,
options: {
...(brainPayload.options ?? {}),
mode: "answer_correction",
question: correction.question,
student_answer: correction.studentAnswer,
marks: correction.marks,
subject: correction.subject,
chapter: correction.chapter,
},
});
const output = result.output as BrainAnswerCorrectionOutput;
const isSourceMismatch = output.source_truth.toLowerCase().includes("not found");
const correctionSections: StudioRunSection[] = [
{ title: "Syllabus position", items: [output.syllabus_position, output.mark_scheme_assumption, output.score] },
{ title: "What you wrote correctly", items: output.what_correct },
{ title: "Marks lost", items: output.marks_lost, variant: isSourceMismatch ? "caution" : "default" },
{ title: "Missing keywords", items: output.missing_keywords, variant: "keywords" },
{ title: "Corrected board-answer version", items: [output.corrected_board_answer] },
{ title: "How to improve next time", items: output.how_to_improve },
{ title: "Quick retry task", items: [output.quick_retry_task], variant: "action" },
...(output.source_evidence?.length ? [{ title: "Source evidence", items: output.source_evidence, variant: "keywords" as const }] : []),
{ title: "Source truth", items: [output.source_truth], variant: isSourceMismatch ? "caution" : "default" },
];
return baseResult(
input,
isSourceMismatch
? output.source_truth
: `${output.score}. DocDoe found the marks lost and rewrote the answer in board format.`,
correctionSections,
result,
result.evidence_label,
);
}
if (input.toolId === "pyq-intelligence") {
const papers = await listPreviousPapers();
if (papers.length === 0) {
return {
toolId: input.toolId,
title: "Upload question papers to unlock PYQ signals",
summary: "DocDoe can explain this material, but PYQ trends need actual previous papers. Add past papers for this subject to compare patterns.",
sections: [
{ title: "Upload question paper", items: ["Upload question papers to unlock PYQ signals."] },
{ title: "Continue with material-only questions", items: ["Likely questions from this material"] },
],
status: "empty",
evidenceLabel: "Based on uploaded material only",
};
}
const result = await analyzeStructuredPreviousPapers({
paper_ids: papers.map((paper) => paper.id),
subject: papers[0]?.subject ?? "Study",
syllabus: papers[0]?.syllabus ?? "Indian Board Exams",
target_exam: "Board exam",
});
const analysis = normalizePYQAnalysis(result);
const pyqSections: StudioRunSection[] = [
{
title: "Repeated topics",
items: analysis?.repeated_topics.map((topic) => `${topic.topic}: ${topic.reason}`) ?? [],
},
{
title: "High probability",
items: analysis?.high_probability_topics.map((topic) => `${topic.topic}: ${topic.reason}`) ?? [],
},
{ title: "Study priority", items: analysis?.study_priority ?? [] },
...(analysis?.one_paper_limit_note ? [{ title: "Note", items: [analysis.one_paper_limit_note], variant: "caution" as const }] : []),
...(analysis?.next_study_action ? [{ title: "Next move", items: [analysis.next_study_action], variant: "action" as const }] : []),
...(analysis?.caution_note ? [{ title: "Caution", items: [analysis.caution_note], variant: "caution" as const }] : []),
];
const evidenceLabel = evidenceLabelFromRaw(result) ?? uploadedPaperEvidenceLabel(papers.length);
const pyqResult = baseResult(
input,
analysis?.summary ?? "DocDoe checked available previous papers for patterns.",
pyqSections,
result,
evidenceLabel,
);
return {
...pyqResult,
title: pyqTitleFromEvidence(evidenceLabel, papers.length),
evidenceLabel,
};
}
if (input.toolId === "what-to-study") {
const result = await getWhatToStudy(input.documentId, input.studentLevel ?? "intermediate", "good marks");
return baseResult(
input,
"DocDoe prioritized the chapter from your source, goal, and current level.",
[
{ title: "Study first", items: result.must_study_topics.map((topic) => `${topic.topic}: ${topic.reason}`) },
{ title: "Quick wins", items: result.easy_marks.map((topic) => `${topic.topic}: ${topic.reason}`) },
{ title: "Skip for now", items: result.skip_for_now_topics.map((topic) => `${topic.topic}: ${topic.reason}`) },
],
result,
result.evidence_label ?? null,
);
}
if (input.toolId === "study-plan" || input.toolId === "last-night-prep" || input.toolId === "exam-war-mode") {
if (input.toolId === "last-night-prep") {
const result = await generateBrainLastNightPlan({
...brainPayload,
time_left: input.timeLeft ?? "5 hours",
});
const plan = result.output;
const lnpSections: StudioRunSection[] = [
...(plan.do_first?.length ? [{ title: "Do first", items: plan.do_first }] : []),
{ title: "Time blocks", items: plan.time_blocks.map((block) => `${block.label}: ${block.minutes} min`) },
...(plan.if_30_minutes?.length ? [{ title: "If 30 minutes", items: plan.if_30_minutes }] : []),
...(plan.if_2_hours?.length ? [{ title: "If 2 hours", items: plan.if_2_hours }] : []),
...(plan.if_half_day?.length ? [{ title: "If half day", items: plan.if_half_day }] : []),
{ title: "Must study", items: plan.must_study },
{ title: "Skip for now", items: plan.skip_now },
...(plan.skip_or_skim?.length ? [{ title: "Skip or skim", items: plan.skip_or_skim }] : []),
...(plan.quick_recall_questions?.length ? [{ title: "Quick recall", items: plan.quick_recall_questions }] : []),
{ title: "Final checklist", items: plan.final_checklist },
...(plan.final_check_before_sleep?.length ? [{ title: "Final check before sleep", items: plan.final_check_before_sleep }] : []),
...(plan.caution_note ? [{ title: "Caution", items: [plan.caution_note], variant: "caution" as const }] : []),
{ title: "Trust note", items: [result.trust_note ?? plan.trust_note ?? "Using your selected source and study goal."] },
];
return baseResult(
input,
plan.calming_note ?? "DocDoe built a calm last-night plan with scoring priorities.",
lnpSections,
result,
result.evidence_label,
);
}
const planType = planTypeForTool(input.toolId);
const goal = input.toolId === "exam-war-mode" ? "pass" : "good marks";
const result = sourceIds.length > 1
? await generateStudyPath({
source_id: input.documentId,
source_ids: sourceIds,
topic,
goal,
level: input.studentLevel ?? "intermediate",
language_preference: language,
use_my_profile: true,
})
: await getStudyPath(input.documentId, planType, input.studentLevel ?? "intermediate", goal);
const plan = "study_timeline" in result
? {
blocks: result.study_timeline.map((item) => ({
time_block: `${item.duration_minutes} min`,
topic: item.title,
reason: item.reason,
task: item.task,
output_expected: item.expected_output,
revision_checkpoint: item.actions.join(", "),
})),
}
: normalizeStudyPlan(result);
const studyPathEvidenceLabel = "evidence_label" in result && typeof (result as { evidence_label?: unknown }).evidence_label === "string"
? (result as { evidence_label: string }).evidence_label
: null;
return baseResult(
input,
`DocDoe built a ${planType} plan with checkpoints and revision blocks.`,
[
{
title: "Time blocks",
items: plan?.blocks.map((block) => `${block.time_block}: ${block.task}`) ?? [],
},
{
title: "Why this order",
items: plan?.blocks.map((block) => `${block.topic}: ${block.reason}`) ?? [],
},
],
result,
studyPathEvidenceLabel,
);
}
if (input.toolId === "study-videos") {
const brainPlan = await generateBrainVideoPlan({
topic,
video_mode: "exam_focus",
teaching_style: "visual_tutor",
language_preference: language,
format: "16:9",
source_id: input.documentId,
source_ids: sourceIds,
level: input.studentLevel ?? "intermediate",
goal: input.goal ?? "good marks",
});
if (brainPlan.planning_only || !brainPlan.render_supported) {
return baseResult(
input,
"DocDoe created a study video plan. This long video is planned first. Rendering support will be added later.",
[
{ title: "Duration", items: [brainPlan.target_duration_range, `${brainPlan.estimated_duration_minutes} minutes estimated`] },
{ title: "Scene plan", items: brainPlan.scenes.map((scene) => `${scene.title}: ${scene.on_screen_text}`) },
{ title: "Trust note", items: [brainPlan.trust_note] },
],
brainPlan,
brainPlan.evidence_label,
);
}
const result = await createVideoScenePlan({
document_id: input.documentId,
title: input.sourceTitle,
language,
duration_minutes: 5,
visual_style: "clean_explainer",
video_format: "16:9",
});
const plan = normalizeVideoPlan(result);
const scenes = "scenes" in (plan ?? {}) ? (plan as { scenes: VideoScene[] }).scenes : [];
return baseResult(
input,
"DocDoe created a teachable scene plan. Long videos can take more time to prepare.",
[{ title: "Scene plan", items: scenes.map((scene) => `${scene.screen_text}: ${scene.visual_hint}`) }],
result,
result.evidence_label ?? null,
);
}
return {
toolId: input.toolId,
title: `${toolTitles[input.toolId]}: ${input.sourceTitle}`,
summary: "Choose this tool from Studio to continue.",
sections: [{ title: "Next step", items: ["DocDoe is ready when you are."] }],
status: "fallback",
};
}