Commit 8e5526b8 authored by Kantz's avatar Kantz
Browse files

start mit einer Bot nachricht

parent 3c67b18f
......@@ -3,7 +3,10 @@ from __future__ import annotations
import logging
from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.LLM_services import socratic_LLM
from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator,
is_valid_orchestrator,
......@@ -66,6 +69,16 @@ class ChatArchiveDetail(BaseModel):
orchestrator: str
class SocraticBootstrapRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
class SocraticBootstrapResponse(BaseModel):
reply: str
sources: List[dict] = []
@router.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
if not request.messages:
......@@ -170,3 +183,41 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id)
@router.post("/api/chat/bootstrap-socratic", response_model=SocraticBootstrapResponse)
def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
if not task_catalog.select_subsection_by_key(sheet, request.subsection_key):
raise HTTPException(status_code=404, detail="subsection not found")
refs = task_catalog.get_selected_subsection_refs(sheet)
sources = retrieval_store.retrieve_for_subsections(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
)
context_store.set_sources(sheet, sources)
context_store.set_initialized(sheet, True)
context_store.save_sheet(sheet)
sources_text = "\n".join(source.to_string() for source in sources)
reply = socratic_LLM.generate_dialog(
query="Was sind die Themen dieses Abschnitts? Frage mich, mit welchem ich mich zuerst beschäftigen möchte.",
subsection_refs=refs,
history=None,
sources=sources_text,
)
if not reply:
reply = "Was sind die Themen dieses Abschnitts? Womit möchtest du anfangen?"
context_store.append_history_message(sheet, "assistant", reply)
context_store.save_sheet(sheet)
return SocraticBootstrapResponse(
reply=reply,
sources=[source.model_dump() for source in sources],
)
......@@ -13,7 +13,7 @@ os.environ.setdefault("POSTGRES_URL", "postgresql://localhost/test")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import tasks
from app.api import chat, tasks
from app.deterministic_services import session_store, task_catalog
......@@ -152,6 +152,52 @@ class TaskApiSocraticTest(unittest.TestCase):
select_mock.assert_called_once_with(sheet, subsection_key="quadratische_gleichungen")
class ChatBootstrapSocraticTest(unittest.TestCase):
def setUp(self) -> None:
app = FastAPI()
app.include_router(chat.router)
self.client = TestClient(app)
def test_bootstrap_socratic_loads_sources_and_writes_only_assistant_history(self) -> None:
sheet: dict[str, object] = {}
with patch("app.api.chat.context_store.get_chat_id", return_value="chat-1"), patch(
"app.api.chat.context_store.load_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.save_sheet"
), patch(
"app.api.chat.context_store.set_sources"
), patch(
"app.api.chat.context_store.set_initialized"
), patch(
"app.api.chat.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
), patch(
"app.api.chat.retrieval_store.retrieve_for_subsections",
return_value=[],
) as retrieve_mock, patch(
"app.api.chat.socratic_LLM.generate_dialog",
return_value="Was sind die Themen dieses Abschnitts?",
):
response = self.client.post(
"/api/chat/bootstrap-socratic",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["reply"], "Was sind die Themen dieses Abschnitts?")
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", subsection_refs=[(1, 3, 3)])
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["history"], [{"role": "assistant", "content": "Was sind die Themen dieses Abschnitts?"}])
class SessionStoreSocraticTest(unittest.TestCase):
def test_load_archive_restores_selected_subsection(self) -> None:
record = {
......
......@@ -116,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()
......@@ -169,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") || "")
......@@ -324,6 +374,87 @@ export default function ChatPage() {
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;
......@@ -582,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;
......
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