Commit c4ee9dda authored by Kantz's avatar Kantz
Browse files

anpassung des Sokrates auf neue Struktur.

parent e31b3ff9
......@@ -127,7 +127,7 @@ VITE_PROXY_TARGET="http://<BACKEND_HOST>:8000"
Template files:
- `math-tutor/docker/docker-compose.yml`
- `math-tutor/docker/docker-compose.yaml`
- `math-tutor/docker/nginx.conf`
- `math-tutor/backend/Dockerfile`
- `math-tutor/frontend/Dockerfile`
......@@ -190,7 +190,7 @@ python -m scripts.retrieval_cli query --q "Was ist eine Teilmenge?" --k 8 --expa
```powershell
cd math-tutor/backend
.\.venv\Scripts\Activate.ps1
python -m scripts.generate_socratic_chats --source-root sources/lecture_script --output sources/inital_socratic_prompt/initial_prompts.yml
python -m scripts.generate_socratic_chats --source-root sources/lecture_script --output sources/inital_socratic_prompt/initial_prompts.yaml
```
## Configuration
......
......@@ -5,12 +5,16 @@ from typing import Iterable
from app.deterministic_services import llm_client
HINT_SYSTEM_PROMPT = """
ParentRef = tuple[int, int, int, int]
SubsectionRef = tuple[int, int, int]
HINT_SYSTEM_PROMPT = r"""
Du bist ein didaktischer Mathe-Tutor.
Antworte auf Deutsch, kurz und präzise.
Nutze ausschließlich den bereitgestellten Kontext.
Wenn der Student eine Frage stellt die nichts mit dem Thema zu tun hat gehe nicht darauf ein.
Wenn der Student eine Frage stellt die nichts mit dem Thema zu tun hat gehe nicht darauf ein.
Stelle offene Verständnisfragen und leite den Studeten sokratisch.
Nutze keine Lückentexte oder Frage auf die man mit einem Wort antworten kann.
Fordere den Nutzer auf mit einem ganzen Satz oder einer Formel zu antworten, falls er wiederholt nur 1 Wort eingaben macht.
......@@ -25,21 +29,33 @@ minf steht für minus unendlich.
"""
def _format_subsection_refs(subsection_refs: Iterable[tuple[int, int, int]] | None) -> str:
if not subsection_refs:
return "Keine Subsection-Referenzen übergeben."
formatted = [f"{chap}:{sec}:{sub}" for chap, sec, sub in subsection_refs]
def _format_parent_refs(parent_refs: Iterable[ParentRef] | None) -> str:
if not parent_refs:
return "Keine Parent-Referenzen übergeben."
formatted: list[str] = []
for chap, sec, sub, subsub in parent_refs:
if sub <= 0:
formatted.append(f"{chap}:{sec}")
elif subsub <= 0:
formatted.append(f"{chap}:{sec}:{sub}")
else:
formatted.append(f"{chap}:{sec}:{sub}:{subsub}")
return ", ".join(formatted)
def generate_dialog(
query: str | None,
subsection_refs: list[tuple[int, int, int]] | None = None,
subsection_refs: list[SubsectionRef] | None = None,
parent_refs: list[ParentRef] | None = None,
history: list[dict] | None = None,
sources: str | None = None,
) -> str:
effective_parent_refs = parent_refs
if effective_parent_refs is None and subsection_refs is not None:
effective_parent_refs = [(chap, sec, sub, 0) for chap, sec, sub in subsection_refs]
context_parts = [
f"Subsection-Referenzen:\n{_format_subsection_refs(subsection_refs)}",
f"Parent-Referenzen:\n{_format_parent_refs(effective_parent_refs)}",
]
if sources:
context_parts.append(f"Kontext/Sources:\n{sources}")
......
......@@ -195,10 +195,10 @@ def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapRe
if not task_catalog.select_subsection_by_key(sheet, request.subsection_key):
raise HTTPException(status_code=404, detail="subsection not found")
refs = task_catalog.get_selected_subsection_refs(sheet)
sources = retrieval_store.retrieve_for_subsections(
refs = task_catalog.get_selected_subsection_parent_refs(sheet)
sources = retrieval_store.retrieve_for_parent_refs(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
parent_refs=refs,
)
context_store.set_sources(sheet, sources)
context_store.set_initialized(sheet, True)
......
......@@ -36,6 +36,7 @@ class TopicEntry(BaseModel):
class SubsectionEntry(BaseModel):
subsection_key: str
label: str
level: str = ""
refs: List[List[int]]
summary: str = ""
......
......@@ -21,28 +21,27 @@ def _apply_selected_subsection(
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_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_socratic_subsections",
"retrieve_context_with_socratic_topics",
{
"query": query_text,
"subsection_refs": refs,
"parent_refs": refs,
},
_retrieve,
)
......@@ -54,16 +53,16 @@ def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
def _on_turn_logic(state: base.ChatState) -> None:
if not task_catalog.get_selected_subsection_refs(state.sheet):
if not task_catalog.get_selected_subsection_parent_refs(state.sheet):
return
def _on_build_reply(state: base.ChatState) -> str | None:
history_turns = context_store.get_history_turns(state.sheet)
subsection_refs = task_catalog.get_selected_subsection_refs(state.sheet)
parent_refs = task_catalog.get_selected_subsection_parent_refs(state.sheet)
args = {
"query": state.last_user,
"subsection_refs": subsection_refs,
"parent_refs": parent_refs,
"history": history_turns,
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)]),
}
......
......@@ -9,7 +9,7 @@ import yaml
from app.deterministic_services import task_catalog
PROMPTS_PATH = Path(__file__).resolve().parents[2] / "sources" / "inital_socratic_prompt" / "initial_prompts.yml"
PROMPTS_PATH = Path(__file__).resolve().parents[2] / "sources" / "inital_socratic_prompt" / "initial_prompts.yaml"
@lru_cache(maxsize=1)
......@@ -28,7 +28,7 @@ def load_initial_prompt_items(path: Path = PROMPTS_PATH) -> dict[str, dict[str,
items: dict[str, dict[str, Any]] = {}
for raw_key, raw_value in raw_items.items():
key = task_catalog._normalize_subsection_key(str(raw_key))
key = task_catalog._normalize_parent_ref_key(str(raw_key))
if not key or not isinstance(raw_value, dict):
continue
items[key] = raw_value
......@@ -36,7 +36,7 @@ def load_initial_prompt_items(path: Path = PROMPTS_PATH) -> dict[str, dict[str,
def get_initial_message(subsection_key: str, path: Path = PROMPTS_PATH) -> str:
normalized_key = task_catalog._normalize_subsection_key(subsection_key)
normalized_key = task_catalog._normalize_parent_ref_key(subsection_key)
if not normalized_key:
raise ValueError("invalid subsection key")
......@@ -53,31 +53,41 @@ def get_initial_message(subsection_key: str, path: Path = PROMPTS_PATH) -> str:
def build_subsection_catalog(path: Path = PROMPTS_PATH) -> list[dict[str, Any]]:
items = load_initial_prompt_items(path)
subsection_summaries = task_catalog.load_subsection_summaries()
topic_summaries = task_catalog.load_topic_summaries()
response: list[dict[str, Any]] = []
for raw_key, item in items.items():
subsection_key = task_catalog._normalize_subsection_key(str(raw_key))
subsection_key = task_catalog._normalize_parent_ref_key(str(raw_key))
if not subsection_key:
continue
label_source = str(item.get("subsection") or subsection_key)
label_source = str(item.get("label") or item.get("subsection") or subsection_key)
label = task_catalog._format_subsection_label(label_source)
ref = task_catalog._parse_subsection_ref(str(item.get("index") or ""))
refs = [[int(ref[0]), int(ref[1]), int(ref[2])]] if ref is not None else []
refs: list[list[int]] = []
refs_raw = item.get("refs", [])
if isinstance(refs_raw, list):
for raw_ref in refs_raw:
parsed = task_catalog._parse_parent_ref(raw_ref)
if parsed is not None:
refs.append(task_catalog._parent_ref_to_list(parsed))
if not refs:
parsed_index = task_catalog._parse_parent_ref(str(item.get("index") or ""))
if parsed_index is not None:
refs.append(task_catalog._parent_ref_to_list(parsed_index))
response.append(
{
"subsection_key": subsection_key,
"label": label or task_catalog._format_subsection_label(subsection_key),
"level": str(item.get("level") or (task_catalog._parent_ref_level(tuple(refs[0])) if refs else "")).strip(),
"refs": refs,
"summary": subsection_summaries.get(subsection_key, ""),
"summary": topic_summaries.get(subsection_key, ""),
}
)
response.sort(
key=lambda item: (
item["refs"][0] if item["refs"] else (9999, 9999, 9999),
item["refs"][0] if item["refs"] else (9999, 9999, 9999, 9999),
item["subsection_key"],
)
)
......
......@@ -172,6 +172,12 @@ def _extract_subsection_summary(text: str) -> str:
def load_topic_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
summaries: dict[str, str] = {}
folders = (
base_dir / "lecture_script" / "sections",
base_dir / "lecture_script" / "subsections",
base_dir / "lecture_script" / "subsubsections",
base_dir / "stochastik_context" / "sections",
base_dir / "stochastik_context" / "subsections",
base_dir / "stochastik_context" / "subsubsections",
base_dir / "lecture_script_new" / "sections",
base_dir / "lecture_script_new" / "subsections",
base_dir / "lecture_script_new" / "subsubsections",
......@@ -343,14 +349,15 @@ def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str,
response: list[dict[str, Any]] = []
for item in build_topic_catalog(path):
refs = item.get("refs", [])
if item.get("level") != "subsection" or not refs:
if 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])]],
"level": item["level"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2]), int(ref[3])]],
"summary": item["summary"],
}
)
......@@ -371,14 +378,15 @@ def _resolve_task_subsection_options(
response: list[dict[str, Any]] = []
for option in options:
refs = option.get("refs", [])
if option.get("level") != "subsection" or not refs:
if not refs:
continue
ref = refs[0]
response.append(
{
"subsection_key": option["topic_key"],
"label": option["label"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2])]],
"level": option["level"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2]), int(ref[3])]],
"summary": option.get("summary", ""),
}
)
......@@ -591,6 +599,7 @@ def set_selected_task(
sheet.pop("task_subsection_refs", None)
sheet.pop("selected_subsection_key", None)
sheet.pop("selected_subsection_label", None)
sheet.pop("selected_subsection_parent_refs", None)
sheet.pop("selected_subsection_refs", None)
......@@ -600,21 +609,27 @@ def set_selected_subsection(
subsection_option: dict[str, Any],
) -> None:
refs_raw = subsection_option.get("refs", [])
refs: list[tuple[int, int, int]] = []
refs: list[ParentRef] = []
if isinstance(refs_raw, list):
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.append((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
parsed = _parse_parent_ref(item)
if parsed is not None:
refs.append(parsed)
if not refs:
return
sheet["selected_subsection_key"] = str(subsection_option.get("subsection_key", "")).strip()
key = str(subsection_option.get("subsection_key") or subsection_option.get("topic_key") or "").strip()
sheet["selected_subsection_key"] = key
sheet["selected_subsection_label"] = str(subsection_option.get("label", "")).strip()
sheet["selected_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in sorted({*refs})]
parent_refs = sorted({_normalize_parent_ref(ref) for ref in refs})
sheet["selected_subsection_parent_refs"] = [_parent_ref_to_list(ref) for ref in parent_refs]
sheet["selected_subsection_refs"] = [
[chap, sec, sub]
for chap, sec, sub, subsub in parent_refs
if sub > 0 and subsub <= 0
]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
sheet.pop("task_subsection_refs", None)
......@@ -651,8 +666,7 @@ def select_subsection_by_ids(
if not task_file:
return False
subsection_map = load_subsection_map()
options = _resolve_task_subsection_options(task_file, subsection_map=subsection_map)
options = _resolve_task_subsection_options(task_file)
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
......@@ -669,7 +683,11 @@ def select_subsection_by_key(
subsection_key: str,
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> bool:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
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()}
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
......@@ -679,7 +697,13 @@ def select_subsection_by_key(
sheet["selected_subsection_key"] = normalized_key
sheet["selected_subsection_label"] = _format_subsection_label(normalized_key)
sheet["selected_subsection_refs"] = [[int(ref[0]), int(ref[1]), int(ref[2])]]
normalized_ref = _normalize_parent_ref(ref)
sheet["selected_subsection_parent_refs"] = [_parent_ref_to_list(normalized_ref)]
chap, sec, sub, subsub = normalized_ref
if sub > 0 and subsub <= 0:
sheet["selected_subsection_refs"] = [[chap, sec, sub]]
else:
sheet["selected_subsection_refs"] = []
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
......@@ -702,17 +726,24 @@ def get_selected_subsection_ids(sheet: dict[str, Any]) -> tuple[str | None, str
def get_selected_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("selected_subsection_refs", [])
refs: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in get_selected_subsection_parent_refs(sheet):
if sub <= 0 or subsub > 0:
continue
refs.append((chap, sec, sub))
return sorted(set(refs))
def get_selected_subsection_parent_refs(sheet: dict[str, Any]) -> list[ParentRef]:
refs_raw = sheet.get("selected_subsection_parent_refs")
if not isinstance(refs_raw, list):
return []
refs_raw = sheet.get("selected_subsection_refs", [])
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)
......
......@@ -14,17 +14,17 @@ from app.deterministic_services.vector_store import parse_markdown_with_frontmat
BACKEND_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SOURCE_ROOT = BACKEND_ROOT / "sources" / "with_chapters"
DEFAULT_OUTPUT = BACKEND_ROOT / "sources" / "inital_socratic_prompt" / "initial_prompts.yml"
DEFAULT_TEMPLATE = BACKEND_ROOT / "sources" / "inital_socratic_prompt" / "template.yml"
DEFAULT_SOURCE_ROOT = BACKEND_ROOT / "sources" / "lecture_script"
DEFAULT_OUTPUT = BACKEND_ROOT / "sources" / "inital_socratic_prompt" / "initial_prompts.yaml"
DEFAULT_TEMPLATE = BACKEND_ROOT / "sources" / "inital_socratic_prompt" / "template.yaml"
DEFAULT_PROMPT = (
"Was sind die Themen dieses Abschnitts? Numeriere sie durch. Frage mich, mit welchem ich mich "
"zuerst beschäftigen möchte."
)
DEFAULT_TEMPLATE_FIELDS = ["subsection", "index", "inital_message", "sources"]
DEFAULT_TEMPLATE_FIELDS = ["label", "level", "index", "refs", "inital_message", "sources"]
SubsectionRef = tuple[int, int, int]
ParentRef = tuple[int, int, int, int]
@dataclass(frozen=True)
......@@ -33,8 +33,9 @@ class MarkdownDoc:
relative_path: str
meta: dict[str, Any]
body: str
ref: SubsectionRef | None
ref: ParentRef | None
title: str
level: str
def _meta_int(meta: dict[str, Any], key: str) -> int | None:
......@@ -47,87 +48,119 @@ def _meta_int(meta: dict[str, Any], key: str) -> int | None:
return None
def _detect_doc_level(path: Path) -> str:
folder = path.parent.name.lower()
if folder == "sections":
return "section"
if folder == "subsections":
return "subsection"
if folder == "subsubsections":
return "subsubsection"
return "child"
def _resolve_parent_ref(meta: dict[str, Any], title: str, topic_map: dict[str, ParentRef]) -> ParentRef | None:
chapter_index = _meta_int(meta, "chapter_index")
section_index = _meta_int(meta, "section_index")
if chapter_index is not None and section_index is not None:
subsection_index = _meta_int(meta, "subsection_index") or 0
subsubsection_index = _meta_int(meta, "subsubsection_index") or 0
return task_catalog._normalize_parent_ref(
(chapter_index, section_index, subsection_index, subsubsection_index)
)
key_candidates = [
title,
str(meta.get("subsubsection_title") or ""),
str(meta.get("subsection_title") or ""),
str(meta.get("section_title") or ""),
]
for candidate in key_candidates:
key = task_catalog._normalize_parent_ref_key(candidate)
if key and key in topic_map:
return topic_map[key]
return None
def _read_markdown_doc(
path: Path,
source_root: Path,
subsection_map: dict[str, SubsectionRef],
topic_map: dict[str, ParentRef],
) -> MarkdownDoc:
text = path.read_text(encoding="utf-8")
meta, body = parse_markdown_with_frontmatter(text)
title = str(
meta.get("title")
or meta.get("subsubsection_title")
or meta.get("subsection_title")
or meta.get("section_title")
or path.stem
).strip()
ref = _resolve_subsection_ref(meta, title, subsection_map)
return MarkdownDoc(
path=path,
relative_path=path.relative_to(source_root).as_posix(),
meta=meta,
body=body.strip(),
ref=ref,
ref=_resolve_parent_ref(meta, title, topic_map),
title=title,
level=_detect_doc_level(path),
)
def _resolve_subsection_ref(
meta: dict[str, Any],
title: str,
subsection_map: dict[str, SubsectionRef],
) -> SubsectionRef | None:
chapter_index = _meta_int(meta, "chapter_index")
section_index = _meta_int(meta, "section_index")
subsection_index = _meta_int(meta, "subsection_index")
if chapter_index is not None and section_index is not None and subsection_index is not None:
return (chapter_index, section_index, subsection_index)
key_candidates = [
title,
str(meta.get("subsection_title") or ""),
str(meta.get("title") or ""),
]
for candidate in key_candidates:
key = task_catalog._normalize_subsection_key(candidate)
if key and key in subsection_map:
return subsection_map[key]
if section_index is not None and subsection_index is not None:
matches = [
ref
for ref in subsection_map.values()
if int(ref[1]) == section_index and int(ref[2]) == subsection_index
]
unique_matches = sorted(set(matches))
if len(unique_matches) == 1:
return unique_matches[0]
return None
def _load_markdown_docs(
source_root: Path,
folder_name: str,
subsection_map: dict[str, SubsectionRef],
topic_map: dict[str, ParentRef],
) -> list[MarkdownDoc]:
folder = source_root / folder_name
if not folder.exists():
return []
return [
_read_markdown_doc(path, source_root, subsection_map)
_read_markdown_doc(path, source_root, topic_map)
for path in sorted(folder.glob("*.md"))
]
def load_subsections_and_children(
def _is_descendant_ref(candidate: ParentRef, ancestor: ParentRef) -> bool:
cand = task_catalog._normalize_parent_ref(candidate)
anc = task_catalog._normalize_parent_ref(ancestor)
if cand == anc:
return False
if anc[2] <= 0:
return cand[0] == anc[0] and cand[1] == anc[1]
if anc[3] <= 0:
return cand[0] == anc[0] and cand[1] == anc[1] and cand[2] == anc[2]
return False
def _leaf_topic_map(topic_map: dict[str, ParentRef]) -> dict[str, ParentRef]:
refs = list(topic_map.values())
leaves: dict[str, ParentRef] = {}
for key, ref in topic_map.items():
if any(_is_descendant_ref(other, ref) for other in refs):
continue
leaves[key] = task_catalog._normalize_parent_ref(ref)
return leaves
def load_topics_and_children(
source_root: Path,
subsection_map: dict[str, SubsectionRef] | None = None,
) -> tuple[list[MarkdownDoc], dict[SubsectionRef, list[MarkdownDoc]]]:
mapping = subsection_map if subsection_map is not None else task_catalog.load_subsection_map()
subsections = _load_markdown_docs(source_root, "subsections", mapping)
children = _load_markdown_docs(source_root, "childs", mapping)
topic_map: dict[str, ParentRef] | None = None,
) -> tuple[dict[ParentRef, MarkdownDoc], dict[ParentRef, list[MarkdownDoc]]]:
mapping = topic_map if topic_map is not None else task_catalog.load_topic_map()
parents: dict[ParentRef, MarkdownDoc] = {}
parent_docs = [
*_load_markdown_docs(source_root, "sections", mapping),
*_load_markdown_docs(source_root, "subsections", mapping),
*_load_markdown_docs(source_root, "subsubsections", mapping),
]
for doc in parent_docs:
if doc.ref is None:
continue
parents.setdefault(doc.ref, doc)
children_by_ref: dict[SubsectionRef, list[MarkdownDoc]] = {}
children = _load_markdown_docs(source_root, "childs", mapping)
children_by_ref: dict[ParentRef, list[MarkdownDoc]] = {}
for child in children:
if child.ref is None:
continue
......@@ -136,25 +169,27 @@ def load_subsections_and_children(
for ref_children in children_by_ref.values():
ref_children.sort(key=lambda item: (_meta_int(item.meta, "child_index") or 0, item.relative_path))
return subsections, children_by_ref
return parents, children_by_ref
def _subsection_key(subsection: MarkdownDoc) -> str:
return task_catalog._normalize_subsection_key(subsection.title or subsection.path.stem)
def _format_ref(ref: ParentRef) -> str:
chap, sec, sub, subsub = task_catalog._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 _format_ref(ref: SubsectionRef) -> str:
return f"{ref[0]}-{ref[1]}-{ref[2]}"
def build_llm_sources_text(subsection: MarkdownDoc, children: list[MarkdownDoc]) -> str:
ref_text = _format_ref(subsection.ref) if subsection.ref else ""
def build_llm_sources_text(topic: MarkdownDoc, children: list[MarkdownDoc]) -> str:
ref_text = _format_ref(topic.ref) if topic.ref else ""
parts = [
f"Abschnitt: {subsection.title}",
f"Thema: {topic.title}",
f"Level: {topic.level}",
f"Index: {ref_text}",
f"Datei: {subsection.relative_path}",
"Abschnittsinhalt:",
subsection.body,
f"Datei: {topic.relative_path}",
"Themeninhalt:",
topic.body,
]
for child in children:
......@@ -171,12 +206,12 @@ def build_llm_sources_text(subsection: MarkdownDoc, children: list[MarkdownDoc])
return "\n".join(parts).strip()
def generate_initial_message(subsection: MarkdownDoc, children: list[MarkdownDoc]) -> str:
sources_text = build_llm_sources_text(subsection, children)
refs = [subsection.ref] if subsection.ref else None
def generate_initial_message(topic: MarkdownDoc, children: list[MarkdownDoc]) -> str:
sources_text = build_llm_sources_text(topic, children)
refs = [topic.ref] if topic.ref else None
return socratic_LLM.generate_dialog(
query=DEFAULT_PROMPT,
subsection_refs=refs,
parent_refs=refs,
history=None,
sources=sources_text,
).strip()
......@@ -240,43 +275,46 @@ def build_initial_prompt_index(
output_path = output_path.resolve()
template_fields = _load_template_fields(template_path)
existing_items = _load_existing_items(output_path)
subsections, children_by_ref = load_subsections_and_children(source_root)
topic_map = task_catalog.load_topic_map()
leaf_topics = _leaf_topic_map(topic_map)
parents_by_ref, children_by_ref = load_topics_and_children(source_root, topic_map)
items: dict[str, dict[str, Any]] = {}
warnings: list[str] = []
processed = 0
for subsection in subsections:
for key, ref in sorted(leaf_topics.items(), key=lambda item: item[1]):
if limit is not None and processed >= limit:
break
key = _subsection_key(subsection)
if not key:
warnings.append(f"Skipping subsection without key: {subsection.relative_path}")
continue
if subsection.ref is None:
warnings.append(f"Skipping subsection without resolved index: {subsection.relative_path}")
topic = parents_by_ref.get(ref)
if topic is None:
message = f"Missing parent markdown doc for topic '{key}' ({_format_ref(ref)})"
if strict:
raise ValueError(message)
warnings.append(message)
continue
children = children_by_ref.get(subsection.ref, [])
children = children_by_ref.get(ref, [])
if not children:
message = f"No child chunks matched subsection {subsection.relative_path}"
message = f"No child chunks matched topic {topic.relative_path}"
if strict:
raise ValueError(message)
warnings.append(message)
sources = [subsection.relative_path, *[child.relative_path for child in children]]
existing_message = str(existing_items.get(key, {}).get("inital_message") or "").strip()
if existing_message and not force:
initial_message = existing_message
else:
initial_message = generate_initial_message(subsection, children)
initial_message = generate_initial_message(topic, children)
item = {
"subsection": subsection.title,
"index": _format_ref(subsection.ref),
"label": topic.title,
"level": task_catalog._parent_ref_level(ref),
"index": _format_ref(ref),
"refs": [task_catalog._parent_ref_to_list(ref)],
"inital_message": initial_message,
"sources": sources,
"sources": [topic.relative_path, *[child.relative_path for child in children]],
}
items[key] = _ordered_item(item, template_fields)
processed += 1
......@@ -325,7 +363,7 @@ def generate_file(
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate pregenerated Socratic initial prompts for subsections."
description="Generate pregenerated Socratic initial prompts for leaf topics."
)
parser.add_argument("--source-root", type=Path, default=DEFAULT_SOURCE_ROOT)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
......
from __future__ import annotations
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:9999")
os.environ.setdefault("OPENAI_API_KEY", "test-key")
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from scripts import generate_socratic_chats
def _write_markdown(path: Path, frontmatter: str, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"---\n{frontmatter}\n---\n{body}\n", encoding="utf-8")
class GenerateSocraticChatsTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = Path(tempfile.mkdtemp(prefix="socratic-prompts-"))
self.source_root = self.temp_dir / "lecture_script"
self.output_path = self.temp_dir / "initial_prompts.yaml"
self.template_path = self.temp_dir / "template.yaml"
self.template_path.write_text(
"label: ''\nlevel: ''\nindex: ''\nrefs:\n- [0, 0, 0, 0]\ninital_message: ''\nsources:\n- ''\n",
encoding="utf-8",
)
def tearDown(self) -> None:
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_build_initial_prompt_index_uses_lowest_available_leaf_level(self) -> None:
_write_markdown(
self.source_root / "sections/section-leaf.md",
'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"',
"Section leaf body",
)
_write_markdown(
self.source_root / "subsections/subsection-leaf.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 3\ntitle: "Potenz- und Wurzelrechnung"',
"Subsection leaf body",
)
_write_markdown(
self.source_root / "subsections/subsection-parent.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 2\ntitle: "Grundrechenarten bei reellen Zahlen"',
"Subsection parent body",
)
_write_markdown(
self.source_root / "subsubsections/subsubsection-leaf.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 2\nsubsubsection_index: 1\ntitle: "Klammerrechnung"',
"Subsubsection leaf body",
)
_write_markdown(
self.source_root / "childs/section-child.md",
'chapter_index: 1\nsection_index: 1\nsubsection_index: 0\nsubsubsection_index: 0\nchild_index: 1\ntitle: "Mengen Child"',
"Section child body",
)
_write_markdown(
self.source_root / "childs/subsection-child.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 3\nsubsubsection_index: 0\nchild_index: 1\ntitle: "Potenz Child"',
"Subsection child body",
)
_write_markdown(
self.source_root / "childs/subsubsection-child.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 2\nsubsubsection_index: 1\nchild_index: 1\ntitle: "Klammer Child"',
"Subsubsection child body",
)
_write_markdown(
self.source_root / "childs/subsection-parent-child.md",
'chapter_index: 1\nsection_index: 2\nsubsection_index: 2\nsubsubsection_index: 0\nchild_index: 1\ntitle: "Grundrechenarten Child"',
"Subsection parent child body",
)
topic_map = {
"mengen": (1, 1, 0, 0),
"grundrechenarten bei reellen zahlen": (1, 2, 2, 0),
"klammerrechnung": (1, 2, 2, 1),
"potenz und wurzelrechnung": (1, 2, 3, 0),
}
with patch(
"scripts.generate_socratic_chats.task_catalog.load_topic_map",
return_value=topic_map,
), patch(
"scripts.generate_socratic_chats.generate_initial_message",
side_effect=lambda topic, children: f"{topic.title}|{len(children)}",
):
payload = generate_socratic_chats.build_initial_prompt_index(
source_root=self.source_root,
output_path=self.output_path,
template_path=self.template_path,
)
items = payload["items"]
self.assertEqual(sorted(items.keys()), ["klammerrechnung", "mengen", "potenz und wurzelrechnung"])
self.assertEqual(items["mengen"]["level"], "section")
self.assertEqual(items["mengen"]["index"], "1-1")
self.assertEqual(items["mengen"]["refs"], [[1, 1, 0, 0]])
self.assertEqual(items["mengen"]["inital_message"], "Mengen|1")
self.assertEqual(items["potenz und wurzelrechnung"]["level"], "subsection")
self.assertEqual(items["potenz und wurzelrechnung"]["index"], "1-2-3")
self.assertEqual(items["potenz und wurzelrechnung"]["refs"], [[1, 2, 3, 0]])
self.assertEqual(items["potenz und wurzelrechnung"]["inital_message"], "Potenz- und Wurzelrechnung|1")
self.assertEqual(items["klammerrechnung"]["level"], "subsubsection")
self.assertEqual(items["klammerrechnung"]["index"], "1-2-2-1")
self.assertEqual(items["klammerrechnung"]["refs"], [[1, 2, 2, 1]])
self.assertEqual(items["klammerrechnung"]["inital_message"], "Klammerrechnung|1")
self.assertNotIn("grundrechenarten bei reellen zahlen", items)
def test_existing_message_reuse_matches_leaf_key(self) -> None:
_write_markdown(
self.source_root / "sections/section-leaf.md",
'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"',
"Section leaf body",
)
_write_markdown(
self.source_root / "childs/section-child.md",
'chapter_index: 1\nsection_index: 1\nsubsection_index: 0\nsubsubsection_index: 0\nchild_index: 1\ntitle: "Mengen Child"',
"Section child body",
)
self.output_path.write_text(
"items:\n mengen:\n inital_message: Bereits vorhanden\n",
encoding="utf-8",
)
with patch(
"scripts.generate_socratic_chats.task_catalog.load_topic_map",
return_value={"mengen": (1, 1, 0, 0)},
), patch(
"scripts.generate_socratic_chats.generate_initial_message",
side_effect=AssertionError("should not regenerate"),
):
payload = generate_socratic_chats.build_initial_prompt_index(
source_root=self.source_root,
output_path=self.output_path,
template_path=self.template_path,
)
self.assertEqual(payload["items"]["mengen"]["inital_message"], "Bereits vorhanden")
if __name__ == "__main__":
unittest.main()
......@@ -120,16 +120,18 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items",
return_value={
"quadratische gleichungen": {
"subsection": "Quadratische Gleichungen",
"index": "1-3-3",
"label": "Quadratische Gleichungen",
"level": "subsection",
"refs": [[1, 3, 3, 0]],
},
"mengen": {
"subsection": "Mengen",
"index": "1-1-1",
"label": "Mengen",
"level": "section",
"refs": [[1, 1, 0, 0]],
},
},
), patch(
"app.deterministic_services.socratic_oranisator.task_catalog.load_subsection_summaries",
"app.deterministic_services.socratic_oranisator.task_catalog.load_topic_summaries",
return_value={
"mengen": "Mengen summary text",
"quadratische gleichungen": "Quadratische summary text",
......@@ -143,13 +145,15 @@ class TaskCatalogSocraticTest(unittest.TestCase):
{
"subsection_key": "mengen",
"label": "Mengen",
"refs": [[1, 1, 1]],
"level": "section",
"refs": [[1, 1, 0, 0]],
"summary": "Mengen summary text",
},
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
"level": "subsection",
"refs": [[1, 3, 3, 0]],
"summary": "Quadratische summary text",
},
],
......@@ -177,13 +181,15 @@ class TaskCatalogSocraticTest(unittest.TestCase):
{
"subsection_key": "mengen",
"label": "Mengen",
"refs": [[1, 1, 1]],
"level": "section",
"refs": [[1, 1, 0, 0]],
"summary": "Mengen summary text",
},
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
"level": "subsection",
"refs": [[1, 3, 3, 0]],
"summary": "Quadratische summary text",
},
],
......@@ -289,8 +295,8 @@ Zweite Zeile.
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
"app.deterministic_services.task_catalog.load_topic_map",
return_value={"quadratische gleichungen": (1, 3, 3, 1)},
):
updated = task_catalog.select_subsection_by_ids(
sheet,
......@@ -301,20 +307,31 @@ Zweite Zeile.
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["selected_subsection_refs"], [])
def test_select_subsection_by_key_sets_sheet_fields(self) -> None:
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
"app.deterministic_services.task_catalog.load_topic_map",
return_value={"quadratische gleichungen": (1, 3, 3, 1)},
):
updated = task_catalog.select_subsection_by_key(sheet, "quadratische_gleichungen")
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["selected_subsection_refs"], [])
def test_get_selected_subsection_parent_refs_reads_normalized_refs(self) -> None:
sheet: dict[str, object] = {
"selected_subsection_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
}
refs = task_catalog.get_selected_subsection_parent_refs(sheet)
self.assertEqual(refs, [(1, 1, 0, 0), (1, 3, 2, 1)])
def test_set_selected_task_stores_generic_parent_refs(self) -> None:
sheet: dict[str, object] = {}
......@@ -428,7 +445,8 @@ class TaskApiSocraticTest(unittest.TestCase):
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
"level": "subsubsection",
"refs": [[1, 3, 3, 1]],
"summary": "Quadratische summary text",
}
]
......@@ -498,10 +516,10 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
), patch(
"app.api.chat.context_store.set_initialized"
), patch(
"app.api.chat.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
"app.api.chat.task_catalog.load_topic_map",
return_value={"quadratische gleichungen": (1, 3, 3, 1)},
), patch(
"app.api.chat.retrieval_store.retrieve_for_subsections",
"app.api.chat.retrieval_store.retrieve_for_parent_refs",
return_value=[],
) as retrieve_mock, patch(
"app.api.chat.socratic_oranisator.get_initial_message",
......@@ -517,8 +535,9 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["reply"], "Was sind die Themen dieses Abschnitts?")
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", subsection_refs=[(1, 3, 3)])
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", parent_refs=[(1, 3, 3, 1)])
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["history"], [{"role": "assistant", "content": "Was sind die Themen dieses Abschnitts?"}])
......
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