Commit 72d5b5d1 authored by Kantz's avatar Kantz
Browse files

umstellung auf neune Modus auch bei den Tasks

parent 75a26eb5
......@@ -10,7 +10,7 @@
if you want to use the task mode you need :
- 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
......
......@@ -26,6 +26,14 @@ class TaskItem(BaseModel):
images: List[TaskImage] = Field(default_factory=list)
class TopicEntry(BaseModel):
topic_key: str
label: str
refs: List[List[int]]
level: str
summary: str = ""
class SubsectionEntry(BaseModel):
subsection_key: str
label: str
......@@ -38,14 +46,15 @@ class TaskFile(BaseModel):
title: str
intro: str
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):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
subsections: List[SubsectionEntry] = Field(default_factory=list)
topics: List[TopicEntry] = Field(default_factory=list)
class SocraticResponse(BaseModel):
......@@ -104,12 +113,12 @@ def get_task_config() -> dict[str, object]:
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
subsections = task_catalog.build_subsection_catalog()
topics = task_catalog.build_topic_catalog()
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files,
subsections=subsections,
topics=topics,
)
......
......@@ -55,28 +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:
# Deprecated: this still uses subsection refs until task retrieval is migrated to the new parent model.
refs = task_catalog.get_selected_task_subsection_refs(state.sheet)
refs = task_catalog.get_selected_task_parent_refs(state.sheet)
if not refs:
return 0
def _retrieve() -> dict:
sources = retrieval_store.retrieve_for_subsections(
sources = retrieval_store.retrieve_for_parent_refs(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
parent_refs=refs,
)
context_store.update_retrieval_context(state.sheet, sources)
return {
"subsection_refs": refs,
"parent_refs": refs,
"source_count": len(sources),
}
result = base.log_timed_call(
state.tool_log,
"retrieve_context_with_task_subsections",
"retrieve_context_with_task_parents",
{
"query": query_text,
"subsection_refs": refs,
"parent_refs": refs,
},
_retrieve,
)
......
......@@ -109,3 +109,13 @@ def retrieve_for_subsections(
pg_url=pg_url,
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,
)
......@@ -16,8 +16,14 @@ from app.deterministic_services.vector_store import parse_markdown_with_frontmat
TASKS_DIR = config.get_task_folder()
TASK_IMAGES_DIR = TASKS_DIR / "images"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.yaml"
TOPIC_MAP_PATH = TASKS_DIR / "_topic_to_index_map.yaml"
SUBSECTION_MAP_PATH = TOPIC_MAP_PATH
TASK_ASSET_URL_PREFIX = "/api/tasks/assets"
ParentRef = tuple[int, int, int, int]
def _normalize_parent_ref_key(value: str) -> str:
return _normalize_subsection_key(value)
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
......@@ -42,7 +48,80 @@ def _parse_subsection_ref(value: str) -> tuple[int, int, int] | None:
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int, int, int]]:
def _parse_parent_ref(value: Any) -> ParentRef | None:
if isinstance(value, (list, tuple)):
parts = [str(item).strip() for item in value if str(item).strip()]
elif isinstance(value, dict):
try:
chapter = int(value.get("chapter_index"))
section = int(value.get("section_index"))
except Exception:
return None
subsection = int(value.get("subsection_index") or 0)
subsubsection = int(value.get("subsubsection_index") or 0)
return _normalize_parent_ref((chapter, section, subsection, subsubsection))
else:
token = str(value).strip()
if not token:
return None
parts = [part for part in re.split(r"\s*[-.:]\s*", token) if part]
try:
ints = [int(part) for part in parts]
except Exception:
return None
if len(ints) == 2:
return _normalize_parent_ref((ints[0], ints[1], 0, 0))
if len(ints) == 3:
return _normalize_parent_ref((ints[0], ints[1], ints[2], 0))
if len(ints) == 4:
return _normalize_parent_ref((ints[0], ints[1], ints[2], ints[3]))
return None
def _normalize_parent_ref(ref: ParentRef) -> ParentRef:
chap, sec, sub, subsub = [int(part) for part in ref]
if sub <= 0:
return (chap, sec, 0, 0)
if subsub <= 0:
return (chap, sec, sub, 0)
return (chap, sec, sub, subsub)
def _parent_ref_level(ref: ParentRef) -> str:
_, _, sub, subsub = _normalize_parent_ref(ref)
if sub <= 0:
return "section"
if subsub <= 0:
return "subsection"
return "subsubsection"
def _parent_ref_to_key(ref: ParentRef) -> str:
chap, sec, sub, subsub = _normalize_parent_ref(ref)
if sub <= 0:
return f"{chap}-{sec}"
if subsub <= 0:
return f"{chap}-{sec}-{sub}"
return f"{chap}-{sec}-{sub}-{subsub}"
def _parent_ref_to_list(ref: ParentRef) -> list[int]:
chap, sec, sub, subsub = _normalize_parent_ref(ref)
return [chap, sec, sub, subsub]
def _format_parent_ref_label(ref: ParentRef) -> str:
chap, sec, sub, subsub = _normalize_parent_ref(ref)
if sub <= 0:
return f"Section {chap}.{sec}"
if subsub <= 0:
return f"Subsection {chap}.{sec}.{sub}"
return f"Subsubsection {chap}.{sec}.{sub}.{subsub}"
def load_topic_map(path: Path = TOPIC_MAP_PATH) -> dict[str, ParentRef]:
if not path.exists():
return {}
try:
......@@ -52,13 +131,24 @@ def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int
if not isinstance(content, dict):
return {}
mapped: dict[str, tuple[int, int, int]] = {}
mapped: dict[str, ParentRef] = {}
for raw_key, raw_ref in content.items():
key = _normalize_subsection_key(str(raw_key))
parsed = _parse_subsection_ref(str(raw_ref))
key = _normalize_parent_ref_key(str(raw_key))
parsed = _parse_parent_ref(raw_ref)
if not key or parsed is None:
continue
mapped[key] = parsed
mapped[key] = _normalize_parent_ref(parsed)
return mapped
def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int, int, int]]:
topic_map = load_topic_map(path)
mapped: dict[str, tuple[int, int, int]] = {}
for key, ref in topic_map.items():
chap, sec, sub, subsub = ref
if sub <= 0 or subsub > 0:
continue
mapped[key] = (chap, sec, sub)
return mapped
......@@ -79,13 +169,18 @@ def _extract_subsection_summary(text: str) -> str:
@lru_cache(maxsize=1)
def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
def load_topic_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
summaries: dict[str, str] = {}
for folder in (
folders = (
base_dir / "lecture_script_new" / "sections",
base_dir / "lecture_script_new" / "subsections",
base_dir / "lecture_script_new" / "subsubsections",
base_dir / "with_chapters" / "sections",
base_dir / "with_chapters" / "subsections",
base_dir / "child_lvl" / "subsections",
base_dir / "subsection_lvl" / "subsections",
):
)
for folder in folders:
if not folder.exists():
continue
for path in sorted(folder.glob("*.md")):
......@@ -94,8 +189,14 @@ def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
except Exception:
continue
meta, body = parse_markdown_with_frontmatter(text)
title = str(meta.get("title") or meta.get("subsection_title") or meta.get("section_title") or "").strip()
key = _normalize_subsection_key(title)
title = str(
meta.get("title")
or meta.get("subsubsection_title")
or meta.get("subsection_title")
or meta.get("section_title")
or ""
).strip()
key = _normalize_parent_ref_key(title)
summary = _extract_subsection_summary(body)
if not key or not summary:
continue
......@@ -103,24 +204,114 @@ def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
return summaries
def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
return load_topic_summaries(base_dir)
def _normalize_topic_ref_entries(
raw_entries: Any,
topic_map: dict[str, ParentRef],
) -> list[dict[str, Any]]:
if not isinstance(raw_entries, list):
return []
options: list[dict[str, Any]] = []
seen_refs: set[ParentRef] = set()
for raw_entry in raw_entries:
normalized_key = ""
label = ""
summary = ""
ref: ParentRef | None = None
if isinstance(raw_entry, str):
normalized_key = _normalize_parent_ref_key(raw_entry)
label = _format_subsection_label(raw_entry)
ref = topic_map.get(normalized_key)
elif isinstance(raw_entry, dict):
raw_key = str(raw_entry.get("key") or raw_entry.get("topic_key") or raw_entry.get("subsection_key") or "").strip()
normalized_key = _normalize_parent_ref_key(raw_key) if raw_key else ""
label = str(raw_entry.get("label") or "").strip()
summary = str(raw_entry.get("summary") or "").strip()
ref = _parse_parent_ref(raw_entry)
if ref is None and normalized_key:
ref = topic_map.get(normalized_key)
else:
continue
if ref is None:
continue
normalized_ref = _normalize_parent_ref(ref)
if normalized_ref in seen_refs:
continue
seen_refs.add(normalized_ref)
key = normalized_key or _parent_ref_to_key(normalized_ref)
options.append(
{
"topic_key": key,
"label": label or _format_parent_ref_label(normalized_ref),
"summary": summary,
"level": _parent_ref_level(normalized_ref),
"refs": [_parent_ref_to_list(normalized_ref)],
}
)
return options
def _resolve_task_topic_options(
task_file: dict[str, Any],
topic_map: dict[str, ParentRef] | None = None,
topic_summaries: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
mapping = topic_map if topic_map is not None else load_topic_map()
summaries = topic_summaries if topic_summaries is not None else load_topic_summaries()
raw_entries: list[Any] = []
legacy_topics = task_file.get("subsections", [])
if isinstance(legacy_topics, list):
raw_entries.extend(legacy_topics)
explicit_refs = task_file.get("topic_refs", [])
if isinstance(explicit_refs, list):
raw_entries.extend(explicit_refs)
options = _normalize_topic_ref_entries(raw_entries, mapping)
for option in options:
if not option.get("summary"):
option["summary"] = summaries.get(str(option.get("topic_key", "")).strip(), "")
return options
def _resolve_task_topic_refs(
task_file: dict[str, Any],
topic_map: dict[str, ParentRef] | None = None,
) -> list[ParentRef]:
refs: set[ParentRef] = set()
for option in _resolve_task_topic_options(task_file, topic_map=topic_map):
refs_raw = option.get("refs", [])
if not isinstance(refs_raw, list):
continue
for item in refs_raw:
parsed = _parse_parent_ref(item)
if parsed is not None:
refs.add(_normalize_parent_ref(parsed))
return sorted(refs)
def _resolve_task_subsection_refs(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> list[tuple[int, int, int]]:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
subsections = task_file.get("subsections", [])
if not isinstance(subsections, list):
return []
refs: set[tuple[int, int, int]] = set()
for subsection in subsections:
key = _normalize_subsection_key(str(subsection))
if not key:
mapping: dict[str, ParentRef]
if subsection_map is None:
mapping = load_topic_map()
else:
mapping = {key: (int(ref[0]), int(ref[1]), int(ref[2]), 0) for key, ref in subsection_map.items()}
refs = _resolve_task_topic_refs(task_file, topic_map=mapping)
normalized: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in refs:
if sub <= 0 or subsub > 0:
continue
ref = mapping.get(key)
if ref is not None:
refs.add((int(ref[0]), int(ref[1]), int(ref[2])))
return sorted(refs)
normalized.append((chap, sec, sub))
return sorted(normalized)
def _format_subsection_label(value: str) -> str:
......@@ -131,17 +322,36 @@ def _format_subsection_label(value: str) -> str:
return cleaned.title()
def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str, Any]]:
subsection_map = load_subsection_map(path)
subsection_summaries = load_subsection_summaries()
def build_topic_catalog(path: Path = TOPIC_MAP_PATH) -> list[dict[str, Any]]:
topic_map = load_topic_map(path)
topic_summaries = load_topic_summaries()
response: list[dict[str, Any]] = []
for key, ref in sorted(subsection_map.items(), key=lambda item: (item[0], item[1])):
for key, ref in sorted(topic_map.items(), key=lambda item: (item[0], item[1])):
response.append(
{
"subsection_key": key,
"topic_key": key,
"label": _format_subsection_label(key),
"level": _parent_ref_level(ref),
"refs": [_parent_ref_to_list(ref)],
"summary": topic_summaries.get(key, ""),
}
)
return response
def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str, Any]]:
response: list[dict[str, Any]] = []
for item in build_topic_catalog(path):
refs = item.get("refs", [])
if item.get("level") != "subsection" or not refs:
continue
ref = refs[0]
response.append(
{
"subsection_key": item["topic_key"],
"label": item["label"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2])]],
"summary": subsection_summaries.get(key, ""),
"summary": item["summary"],
}
)
return response
......@@ -151,33 +361,28 @@ def _resolve_task_subsection_options(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> list[dict[str, Any]]:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
subsections = task_file.get("subsections", [])
if not isinstance(subsections, list):
return []
mapping: dict[str, ParentRef]
if subsection_map is None:
mapping = load_topic_map()
else:
mapping = {key: (int(ref[0]), int(ref[1]), int(ref[2]), 0) for key, ref in subsection_map.items()}
options: list[dict[str, Any]] = []
seen_refs: set[tuple[int, int, int]] = set()
for subsection in subsections:
raw_label = str(subsection).strip()
key = _normalize_subsection_key(raw_label)
if not key:
continue
ref = mapping.get(key)
if ref is None:
continue
normalized_ref = (int(ref[0]), int(ref[1]), int(ref[2]))
if normalized_ref in seen_refs:
options = _resolve_task_topic_options(task_file, topic_map=mapping)
response: list[dict[str, Any]] = []
for option in options:
refs = option.get("refs", [])
if option.get("level") != "subsection" or not refs:
continue
seen_refs.add(normalized_ref)
options.append(
ref = refs[0]
response.append(
{
"subsection_key": key,
"label": _format_subsection_label(raw_label) or raw_label,
"refs": [[normalized_ref[0], normalized_ref[1], normalized_ref[2]]],
"subsection_key": option["topic_key"],
"label": option["label"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2])]],
"summary": option.get("summary", ""),
}
)
return options
return response
def _match_score(query_text: str, candidate_text: str) -> int:
......@@ -289,6 +494,7 @@ def _normalize_yaml_task_file(content: Any, path: Path) -> dict[str, Any] | None
slug = str(content.get("slug", "")).strip() or _slugify(title or file_id)
level = str(content.get("level", "")).strip()
subsections = _normalize_exercise_topics(content.get("exercise_topic", []))
topic_refs_raw = content.get("topic_refs", [])
if not title or not file_id or not slug or not level:
return None
......@@ -308,6 +514,7 @@ def _normalize_yaml_task_file(content: Any, path: Path) -> dict[str, Any] | None
"slug": slug,
"level": level,
"subsections": subsections,
"topic_refs": topic_refs_raw if isinstance(topic_refs_raw, list) else [],
"tasks": tasks,
}
......@@ -381,8 +588,9 @@ def set_selected_task(
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)
refs = _resolve_task_subsection_refs(task_file)
sheet["task_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in refs]
refs = _resolve_task_topic_refs(task_file)
sheet["task_parent_refs"] = [_parent_ref_to_list(ref) for ref in refs]
sheet.pop("task_subsection_refs", None)
sheet.pop("selected_subsection_key", None)
sheet.pop("selected_subsection_label", None)
sheet.pop("selected_subsection_refs", None)
......@@ -410,6 +618,7 @@ def set_selected_subsection(
sheet["selected_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in sorted({*refs})]
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
......@@ -475,6 +684,7 @@ def select_subsection_by_key(
sheet["selected_subsection_refs"] = [[int(ref[0]), int(ref[1]), int(ref[2])]]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
......@@ -508,18 +718,25 @@ def get_selected_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int,
return sorted(refs)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("task_subsection_refs", [])
def get_selected_task_parent_refs(sheet: dict[str, Any]) -> list[ParentRef]:
refs_raw = sheet.get("task_parent_refs", [])
if not isinstance(refs_raw, list):
return []
refs: set[tuple[int, int, int]] = set()
refs: set[ParentRef] = set()
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.add((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
parsed = _parse_parent_ref(item)
if parsed is not None:
refs.add(_normalize_parent_ref(parsed))
return sorted(refs)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in get_selected_task_parent_refs(sheet):
if sub <= 0 or subsub > 0:
continue
refs.append((chap, sec, sub))
return sorted(refs)
......@@ -600,8 +817,12 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
file_id = str(task_file.get("_file_id", ""))
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
subsections = task_file.get("subsections", [])
subsection_options = _resolve_task_subsection_options(task_file)
topic_options = _resolve_task_topic_options(task_file)
topics_raw = task_file.get("subsections", [])
if isinstance(topics_raw, list) and topics_raw:
topics = [str(item).strip() for item in topics_raw if str(item).strip()]
else:
topics = [str(item.get("topic_key", "")).strip() for item in topic_options if str(item.get("topic_key", "")).strip()]
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
......@@ -625,8 +846,8 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"title": title,
"intro": intro,
"tasks": tasks,
"subsections": subsections,
"subsection_options": subsection_options,
"topics": topics,
"topic_options": topic_options,
}
)
return response
......@@ -862,6 +862,34 @@ def retrieve(
# Deprecated: subsection refs only represent the legacy subsection-centric task/socratic flow.
SubsectionRef = Tuple[int, int, int]
ParentRef = Tuple[int, int, int, int]
def _normalize_parent_refs(
parent_refs: Optional[List[ParentRef]],
) -> List[ParentRef]:
if not parent_refs:
return []
normalized: set[ParentRef] = set()
for chap, sec, sub, subsub in parent_refs:
chap_i, sec_i, sub_i, subsub_i = int(chap), int(sec), int(sub), int(subsub)
if sub_i <= 0:
normalized.add((chap_i, sec_i, 0, 0))
elif subsub_i <= 0:
normalized.add((chap_i, sec_i, sub_i, 0))
else:
normalized.add((chap_i, sec_i, sub_i, subsub_i))
return sorted(normalized)
def _parent_ref_scope(ref: ParentRef) -> DominantScope:
chap, sec, sub, subsub = ref
if sub <= 0:
return DominantScope(chapter_index=chap, section_index=sec, subsection_index=None, subsubsection_index=None, level="section")
if subsub <= 0:
return DominantScope(chapter_index=chap, section_index=sec, subsection_index=sub, subsubsection_index=None, level="subsection")
return DominantScope(chapter_index=chap, section_index=sec, subsection_index=sub, subsubsection_index=subsub, level="subsubsection")
def _normalize_subsection_refs(
......@@ -917,6 +945,143 @@ def load_children_for_subsections(
return _retrivla_to_sources({"task_childs": children})
def load_sources_for_parent_refs(
pg_url: str,
parent_refs: Optional[List[ParentRef]],
) -> List[Source]:
refs = _normalize_parent_refs(parent_refs)
if not refs:
return []
groups: Dict[str, List[Retrieved]] = {
"sections": [],
"subsections": [],
"subsubsections": [],
"task_childs": [],
}
with psycopg.connect(pg_url, row_factory=dict_row) as conn:
with conn.cursor() as cur:
for ref in refs:
scope = _parent_ref_scope(ref)
if scope.level == "section":
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'section'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
""",
{"cpt": scope.chapter_index, "sec": scope.section_index},
)
groups["sections"].extend([_row_to_retrieved(row, source_type="section") for row in cur.fetchall()])
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'child'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
AND d.subsection_index = 0
AND d.subsubsection_index = 0
ORDER BY d.child_index
""",
{"cpt": scope.chapter_index, "sec": scope.section_index},
)
groups["task_childs"].extend([_row_to_retrieved(row) for row in cur.fetchall()])
continue
if scope.level == "subsection":
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'subsection'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
AND d.subsection_index = %(sub)s
""",
{"cpt": scope.chapter_index, "sec": scope.section_index, "sub": scope.subsection_index},
)
groups["subsections"].extend([_row_to_retrieved(row, source_type="subsection") for row in cur.fetchall()])
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'child'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
AND d.subsection_index = %(sub)s
AND d.subsubsection_index = 0
ORDER BY d.child_index
""",
{"cpt": scope.chapter_index, "sec": scope.section_index, "sub": scope.subsection_index},
)
groups["task_childs"].extend([_row_to_retrieved(row) for row in cur.fetchall()])
continue
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'subsubsection'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
AND d.subsection_index = %(sub)s
AND d.subsubsection_index = %(subsub)s
""",
{"cpt": scope.chapter_index, "sec": scope.section_index, "sub": scope.subsection_index, "subsub": scope.subsubsection_index},
)
groups["subsubsections"].extend([_row_to_retrieved(row, source_type="subsubsection") for row in cur.fetchall()])
cur.execute(
"""
SELECT
d.uid, d.doc_type,
d.chapter_index, d.section_index, d.subsection_index, d.subsubsection_index, d.child_index,
d.chapter_title, d.section_title, d.subsection_title, d.subsubsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
WHERE d.doc_type = 'child'
AND d.chapter_index = %(cpt)s
AND d.section_index = %(sec)s
AND d.subsection_index = %(sub)s
AND d.subsubsection_index = %(subsub)s
ORDER BY d.child_index
""",
{"cpt": scope.chapter_index, "sec": scope.section_index, "sub": scope.subsection_index, "subsub": scope.subsubsection_index},
)
groups["task_childs"].extend([_row_to_retrieved(row) for row in cur.fetchall()])
return merge_sources([], _retrivla_to_sources(groups))
def merge_sources(primary: List[Source], additional: List[Source]) -> List[Source]:
merged: List[Source] = []
index_by_key: Dict[Tuple[str, str, str, str, str, str, str], int] = {}
......
......@@ -223,7 +223,7 @@ def convert_all(tasks_dir: Path = TASKS_DIR, dry_run: bool = False) -> tuple[lis
images_root = tasks_dir / "images"
used_names: set[str] = set()
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
payload = load_json_task(json_path)
source_file_id = json_path.stem
......
......@@ -62,6 +62,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische_gleichungen"],
"topic_refs": [],
"tasks": [
{
"id": "01",
......@@ -92,6 +93,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
}
],
)
self.assertEqual(catalog[0]["topics"], ["quadratische_gleichungen"])
def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None:
with patch(
......@@ -135,13 +137,13 @@ class TaskCatalogSocraticTest(unittest.TestCase):
def test_build_subsection_catalog_includes_summary(self) -> None:
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
"app.deterministic_services.task_catalog.load_topic_map",
return_value={
"quadratische gleichungen": (1, 3, 3),
"mengen": (1, 1, 1),
"quadratische gleichungen": (1, 3, 3, 0),
"mengen": (1, 1, 1, 0),
},
), patch(
"app.deterministic_services.task_catalog.load_subsection_summaries",
"app.deterministic_services.task_catalog.load_topic_summaries",
return_value={
"mengen": "Mengen summary text",
"quadratische gleichungen": "Quadratische summary text",
......@@ -167,6 +169,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:
temp_dir = Path(__file__).resolve().parent / "_tmp_subsection_summaries"
summary_root = temp_dir / "child_lvl" / "subsections"
......@@ -249,6 +296,42 @@ Zweite Zeile.
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
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):
def setUp(self) -> None:
......@@ -256,20 +339,23 @@ class TaskApiSocraticTest(unittest.TestCase):
app.include_router(tasks.router)
self.client = TestClient(app)
def test_list_tasks_includes_subsection_options(self) -> None:
def test_list_tasks_includes_topic_options(self) -> None:
payload = [
{
"file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"tasks": [],
"topics": ["quadratische gleichungen"],
"topic_options": [],
}
]
subsections = [
topics = [
{
"subsection_key": "quadratische gleichungen",
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
"refs": [[1, 3, 3, 0]],
"level": "subsection",
}
]
......@@ -277,15 +363,15 @@ class TaskApiSocraticTest(unittest.TestCase):
"app.api.tasks.task_catalog.build_task_catalog",
return_value=payload,
), patch(
"app.api.tasks.task_catalog.build_subsection_catalog",
return_value=subsections,
"app.api.tasks.task_catalog.build_topic_catalog",
return_value=topics,
):
response = self.client.get("/api/tasks")
self.assertEqual(response.status_code, 200)
body = response.json()
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:
temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
......
......@@ -142,6 +142,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self.assertEqual(catalog[0]["file_id"], "analysis_1")
self.assertEqual(catalog[0]["title"], "Analysis")
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]["statement"], "Bestimme f(x).")
self.assertEqual(
......
......@@ -15,8 +15,16 @@ export type TaskFile = {
title: string;
intro: string;
tasks: TaskItem[];
subsections?: string[];
subsection_options?: SubsectionOption[];
topics?: string[];
topic_options?: TopicOption[];
};
export type TopicOption = {
topic_key: string;
label: string;
refs: [number, number, number, number][];
level: string;
summary: string;
};
export type SubsectionOption = {
......@@ -30,7 +38,7 @@ export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
subsections: SubsectionOption[];
topics: TopicOption[];
};
export type SocraticResponse = {
......
......@@ -9,9 +9,11 @@ export type RetrievedDoc = {
metadata: {
section_index?: number | null;
subsection_index?: number | null;
subsubsection_index?: number | null;
child_index?: number | null;
section_title?: string | null;
subsection_title?: string | null;
subsubsection_title?: string | null;
title?: string | null;
type?: string | null;
box_hint?: string | null;
......@@ -25,6 +27,7 @@ type DocPanelProps = {
taskChildren: RetrievedDoc[];
indirectChildren: RetrievedDoc[];
subsections: RetrievedDoc[];
subsubsections: RetrievedDoc[];
sections: RetrievedDoc[];
isLoading?: boolean;
error?: string | null;
......@@ -36,6 +39,7 @@ const buildTitle = (doc: RetrievedDoc) => {
const meta = doc.metadata;
return (
meta.title ||
meta.subsubsection_title ||
meta.subsection_title ||
meta.section_title ||
meta.path ||
......@@ -52,6 +56,9 @@ const buildSubtitle = (doc: RetrievedDoc) => {
if (meta.subsection_index !== null && meta.subsection_index !== undefined) {
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) {
parts.push(`c${meta.child_index}`);
}
......@@ -102,6 +109,7 @@ export default function DocPanel({
taskChildren,
indirectChildren,
subsections,
subsubsections,
sections,
isLoading,
error,
......@@ -113,6 +121,7 @@ export default function DocPanel({
taskChildren.length +
indirectChildren.length +
subsections.length +
subsubsections.length +
sections.length;
return (
......@@ -126,6 +135,7 @@ export default function DocPanel({
{renderGroup(t("taskChildren"), taskChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("directChildren"), directChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("indirectChildren"), indirectChildren, onCiteDoc, onInspectDoc)}
{renderGroup(t("subsubsectionSummary"), subsubsections, onCiteDoc, onInspectDoc)}
{renderGroup(t("subsectionSummary"), subsections, onCiteDoc, onInspectDoc)}
{renderGroup(t("sectionSummary"), sections, onCiteDoc, onInspectDoc)}
</div>
......
......@@ -31,6 +31,7 @@
directChildren: "Direct children",
taskChildren: "Task sources",
indirectChildren: "Indirect children",
subsubsectionSummary: "Subsubsection summary",
subsectionSummary: "Subsection summary",
sectionSummary: "Section summary",
untitled: "Untitled",
......@@ -130,6 +131,7 @@
directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen",
subsubsectionSummary: "Unterunterabschnitt-Zusammenfassung",
subsectionSummary: "Unterabschnitt-Zusammenfassung",
sectionSummary: "Abschnitt-Zusammenfassung",
untitled: "Ohne Titel",
......
......@@ -61,6 +61,7 @@ type ContextSource = {
chapter_title?: string | null;
section_title?: string | null;
subsection_title?: string | null;
subsubsection_title?: string | null;
title: string;
doc_type: string;
};
......@@ -75,6 +76,7 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
source.source_id.chapter_title,
source.source_id.section_title,
source.source_id.subsection_title,
source.source_id.subsubsection_title,
source.source_id.title,
source.source_id.doc_type,
].filter(Boolean);
......@@ -86,6 +88,7 @@ const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
sourceId.chapter_title ?? "",
sourceId.section_title ?? "",
sourceId.subsection_title ?? "",
sourceId.subsubsection_title ?? "",
sourceId.title ?? "",
sourceId.doc_type ?? "",
];
......@@ -97,6 +100,7 @@ const sourceIdToPath = (source: ContextSource) => {
source.source_id.chapter_title,
source.source_id.section_title,
source.source_id.subsection_title,
source.source_id.subsubsection_title,
].filter(Boolean);
return parts.length ? parts.join(" / ") : null;
};
......@@ -109,6 +113,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => (
metadata: {
section_title: source.source_id.section_title ?? null,
subsection_title: source.source_id.subsection_title ?? null,
subsubsection_title: source.source_id.subsubsection_title ?? null,
title: source.source_id.title ?? null,
type: source.source_type ?? null,
path: sourceIdToPath(source),
......@@ -123,6 +128,7 @@ const applyRetrievedSources = (
setTaskChildren: (value: RetrievedDoc[]) => void;
setIndirectChildren: (value: RetrievedDoc[]) => void;
setSubsections: (value: RetrievedDoc[]) => void;
setSubsubsections: (value: RetrievedDoc[]) => void;
setSections: (value: RetrievedDoc[]) => void;
}
) => {
......@@ -130,6 +136,7 @@ const applyRetrievedSources = (
const nextTask: RetrievedDoc[] = [];
const nextIndirect: RetrievedDoc[] = [];
const nextSubsections: RetrievedDoc[] = [];
const nextSubsubsections: RetrievedDoc[] = [];
const nextSections: RetrievedDoc[] = [];
sources.forEach((source, index) => {
......@@ -147,6 +154,9 @@ const applyRetrievedSources = (
case "subsections":
nextSubsections.push(doc);
break;
case "subsubsections":
nextSubsubsections.push(doc);
break;
case "sections":
nextSections.push(doc);
break;
......@@ -162,6 +172,7 @@ const applyRetrievedSources = (
setters.setTaskChildren(nextTask);
setters.setIndirectChildren(nextIndirect);
setters.setSubsections(nextSubsections);
setters.setSubsubsections(nextSubsubsections);
setters.setSections(nextSections);
};
......@@ -210,6 +221,7 @@ export default function ChatPage() {
const [taskChildren, setTaskChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
const [subsections, setSubsections] = useState<RetrievedDoc[]>([]);
const [subsubsections, setSubsubsections] = useState<RetrievedDoc[]>([]);
const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null);
......@@ -420,6 +432,7 @@ export default function ChatPage() {
setTaskChildren,
setIndirectChildren,
setSubsections,
setSubsubsections,
setSections,
});
socraticBootstrapRef.current = bootstrapKey;
......@@ -492,6 +505,7 @@ export default function ChatPage() {
...taskChildren,
...indirectChildren,
...subsections,
...subsubsections,
...sections,
];
......@@ -522,7 +536,7 @@ export default function ChatPage() {
});
return { bySourceKey, bySlug };
}, [directChildren, taskChildren, indirectChildren, subsections, sections]);
}, [directChildren, taskChildren, indirectChildren, subsections, subsubsections, sections]);
const nextTaskRef = useMemo(() => {
if (!selectedTaskFile || !selectedTaskRef) {
......@@ -734,6 +748,7 @@ export default function ChatPage() {
setTaskChildren,
setIndirectChildren,
setSubsections,
setSubsubsections,
setSections,
});
} catch (error) {
......@@ -806,6 +821,7 @@ export default function ChatPage() {
setTaskChildren([]);
setIndirectChildren([]);
setSubsections([]);
setSubsubsections([]);
setSections([]);
setRetrievalLoading(false);
setRetrievalError(null);
......@@ -1281,6 +1297,7 @@ export default function ChatPage() {
taskChildren={taskChildren}
indirectChildren={indirectChildren}
subsections={subsections}
subsubsections={subsubsections}
sections={sections}
isLoading={retrievalLoading}
error={retrievalError}
......
......@@ -20,6 +20,7 @@ import {
type SelectedSubsectionRef,
type SelectedTaskRef,
type SubsectionOption,
type TopicOption,
type TaskImage,
type TaskFile,
} from "../api/taskApi";
......@@ -44,6 +45,7 @@ export type SelectedSubsection = SelectedSubsectionRef & {
export type TaskSelectionState = {
taskFiles: TaskFile[];
topics: TopicOption[];
selectedTaskRef: SelectedTaskRef | null;
selectedTask: SelectedTask | null;
selectedTaskFile: TaskFile | null;
......@@ -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 || "";
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 =>
value === "task" || value === "socratic";
......@@ -107,6 +110,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [topics, setTopics] = useState<TopicOption[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
......@@ -199,6 +203,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try {
const isSocratic = orchestrator === "socratic";
let files: TaskFile[] = [];
let topicsPayload: TopicOption[] = [];
let subsectionsPayload: SubsectionOption[] = [];
if (isSocratic) {
......@@ -207,12 +212,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} else {
const payload = await fetchTasks();
files = payload.task_files || [];
subsectionsPayload = payload.subsections || [];
topicsPayload = payload.topics || [];
}
const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files);
setTopics(topicsPayload);
setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => {
if (isSocratic) {
......@@ -257,6 +263,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} catch (error) {
setTasksError(t("failedLoadTasks"));
setTaskFiles([]);
setTopics([]);
setSubsections([]);
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
......@@ -384,6 +391,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable,
orchestratorError,
taskFiles,
topics,
subsections,
selectedTaskRef,
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