DocDoeAI / src /lib /api /previous-papers.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
13.4 kB
import {
delay,
demoUser,
} from "@/lib/frontend-demo-data";
import {
getLocalPreviousPaperAnalysis,
getLocalPreviousPapers,
getLocalPreviousQuestions,
saveLocalPreviousPaper,
} from "@/lib/frontend-local-store";
import { apiGet, apiPost, apiUpload } from "@/lib/api/client";
import { shouldUseDemoData, withDemoFallback } from "@/lib/api/mode";
import type {
LocalOfflinePreviousPaper,
PreviousPaper,
PreviousPaperAnalysisResponse,
PreviousPaperUploadInput,
PreviousQuestion,
PreviousQuestionExtractRequest,
PreviousQuestionExtractResponse,
StructuredPreviousPaperAnalyzeRequest,
} from "@/lib/api/types";
export type PYQDiscoveryCandidate = {
title: string;
url: string;
snippet: string;
year: number | null;
subject: string | null;
board: string | null;
class_level: string | null;
source_domain: string;
source_title: string | null;
confidence_score: number;
official_source: boolean;
file_hash: string | null;
notes: string | null;
warnings: string[];
};
export type PYQDiscoverySearchPayload = {
board?: string | null;
class_level?: string | null;
subject?: string | null;
year_start?: number | null;
year_end?: number | null;
manual_urls?: string[];
};
export type PYQDiscoverySearchResponse = {
enabled: boolean;
provider: string;
configured: boolean;
message: string;
candidates: PYQDiscoveryCandidate[];
};
export type PYQDiscoveryImportResponse = {
id: string;
title: string;
year: number | null;
subject: string | null;
verification_status: string;
confidence_score: number | null;
official_source: boolean;
source_url: string | null;
deduped: boolean;
};
export type PyqAnalysisRequest = {
subject?: string | null;
board?: string | null;
class_level?: string | null;
exam?: string | null;
chapter?: string | null;
topic?: string | null;
paper_ids?: string[];
source_id?: string | null;
years?: number[];
};
export type PyqAnalysis = {
available: boolean;
status: "available" | "unavailable" | "awaiting_verification";
provenance: "live" | "sample";
message: string;
disclaimer: "Signals, not guaranteed prediction.";
subject: string | null;
board: string | null;
class_level?: string | null;
repeated_topics: Array<{
topic: string;
chapter: string;
count: number;
years: number[];
question_ids: string[];
}>;
question_type_distribution: Array<{ type: string; count: number; percentage: number }>;
marks_distribution: Array<{ marks: number; count: number; percentage: number }>;
chapter_priorities: Array<{
chapter: string;
total_questions: number;
total_marks: number;
topics: string[];
}>;
evidence: Array<{
question_id: string;
paper_id: string;
source_id: string;
paper_title: string;
year: number | null;
question_excerpt: string;
marks: number | null;
chapter: string;
topic: string;
source_url: string | null;
}>;
revision_plan: string[];
data_coverage: { papers: number; questions: number; years: number[] };
};
function domainFromUrl(value: string) {
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
function yearFromText(value: string) {
const match = value.match(/\b(19|20)\d{2}\b/);
return match ? Number(match[0]) : null;
}
function buildLocalDiscoveryFallback(payload: PYQDiscoverySearchPayload): PYQDiscoverySearchResponse {
const candidates = (payload.manual_urls ?? [])
.map((url): PYQDiscoveryCandidate | null => {
const sourceDomain = domainFromUrl(url);
if (!sourceDomain) return null;
const year = yearFromText(url) ?? payload.year_end ?? null;
const officialSource = /(dhsekerala|scertkerala|keralapareekshabhavan|pareekshabhavan|kerala\.gov)\./i.test(sourceDomain);
return {
title: `${payload.subject || "Previous paper"} ${year ?? ""}`.trim(),
url,
snippet: "Public PYQ link supplied for review before import.",
year,
subject: payload.subject ?? null,
board: payload.board ?? null,
class_level: payload.class_level ?? null,
source_domain: sourceDomain,
source_title: sourceDomain,
confidence_score: officialSource ? 0.82 : 0.55,
official_source: officialSource,
file_hash: null,
notes: officialSource ? "Official/public source domain detected." : "Review this source before using it for analysis.",
warnings: [],
};
})
.filter((candidate): candidate is PYQDiscoveryCandidate => Boolean(candidate));
return {
enabled: false,
provider: "disabled",
configured: false,
message: candidates.length
? "Review the public PYQ links below before importing them for analysis."
: "Automatic PYQ discovery is not configured yet. Paste a public PYQ link, upload a paper, or broaden your search.",
candidates,
};
}
function buildLocalQuestionExtraction(paperId: string): PreviousQuestionExtractResponse {
const questions = getLocalPreviousQuestions(paperId);
return {
previous_paper_id: paperId,
model_used: "DocDoe local preview",
question_count: questions.length,
questions,
};
}
export async function analyzePreviousPapers(payload: PyqAnalysisRequest) {
return apiPost<PyqAnalysis, PyqAnalysisRequest>("/pyq/analyze", payload);
}
export function loadSamplePreviousPaperAnalysis(): PyqAnalysis {
return {
available: true,
status: "available",
provenance: "sample",
message: "Example layout using clearly labelled sample evidence.",
disclaimer: "Signals, not guaranteed prediction.",
subject: "Biology",
board: "Kerala HSE",
class_level: "+2",
repeated_topics: [{
topic: "Cell division",
chapter: "Cell Cycle",
count: 2,
years: [2022, 2023],
question_ids: ["sample-q-1", "sample-q-2"],
}],
question_type_distribution: [
{ type: "short", count: 1, percentage: 50 },
{ type: "long", count: 1, percentage: 50 },
],
marks_distribution: [
{ marks: 2, count: 1, percentage: 50 },
{ marks: 5, count: 1, percentage: 50 },
],
chapter_priorities: [{
chapter: "Cell Cycle",
total_questions: 2,
total_marks: 7,
topics: ["Cell division"],
}],
evidence: [{
question_id: "sample-q-1",
paper_id: "sample-paper-2022",
source_id: "sample-paper-2022",
paper_title: "Sample Biology Paper 2022",
year: 2022,
question_excerpt: "Compare mitosis and meiosis.",
marks: 5,
chapter: "Cell Cycle",
topic: "Cell division",
source_url: null,
}],
revision_plan: ["Revise Cell Cycle, then practise one short and one long answer."],
data_coverage: { papers: 2, questions: 2, years: [2022, 2023] },
};
}
export async function listPreviousPapers() {
if (shouldUseDemoData()) return delay(getLocalPreviousPapers());
return withDemoFallback(
() => apiGet<PreviousPaper[]>("/previous-papers"),
() => delay(getLocalPreviousPapers()),
);
}
export async function uploadPreviousPaper(input: PreviousPaperUploadInput) {
if (shouldUseDemoData()) {
const paper: LocalOfflinePreviousPaper = {
id: `paper-local-${Date.now()}`,
user_id: input.userId ?? demoUser.id,
title: input.title || input.file.name.replace(/\.[^.]+$/, "") || "Uploaded previous paper",
subject: input.subject || "Physics",
syllabus: input.syllabus || "Indian Board Exams",
year: input.year ?? new Date().getFullYear(),
file_name: input.file.name,
file_type: input.file.type || "PDF",
file_path: "/demo/local-previous-paper",
status: "ready",
extracted_text: null,
extraction_error: null,
extracted_questions_json: [],
created_at: new Date().toISOString(),
};
return delay(saveLocalPreviousPaper(paper));
}
const formData = new FormData();
formData.append("file", input.file);
if (input.title) formData.append("title", input.title);
if (input.subject) formData.append("subject", input.subject);
if (input.syllabus) formData.append("syllabus", input.syllabus);
if (input.year) formData.append("year", String(input.year));
return withDemoFallback(
() => apiUpload<PreviousPaper>("/previous-papers/upload", formData, { timeoutMs: 60000 }),
async () => {
const paper: LocalOfflinePreviousPaper = {
id: `paper-local-${Date.now()}`,
user_id: input.userId ?? demoUser.id,
title: input.title || input.file.name.replace(/\.[^.]+$/, "") || "Uploaded previous paper",
subject: input.subject || "Study",
syllabus: input.syllabus || "Indian Board Exams",
year: input.year ?? new Date().getFullYear(),
file_name: input.file.name,
file_type: input.file.type || "PDF",
file_path: "/demo/local-previous-paper",
status: "ready",
extracted_text: null,
extraction_error: null,
extracted_questions_json: [],
created_at: new Date().toISOString(),
};
return saveLocalPreviousPaper(paper);
},
);
}
export async function listPreviousQuestions(paperId: string) {
if (shouldUseDemoData(paperId)) {
return delay(getLocalPreviousQuestions(paperId));
}
return withDemoFallback(
() => apiGet<PreviousQuestion[]>(`/previous-papers/${paperId}/questions`),
() => delay(getLocalPreviousQuestions(paperId)),
);
}
export async function extractPreviousQuestions(
paperId: string,
payload: PreviousQuestionExtractRequest = {},
) {
if (shouldUseDemoData(paperId)) {
void payload;
return delay(buildLocalQuestionExtraction(paperId));
}
return withDemoFallback(
() => apiPost<PreviousQuestionExtractResponse, PreviousQuestionExtractRequest>(
`/previous-papers/${paperId}/extract-questions`,
payload,
),
() => delay(buildLocalQuestionExtraction(paperId)),
);
}
export async function analyzeStructuredPreviousPapers(
payload: StructuredPreviousPaperAnalyzeRequest,
) {
if (shouldUseDemoData()) {
return delay(
getLocalPreviousPaperAnalysis(
payload.paper_ids ?? [],
payload.subject,
payload.syllabus,
),
);
}
return withDemoFallback(
() => apiPost<PreviousPaperAnalysisResponse, StructuredPreviousPaperAnalyzeRequest>(
"/previous-papers/analyze-structured",
payload,
),
() => delay(
getLocalPreviousPaperAnalysis(
payload.paper_ids ?? [],
payload.subject,
payload.syllabus,
),
),
);
}
export async function searchPYQDiscovery(payload: PYQDiscoverySearchPayload) {
const fallback = buildLocalDiscoveryFallback(payload);
return withDemoFallback(
() => apiPost<PYQDiscoverySearchResponse, PYQDiscoverySearchPayload>(
"/pyq-discovery/search",
payload,
{ timeoutMs: 30000 },
),
() => delay(fallback),
);
}
export async function importPYQDiscoveryCandidate(candidate: PYQDiscoveryCandidate) {
const fallback = () => {
const paper: LocalOfflinePreviousPaper = {
id: `paper-local-${Date.now()}`,
user_id: demoUser.id,
title: candidate.title || "Imported previous paper",
subject: candidate.subject || "Physics",
syllabus: candidate.board || "Indian Board Exams",
year: candidate.year ?? new Date().getFullYear(),
file_name: candidate.url.split("/").pop() || "previous-paper.pdf",
file_type: "PDF",
file_path: candidate.url,
status: "ready",
extracted_text: null,
extraction_error: null,
extracted_questions_json: [],
created_at: new Date().toISOString(),
};
const saved = saveLocalPreviousPaper(paper);
return {
id: saved.id,
title: saved.title,
year: saved.year,
subject: saved.subject,
verification_status: "candidate",
confidence_score: candidate.confidence_score,
official_source: candidate.official_source,
source_url: candidate.url,
deduped: false,
} satisfies PYQDiscoveryImportResponse;
};
if (shouldUseDemoData()) return delay(fallback());
return withDemoFallback(
() => apiPost<PYQDiscoveryImportResponse, PYQDiscoveryCandidate>(
"/pyq-discovery/import",
candidate,
{ timeoutMs: 30000 },
),
() => delay(fallback()),
);
}
// ─── Real Kerala previous-year papers (verified public archives) ──────────────
export type RealPyqPaper = {
title: string;
url: string;
years?: number[];
medium?: string;
source: string;
};
export type RealPyqPapersResponse = {
available: boolean;
level: string;
board: string;
subject: string | null;
papers: RealPyqPaper[];
years_covered: number[];
trust_note: string;
};
/** Fetch REAL downloadable Kerala previous-year question papers (not AI-generated). */
export async function getRealPyqPapers(params?: {
subject?: string | null;
classLevel?: string | null;
board?: string | null;
}): Promise<RealPyqPapersResponse> {
const q = new URLSearchParams();
if (params?.subject) q.set("subject", params.subject);
if (params?.classLevel) q.set("class_level", params.classLevel);
if (params?.board) q.set("board", params.board);
const suffix = q.toString() ? `?${q.toString()}` : "";
return apiGet<RealPyqPapersResponse>(`/pyq/papers${suffix}`);
}