Commit f0368860 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev_socratic' into 'dev'

Dev socratic

See merge request kantz/tutor_react!17
parents 22db3d83 8e5526b8
......@@ -5,19 +5,27 @@ import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import DocPanel from "../components/Retrieval/DocPanel";
import SocraticPanel from "../components/Task/SocraticPanel";
import TaskPanel from "../components/Task/TaskPanel";
import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi";
import { selectSubsection, selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator, isSocraticOrchestrator } from "../utils/orchestratorRoutes";
import sumintLogo from "../../SuMINT-Logo.png";
const initialMessages: ChatMessage[] = [];
const normalizeOrchestrator = (value: string | null | undefined): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task" || value === "feedback") {
if (
value === "qa" ||
value === "tutor" ||
value === "task" ||
value === "feedback" ||
value === "socratic"
) {
return value;
}
return null;
......@@ -43,6 +51,9 @@ type ArchivedChatDetail = {
file_id: string;
task_id: string;
} | null;
selected_subsection?: {
subsection_key: string;
} | null;
};
type ContextSource = {
......@@ -105,6 +116,55 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => (
markdown: source.markdown || "",
});
const applyRetrievedSources = (
sources: ContextSource[],
setters: {
setDirectChildren: (value: RetrievedDoc[]) => void;
setTaskChildren: (value: RetrievedDoc[]) => void;
setIndirectChildren: (value: RetrievedDoc[]) => void;
setSubsections: (value: RetrievedDoc[]) => void;
setSections: (value: RetrievedDoc[]) => void;
}
) => {
const nextDirect: RetrievedDoc[] = [];
const nextTask: RetrievedDoc[] = [];
const nextIndirect: RetrievedDoc[] = [];
const nextSubsections: RetrievedDoc[] = [];
const nextSections: RetrievedDoc[] = [];
sources.forEach((source, index) => {
const doc = toRetrievedDoc(source, index);
switch (source.retrieved_as) {
case "children_direct":
nextDirect.push(doc);
break;
case "children_expanded":
nextIndirect.push(doc);
break;
case "task_childs":
nextTask.push(doc);
break;
case "subsections":
nextSubsections.push(doc);
break;
case "sections":
nextSections.push(doc);
break;
case "neighbors":
nextIndirect.push(doc);
break;
default:
nextIndirect.push(doc);
}
});
setters.setDirectChildren(nextDirect);
setters.setTaskChildren(nextTask);
setters.setIndirectChildren(nextIndirect);
setters.setSubsections(nextSubsections);
setters.setSections(nextSections);
};
export default function ChatPage() {
const showChatsButton = String(import.meta.env.VITE_SHOW_CHATS_BUTTON ?? "true")
.trim()
......@@ -120,6 +180,8 @@ export default function ChatPage() {
selectedTaskRef,
selectedTask,
selectedTaskFile,
selectedSubsectionRef,
selectedSubsection,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
......@@ -129,6 +191,7 @@ export default function ChatPage() {
lockTask,
unlockTask,
setTaskRef,
setSubsectionRef,
resetForNewChat,
} = useTutorSession();
......@@ -155,6 +218,7 @@ export default function ChatPage() {
message: string;
} | null>(null);
const processedDeepLinkRef = useRef<string>("");
const socraticBootstrapRef = useRef<string>("");
const deepLinkTarget = useMemo(() => {
const orchestrator = String(searchParams.get("orchestrator") || "")
......@@ -162,23 +226,31 @@ export default function ChatPage() {
.toLowerCase();
const fileId = String(searchParams.get("file_id") || "").trim();
const rawTaskId = String(searchParams.get("task_id") || "").trim();
const rawSubsectionKey = String(searchParams.get("subsection_key") || "").trim();
const taskId =
/^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2
? rawTaskId.padStart(2, "0")
: rawTaskId;
const subsectionKey = rawSubsectionKey;
const hasFileId = Boolean(fileId);
const hasTaskId = Boolean(taskId);
const hasSubsectionKey = Boolean(subsectionKey);
const isTaskOrchestrator = isTaskCoupledOrchestrator(orchestrator);
const hasAnyTaskParam = hasFileId || hasTaskId;
const hasRequiredParams = hasFileId && hasTaskId;
const key = `${orchestrator}|${fileId}|${taskId}`;
const isSocratic = isSocraticOrchestrator(orchestrator as OrchestratorName);
const hasAnyTaskParam = hasFileId || hasTaskId || hasSubsectionKey;
const hasRequiredTaskParams = hasFileId && hasTaskId;
const hasRequiredSubsectionParams = hasSubsectionKey;
const key = `${orchestrator}|${fileId}|${taskId}|${subsectionKey}`;
return {
fileId,
taskId,
subsectionKey,
isTaskOrchestrator,
isSocratic,
hasAnyTaskParam,
hasRequiredParams,
hasRequiredTaskParams,
hasRequiredSubsectionParams,
key,
};
}, [searchParams]);
......@@ -187,7 +259,7 @@ export default function ChatPage() {
if (!isTasksInitialized) {
return;
}
if (!deepLinkTarget.isTaskOrchestrator || !deepLinkTarget.hasAnyTaskParam) {
if ((!deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.isSocratic) || !deepLinkTarget.hasAnyTaskParam) {
return;
}
if (processedDeepLinkRef.current === deepLinkTarget.key) {
......@@ -195,49 +267,93 @@ export default function ChatPage() {
}
processedDeepLinkRef.current = deepLinkTarget.key;
if (!deepLinkTarget.hasRequiredParams) {
setDeepLinkError(t("deepLinkInvalidTask"));
const targetRoute = getSelectionRouteForOrchestrator(
searchParams.get("orchestrator") === "feedback"
? "feedback"
: searchParams.get("orchestrator") === "socratic"
? "socratic"
: "task"
);
if (
(deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.hasRequiredTaskParams) ||
(deepLinkTarget.isSocratic && !deepLinkTarget.hasRequiredSubsectionParams)
) {
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInvalidSubsection") : t("deepLinkInvalidTask")
);
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-task", { replace: true });
navigate(targetRoute, { replace: true });
return;
}
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedFile || !selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
if (deepLinkTarget.isSocratic) {
setSelectedOrchestrator("socratic");
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
return;
setSubsectionRef({ subsectionKey: deepLinkTarget.subsectionKey });
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
if (!selectedFile) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
}
const selectedTask = selectedFile.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
}
setSelectedOrchestrator(
searchParams.get("orchestrator") === "feedback"
? "feedback"
: "task"
);
setSubsectionRef(null);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
}
setSelectedOrchestrator(
deepLinkTarget.isTaskOrchestrator && searchParams.get("orchestrator") === "feedback"
? "feedback"
: "task"
);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
unlockTask();
let cancelled = false;
void (async () => {
try {
await selectTask({
draft: chatSessionId,
fileId: selectedFile.file_id,
taskId: selectedTask.task_id,
});
if (deepLinkTarget.isSocratic) {
await selectSubsection({
draft: chatSessionId,
subsectionKey: deepLinkTarget.subsectionKey,
});
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedFile || !selectedTask) {
throw new Error("invalid task deep link");
}
await selectTask({
draft: chatSessionId,
fileId: selectedFile.file_id,
taskId: selectedTask.task_id,
});
}
if (!cancelled) {
setDeepLinkError(null);
}
} catch (error) {
if (!cancelled) {
setDeepLinkError(t("deepLinkInitFailed"));
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInitFailedSubsection") : t("deepLinkInitFailed")
);
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-task", { replace: true });
navigate(targetRoute, { replace: true });
}
void error;
}
......@@ -253,10 +369,92 @@ export default function ChatPage() {
navigate,
setSelectedOrchestrator,
setTaskRef,
setSubsectionRef,
taskFiles,
unlockTask,
]);
useEffect(() => {
if (
!isTasksInitialized ||
selectedOrchestrator !== "socratic" ||
!selectedSubsectionRef ||
messages.length > 0
) {
return;
}
const bootstrapKey = `${chatSessionId}|${selectedSubsectionRef.subsectionKey}`;
if (socraticBootstrapRef.current === bootstrapKey) {
return;
}
let cancelled = false;
void (async () => {
try {
const response = await fetch(`/api/chat/bootstrap-socratic`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: chatSessionId,
subsection_key: selectedSubsectionRef.subsectionKey,
}),
});
if (!response.ok) {
throw new Error(`Bootstrap failed: ${response.status}`);
}
const payload: { reply?: string; sources?: ContextSource[] } = await response.json();
if (cancelled) {
return;
}
const reply = typeof payload.reply === "string" && payload.reply.trim().length > 0
? payload.reply
: t("chatRequestFailed");
setMessages([
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: reply,
},
]);
applyRetrievedSources(payload.sources || [], {
setDirectChildren,
setTaskChildren,
setIndirectChildren,
setSubsections,
setSections,
});
socraticBootstrapRef.current = bootstrapKey;
} catch (error) {
if (!cancelled) {
setRetrievalError(t("retrievalFailed"));
setMessages([
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: t("chatRequestFailed"),
},
]);
}
void error;
}
})();
return () => {
cancelled = true;
};
}, [
applyRetrievedSources,
chatSessionId,
isTasksInitialized,
messages.length,
selectedOrchestrator,
selectedSubsectionRef,
]);
useEffect(() => {
if (!isTasksInitialized || !isTaskModeEnabled) {
return;
......@@ -268,10 +466,24 @@ export default function ChatPage() {
) {
return;
}
if (selectedOrchestrator === "socratic") {
if (!selectedSubsectionRef) {
navigate("/select-socratic", { replace: true });
}
return;
}
if (!selectedTaskRef) {
navigate("/select-task", { replace: true });
}
}, [deepLinkTarget, isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef]);
}, [
deepLinkTarget,
isTaskModeEnabled,
isTasksInitialized,
navigate,
selectedOrchestrator,
selectedSubsectionRef,
selectedTaskRef,
]);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
......@@ -424,6 +636,7 @@ export default function ChatPage() {
draft: string;
orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string };
selected_subsection?: { subsection_key: string };
} = {
messages: [...messages, userMessage].map((message) => ({
role: message.role,
......@@ -432,11 +645,17 @@ export default function ChatPage() {
draft: chatSessionId,
orchestrator: selectedOrchestrator,
};
if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
if (isTaskModeEnabled) {
if (selectedOrchestrator === "socratic" && selectedSubsectionRef) {
chatPayload.selected_subsection = {
subsection_key: selectedSubsectionRef.subsectionKey,
};
} else if (selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
}
}
const response = await fetch(`/api/chat`, {
......@@ -494,43 +713,13 @@ export default function ChatPage() {
}
const sources: ContextSource[] = await contextResponse.json();
const nextDirect: RetrievedDoc[] = [];
const nextTask: RetrievedDoc[] = [];
const nextIndirect: RetrievedDoc[] = [];
const nextSubsections: RetrievedDoc[] = [];
const nextSections: RetrievedDoc[] = [];
sources.forEach((source, index) => {
const doc = toRetrievedDoc(source, index);
switch (source.retrieved_as) {
case "children_direct":
nextDirect.push(doc);
break;
case "children_expanded":
nextIndirect.push(doc);
break;
case "task_childs":
nextTask.push(doc);
break;
case "subsections":
nextSubsections.push(doc);
break;
case "sections":
nextSections.push(doc);
break;
case "neighbors":
nextIndirect.push(doc);
break;
default:
nextIndirect.push(doc);
}
applyRetrievedSources(sources, {
setDirectChildren,
setTaskChildren,
setIndirectChildren,
setSubsections,
setSections,
});
setDirectChildren(nextDirect);
setTaskChildren(nextTask);
setIndirectChildren(nextIndirect);
setSubsections(nextSubsections);
setSections(nextSections);
} catch (error) {
setRetrievalError(t("retrievalFailed"));
void error;
......@@ -599,30 +788,54 @@ export default function ChatPage() {
: selectedOrchestrator;
setSelectedOrchestrator(nextOrchestrator);
if (isTaskCoupledOrchestrator(nextOrchestrator)) {
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
lockTask();
try {
await selectTask({
draft: payload.chat_id,
if (nextOrchestrator === "socratic" || isTaskCoupledOrchestrator(nextOrchestrator)) {
if (nextOrchestrator === "socratic") {
const restoredSubsection = payload.selected_subsection;
if (restoredSubsection?.subsection_key) {
setTaskRef(null);
setSubsectionRef({ subsectionKey: restoredSubsection.subsection_key });
lockTask();
try {
await selectSubsection({
draft: payload.chat_id,
subsectionKey: restoredSubsection.subsection_key,
});
} catch (error) {
void error;
}
} else {
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-socratic", { replace: true });
}
} else {
setSubsectionRef(null);
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
} catch (error) {
void error;
lockTask();
try {
await selectTask({
draft: payload.chat_id,
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
} catch (error) {
void error;
}
} else {
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
}
} else {
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
}
} else {
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/chat", { replace: true });
}
......@@ -658,7 +871,7 @@ export default function ChatPage() {
resetChatState();
resetForNewChat();
await loadArchives();
navigate(isTaskModeEnabled ? "/select-task" : "/chat");
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
};
const handleSwitchTask = async (target: { fileId: string; taskId: string } | null) => {
......@@ -719,8 +932,9 @@ export default function ChatPage() {
};
const handleChangeTaskArea = () => {
resetForNewChat();
unlockTask();
navigate("/select-task");
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
};
const handleOpenSidebar = () => {
......@@ -734,7 +948,7 @@ export default function ChatPage() {
}
resetChatState();
switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat");
navigate(getSelectionRouteForOrchestrator(next));
};
const handleCanvasSave = async (
......@@ -967,7 +1181,16 @@ export default function ChatPage() {
</section>
<aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}>
{isTaskModeEnabled && selectedTask ? (
{selectedOrchestrator === "socratic" && selectedSubsection ? (
<SocraticPanel
selectedSubsectionLabel={selectedSubsection.label}
selectedSubsectionKey={selectedSubsection.subsectionKey}
selectedSubsectionRefsText={selectedSubsection.refsText}
onChangeSelection={handleChangeTaskArea}
/>
) : null}
{selectedOrchestrator !== "socratic" && isTaskModeEnabled && selectedTask ? (
<TaskPanel
readOnly
selectedTaskText={selectedTask.fullText}
......
import { useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectSubsection } from "../api/taskApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png";
export default function SocraticSelectionPage() {
const navigate = useNavigate();
const {
chatSessionId,
selectedOrchestrator,
availableOrchestrators,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
subsections,
selectedSubsectionRef,
selectedSubsection,
tasksError,
setSubsectionKey,
lockTask,
unlockTask,
isTasksInitialized,
} = useTutorSession();
const subsectionDisplayRef = useRef<HTMLDivElement | null>(null);
const subsectionMenuOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
})),
[subsections]
);
useEffect(() => {
if (!isTasksInitialized) {
return;
}
if (selectedOrchestrator !== "socratic") {
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator), { replace: true });
return;
}
unlockTask();
}, [isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => {
if (!selectedSubsection?.label || !subsectionDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([subsectionDisplayRef.current]).catch(() => undefined);
}, [selectedSubsection?.label]);
useEffect(() => {
if (!subsectionMenuOptions.length) {
return;
}
if (
selectedSubsectionRef &&
subsectionMenuOptions.some((option) => option.value === selectedSubsectionRef.subsectionKey)
) {
return;
}
setSubsectionKey(subsectionMenuOptions[0].value);
}, [selectedSubsectionRef, setSubsectionKey, subsectionMenuOptions]);
const handleStartSocratic = async () => {
if (!selectedSubsectionRef) {
return;
}
try {
await selectSubsection({
draft: chatSessionId,
subsectionKey: selectedSubsectionRef.subsectionKey,
});
lockTask();
navigate("/chat");
} catch (error) {
void error;
}
};
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
switchOrchestrator(next);
navigate(getSelectionRouteForOrchestrator(next), { replace: true });
};
if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>;
}
return (
<div className="app-shell">
<header className="app-header">
<div className="brand">
<img className="brand-logo" src={sumintLogo} alt="SuMINT Logo" />
<div className="brand-text">
<div className="brand-title">Mathe Tutor</div>
<div className="brand-subtitle">{t("socraticSelectionSubtitle")}</div>
</div>
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="task-select-main">
<div className="task-select-wrap">
<div className="task-mode-row">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
<section className="task-select-card">
<div className="task-select-header">
<h2 className="task-select-title">{t("socraticSelectionTitle")}</h2>
</div>
<div className="task-select-controls">
<label className="task-select-label" htmlFor="socratic-subsection-select">
{t("subsection")}
</label>
<select
id="socratic-subsection-select"
className="task-select"
value={selectedSubsectionRef?.subsectionKey || ""}
onChange={(event) => setSubsectionKey(event.target.value)}
disabled={!subsectionMenuOptions.length}
>
{subsectionMenuOptions.length ? (
subsectionMenuOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noSubsectionsAvailable")}</option>
)}
</select>
</div>
<div className="task-panel-content" ref={subsectionDisplayRef}>
{selectedSubsection ? (
<div>
<div>{selectedSubsection.label}</div>
<div>{selectedSubsection.refsText}</div>
</div>
) : (
t("noSubsectionSelected")
)}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
<button
type="button"
className="btn primary task-solve-btn"
onClick={handleStartSocratic}
disabled={!selectedSubsectionRef}
>
{t("startSocratic")}
</button>
</section>
</div>
</main>
</div>
);
}
......@@ -5,12 +5,10 @@ import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png";
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback";
export default function TaskSelectionPage() {
const navigate = useNavigate();
const {
......@@ -42,8 +40,12 @@ export default function TaskSelectionPage() {
navigate("/chat", { replace: true });
return;
}
if (selectedOrchestrator === "socratic") {
navigate("/select-socratic", { replace: true });
return;
}
unlockTask();
}, [isTaskModeEnabled, isTasksInitialized, navigate, unlockTask]);
}, [isTaskModeEnabled, isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => {
if (!taskFileOptions.length) {
......@@ -89,7 +91,7 @@ export default function TaskSelectionPage() {
return;
}
switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat", { replace: true });
navigate(getSelectionRouteForOrchestrator(next), { replace: true });
};
if (!isTasksInitialized) {
......
......@@ -14,7 +14,13 @@ import {
getFallbackOrchestrators,
type OrchestratorName,
} from "../api/orchestratorApi";
import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi";
import {
fetchTasks,
type SelectedSubsectionRef,
type SelectedTaskRef,
type SubsectionOption,
type TaskFile,
} from "../api/taskApi";
export type SelectOption = {
value: string;
......@@ -26,6 +32,12 @@ export type SelectedTask = SelectedTaskRef & {
fullText: string;
};
export type SelectedSubsection = SelectedSubsectionRef & {
label: string;
refs: [number, number, number][];
refsText: string;
};
export type TaskSelectionState = {
taskFiles: TaskFile[];
selectedTaskRef: SelectedTaskRef | null;
......@@ -33,6 +45,10 @@ export type TaskSelectionState = {
selectedTaskFile: TaskFile | null;
taskFileOptions: SelectOption[];
taskOptions: SelectOption[];
selectedSubsectionRef: SelectedSubsectionRef | null;
selectedSubsection: SelectedSubsection | null;
subsections: SubsectionOption[];
subsectionOptions: SelectOption[];
tasksError: string | null;
isTaskModeEnabled: boolean;
isTasksInitialized: boolean;
......@@ -50,8 +66,10 @@ export type TutorSessionState = TaskSelectionState & {
isOrchestratorSelectable: boolean;
orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void;
setSubsectionRef: (value: SelectedSubsectionRef | null) => void;
setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void;
setSubsectionKey: (subsectionKey: string) => void;
lockTask: () => void;
unlockTask: () => void;
resetForNewChat: () => void;
......@@ -69,7 +87,10 @@ const isSelectableTaskFile = (file: TaskFile): boolean =>
Array.isArray(file.subsections) && file.subsections.length > 0;
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback";
value === "task" || value === "feedback" || value === "socratic";
const formatSubsectionRefs = (refs: [number, number, number][]): string =>
refs.map((ref) => ref.join(":")).join(", ");
export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
......@@ -82,7 +103,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
useState<SelectedSubsectionRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
......@@ -136,6 +160,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskFile]
);
const subsectionOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
})),
[subsections]
);
const selectedSubsection = useMemo<SelectedSubsection | null>(() => {
if (!selectedSubsectionRef) {
return null;
}
const option = subsections.find(
(item) => item.subsection_key === selectedSubsectionRef.subsectionKey
);
if (!option) {
return null;
}
const refs = option.refs ?? [];
return {
subsectionKey: option.subsection_key,
label: option.label,
refs,
refsText: formatSubsectionRefs(refs),
};
}, [selectedSubsectionRef, subsections]);
const initTasks = useCallback(async () => {
setTasksError(null);
setOrchestratorError(null);
......@@ -160,9 +212,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try {
const payload = await fetchTasks();
const files = payload.task_files || [];
const subsectionsPayload = payload.subsections || [];
const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files);
setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => {
if (prev) {
const file = selectableFiles.find((item) => item.file_id === prev.fileId);
......@@ -181,10 +235,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
});
setSelectedSubsectionRef((prev) => {
if (prev) {
const option = subsectionsPayload.find((item) => item.subsection_key === prev.subsectionKey);
if (option) {
return prev;
}
}
const firstOption = subsectionsPayload[0];
if (!firstOption) {
return null;
}
return { subsectionKey: firstOption.subsection_key };
});
} catch (error) {
setTasksError(t("failedLoadTasks"));
setTaskFiles([]);
setSubsections([]);
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
void error;
} finally {
setIsTasksInitialized(true);
......@@ -223,6 +293,16 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskRef]
);
const setSubsectionKey = useCallback(
(subsectionKey: string) => {
if (!subsectionKey) {
return;
}
setSelectedSubsectionRef({ subsectionKey });
},
[]
);
const setSelectedOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
}, []);
......@@ -231,9 +311,14 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedOrchestratorState(value);
setChatSessionId(createSessionId());
setTaskLocked(false);
if (!isTaskCoupledOrchestrator(value)) {
if (value === "socratic") {
setSelectedTaskRef(null);
} else if (!isTaskCoupledOrchestrator(value)) {
setSelectedTaskRef(null);
}
if (value !== "socratic") {
setSelectedSubsectionRef(null);
}
}, []);
const lockTask = useCallback(() => {
......@@ -247,6 +332,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const resetForNewChat = useCallback(() => {
setChatSessionId(createSessionId());
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setTaskLocked(false);
}, []);
......@@ -261,14 +347,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable,
orchestratorError,
taskFiles,
subsections,
selectedTaskRef,
selectedTask,
selectedTaskFile,
taskFileOptions,
taskOptions,
selectedSubsectionRef,
selectedSubsection,
subsectionOptions,
setTaskRef: setSelectedTaskRef,
setSubsectionRef: setSelectedSubsectionRef,
setTaskFile,
setTaskId,
setSubsectionKey,
tasksError,
isTaskModeEnabled,
isTasksInitialized,
......
import type { OrchestratorName } from "../api/orchestratorApi";
export const isTaskSelectionOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback";
export const isSocraticOrchestrator = (value: OrchestratorName): boolean =>
value === "socratic";
export const getSelectionRouteForOrchestrator = (value: OrchestratorName): string => {
if (value === "socratic") {
return "/select-socratic";
}
if (value === "task" || value === "feedback") {
return "/select-task";
}
return "/chat";
};
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment