DocDoeAI / src /lib /api /chat.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
8.19 kB
import { apiPost, getApiBaseUrl } from "@/lib/api/client";
import { getAuthToken } from "@/lib/auth/auth-client";
export const CHAT_ORIGIN_PAGES = [
"home",
"tuition",
"learn-anything",
"study-chat",
"classes",
"notes",
"quizzes",
"pyq-analyzer",
"progress",
"bookmarks",
"notifications",
"settings",
"workspace",
] as const;
export type ChatOriginPage = (typeof CHAT_ORIGIN_PAGES)[number];
export type StudyChatAcademicContext = {
origin?: string | null;
subject?: string | null;
chapter?: string | null;
topic?: string | null;
objective?: string | null;
current_step?: string | null;
relevant_context?: string | null;
source_id?: string | null;
source_ref?: string | null;
source_label?: string | null;
activity_type?: string | null;
question_id?: string | null;
question_label?: string | null;
return_href?: string | null;
captured_at?: string | null;
};
export function isChatOriginPage(value: string | null): value is ChatOriginPage {
return CHAT_ORIGIN_PAGES.some((page) => page === value);
}
export type ChatRequest = {
message: string;
intent?: string;
source_ids?: string[];
subject?: string;
language?: string;
origin_page?: ChatOriginPage;
academic_context?: StudyChatAcademicContext;
mode?: StudyChatMode;
correction_question?: string;
student_answer?: string;
marks?: number;
session_id?: string;
idempotency_key?: string;
};
export type StudyChatMode =
| "general_explanation"
| "selected_sources"
| "pyq_verified"
| "pyq_style"
| "answer_correction";
export type SourceCitation = {
source_id: string;
source_title: string;
source_version: string;
chunk_id: string;
chunk_index: number;
page_number?: number | null;
heading?: string | null;
excerpt: string;
confidence: number;
availability?: "live" | "snapshot";
};
export type VerifiedPyqAnalysis = {
available: boolean;
status: "available" | "unavailable" | "awaiting_verification";
message: string;
disclaimer: string;
repeated_topics: Array<{ topic: string; chapter: string; count: number; years: number[] }>;
marks_distribution: Array<{ marks: number; count: number; percentage: number }>;
data_coverage: { papers: number; questions: number; years: number[] };
source_count: number;
};
export type StudyChatEvidence = {
mode: StudyChatMode;
trust_state:
| "general_knowledge"
| "source_grounded"
| "verified_pyq"
| "pyq_style_only"
| "deterministic_correction"
| "web_grounded"
| "web_unavailable";
evidence_scope: string;
source_ids: string[];
citations: SourceCitation[];
pyq_classification: "verified_pyq" | "pyq_style" | "not_pyq";
pyq_analysis?: VerifiedPyqAnalysis | null;
};
export type WebSource = {
title: string;
url: string;
publisher: string;
published_date?: string | null;
author?: string | null;
snippet?: string | null;
is_official: boolean;
};
export type ChatApiResponse = {
answer: string;
intent_detected: string;
model_used: string;
evidence_label?: string | null;
web_sources: WebSource[];
evidence: StudyChatEvidence;
correction?: Record<string, unknown> | null;
};
export async function sendChatMessage(
payload: ChatRequest,
options?: { signal?: AbortSignal; timeoutMs?: number },
): Promise<ChatApiResponse> {
return apiPost<ChatApiResponse, ChatRequest>("/chat", payload, {
signal: options?.signal,
timeoutMs: options?.timeoutMs ?? 25000,
});
}
/**
* Stream chat tokens from DocDoe via SSE.
* Calls onDelta for each token, onDone when stream ends, onError on failure.
*/
export async function streamChatMessage(
payload: ChatRequest,
callbacks: {
onDelta: (token: string) => void;
onDone: (meta: {
intent: string;
model: string;
evidenceLabel: string | null;
webSources: WebSource[];
evidence: StudyChatEvidence;
correction: Record<string, unknown> | null;
}) => void;
onError: (message: string) => void;
},
signal?: AbortSignal,
): Promise<void> {
const authToken = getAuthToken();
const streamSignal = AbortSignal.any([
AbortSignal.timeout(120_000), ...(signal ? [signal] : []),
]);
let response: Response;
try {
response = await fetch(`${getApiBaseUrl()}/chat/stream`, {
method: "POST",
signal: streamSignal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
},
body: JSON.stringify(payload),
});
} catch {
if (streamSignal.aborted) {
callbacks.onError(signal?.aborted ? "Response stopped." : "The response timed out. Please retry.");
return;
}
callbacks.onError("DocDoe backend is offline or unreachable.");
return;
}
if (!response.ok) {
// Forward the real backend reason so quota/limit/auth errors are classified
// correctly downstream instead of all looking like "AI service offline".
let detail = "";
try {
const body = (await response.clone().json()) as { error?: { message?: string }; detail?: unknown; message?: string };
detail =
body?.error?.message ||
(typeof body?.detail === "string" ? body.detail : "") ||
(body?.detail && typeof body.detail === "object" && "message" in body.detail
? String((body.detail as { message?: unknown }).message ?? "")
: "") ||
body?.message ||
"";
} catch {
detail = "";
}
if (response.status === 402) {
callbacks.onError(detail || "You've reached your plan limit. Try again later or upgrade.");
} else if (response.status === 429) {
callbacks.onError("Too many requests. Wait a few seconds and try again.");
} else if (response.status === 401 || response.status === 403) {
callbacks.onError("Your session expired. Please sign in again to continue.");
} else {
callbacks.onError(detail || "DocDoe could not reach the AI service. Please try again.");
}
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError("Streaming not supported. Please try again.");
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
callbacks.onError("The response ended before it was complete. Please retry.");
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (!raw) continue;
try {
const event = JSON.parse(raw) as Record<string, unknown>;
if (event.error) {
callbacks.onError(String(event.error));
return;
}
if (event.delta) {
callbacks.onDelta(String(event.delta));
}
if (event.done) {
callbacks.onDone({
intent: String(event.intent ?? ""),
model: String(event.model ?? ""),
evidenceLabel:
typeof event.evidence_label === "string"
? event.evidence_label
: null,
webSources: Array.isArray(event.web_sources)
? (event.web_sources as WebSource[])
: [],
evidence: event.evidence as StudyChatEvidence,
correction:
event.correction && typeof event.correction === "object"
? (event.correction as Record<string, unknown>)
: null,
});
return;
}
} catch {
// malformed SSE line — skip
}
}
}
} catch {
if (streamSignal.aborted) {
callbacks.onError(signal?.aborted ? "Response stopped." : "The response timed out. Please retry.");
return;
}
callbacks.onError("Connection lost. Please try again.");
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}