Commit e31b3ff9 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!24
parents c2af99d6 b61d4201
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
if you want to use the task mode you need : if you want to use the task mode you need :
- task folder in sources with your tasks - task folder in sources with your tasks
- `_subsection_map.yaml`. - `_subsection_map.yaml` as the topic-to-index map for section, subsection, or subsubsection references.
## Setup ## Setup
......
...@@ -26,6 +26,13 @@ class TaskItem(BaseModel): ...@@ -26,6 +26,13 @@ class TaskItem(BaseModel):
images: List[TaskImage] = Field(default_factory=list) images: List[TaskImage] = Field(default_factory=list)
class TopicEntry(BaseModel):
topic_key: str
label: str
refs: List[List[int]]
summary: str = ""
class SubsectionEntry(BaseModel): class SubsectionEntry(BaseModel):
subsection_key: str subsection_key: str
label: str label: str
...@@ -38,14 +45,15 @@ class TaskFile(BaseModel): ...@@ -38,14 +45,15 @@ class TaskFile(BaseModel):
title: str title: str
intro: str intro: str
tasks: List[TaskItem] tasks: List[TaskItem]
subsections: List[str] = Field(default_factory=list) topics: List[str] = Field(default_factory=list)
topic_options: List[TopicEntry] = Field(default_factory=list)
class TasksResponse(BaseModel): class TasksResponse(BaseModel):
orchestrator: str orchestrator: str
enabled: bool enabled: bool
task_files: List[TaskFile] task_files: List[TaskFile]
subsections: List[SubsectionEntry] = Field(default_factory=list) topics: List[TopicEntry] = Field(default_factory=list)
class SocraticResponse(BaseModel): class SocraticResponse(BaseModel):
...@@ -104,12 +112,12 @@ def get_task_config() -> dict[str, object]: ...@@ -104,12 +112,12 @@ def get_task_config() -> dict[str, object]:
def list_tasks() -> TasksResponse: def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator() orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog() task_files = task_catalog.build_task_catalog()
subsections = task_catalog.build_subsection_catalog() topics = task_catalog.build_topic_catalog()
return TasksResponse( return TasksResponse(
orchestrator=orchestrator, orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS, enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files, task_files=task_files,
subsections=subsections, topics=topics,
) )
......
...@@ -27,7 +27,6 @@ class OpenAILikeConfig(BaseModel): ...@@ -27,7 +27,6 @@ class OpenAILikeConfig(BaseModel):
model: str = Field(..., model: str = Field(...,
description="Modellname (z. B. 'nomic-embed-text')") description="Modellname (z. B. 'nomic-embed-text')")
target_dim: int = Field(1024, description="Ziel-Dimension der Embeddings") target_dim: int = Field(1024, description="Ziel-Dimension der Embeddings")
timeout: float | None = Field(None, description="Request timeout in seconds")
class SentenceTransformerConfig(BaseModel): class SentenceTransformerConfig(BaseModel):
...@@ -100,14 +99,12 @@ class OpenAILikeEmbeddings(BaseEmbeddings): ...@@ -100,14 +99,12 @@ class OpenAILikeEmbeddings(BaseEmbeddings):
self.base_url = config.base_url.rstrip("/") self.base_url = config.base_url.rstrip("/")
self.api_key = config.api_key self.api_key = config.api_key
self.model = config.model self.model = config.model
self.timeout = config.timeout or 60.0
def _embed(self, inputs: List[str] | str) -> List[List[float]]: def _embed(self, inputs: List[str] | str) -> List[List[float]]:
"""Ruft die externe Embedding-API auf.""" """Ruft die externe Embedding-API auf."""
client = OpenAI( client = OpenAI(
api_key=self.api_key, api_key=self.api_key,
base_url=self.base_url, base_url=self.base_url,
timeout=self.timeout,
) )
response = client.embeddings.create( response = client.embeddings.create(
input=inputs, input=inputs,
......
...@@ -21,6 +21,7 @@ def _apply_selected_subsection( ...@@ -21,6 +21,7 @@ def _apply_selected_subsection(
def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int: def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int:
# Deprecated: socratic retrieval still uses subsection refs until it is migrated to the new parent model.
refs = task_catalog.get_selected_subsection_refs(state.sheet) refs = task_catalog.get_selected_subsection_refs(state.sheet)
if not refs: if not refs:
return 0 return 0
......
...@@ -55,27 +55,27 @@ def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple ...@@ -55,27 +55,27 @@ def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple
def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int: def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_task_subsection_refs(state.sheet) refs = task_catalog.get_selected_task_parent_refs(state.sheet)
if not refs: if not refs:
return 0 return 0
def _retrieve() -> dict: def _retrieve() -> dict:
sources = retrieval_store.retrieve_for_subsections( sources = retrieval_store.retrieve_for_parent_refs(
pg_url=config.get_postgres_url(), pg_url=config.get_postgres_url(),
subsection_refs=refs, parent_refs=refs,
) )
context_store.update_retrieval_context(state.sheet, sources) context_store.update_retrieval_context(state.sheet, sources)
return { return {
"subsection_refs": refs, "parent_refs": refs,
"source_count": len(sources), "source_count": len(sources),
} }
result = base.log_timed_call( result = base.log_timed_call(
state.tool_log, state.tool_log,
"retrieve_context_with_task_subsections", "retrieve_context_with_task_parents",
{ {
"query": query_text, "query": query_text,
"subsection_refs": refs, "parent_refs": refs,
}, },
_retrieve, _retrieve,
) )
......
...@@ -12,6 +12,7 @@ class _SourceIDLike(Protocol): ...@@ -12,6 +12,7 @@ class _SourceIDLike(Protocol):
chapter_title: str | None chapter_title: str | None
section_title: str | None section_title: str | None
subsection_title: str | None subsection_title: str | None
subsubsection_title: str | None
title: str | None title: str | None
doc_type: str | None doc_type: str | None
...@@ -27,6 +28,7 @@ def _source_id_key(source_id: _SourceIDLike) -> str: ...@@ -27,6 +28,7 @@ def _source_id_key(source_id: _SourceIDLike) -> str:
source_id.chapter_title or "", source_id.chapter_title or "",
source_id.section_title or "", source_id.section_title or "",
source_id.subsection_title or "", source_id.subsection_title or "",
source_id.subsubsection_title or "",
source_id.title or "", source_id.title or "",
source_id.doc_type or "", source_id.doc_type or "",
] ]
...@@ -58,6 +60,7 @@ def _build_source_index(sources: Iterable[_SourceLike]) -> Tuple[Dict[str, str], ...@@ -58,6 +60,7 @@ def _build_source_index(sources: Iterable[_SourceLike]) -> Tuple[Dict[str, str],
source.source_id.chapter_title, source.source_id.chapter_title,
source.source_id.section_title, source.source_id.section_title,
source.source_id.subsection_title, source.source_id.subsection_title,
source.source_id.subsubsection_title,
source.source_id.title, source.source_id.title,
] ]
for start in range(len(parts)): for start in range(len(parts)):
......
...@@ -19,6 +19,7 @@ def retrieve( ...@@ -19,6 +19,7 @@ def retrieve(
chapter_index: int | None = None, chapter_index: int | None = None,
section_index: int | None = None, section_index: int | None = None,
subsection_index: int | None = None, subsection_index: int | None = None,
subsubsection_index: int | None = None,
source_type_filter: list[str] | None = None, source_type_filter: list[str] | None = None,
expand_links: bool = True, expand_links: bool = True,
neighbor_expand: int = 0, neighbor_expand: int = 0,
...@@ -32,6 +33,7 @@ def retrieve( ...@@ -32,6 +33,7 @@ def retrieve(
chapter_index=chapter_index, chapter_index=chapter_index,
section_index=section_index, section_index=section_index,
subsection_index=subsection_index, subsection_index=subsection_index,
subsubsection_index=subsubsection_index,
source_type_filter=source_type_filter, source_type_filter=source_type_filter,
expand_links=expand_links, expand_links=expand_links,
neighbor_expand=neighbor_expand, neighbor_expand=neighbor_expand,
...@@ -45,6 +47,7 @@ def retrieve( ...@@ -45,6 +47,7 @@ def retrieve(
chapter_index=chapter_index, chapter_index=chapter_index,
section_index=section_index, section_index=section_index,
subsection_index=subsection_index, subsection_index=subsection_index,
subsubsection_index=subsubsection_index,
source_type_filter=source_type_filter, source_type_filter=source_type_filter,
expand_links=expand_links, expand_links=expand_links,
neighbor_expand=neighbor_expand, neighbor_expand=neighbor_expand,
...@@ -60,6 +63,7 @@ def retrieve_with_subsections( ...@@ -60,6 +63,7 @@ def retrieve_with_subsections(
chapter_index: int | None = None, chapter_index: int | None = None,
section_index: int | None = None, section_index: int | None = None,
subsection_index: int | None = None, subsection_index: int | None = None,
subsubsection_index: int | None = None,
source_type_filter: list[str] | None = None, source_type_filter: list[str] | None = None,
expand_links: bool = True, expand_links: bool = True,
neighbor_expand: int = 0, neighbor_expand: int = 0,
...@@ -74,6 +78,7 @@ def retrieve_with_subsections( ...@@ -74,6 +78,7 @@ def retrieve_with_subsections(
chapter_index=chapter_index, chapter_index=chapter_index,
section_index=section_index, section_index=section_index,
subsection_index=subsection_index, subsection_index=subsection_index,
subsubsection_index=subsubsection_index,
source_type_filter=source_type_filter, source_type_filter=source_type_filter,
expand_links=expand_links, expand_links=expand_links,
neighbor_expand=neighbor_expand, neighbor_expand=neighbor_expand,
...@@ -88,6 +93,7 @@ def retrieve_with_subsections( ...@@ -88,6 +93,7 @@ def retrieve_with_subsections(
chapter_index=chapter_index, chapter_index=chapter_index,
section_index=section_index, section_index=section_index,
subsection_index=subsection_index, subsection_index=subsection_index,
subsubsection_index=subsubsection_index,
source_type_filter=source_type_filter, source_type_filter=source_type_filter,
expand_links=expand_links, expand_links=expand_links,
neighbor_expand=neighbor_expand, neighbor_expand=neighbor_expand,
...@@ -98,7 +104,18 @@ def retrieve_for_subsections( ...@@ -98,7 +104,18 @@ def retrieve_for_subsections(
pg_url: str, pg_url: str,
subsection_refs: list[vector_store.SubsectionRef] | None = None, subsection_refs: list[vector_store.SubsectionRef] | None = None,
) -> List[Source]: ) -> List[Source]:
# Deprecated: subsection-ref retrieval is kept only for legacy task/socratic flows.
return vector_store.load_children_for_subsections( return vector_store.load_children_for_subsections(
pg_url=pg_url, pg_url=pg_url,
subsection_refs=subsection_refs, subsection_refs=subsection_refs,
) )
def retrieve_for_parent_refs(
pg_url: str,
parent_refs: list[vector_store.ParentRef] | None = None,
) -> List[Source]:
return vector_store.load_sources_for_parent_refs(
pg_url=pg_url,
parent_refs=parent_refs,
)
...@@ -18,6 +18,8 @@ from app.deterministic_services.vector_store import ( ...@@ -18,6 +18,8 @@ from app.deterministic_services.vector_store import (
merge_sources, merge_sources,
) )
# Deprecated: this module keeps the legacy subsection-centric retrieval path for compatibility.
def retrieve( def retrieve(
pg_url: str, pg_url: str,
...@@ -27,13 +29,15 @@ def retrieve( ...@@ -27,13 +29,15 @@ def retrieve(
chapter_index: Optional[int] = None, chapter_index: Optional[int] = None,
section_index: Optional[int] = None, section_index: Optional[int] = None,
subsection_index: Optional[int] = None, subsection_index: Optional[int] = None,
subsubsection_index: Optional[int] = None,
source_type_filter: Optional[List[str]] = None, source_type_filter: Optional[List[str]] = None,
expand_links: bool = False, expand_links: bool = False,
neighbor_expand: int = 0, neighbor_expand: int = 0,
) -> List[Source]: ) -> List[Source]:
# Parameters kept for drop-in compatibility with child-level retrieve. # Deprecated compatibility path. Parameters kept for drop-in compatibility with child-level retrieve.
_ = expand_links _ = expand_links
_ = neighbor_expand _ = neighbor_expand
_ = subsubsection_index
qvec = Vector(embed_query(embedder, query)) qvec = Vector(embed_query(embedder, query))
...@@ -64,8 +68,8 @@ def retrieve( ...@@ -64,8 +68,8 @@ def retrieve(
sql = f""" sql = f"""
SELECT SELECT
uid, doc_type, uid, doc_type,
chapter_index, section_index, subsection_index, child_index, chapter_index, section_index, subsection_index, subsubsection_index, child_index,
chapter_title, section_title, subsection_title, title, source_type, chapter_title, section_title, subsection_title, subsubsection_title, title, source_type,
path, markdown, path, markdown,
1 - (embedding <=> %(qvec)s) AS score 1 - (embedding <=> %(qvec)s) AS score
FROM docs FROM docs
...@@ -94,10 +98,12 @@ def retrieve_with_subsections( ...@@ -94,10 +98,12 @@ def retrieve_with_subsections(
chapter_index: Optional[int] = None, chapter_index: Optional[int] = None,
section_index: Optional[int] = None, section_index: Optional[int] = None,
subsection_index: Optional[int] = None, subsection_index: Optional[int] = None,
subsubsection_index: Optional[int] = None,
source_type_filter: Optional[List[str]] = None, source_type_filter: Optional[List[str]] = None,
expand_links: bool = False, expand_links: bool = False,
neighbor_expand: int = 0, neighbor_expand: int = 0,
) -> List[Source]: ) -> List[Source]:
# Deprecated compatibility path for subsection-only retrieval composition.
vector_k = max(k * 4, k + 16) vector_k = max(k * 4, k + 16)
vector_sources = retrieve( vector_sources = retrieve(
pg_url=pg_url, pg_url=pg_url,
...@@ -107,6 +113,7 @@ def retrieve_with_subsections( ...@@ -107,6 +113,7 @@ def retrieve_with_subsections(
chapter_index=chapter_index, chapter_index=chapter_index,
section_index=section_index, section_index=section_index,
subsection_index=subsection_index, subsection_index=subsection_index,
subsubsection_index=subsubsection_index,
source_type_filter=source_type_filter, source_type_filter=source_type_filter,
expand_links=expand_links, expand_links=expand_links,
neighbor_expand=neighbor_expand, neighbor_expand=neighbor_expand,
......
...@@ -103,7 +103,6 @@ def convert_task_payload(payload: dict[str, Any], source_file_id: str, image_pat ...@@ -103,7 +103,6 @@ def convert_task_payload(payload: dict[str, Any], source_file_id: str, image_pat
return { return {
"id": source_file_id, "id": source_file_id,
"titel": title, "titel": title,
"level": DEFAULT_LEVEL,
"slug": slug, "slug": slug,
"description": intro, "description": intro,
"exercise_topic": _build_topic_entries(payload.get("subsections", [])), "exercise_topic": _build_topic_entries(payload.get("subsections", [])),
...@@ -223,7 +222,7 @@ def convert_all(tasks_dir: Path = TASKS_DIR, dry_run: bool = False) -> tuple[lis ...@@ -223,7 +222,7 @@ def convert_all(tasks_dir: Path = TASKS_DIR, dry_run: bool = False) -> tuple[lis
images_root = tasks_dir / "images" images_root = tasks_dir / "images"
used_names: set[str] = set() used_names: set[str] = set()
for json_path in sorted(tasks_dir.glob("*.json")): for json_path in sorted(tasks_dir.glob("*.json")):
if json_path.name == "_subsection_map.json": if json_path.name == "_topic-to-index-map.json":
continue continue
payload = load_json_task(json_path) payload = load_json_task(json_path)
source_file_id = json_path.stem source_file_id = json_path.stem
......
...@@ -92,7 +92,6 @@ class SentenceTransformerJinaV5Test(unittest.TestCase): ...@@ -92,7 +92,6 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
api_key="gwdg-key", api_key="gwdg-key",
model="e5-mistral-7b-instruct", model="e5-mistral-7b-instruct",
target_dim=2, target_dim=2,
timeout=12.5,
) )
) )
result = embedder.embed_documents(["a", "b"]) result = embedder.embed_documents(["a", "b"])
...@@ -102,7 +101,6 @@ class SentenceTransformerJinaV5Test(unittest.TestCase): ...@@ -102,7 +101,6 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
{ {
"api_key": "gwdg-key", "api_key": "gwdg-key",
"base_url": "https://chat-ai.academiccloud.de/v1", "base_url": "https://chat-ai.academiccloud.de/v1",
"timeout": 12.5,
}, },
) )
self.assertEqual( self.assertEqual(
......
...@@ -62,6 +62,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -62,6 +62,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"title": "Analysis", "title": "Analysis",
"intro": "Intro", "intro": "Intro",
"subsections": ["quadratische_gleichungen"], "subsections": ["quadratische_gleichungen"],
"topic_refs": [],
"tasks": [ "tasks": [
{ {
"id": "01", "id": "01",
...@@ -92,6 +93,27 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -92,6 +93,27 @@ class TaskCatalogSocraticTest(unittest.TestCase):
} }
], ],
) )
self.assertEqual(catalog[0]["topics"], ["quadratische_gleichungen"])
def test_normalize_yaml_task_entry_excludes_images_from_hints(self) -> None:
task_entry = {
"aufgabe": [
{"type": "text", "text": "Bestimme f(x)."},
],
"hinweise": [
{"type": "text", "text": "Nutze den Graphen."},
{"type": "image", "src": "analysis/h1.png", "alt": "Hinweisgrafik."},
],
"loesung": [
{"type": "text", "text": "f(x)=x^2"},
],
}
normalized = task_catalog._normalize_yaml_task_entry(task_entry, 1)
self.assertIsNotNone(normalized)
self.assertEqual(normalized["hints"], ["Nutze den Graphen."])
self.assertEqual(normalized["images"], [])
def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None: def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None:
with patch( with patch(
...@@ -135,13 +157,13 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -135,13 +157,13 @@ class TaskCatalogSocraticTest(unittest.TestCase):
def test_build_subsection_catalog_includes_summary(self) -> None: def test_build_subsection_catalog_includes_summary(self) -> None:
with patch( with patch(
"app.deterministic_services.task_catalog.load_subsection_map", "app.deterministic_services.task_catalog.load_topic_map",
return_value={ return_value={
"quadratische gleichungen": (1, 3, 3), "quadratische gleichungen": (1, 3, 3, 0),
"mengen": (1, 1, 1), "mengen": (1, 1, 1, 0),
}, },
), patch( ), patch(
"app.deterministic_services.task_catalog.load_subsection_summaries", "app.deterministic_services.task_catalog.load_topic_summaries",
return_value={ return_value={
"mengen": "Mengen summary text", "mengen": "Mengen summary text",
"quadratische gleichungen": "Quadratische summary text", "quadratische gleichungen": "Quadratische summary text",
...@@ -167,6 +189,51 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -167,6 +189,51 @@ class TaskCatalogSocraticTest(unittest.TestCase):
], ],
) )
def test_build_topic_catalog_supports_mixed_parent_levels(self) -> None:
with patch(
"app.deterministic_services.task_catalog.load_topic_map",
return_value={
"mengen": (1, 1, 0, 0),
"quadratische gleichungen": (1, 3, 2, 0),
"hornerschema": (1, 3, 3, 1),
},
), patch(
"app.deterministic_services.task_catalog.load_topic_summaries",
return_value={
"mengen": "Mengen summary text",
"quadratische gleichungen": "Quadratische summary text",
"hornerschema": "Hornerschema summary text",
},
):
catalog = task_catalog.build_topic_catalog()
self.assertEqual(
catalog,
[
{
"topic_key": "hornerschema",
"label": "Hornerschema",
"level": "subsubsection",
"refs": [[1, 3, 3, 1]],
"summary": "Hornerschema summary text",
},
{
"topic_key": "mengen",
"label": "Mengen",
"level": "section",
"refs": [[1, 1, 0, 0]],
"summary": "Mengen summary text",
},
{
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"level": "subsection",
"refs": [[1, 3, 2, 0]],
"summary": "Quadratische summary text",
},
],
)
def test_load_subsection_summaries_extracts_body(self) -> None: def test_load_subsection_summaries_extracts_body(self) -> None:
temp_dir = Path(__file__).resolve().parent / "_tmp_subsection_summaries" temp_dir = Path(__file__).resolve().parent / "_tmp_subsection_summaries"
summary_root = temp_dir / "child_lvl" / "subsections" summary_root = temp_dir / "child_lvl" / "subsections"
...@@ -249,6 +316,42 @@ Zweite Zeile. ...@@ -249,6 +316,42 @@ Zweite Zeile.
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen") self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]]) self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
def test_set_selected_task_stores_generic_parent_refs(self) -> None:
sheet: dict[str, object] = {}
task_file = {
"_file_id": "analysis_1",
"title": "Analysis",
"subsections": ["mengen"],
"topic_refs": [
{
"chapter_index": 1,
"section_index": 3,
"subsection_index": 2,
"subsubsection_index": 1,
"key": "hornerschema",
"label": "Hornerschema",
}
],
}
task_entry = {"id": "01", "statement": "Bestimme f(x).", "hints": [], "solution": "", "images": []}
with patch(
"app.deterministic_services.task_catalog.load_topic_map",
return_value={"mengen": (1, 1, 0, 0)},
):
task_catalog.set_selected_task(sheet, task_file, task_entry)
self.assertEqual(sheet["task_parent_refs"], [[1, 1, 0, 0], [1, 3, 2, 1]])
def test_get_selected_task_parent_refs_reads_normalized_refs(self) -> None:
sheet: dict[str, object] = {
"task_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
}
refs = task_catalog.get_selected_task_parent_refs(sheet)
self.assertEqual(refs, [(1, 1, 0, 0), (1, 3, 2, 1)])
class TaskApiSocraticTest(unittest.TestCase): class TaskApiSocraticTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
...@@ -256,20 +359,23 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -256,20 +359,23 @@ class TaskApiSocraticTest(unittest.TestCase):
app.include_router(tasks.router) app.include_router(tasks.router)
self.client = TestClient(app) self.client = TestClient(app)
def test_list_tasks_includes_subsection_options(self) -> None: def test_list_tasks_includes_topic_options(self) -> None:
payload = [ payload = [
{ {
"file_id": "analysis_1", "file_id": "analysis_1",
"title": "Analysis", "title": "Analysis",
"intro": "Intro", "intro": "Intro",
"tasks": [], "tasks": [],
"topics": ["quadratische gleichungen"],
"topic_options": [],
} }
] ]
subsections = [ topics = [
{ {
"subsection_key": "quadratische gleichungen", "topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen", "label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]], "refs": [[1, 3, 3, 0]],
"level": "subsection",
} }
] ]
...@@ -277,15 +383,15 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -277,15 +383,15 @@ class TaskApiSocraticTest(unittest.TestCase):
"app.api.tasks.task_catalog.build_task_catalog", "app.api.tasks.task_catalog.build_task_catalog",
return_value=payload, return_value=payload,
), patch( ), patch(
"app.api.tasks.task_catalog.build_subsection_catalog", "app.api.tasks.task_catalog.build_topic_catalog",
return_value=subsections, return_value=topics,
): ):
response = self.client.get("/api/tasks") response = self.client.get("/api/tasks")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = response.json() body = response.json()
self.assertEqual(body["orchestrator"], "socratic") self.assertEqual(body["orchestrator"], "socratic")
self.assertEqual(body["subsections"][0]["subsection_key"], "quadratische gleichungen") self.assertEqual(body["topics"][0]["topic_key"], "quadratische gleichungen")
def test_task_asset_endpoint_serves_files_from_task_image_dir(self) -> None: def test_task_asset_endpoint_serves_files_from_task_image_dir(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
......
...@@ -142,6 +142,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -142,6 +142,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self.assertEqual(catalog[0]["file_id"], "analysis_1") self.assertEqual(catalog[0]["file_id"], "analysis_1")
self.assertEqual(catalog[0]["title"], "Analysis") self.assertEqual(catalog[0]["title"], "Analysis")
self.assertEqual(catalog[0]["intro"], "Intro") self.assertEqual(catalog[0]["intro"], "Intro")
self.assertEqual(catalog[0]["topics"], ["quadratische-gleichungen"])
self.assertEqual(catalog[0]["tasks"][0]["task_id"], "01") self.assertEqual(catalog[0]["tasks"][0]["task_id"], "01")
self.assertEqual(catalog[0]["tasks"][0]["statement"], "Bestimme f(x).") self.assertEqual(catalog[0]["tasks"][0]["statement"], "Bestimme f(x).")
self.assertEqual( self.assertEqual(
......
...@@ -24,6 +24,7 @@ def _mk_retrieved( ...@@ -24,6 +24,7 @@ def _mk_retrieved(
chapter_index: int, chapter_index: int,
section_index: int, section_index: int,
subsection_index: int | None, subsection_index: int | None,
subsubsection_index: int | None = 0,
child_index: int = 1, child_index: int = 1,
) -> Retrieved: ) -> Retrieved:
return Retrieved( return Retrieved(
...@@ -34,10 +35,12 @@ def _mk_retrieved( ...@@ -34,10 +35,12 @@ def _mk_retrieved(
"chapter_index": chapter_index, "chapter_index": chapter_index,
"section_index": section_index, "section_index": section_index,
"subsection_index": subsection_index, "subsection_index": subsection_index,
"subsubsection_index": subsubsection_index,
"child_index": child_index, "child_index": child_index,
"chapter_title": "C", "chapter_title": "C",
"section_title": "S", "section_title": "S",
"subsection_title": "SS", "subsection_title": "SS",
"subsubsection_title": "SSS" if subsubsection_index else "",
"title": uid, "title": uid,
"source_type": "child", "source_type": "child",
"path": "", "path": "",
...@@ -91,37 +94,77 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -91,37 +94,77 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertTrue(cfg.enable_scoped_child_search) self.assertTrue(cfg.enable_scoped_child_search)
self.assertTrue(cfg.enable_context_docs) self.assertTrue(cfg.enable_context_docs)
def test_select_dominant_scope_prefers_count(self) -> None: def test_select_dominant_scope_prefers_subsection_count(self) -> None:
children = [ children = [
_mk_retrieved("a", 0.9, 1, 1, 1), _mk_retrieved("a", 0.9, 1, 1, 1),
_mk_retrieved("b", 0.8, 1, 1, 1), _mk_retrieved("b", 0.8, 1, 1, 1),
_mk_retrieved("c", 0.95, 1, 1, 2), _mk_retrieved("c", 0.95, 1, 1, 2),
] ]
scope = select_dominant_scope(children, level="subsection") scope = select_dominant_scope(children)
self.assertIsNotNone(scope) self.assertIsNotNone(scope)
assert scope is not None assert scope is not None
self.assertEqual(scope.level, "subsection")
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 1)) self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 1))
def test_select_dominant_scope_resolves_section_parent(self) -> None:
children = [
_mk_retrieved("a", 0.9, 1, 1, 0, 0),
_mk_retrieved("b", 0.8, 1, 1, 0, 0),
_mk_retrieved("c", 0.95, 1, 1, 2, 0),
]
scope = select_dominant_scope(children)
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual(scope.level, "section")
self.assertEqual((scope.chapter_index, scope.section_index), (1, 1))
self.assertIsNone(scope.subsection_index)
self.assertIsNone(scope.subsubsection_index)
def test_select_dominant_scope_resolves_subsubsection_parent(self) -> None:
children = [
_mk_retrieved("a", 0.9, 1, 1, 2, 1),
_mk_retrieved("b", 0.8, 1, 1, 2, 1),
_mk_retrieved("c", 0.95, 1, 1, 2, 0),
]
scope = select_dominant_scope(children)
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual(scope.level, "subsubsection")
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index, scope.subsubsection_index), (1, 1, 2, 1))
def test_select_dominant_scope_tiebreak_avg_score(self) -> None: def test_select_dominant_scope_tiebreak_avg_score(self) -> None:
children = [ children = [
_mk_retrieved("a", 0.7, 1, 1, 1), _mk_retrieved("a", 0.7, 1, 1, 1, 0),
_mk_retrieved("b", 0.8, 1, 1, 2), _mk_retrieved("b", 0.8, 1, 1, 2, 0),
] ]
scope = select_dominant_scope(children, level="subsection") scope = select_dominant_scope(children)
self.assertIsNotNone(scope) self.assertIsNotNone(scope)
assert scope is not None assert scope is not None
self.assertEqual(scope.level, "subsection")
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 2)) self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 2))
def test_select_dominant_scope_tiebreak_lexicographic(self) -> None: def test_select_dominant_scope_tiebreak_lexicographic(self) -> None:
children = [ children = [
_mk_retrieved("a", 0.8, 2, 1, 1), _mk_retrieved("a", 0.8, 2, 1, 1, 0),
_mk_retrieved("b", 0.8, 1, 2, 3), _mk_retrieved("b", 0.8, 1, 2, 3, 0),
] ]
scope = select_dominant_scope(children, level="subsection") scope = select_dominant_scope(children)
self.assertIsNotNone(scope) self.assertIsNotNone(scope)
assert scope is not None assert scope is not None
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 2, 3)) self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 2, 3))
def test_select_dominant_scope_keeps_exact_parent_levels_separate(self) -> None:
children = [
_mk_retrieved("section-hit", 0.91, 1, 1, 2, 0),
_mk_retrieved("subsub-hit", 0.92, 1, 1, 2, 1),
]
scope = select_dominant_scope(children)
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual(scope.level, "subsubsection")
self.assertEqual(scope.subsection_index, 2)
self.assertEqual(scope.subsubsection_index, 1)
def test_merge_retrieval_groups_dedup_max_score_and_trim_children(self) -> None: def test_merge_retrieval_groups_dedup_max_score_and_trim_children(self) -> None:
groups = { groups = {
"children_direct": [ "children_direct": [
...@@ -146,10 +189,55 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -146,10 +189,55 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(merged["neighbors"], []) self.assertEqual(merged["neighbors"], [])
def test_expand_neighbor_children_returns_empty_for_zero_expand(self) -> None: def test_expand_neighbor_children_returns_empty_for_zero_expand(self) -> None:
children = [_mk_retrieved("u1", 0.8, 1, 1, 1, child_index=3)] children = [_mk_retrieved("u1", 0.8, 1, 1, 1, 4, child_index=3)]
result = expand_neighbor_children("postgresql://unused", children, neighbor_expand=0) result = expand_neighbor_children("postgresql://unused", children, neighbor_expand=0)
self.assertEqual(result, []) self.assertEqual(result, [])
def test_source_id_to_string_includes_subsubsection_title(self) -> None:
source_id = SourceID(
chapter_title="Kapitel",
section_title="Section",
subsection_title="Subsection",
subsubsection_title="Subsubsection",
title="Child",
doc_type="child",
)
self.assertEqual(source_id.to_string(), "[Kapitel|Section|Subsection|Subsubsection|Child|child]")
def test_merge_sources_distinguishes_subsubsection_title(self) -> None:
first = Source(
source_id=SourceID(
chapter_title="Kapitel",
section_title="Section",
subsection_title="Subsection",
subsubsection_title="A",
title="Child A",
doc_type="child",
),
retrieved_as="children_direct",
source_type="child",
score=0.7,
markdown="same-md",
)
second = Source(
source_id=SourceID(
chapter_title="Kapitel",
section_title="Section",
subsection_title="Subsection",
subsubsection_title="B",
title="Child A",
doc_type="child",
),
retrieved_as="children_direct",
source_type="child",
score=0.6,
markdown="same-md",
)
merged = merge_sources([first], [second])
self.assertEqual(len(merged), 2)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
...@@ -15,8 +15,15 @@ export type TaskFile = { ...@@ -15,8 +15,15 @@ export type TaskFile = {
title: string; title: string;
intro: string; intro: string;
tasks: TaskItem[]; tasks: TaskItem[];
subsections?: string[]; topics?: string[];
subsection_options?: SubsectionOption[]; topic_options?: TopicOption[];
};
export type TopicOption = {
topic_key: string;
label: string;
refs: [number, number, number, number][];
summary: string;
}; };
export type SubsectionOption = { export type SubsectionOption = {
...@@ -30,7 +37,7 @@ export type TasksResponse = { ...@@ -30,7 +37,7 @@ export type TasksResponse = {
orchestrator: string; orchestrator: string;
enabled: boolean; enabled: boolean;
task_files: TaskFile[]; task_files: TaskFile[];
subsections: SubsectionOption[]; topics: TopicOption[];
}; };
export type SocraticResponse = { export type SocraticResponse = {
......
...@@ -9,9 +9,11 @@ export type RetrievedDoc = { ...@@ -9,9 +9,11 @@ export type RetrievedDoc = {
metadata: { metadata: {
section_index?: number | null; section_index?: number | null;
subsection_index?: number | null; subsection_index?: number | null;
subsubsection_index?: number | null;
child_index?: number | null; child_index?: number | null;
section_title?: string | null; section_title?: string | null;
subsection_title?: string | null; subsection_title?: string | null;
subsubsection_title?: string | null;
title?: string | null; title?: string | null;
type?: string | null; type?: string | null;
box_hint?: string | null; box_hint?: string | null;
...@@ -25,6 +27,7 @@ type DocPanelProps = { ...@@ -25,6 +27,7 @@ type DocPanelProps = {
taskChildren: RetrievedDoc[]; taskChildren: RetrievedDoc[];
indirectChildren: RetrievedDoc[]; indirectChildren: RetrievedDoc[];
subsections: RetrievedDoc[]; subsections: RetrievedDoc[];
subsubsections: RetrievedDoc[];
sections: RetrievedDoc[]; sections: RetrievedDoc[];
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
...@@ -36,6 +39,7 @@ const buildTitle = (doc: RetrievedDoc) => { ...@@ -36,6 +39,7 @@ const buildTitle = (doc: RetrievedDoc) => {
const meta = doc.metadata; const meta = doc.metadata;
return ( return (
meta.title || meta.title ||
meta.subsubsection_title ||
meta.subsection_title || meta.subsection_title ||
meta.section_title || meta.section_title ||
meta.path || meta.path ||
...@@ -52,6 +56,9 @@ const buildSubtitle = (doc: RetrievedDoc) => { ...@@ -52,6 +56,9 @@ const buildSubtitle = (doc: RetrievedDoc) => {
if (meta.subsection_index !== null && meta.subsection_index !== undefined) { if (meta.subsection_index !== null && meta.subsection_index !== undefined) {
parts.push(`ss${meta.subsection_index}`); parts.push(`ss${meta.subsection_index}`);
} }
if (meta.subsubsection_index !== null && meta.subsubsection_index !== undefined) {
parts.push(`sss${meta.subsubsection_index}`);
}
if (meta.child_index !== null && meta.child_index !== undefined) { if (meta.child_index !== null && meta.child_index !== undefined) {
parts.push(`c${meta.child_index}`); parts.push(`c${meta.child_index}`);
} }
...@@ -102,6 +109,7 @@ export default function DocPanel({ ...@@ -102,6 +109,7 @@ export default function DocPanel({
taskChildren, taskChildren,
indirectChildren, indirectChildren,
subsections, subsections,
subsubsections,
sections, sections,
isLoading, isLoading,
error, error,
...@@ -113,6 +121,7 @@ export default function DocPanel({ ...@@ -113,6 +121,7 @@ export default function DocPanel({
taskChildren.length + taskChildren.length +
indirectChildren.length + indirectChildren.length +
subsections.length + subsections.length +
subsubsections.length +
sections.length; sections.length;
return ( return (
...@@ -126,6 +135,7 @@ export default function DocPanel({ ...@@ -126,6 +135,7 @@ export default function DocPanel({
{renderGroup(t("taskChildren"), taskChildren, onCiteDoc, onInspectDoc)} {renderGroup(t("taskChildren"), taskChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("directChildren"), directChildren, onCiteDoc, onInspectDoc)} {renderGroup(t("directChildren"), directChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("indirectChildren"), indirectChildren, onCiteDoc, onInspectDoc)} {renderGroup(t("indirectChildren"), indirectChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("subsubsectionSummary"), subsubsections, onCiteDoc, onInspectDoc)}
{renderGroup(t("subsectionSummary"), subsections, onCiteDoc, onInspectDoc)} {renderGroup(t("subsectionSummary"), subsections, onCiteDoc, onInspectDoc)}
{renderGroup(t("sectionSummary"), sections, onCiteDoc, onInspectDoc)} {renderGroup(t("sectionSummary"), sections, onCiteDoc, onInspectDoc)}
</div> </div>
......
...@@ -31,6 +31,7 @@ ...@@ -31,6 +31,7 @@
directChildren: "Direct children", directChildren: "Direct children",
taskChildren: "Task sources", taskChildren: "Task sources",
indirectChildren: "Indirect children", indirectChildren: "Indirect children",
subsubsectionSummary: "Subsubsection summary",
subsectionSummary: "Subsection summary", subsectionSummary: "Subsection summary",
sectionSummary: "Section summary", sectionSummary: "Section summary",
untitled: "Untitled", untitled: "Untitled",
...@@ -130,6 +131,7 @@ ...@@ -130,6 +131,7 @@
directChildren: "Direkte Quellen", directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen", taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen", indirectChildren: "Indirekte Quellen",
subsubsectionSummary: "Unterunterabschnitt-Zusammenfassung",
subsectionSummary: "Unterabschnitt-Zusammenfassung", subsectionSummary: "Unterabschnitt-Zusammenfassung",
sectionSummary: "Abschnitt-Zusammenfassung", sectionSummary: "Abschnitt-Zusammenfassung",
untitled: "Ohne Titel", untitled: "Ohne Titel",
......
...@@ -61,6 +61,7 @@ type ContextSource = { ...@@ -61,6 +61,7 @@ type ContextSource = {
chapter_title?: string | null; chapter_title?: string | null;
section_title?: string | null; section_title?: string | null;
subsection_title?: string | null; subsection_title?: string | null;
subsubsection_title?: string | null;
title: string; title: string;
doc_type: string; doc_type: string;
}; };
...@@ -75,6 +76,7 @@ const sourceIdToUid = (source: ContextSource, index: number) => { ...@@ -75,6 +76,7 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
source.source_id.chapter_title, source.source_id.chapter_title,
source.source_id.section_title, source.source_id.section_title,
source.source_id.subsection_title, source.source_id.subsection_title,
source.source_id.subsubsection_title,
source.source_id.title, source.source_id.title,
source.source_id.doc_type, source.source_id.doc_type,
].filter(Boolean); ].filter(Boolean);
...@@ -86,6 +88,7 @@ const sourceIdToKey = (sourceId: ContextSource["source_id"]) => { ...@@ -86,6 +88,7 @@ const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
sourceId.chapter_title ?? "", sourceId.chapter_title ?? "",
sourceId.section_title ?? "", sourceId.section_title ?? "",
sourceId.subsection_title ?? "", sourceId.subsection_title ?? "",
sourceId.subsubsection_title ?? "",
sourceId.title ?? "", sourceId.title ?? "",
sourceId.doc_type ?? "", sourceId.doc_type ?? "",
]; ];
...@@ -97,6 +100,7 @@ const sourceIdToPath = (source: ContextSource) => { ...@@ -97,6 +100,7 @@ const sourceIdToPath = (source: ContextSource) => {
source.source_id.chapter_title, source.source_id.chapter_title,
source.source_id.section_title, source.source_id.section_title,
source.source_id.subsection_title, source.source_id.subsection_title,
source.source_id.subsubsection_title,
].filter(Boolean); ].filter(Boolean);
return parts.length ? parts.join(" / ") : null; return parts.length ? parts.join(" / ") : null;
}; };
...@@ -109,6 +113,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => ( ...@@ -109,6 +113,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => (
metadata: { metadata: {
section_title: source.source_id.section_title ?? null, section_title: source.source_id.section_title ?? null,
subsection_title: source.source_id.subsection_title ?? null, subsection_title: source.source_id.subsection_title ?? null,
subsubsection_title: source.source_id.subsubsection_title ?? null,
title: source.source_id.title ?? null, title: source.source_id.title ?? null,
type: source.source_type ?? null, type: source.source_type ?? null,
path: sourceIdToPath(source), path: sourceIdToPath(source),
...@@ -123,6 +128,7 @@ const applyRetrievedSources = ( ...@@ -123,6 +128,7 @@ const applyRetrievedSources = (
setTaskChildren: (value: RetrievedDoc[]) => void; setTaskChildren: (value: RetrievedDoc[]) => void;
setIndirectChildren: (value: RetrievedDoc[]) => void; setIndirectChildren: (value: RetrievedDoc[]) => void;
setSubsections: (value: RetrievedDoc[]) => void; setSubsections: (value: RetrievedDoc[]) => void;
setSubsubsections: (value: RetrievedDoc[]) => void;
setSections: (value: RetrievedDoc[]) => void; setSections: (value: RetrievedDoc[]) => void;
} }
) => { ) => {
...@@ -130,6 +136,7 @@ const applyRetrievedSources = ( ...@@ -130,6 +136,7 @@ const applyRetrievedSources = (
const nextTask: RetrievedDoc[] = []; const nextTask: RetrievedDoc[] = [];
const nextIndirect: RetrievedDoc[] = []; const nextIndirect: RetrievedDoc[] = [];
const nextSubsections: RetrievedDoc[] = []; const nextSubsections: RetrievedDoc[] = [];
const nextSubsubsections: RetrievedDoc[] = [];
const nextSections: RetrievedDoc[] = []; const nextSections: RetrievedDoc[] = [];
sources.forEach((source, index) => { sources.forEach((source, index) => {
...@@ -147,6 +154,9 @@ const applyRetrievedSources = ( ...@@ -147,6 +154,9 @@ const applyRetrievedSources = (
case "subsections": case "subsections":
nextSubsections.push(doc); nextSubsections.push(doc);
break; break;
case "subsubsections":
nextSubsubsections.push(doc);
break;
case "sections": case "sections":
nextSections.push(doc); nextSections.push(doc);
break; break;
...@@ -162,6 +172,7 @@ const applyRetrievedSources = ( ...@@ -162,6 +172,7 @@ const applyRetrievedSources = (
setters.setTaskChildren(nextTask); setters.setTaskChildren(nextTask);
setters.setIndirectChildren(nextIndirect); setters.setIndirectChildren(nextIndirect);
setters.setSubsections(nextSubsections); setters.setSubsections(nextSubsections);
setters.setSubsubsections(nextSubsubsections);
setters.setSections(nextSections); setters.setSections(nextSections);
}; };
...@@ -210,6 +221,7 @@ export default function ChatPage() { ...@@ -210,6 +221,7 @@ export default function ChatPage() {
const [taskChildren, setTaskChildren] = useState<RetrievedDoc[]>([]); const [taskChildren, setTaskChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]); const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
const [subsections, setSubsections] = useState<RetrievedDoc[]>([]); const [subsections, setSubsections] = useState<RetrievedDoc[]>([]);
const [subsubsections, setSubsubsections] = useState<RetrievedDoc[]>([]);
const [sections, setSections] = useState<RetrievedDoc[]>([]); const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false); const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null); const [retrievalError, setRetrievalError] = useState<string | null>(null);
...@@ -420,6 +432,7 @@ export default function ChatPage() { ...@@ -420,6 +432,7 @@ export default function ChatPage() {
setTaskChildren, setTaskChildren,
setIndirectChildren, setIndirectChildren,
setSubsections, setSubsections,
setSubsubsections,
setSections, setSections,
}); });
socraticBootstrapRef.current = bootstrapKey; socraticBootstrapRef.current = bootstrapKey;
...@@ -492,6 +505,7 @@ export default function ChatPage() { ...@@ -492,6 +505,7 @@ export default function ChatPage() {
...taskChildren, ...taskChildren,
...indirectChildren, ...indirectChildren,
...subsections, ...subsections,
...subsubsections,
...sections, ...sections,
]; ];
...@@ -522,7 +536,7 @@ export default function ChatPage() { ...@@ -522,7 +536,7 @@ export default function ChatPage() {
}); });
return { bySourceKey, bySlug }; return { bySourceKey, bySlug };
}, [directChildren, taskChildren, indirectChildren, subsections, sections]); }, [directChildren, taskChildren, indirectChildren, subsections, subsubsections, sections]);
const nextTaskRef = useMemo(() => { const nextTaskRef = useMemo(() => {
if (!selectedTaskFile || !selectedTaskRef) { if (!selectedTaskFile || !selectedTaskRef) {
...@@ -734,6 +748,7 @@ export default function ChatPage() { ...@@ -734,6 +748,7 @@ export default function ChatPage() {
setTaskChildren, setTaskChildren,
setIndirectChildren, setIndirectChildren,
setSubsections, setSubsections,
setSubsubsections,
setSections, setSections,
}); });
} catch (error) { } catch (error) {
...@@ -806,6 +821,7 @@ export default function ChatPage() { ...@@ -806,6 +821,7 @@ export default function ChatPage() {
setTaskChildren([]); setTaskChildren([]);
setIndirectChildren([]); setIndirectChildren([]);
setSubsections([]); setSubsections([]);
setSubsubsections([]);
setSections([]); setSections([]);
setRetrievalLoading(false); setRetrievalLoading(false);
setRetrievalError(null); setRetrievalError(null);
...@@ -1281,6 +1297,7 @@ export default function ChatPage() { ...@@ -1281,6 +1297,7 @@ export default function ChatPage() {
taskChildren={taskChildren} taskChildren={taskChildren}
indirectChildren={indirectChildren} indirectChildren={indirectChildren}
subsections={subsections} subsections={subsections}
subsubsections={subsubsections}
sections={sections} sections={sections}
isLoading={retrievalLoading} isLoading={retrievalLoading}
error={retrievalError} error={retrievalError}
......
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
type SelectedSubsectionRef, type SelectedSubsectionRef,
type SelectedTaskRef, type SelectedTaskRef,
type SubsectionOption, type SubsectionOption,
type TopicOption,
type TaskImage, type TaskImage,
type TaskFile, type TaskFile,
} from "../api/taskApi"; } from "../api/taskApi";
...@@ -44,6 +45,7 @@ export type SelectedSubsection = SelectedSubsectionRef & { ...@@ -44,6 +45,7 @@ export type SelectedSubsection = SelectedSubsectionRef & {
export type TaskSelectionState = { export type TaskSelectionState = {
taskFiles: TaskFile[]; taskFiles: TaskFile[];
topics: TopicOption[];
selectedTaskRef: SelectedTaskRef | null; selectedTaskRef: SelectedTaskRef | null;
selectedTask: SelectedTask | null; selectedTask: SelectedTask | null;
selectedTaskFile: TaskFile | null; selectedTaskFile: TaskFile | null;
...@@ -88,7 +90,8 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string => ...@@ -88,7 +90,8 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
tasks.find((task) => task.task_id === "01")?.task_id || tasks[0]?.task_id || ""; tasks.find((task) => task.task_id === "01")?.task_id || tasks[0]?.task_id || "";
const isSelectableTaskFile = (file: TaskFile): boolean => const isSelectableTaskFile = (file: TaskFile): boolean =>
Array.isArray(file.subsections) && file.subsections.length > 0; (Array.isArray(file.topics) && file.topics.length > 0) ||
(Array.isArray(file.topic_options) && file.topic_options.length > 0);
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean => const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "socratic"; value === "task" || value === "socratic";
...@@ -107,6 +110,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -107,6 +110,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null); const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false); const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]); const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [topics, setTopics] = useState<TopicOption[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]); const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null); const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] = const [selectedSubsectionRef, setSelectedSubsectionRef] =
...@@ -199,6 +203,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -199,6 +203,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try { try {
const isSocratic = orchestrator === "socratic"; const isSocratic = orchestrator === "socratic";
let files: TaskFile[] = []; let files: TaskFile[] = [];
let topicsPayload: TopicOption[] = [];
let subsectionsPayload: SubsectionOption[] = []; let subsectionsPayload: SubsectionOption[] = [];
if (isSocratic) { if (isSocratic) {
...@@ -207,12 +212,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -207,12 +212,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} else { } else {
const payload = await fetchTasks(); const payload = await fetchTasks();
files = payload.task_files || []; files = payload.task_files || [];
subsectionsPayload = payload.subsections || []; topicsPayload = payload.topics || [];
} }
const selectableFiles = files.filter((file) => isSelectableTaskFile(file)); const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files); setTaskFiles(files);
setTopics(topicsPayload);
setSubsections(subsectionsPayload); setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => { setSelectedTaskRef((prev) => {
if (isSocratic) { if (isSocratic) {
...@@ -257,6 +263,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -257,6 +263,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} catch (error) { } catch (error) {
setTasksError(t("failedLoadTasks")); setTasksError(t("failedLoadTasks"));
setTaskFiles([]); setTaskFiles([]);
setTopics([]);
setSubsections([]); setSubsections([]);
setSelectedTaskRef(null); setSelectedTaskRef(null);
setSelectedSubsectionRef(null); setSelectedSubsectionRef(null);
...@@ -384,6 +391,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -384,6 +391,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable, isOrchestratorSelectable,
orchestratorError, orchestratorError,
taskFiles, taskFiles,
topics,
subsections, subsections,
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
......
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