Commit 939372b1 authored by Kantz's avatar Kantz
Browse files

grundimplementirung vom Task modus. Muss noch verfeinert werden. Aktuell nur ChatGPT

parent 77449eda
from __future__ import annotations
def generate_hint(
query: str | None,
task: str,
hints: list[str],
solution: str,
history: list[dict] | None = None,
sources: str | None = None,
) -> str:
# Temporary placeholder so orchestrator_task can call this module now.
# Replace this implementation with your custom LLM call.
if hints:
return hints[0]
if solution:
return "Hinweis: Nutze die gegebene Musterloesung als Orientierung fuer den naechsten Schritt."
if task:
return "Hinweis: Lies die Aufgabenstellung genau und bestimme zuerst, was gesucht ist."
return "Kein Hinweis verfuegbar."
from __future__ import annotations
from logging import config
from typing import List, Optional
import logging
......@@ -13,6 +12,8 @@ import app.config as config
if config.get_orchestrator() == "tutor":
from app.deterministic_services.orchestrators import orchestrator_tutor as orchestrator
elif config.get_orchestrator() == "task":
from app.deterministic_services.orchestrators import orchestrator_task as orchestrator
else:
from app.deterministic_services.orchestrators import orchestrator_qa as orchestrator
......@@ -29,6 +30,7 @@ class ChatMessage(BaseModel):
class ChatRequest(BaseModel):
messages: List[ChatMessage]
draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None
class ChatResponse(BaseModel):
......@@ -60,10 +62,18 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=400, detail="messages required")
try:
result = orchestrator.run_chat(
[{"role": m.role, "content": m.text} for m in request.messages],
draft=request.draft,
)
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if config.get_orchestrator() == "task":
result = orchestrator.run_chat(
payload_messages,
draft=request.draft,
selected_task=request.selected_task,
)
else:
result = orchestrator.run_chat(
payload_messages,
draft=request.draft,
)
reply = result["reply"]
sources = result["sources"]
except ValueError as exc:
......
from __future__ import annotations
from typing import List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
import app.config as config
from app.deterministic_services import context_store, task_catalog
router = APIRouter()
class TaskItem(BaseModel):
task_id: str
statement: str
full_text: str
class TaskFile(BaseModel):
file_id: str
title: str
intro: str
tasks: List[TaskItem]
class TasksResponse(BaseModel):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
class SelectTaskRequest(BaseModel):
draft: str = Field(..., min_length=1)
file_id: str = Field(..., min_length=1)
task_id: str = Field(..., min_length=1)
class SelectTaskResponse(BaseModel):
status: str
file_id: str
task_id: str
@router.get("/api/tasks/config")
def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator()
return {"orchestrator": orchestrator, "enabled": orchestrator == "task"}
@router.get("/api/tasks", response_model=TasksResponse)
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator == "task",
task_files=task_files,
)
@router.post("/api/tasks/select", response_model=SelectTaskResponse)
def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
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, [])
updated = task_catalog.select_task_by_ids(
sheet,
file_id=request.file_id,
task_id=request.task_id,
)
if not updated:
raise HTTPException(status_code=404, detail="task not found")
context_store.save_sheet(sheet)
file_id, task_id = task_catalog.get_selected_task_ids(sheet)
return SelectTaskResponse(
status="ok",
file_id=file_id or request.file_id,
task_id=task_id or request.task_id,
)
from __future__ import annotations
from app.LLM_services import task_hint_LLM
from app.deterministic_services import context_store, task_catalog
from app.deterministic_services.orchestrators import orchestrator_base as base
def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple[str, str] | None:
store_new = context_store.context_store_new
has_task = bool(store_new.get_task(state.sheet))
has_hints = bool(store_new.get_hints(state.sheet))
has_solution = bool(store_new.get_solution(state.sheet))
if has_task and has_hints and has_solution:
selected = task_catalog.get_selected_task_ids(state.sheet)
if selected[0] and selected[1]:
return selected[0], selected[1]
return None
sources_text = "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)])
selection = task_catalog.select_task_for_context(
state.sheet,
query_text=query_text,
sources_text=sources_text,
history=context_store.get_history_turns(state.sheet),
)
if not selection:
return None
task_file, task_entry = selection
selected_file_id = str(task_file.get("_file_id", ""))
selected_task_id = str(task_entry.get("id", "")).zfill(2)
base.append_tool_log(
state.tool_log,
"task_json_selected",
{"tasks_dir": str(task_catalog.TASKS_DIR)},
{
"file": task_file.get("_path", ""),
"file_id": selected_file_id,
"task_id": selected_task_id,
"hint_count": len(store_new.get_hints(state.sheet)),
"has_solution": bool(store_new.get_solution(state.sheet)),
},
)
return selected_file_id, selected_task_id
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
base.bootstrap_retrieval(state.sheet, query_text, state.tool_log)
_ensure_context_task_fields(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
_ensure_context_task_fields(state, state.last_user)
def _on_build_reply(state: base.ChatState) -> str | None:
store_new = context_store.context_store_new
history_turns = context_store.get_history_turns(state.sheet)
args = {
"query": state.last_user if not state.new_chat else None,
"task": store_new.get_task(state.sheet),
"hints": store_new.get_hints(state.sheet),
"solution": store_new.get_solution(state.sheet),
"history": history_turns,
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)]),
}
return base.log_timed_call(
state.tool_log,
"new_generate_hint",
args,
lambda: task_hint_LLM.generate_hint(**args),
)
def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
) -> dict:
def on_turn_logic(state: base.ChatState) -> None:
if selected_task:
selected_file_id = str(selected_task.get("file_id", "")).strip()
selected_task_id = str(selected_task.get("task_id", "")).strip()
if selected_file_id and selected_task_id:
task_catalog.select_task_by_ids(
state.sheet,
selected_file_id,
selected_task_id,
)
_on_turn_logic(state)
return base.run_chat_common(
messages=messages,
draft=draft,
on_bootstrap=_on_bootstrap,
on_turn_logic=on_turn_logic,
on_build_reply=_on_build_reply,
)
from __future__ import annotations
from app.LLM_services import decision_LLM, hint_LLM, math_intent_LLM, solver_LLM
from app.LLM_services import decision_LLM, open_hint_LLM, math_intent_LLM, solver_LLM
from app.deterministic_services import context_store
from app.deterministic_services.orchestrators import orchestrator_base as base
......@@ -57,7 +57,7 @@ def _on_build_reply(state: base.ChatState) -> str | None:
state.tool_log,
"generate_hint",
args,
lambda: hint_LLM.generate_hint(**args),
lambda: open_hint_LLM.generate_hint(**args),
)
......
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from app.deterministic_services import context_store
TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "Tasks"
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
def _tokenize(value: str) -> set[str]:
return set(re.findall(r"[a-z0-9_]+", _normalize_text(value)))
def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text)
if not query_tokens:
return 0
candidate_tokens = _tokenize(candidate_text)
return len(query_tokens.intersection(candidate_tokens))
def load_task_files(tasks_dir: Path = TASKS_DIR) -> list[dict[str, Any]]:
if not tasks_dir.exists():
return []
loaded: list[dict[str, Any]] = []
for path in sorted(tasks_dir.glob("*.json")):
try:
content = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(content, dict):
continue
if not isinstance(content.get("tasks"), list):
continue
content["_path"] = str(path)
content["_file_id"] = path.stem
loaded.append(content)
return loaded
def _find_task_file(task_files: list[dict[str, Any]], file_id: str) -> dict[str, Any] | None:
for task_file in task_files:
if str(task_file.get("_file_id", "")) == file_id:
return task_file
return None
def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any] | None:
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
if str(item.get("id", "")).zfill(2) == str(task_id).zfill(2):
return item
return None
def _build_task_payload(task_file: dict[str, Any], task_entry: dict[str, Any]) -> tuple[str, list[str], str]:
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip()
hints_raw = task_entry.get("hints", [])
hints = [str(item).strip() for item in hints_raw if str(item).strip()] if isinstance(hints_raw, list) else []
solution = str(task_entry.get("solution", "")).strip()
task_parts = [part for part in [title, intro, statement] if part]
task_text = "\n".join(task_parts)
return task_text, hints, solution
def set_selected_task(
sheet: dict[str, Any],
task_file: dict[str, Any],
task_entry: dict[str, Any],
) -> None:
task_text, hints, solution = _build_task_payload(task_file, task_entry)
store_new = context_store.context_store_new
store_new.set_task(sheet, task_text)
store_new.set_hints(sheet, hints)
store_new.set_solution(sheet, solution)
sheet["task_file_id"] = str(task_file.get("_file_id", ""))
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
def select_task_by_ids(
sheet: dict[str, Any],
file_id: str,
task_id: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
task_entry = _find_task_entry(task_file, task_id)
if not task_entry:
return False
set_selected_task(sheet, task_file, task_entry)
return True
def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
return (file_id or None, task_id or None)
def select_task_for_context(
sheet: dict[str, Any],
query_text: str,
sources_text: str,
history: list[dict],
task_files: list[dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
catalog = task_files if task_files is not None else load_task_files()
if not catalog:
return None
selected_file_id, selected_task_id = get_selected_task_ids(sheet)
if selected_file_id and selected_task_id:
task_file = _find_task_file(catalog, selected_file_id)
if task_file:
task_entry = _find_task_entry(task_file, selected_task_id)
if task_entry:
set_selected_task(sheet, task_file, task_entry)
return task_file, task_entry
haystack = "\n".join([query_text, sources_text])
best_file: dict[str, Any] | None = None
best_file_score = -1
for task_file in catalog:
title = str(task_file.get("title", ""))
intro = str(task_file.get("intro", ""))
statements = "\n".join(
[str(task.get("statement", "")) for task in task_file.get("tasks", []) if isinstance(task, dict)]
)
score = _match_score(haystack, "\n".join([title, intro, statements]))
if score > best_file_score:
best_file_score = score
best_file = task_file
if not best_file:
return None
tasks = [item for item in best_file.get("tasks", []) if isinstance(item, dict)]
if not tasks:
return None
history_text = "\n".join([str(item.get("content", "")) for item in history if isinstance(item, dict)])
id_match = re.search(r"\b(?:aufgabe|task)?\s*0?([1-9][0-9]?)\b", history_text.lower())
if id_match:
wanted = id_match.group(1).zfill(2)
for item in tasks:
if str(item.get("id", "")).zfill(2) == wanted:
set_selected_task(sheet, best_file, item)
return best_file, item
best_task = tasks[0]
best_task_score = -1
for item in tasks:
statement = str(item.get("statement", ""))
score = _match_score(history_text, statement)
if score > best_task_score:
best_task_score = score
best_task = item
set_selected_task(sheet, best_file, best_task)
return best_file, best_task
def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
catalog = task_files if task_files is not None else load_task_files()
response: list[dict[str, Any]] = []
for task_file in catalog:
file_id = str(task_file.get("_file_id", ""))
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
task_id = str(item.get("id", "")).zfill(2)
statement = str(item.get("statement", "")).strip()
full_text_parts = [part for part in [title, intro, statement] if part]
tasks.append(
{
"task_id": task_id,
"statement": statement,
"full_text": "\n".join(full_text_parts),
}
)
response.append(
{
"file_id": file_id,
"title": title,
"intro": intro,
"tasks": tasks,
}
)
return response
......@@ -3,7 +3,7 @@ import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, context
from app.api import canvas, chat, health, context, tasks
from app.config import get_frontend_url
from app.deterministic_services import embedding_provider
......@@ -36,3 +36,4 @@ app.include_router(chat.router)
app.include_router(canvas.router)
app.include_router(context.router)
app.include_router(health.router)
app.include_router(tasks.router)
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
......@@ -53,6 +53,35 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
return parts.length ? parts.join("|") : `source_${index}`;
};
type TaskItem = {
task_id: string;
statement: string;
full_text: string;
};
type TaskFile = {
file_id: string;
title: string;
intro: string;
tasks: TaskItem[];
};
type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
};
type SelectedTaskRef = {
fileId: string;
taskId: string;
};
type SelectedTask = SelectedTaskRef & {
title: string;
fullText: string;
};
const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
const parts = [
sourceId.chapter_title ?? "",
......@@ -108,6 +137,13 @@ export default function App() {
kind: "info" | "error" | "ok";
message: string;
} | null>(null);
const [isTaskModeEnabled, setIsTaskModeEnabled] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(
null
);
const [tasksError, setTasksError] = useState<string | null>(null);
const taskDisplayRef = useRef<HTMLDivElement | null>(null);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
......@@ -152,6 +188,119 @@ export default function App() {
return { bySourceKey, bySlug };
}, [directChildren, indirectChildren, subsections, sections]);
const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) {
return null;
}
const file = taskFiles.find((item) => item.file_id === selectedTaskRef.fileId);
if (!file) {
return null;
}
const task = file.tasks.find((item) => item.task_id === selectedTaskRef.taskId);
if (!task) {
return null;
}
return {
fileId: file.file_id,
taskId: task.task_id,
title: file.title,
fullText: task.full_text,
};
}, [selectedTaskRef, taskFiles]);
const taskOptions = useMemo(
() =>
taskFiles.flatMap((file) =>
file.tasks.map((task) => ({
value: `${file.file_id}::${task.task_id}`,
label: `${file.title} - ${task.task_id}`,
}))
),
[taskFiles]
);
useEffect(() => {
let isActive = true;
const loadTasks = async () => {
setTasksError(null);
try {
const response = await fetch(`${backendUrl}/api/tasks`);
if (!response.ok) {
throw new Error(`Tasks failed: ${response.status}`);
}
const payload: TasksResponse = await response.json();
if (!isActive) {
return;
}
const enabled = Boolean(payload.enabled);
setIsTaskModeEnabled(enabled);
setTaskFiles(payload.task_files || []);
if (enabled && payload.task_files?.length) {
const firstFile = payload.task_files[0];
const firstTask = firstFile.tasks?.[0];
if (firstTask) {
setSelectedTaskRef((prev) =>
prev || { fileId: firstFile.file_id, taskId: firstTask.task_id }
);
}
} else {
setSelectedTaskRef(null);
}
} catch (error) {
if (!isActive) {
return;
}
setTasksError("Failed to load tasks.");
setIsTaskModeEnabled(false);
setTaskFiles([]);
setSelectedTaskRef(null);
void error;
}
};
void loadTasks();
return () => {
isActive = false;
};
}, []);
useEffect(() => {
if (!isTaskModeEnabled || !selectedTaskRef) {
return;
}
const syncTaskSelection = async () => {
try {
await fetch(`${backendUrl}/api/tasks/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: chatSessionId,
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
}),
});
} catch (error) {
void error;
}
};
void syncTaskSelection();
}, [chatSessionId, isTaskModeEnabled, selectedTaskRef]);
useEffect(() => {
if (!isTaskModeEnabled || !selectedTask?.fullText || !taskDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [isTaskModeEnabled, selectedTask?.fullText]);
const handleSend = async () => {
const trimmed = draft.trim();
if (!trimmed) {
......@@ -171,16 +320,28 @@ export default function App() {
setRetrievalError(null);
try {
const chatPayload: {
messages: Array<{ role: "user" | "assistant"; text: string }>;
draft: string;
selected_task?: { file_id: string; task_id: string };
} = {
messages: [...messages, userMessage].map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
};
if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
}
const response = await fetch(`${backendUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [...messages, userMessage].map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
body: JSON.stringify(chatPayload),
});
if (!response.ok) {
......@@ -275,6 +436,14 @@ export default function App() {
setIsCanvasVisible((prev) => !prev);
};
const handleTaskChange = (value: string) => {
const [fileId, taskId] = value.split("::");
if (!fileId || !taskId) {
return;
}
setSelectedTaskRef({ fileId, taskId });
};
const resetChatState = () => {
setMessages(initialMessages);
setDraft("");
......@@ -504,6 +673,38 @@ export default function App() {
<main className="app-main">
<section className="chat-column">
{isTaskModeEnabled ? (
<section className="task-panel">
<div className="task-panel-header">
<div className="task-panel-title">Task</div>
<select
className="task-select"
value={
selectedTaskRef
? `${selectedTaskRef.fileId}::${selectedTaskRef.taskId}`
: ""
}
onChange={(event) => handleTaskChange(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">No tasks available</option>
)}
</select>
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || "No task selected."}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
</section>
) : null}
<ChatWindow
messages={messages}
draft={draft}
......
......@@ -99,6 +99,56 @@ body {
flex: 1;
}
.task-panel {
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.task-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.task-panel-title {
font-weight: 600;
}
.task-select {
border: 1px solid #d8d1c4;
border-radius: 10px;
padding: 8px 10px;
background: #fff;
font-family: inherit;
font-size: 13px;
max-width: 100%;
}
.task-panel-content {
background: #fff;
border: 1px solid #e2ded5;
border-radius: 12px;
padding: 12px;
white-space: pre-line;
overflow-x: auto;
}
.task-panel-error {
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
}
.chat-archive-error {
padding: 8px 10px;
border-radius: 10px;
......
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