Commit aa1b2cd9 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev_external_task_selection' into 'main'

Dev external task selection

See merge request kantz/tutor_react!6
parents e31efbb9 a678fa77
...@@ -2,10 +2,10 @@ from __future__ import annotations ...@@ -2,10 +2,10 @@ from __future__ import annotations
import json import json
import os import os
from collections import deque
from datetime import datetime from datetime import datetime
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from collections import deque
from app.deterministic_services import context_store from app.deterministic_services import context_store
...@@ -18,6 +18,14 @@ def _utc_now() -> str: ...@@ -18,6 +18,14 @@ def _utc_now() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
if not file_id or not task_id:
return None
return {"file_id": file_id, "task_id": task_id}
def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str: def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str:
chat_id = context_store.get_chat_id(messages, draft=draft) chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id) sheet = context_store.load_sheet(chat_id)
...@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st ...@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
"retrieval_contexts": sheet.get("retrieval_contexts", []), "retrieval_contexts": sheet.get("retrieval_contexts", []),
"math_solutions": sheet.get("math_solutions", []), "math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []), "sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet),
} }
os.makedirs(_LOG_DIR, exist_ok=True) os.makedirs(_LOG_DIR, exist_ok=True)
...@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None: ...@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
continue continue
if record.get("chat_id") == chat_id: if record.get("chat_id") == chat_id:
history = [ history = [
{"role": entry.get("role", "user"), {"role": entry.get("role", "user"), "text": entry.get("content", "")}
"text": entry.get("content", "")}
for entry in record.get("history", []) for entry in record.get("history", [])
] ]
selected_task_raw = record.get("selected_task")
selected_task: dict[str, str] | None = None
if isinstance(selected_task_raw, dict):
file_id = str(selected_task_raw.get("file_id", "")).strip()
task_id = str(selected_task_raw.get("task_id", "")).strip()
if file_id and task_id:
selected_task = {"file_id": file_id, "task_id": task_id}
return { return {
"chat_id": record.get("chat_id", chat_id), "chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""), "saved_at": record.get("saved_at", ""),
"history": history, "history": history,
"selected_task": selected_task,
} }
return None return None
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from app.deterministic_services import context_store
TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "tasks"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json"
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
def _tokenize(value: str) -> set[str]:
return set(re.findall(r"[a-z0-9_]+", _normalize_text(value)))
def _normalize_subsection_key(value: str) -> str:
collapsed = re.sub(r"[-_]+", " ", value.strip().lower())
return re.sub(r"\s+", " ", collapsed)
def _parse_subsection_ref(value: str) -> tuple[int, int, int] | None:
match = re.match(r"^\s*(\d+)\s*[:.]\s*(\d+)\s*[:.]\s*(\d+)\s*$", str(value))
if not match:
return 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]]:
if not path.exists():
return {}
try:
content = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(content, dict):
return {}
mapped: dict[str, tuple[int, int, int]] = {}
for raw_key, raw_ref in content.items():
key = _normalize_subsection_key(str(raw_key))
parsed = _parse_subsection_ref(str(raw_ref))
if not key or parsed is None:
continue
mapped[key] = parsed
return mapped
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:
continue
ref = mapping.get(key)
if ref is not None:
refs.add((int(ref[0]), int(ref[1]), int(ref[2])))
return sorted(refs)
def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text)
if not query_tokens:
return 0
candidate_tokens = _tokenize(candidate_text)
return len(query_tokens.intersection(candidate_tokens))
def load_task_files(tasks_dir: Path = TASKS_DIR) -> list[dict[str, Any]]:
if not tasks_dir.exists():
return []
loaded: list[dict[str, Any]] = []
for path in sorted(tasks_dir.glob("*.json")):
try:
content = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(content, dict):
continue
if not isinstance(content.get("tasks"), list):
continue
content["_path"] = str(path)
content["_file_id"] = path.stem
loaded.append(content)
return loaded
def _find_task_file(task_files: list[dict[str, Any]], file_id: str) -> dict[str, Any] | None:
for task_file in task_files:
if str(task_file.get("_file_id", "")) == file_id:
return task_file
return None
def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any] | None:
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
if str(item.get("id", "")).zfill(2) == str(task_id).zfill(2):
return item
return None
def _build_task_payload(task_file: dict[str, Any], task_entry: dict[str, Any]) -> tuple[str, list[str], str]:
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip()
hints_raw = task_entry.get("hints", [])
hints = [str(item).strip() for item in hints_raw if str(item).strip()] if isinstance(hints_raw, list) else []
solution = str(task_entry.get("solution", "")).strip()
task_parts = [part for part in [title, intro, statement] if part]
task_text = "\n".join(task_parts)
return task_text, hints, solution
def set_selected_task(
sheet: dict[str, Any],
task_file: dict[str, Any],
task_entry: dict[str, Any],
) -> None:
task_text, hints, solution = _build_task_payload(task_file, task_entry)
store_new = context_store.context_store_new
store_new.set_task(sheet, task_text)
store_new.set_hints(sheet, hints)
store_new.set_solution(sheet, solution)
sheet["task_file_id"] = str(task_file.get("_file_id", ""))
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
refs = _resolve_task_subsection_refs(task_file)
sheet["task_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in refs]
def select_task_by_ids(
sheet: dict[str, Any],
file_id: str,
task_id: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
task_entry = _find_task_entry(task_file, task_id)
if not task_entry:
return False
set_selected_task(sheet, task_file, task_entry)
return True
def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
return (file_id or None, task_id or None)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("task_subsection_refs", [])
if not isinstance(refs_raw, list):
return []
refs: set[tuple[int, int, int]] = 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
return sorted(refs)
def select_task_for_context(
sheet: dict[str, Any],
query_text: str,
sources_text: str,
history: list[dict],
task_files: list[dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
catalog = task_files if task_files is not None else load_task_files()
if not catalog:
return None
selected_file_id, selected_task_id = get_selected_task_ids(sheet)
if selected_file_id and selected_task_id:
task_file = _find_task_file(catalog, selected_file_id)
if task_file:
task_entry = _find_task_entry(task_file, selected_task_id)
if task_entry:
set_selected_task(sheet, task_file, task_entry)
return task_file, task_entry
haystack = "\n".join([query_text, sources_text])
best_file: dict[str, Any] | None = None
best_file_score = -1
for task_file in catalog:
title = str(task_file.get("title", ""))
intro = str(task_file.get("intro", ""))
statements = "\n".join(
[str(task.get("statement", "")) for task in task_file.get("tasks", []) if isinstance(task, dict)]
)
score = _match_score(haystack, "\n".join([title, intro, statements]))
if score > best_file_score:
best_file_score = score
best_file = task_file
if not best_file:
return None
tasks = [item for item in best_file.get("tasks", []) if isinstance(item, dict)]
if not tasks:
return None
history_text = "\n".join([str(item.get("content", "")) for item in history if isinstance(item, dict)])
id_match = re.search(r"\b(?:aufgabe|task)?\s*0?([1-9][0-9]?)\b", history_text.lower())
if id_match:
wanted = id_match.group(1).zfill(2)
for item in tasks:
if str(item.get("id", "")).zfill(2) == wanted:
set_selected_task(sheet, best_file, item)
return best_file, item
best_task = tasks[0]
best_task_score = -1
for item in tasks:
statement = str(item.get("statement", ""))
score = _match_score(history_text, statement)
if score > best_task_score:
best_task_score = score
best_task = item
set_selected_task(sheet, best_file, best_task)
return best_file, best_task
def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
catalog = task_files if task_files is not None else load_task_files()
catalog = sorted(
catalog,
key=lambda item: (
str(item.get("title", "")).strip().lower(),
str(item.get("_file_id", "")).strip().lower(),
),
)
response: list[dict[str, Any]] = []
for task_file in catalog:
file_id = str(task_file.get("_file_id", ""))
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
task_id = str(item.get("id", "")).zfill(2)
statement = str(item.get("statement", "")).strip()
full_text_parts = [part for part in [title, intro, statement] if part]
tasks.append(
{
"task_id": task_id,
"statement": statement,
"full_text": "\n".join(full_text_parts),
}
)
tasks.sort(key=lambda item: item["task_id"])
response.append(
{
"file_id": file_id,
"title": title,
"intro": intro,
"tasks": tasks,
}
)
return response
...@@ -10,9 +10,12 @@ from psycopg.rows import dict_row ...@@ -10,9 +10,12 @@ from psycopg.rows import dict_row
from app.deterministic_services.vector_store import ( from app.deterministic_services.vector_store import (
EmbeddingLike, EmbeddingLike,
Source, Source,
SubsectionRef,
_retrivla_to_sources, _retrivla_to_sources,
_row_to_retrieved, _row_to_retrieved,
embed_query, embed_query,
load_children_for_subsections,
merge_sources,
) )
...@@ -21,6 +24,7 @@ def retrieve( ...@@ -21,6 +24,7 @@ def retrieve(
embedder: EmbeddingLike, embedder: EmbeddingLike,
query: str, query: str,
k: int = 4, k: int = 4,
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,
source_type_filter: Optional[List[str]] = None, source_type_filter: Optional[List[str]] = None,
...@@ -40,6 +44,10 @@ def retrieve( ...@@ -40,6 +44,10 @@ def retrieve(
"sub_doc_types": ["subsection", "chapter"], "sub_doc_types": ["subsection", "chapter"],
} }
if chapter_index is not None:
where.append("chapter_index = %(chapter_index)s")
params["chapter_index"] = chapter_index
if section_index is not None: if section_index is not None:
where.append("section_index = %(section_index)s") where.append("section_index = %(section_index)s")
params["section_index"] = section_index params["section_index"] = section_index
...@@ -56,8 +64,8 @@ def retrieve( ...@@ -56,8 +64,8 @@ def retrieve(
sql = f""" sql = f"""
SELECT SELECT
uid, doc_type, uid, doc_type,
section_index, subsection_index, child_index, chapter_index, section_index, subsection_index, child_index,
section_title, subsection_title, title, source_type, chapter_title, section_title, subsection_title, title, source_type,
path, markdown, path, markdown,
1 - (embedding <=> %(qvec)s) AS score 1 - (embedding <=> %(qvec)s) AS score
FROM docs FROM docs
...@@ -75,3 +83,37 @@ def retrieve( ...@@ -75,3 +83,37 @@ def retrieve(
subsections = [_row_to_retrieved( subsections = [_row_to_retrieved(
row, source_type="subsection") for row in rows] row, source_type="subsection") for row in rows]
return _retrivla_to_sources({"subsections_direct": subsections}) return _retrivla_to_sources({"subsections_direct": subsections})
def retrieve_with_subsections(
pg_url: str,
embedder: EmbeddingLike,
query: str,
subsection_refs: Optional[List[SubsectionRef]] = None,
k: int = 4,
chapter_index: Optional[int] = None,
section_index: Optional[int] = None,
subsection_index: Optional[int] = None,
source_type_filter: Optional[List[str]] = None,
expand_links: bool = False,
neighbor_expand: int = 0,
) -> List[Source]:
vector_k = max(k * 4, k + 16)
vector_sources = retrieve(
pg_url=pg_url,
embedder=embedder,
query=query,
k=vector_k,
chapter_index=chapter_index,
section_index=section_index,
subsection_index=subsection_index,
source_type_filter=source_type_filter,
expand_links=expand_links,
neighbor_expand=neighbor_expand,
)
vector_sources = vector_sources[:k]
subsection_children = load_children_for_subsections(
pg_url=pg_url,
subsection_refs=subsection_refs,
)
return merge_sources(vector_sources, subsection_children)
...@@ -3,7 +3,7 @@ import logging ...@@ -3,7 +3,7 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, context from app.api import canvas, chat, health, context, tasks
from app.config import get_frontend_url from app.config import get_frontend_url
from app.deterministic_services import embedding_provider from app.deterministic_services import embedding_provider
...@@ -36,3 +36,4 @@ app.include_router(chat.router) ...@@ -36,3 +36,4 @@ app.include_router(chat.router)
app.include_router(canvas.router) app.include_router(canvas.router)
app.include_router(context.router) app.include_router(context.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(tasks.router)
...@@ -12,7 +12,7 @@ pgvector ...@@ -12,7 +12,7 @@ pgvector
pyyaml pyyaml
sentence-transformers sentence-transformers
transformers transformers==4.57.6
torch torch
peft peft
torchvision torchvision
...@@ -5,6 +5,44 @@ from app.deterministic_services.embeddings import EmbeddingFactory ...@@ -5,6 +5,44 @@ from app.deterministic_services.embeddings import EmbeddingFactory
from app.deterministic_services import retrieval_store from app.deterministic_services import retrieval_store
def _parse_subsections(
raw_values: list[str] | None,
chapter_index: int | None,
section_index: int | None,
) -> list[tuple[int, int, int]]:
if not raw_values:
return []
refs: set[tuple[int, int, int]] = set()
for raw in raw_values:
token = str(raw).strip()
if not token:
continue
parts = token.split(":")
if len(parts) == 3:
chap_raw, sec_raw, sub_raw = parts
refs.add((int(chap_raw), int(sec_raw), int(sub_raw)))
continue
if len(parts) == 2:
if chapter_index is None:
raise ValueError(
"Subsection ohne Chapter ist ungueltig. Nutze '<chap>:<sec>:<sub>' oder --chapter-index."
)
sec_raw, sub_raw = parts
refs.add((int(chapter_index), int(sec_raw), int(sub_raw)))
continue
if section_index is None or chapter_index is None:
raise ValueError(
"Subsection ohne Chapter/Section ist ungueltig. Nutze '<chap>:<sec>:<sub>' oder --chapter-index und --section-index."
)
refs.add((int(chapter_index), int(section_index), int(token)))
return sorted(refs)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Isolierter Vector-Store Retrieval Test.") description="Isolierter Vector-Store Retrieval Test.")
...@@ -13,8 +51,15 @@ def main() -> None: ...@@ -13,8 +51,15 @@ def main() -> None:
help="Postgres URL (oder set POSTGRES_URL)") help="Postgres URL (oder set POSTGRES_URL)")
parser.add_argument("--k", type=int, default=8) parser.add_argument("--k", type=int, default=8)
parser.add_argument("--expand", action="store_true") parser.add_argument("--expand", action="store_true")
parser.add_argument("--chapter-index", type=int, default=None)
parser.add_argument("--section-index", type=int, default=None) parser.add_argument("--section-index", type=int, default=None)
parser.add_argument("--subsection-index", type=int, default=None) parser.add_argument("--subsection-index", type=int, default=None)
parser.add_argument(
"--subsections",
nargs="*",
default=None,
help="Liste von Subsections: '<chap>:<sec>:<sub>'; alternativ '<sec>:<sub>' mit --chapter-index oder '<sub>' mit --chapter-index und --section-index",
)
parser.add_argument("--source-type-filter", nargs="*", default=None) parser.add_argument("--source-type-filter", nargs="*", default=None)
parser.add_argument("--neighbor-expand", type=int, default=0) parser.add_argument("--neighbor-expand", type=int, default=0)
...@@ -23,17 +68,36 @@ def main() -> None: ...@@ -23,17 +68,36 @@ def main() -> None:
pg_url = args.pg or config.get_postgres_url() pg_url = args.pg or config.get_postgres_url()
embedder = EmbeddingFactory.create(config.get_embedding_settings()) embedder = EmbeddingFactory.create(config.get_embedding_settings())
sources = retrieval_store.retrieve( subsection_refs = _parse_subsections(
pg_url=pg_url, args.subsections, args.chapter_index, args.section_index
embedder=embedder,
query=args.query,
k=args.k,
expand_links=args.expand,
section_index=args.section_index,
subsection_index=args.subsection_index,
source_type_filter=args.source_type_filter,
neighbor_expand=args.neighbor_expand,
) )
if subsection_refs:
sources = retrieval_store.retrieve_with_subsections(
pg_url=pg_url,
embedder=embedder,
query=args.query,
subsection_refs=subsection_refs,
k=args.k,
expand_links=args.expand,
chapter_index=args.chapter_index,
section_index=args.section_index,
subsection_index=args.subsection_index,
source_type_filter=args.source_type_filter,
neighbor_expand=args.neighbor_expand,
)
else:
sources = retrieval_store.retrieve(
pg_url=pg_url,
embedder=embedder,
query=args.query,
k=args.k,
expand_links=args.expand,
chapter_index=args.chapter_index,
section_index=args.section_index,
subsection_index=args.subsection_index,
source_type_filter=args.source_type_filter,
neighbor_expand=args.neighbor_expand,
)
if not sources: if not sources:
print("Keine Quellen gefunden.") print("Keine Quellen gefunden.")
...@@ -41,7 +105,9 @@ def main() -> None: ...@@ -41,7 +105,9 @@ def main() -> None:
print(f"Gefundene Quellen: {len(sources)}") print(f"Gefundene Quellen: {len(sources)}")
for source in sources: for source in sources:
print(f"[{source.score:.4f}] {source.source_id.title} ({source.source_type})") print(
f"[{source.score:.4f}] {source.source_id.title} ({source.source_type}) [{source.retrieved_as}]"
)
if __name__ == "__main__": if __name__ == "__main__":
......
from __future__ import annotations
import unittest
from app.deterministic_services.vector_store import (
Retrieved,
Source,
SourceID,
build_default_pipeline_config,
expand_neighbor_children,
merge_retrieval_groups,
merge_sources,
select_dominant_scope,
)
def _mk_retrieved(
uid: str,
score: float,
chapter_index: int,
section_index: int,
subsection_index: int | None,
child_index: int = 1,
) -> Retrieved:
return Retrieved(
uid=uid,
doc_type="child",
score=score,
metadata={
"chapter_index": chapter_index,
"section_index": section_index,
"subsection_index": subsection_index,
"child_index": child_index,
"chapter_title": "C",
"section_title": "S",
"subsection_title": "SS",
"title": uid,
"source_type": "child",
"path": "",
"doc_type": "child",
"uid": uid,
},
markdown=f"md-{uid}",
)
class VectorStorePipelineUnitTest(unittest.TestCase):
def test_merge_sources_prefers_task_childs_on_duplicate(self) -> None:
child_direct = Source(
source_id=SourceID(
chapter_title="Kapitel",
section_title="Section",
subsection_title="Subsection",
title="Child A",
doc_type="child",
),
retrieved_as="children_direct",
source_type="child",
score=0.7,
markdown="same-md",
)
task_child = Source(
source_id=SourceID(
chapter_title="Kapitel",
section_title="Section",
subsection_title="Subsection",
title="Child A",
doc_type="child",
),
retrieved_as="task_childs",
source_type="child",
score=1.0,
markdown="same-md",
)
merged = merge_sources([child_direct], [task_child])
self.assertEqual(len(merged), 1)
self.assertEqual(merged[0].retrieved_as, "task_childs")
def test_build_default_pipeline_config(self) -> None:
cfg = build_default_pipeline_config(k=4, expand_links=True, neighbor_expand=2)
self.assertEqual(cfg.vector_k, 20)
self.assertEqual(cfg.scope_fill_k, 5)
self.assertTrue(cfg.enable_global_search)
self.assertTrue(cfg.enable_dominant_scope)
self.assertTrue(cfg.enable_scoped_child_search)
self.assertTrue(cfg.enable_context_docs)
def test_select_dominant_scope_prefers_count(self) -> None:
children = [
_mk_retrieved("a", 0.9, 1, 1, 1),
_mk_retrieved("b", 0.8, 1, 1, 1),
_mk_retrieved("c", 0.95, 1, 1, 2),
]
scope = select_dominant_scope(children, level="subsection")
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 1))
def test_select_dominant_scope_tiebreak_avg_score(self) -> None:
children = [
_mk_retrieved("a", 0.7, 1, 1, 1),
_mk_retrieved("b", 0.8, 1, 1, 2),
]
scope = select_dominant_scope(children, level="subsection")
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 2))
def test_select_dominant_scope_tiebreak_lexicographic(self) -> None:
children = [
_mk_retrieved("a", 0.8, 2, 1, 1),
_mk_retrieved("b", 0.8, 1, 2, 3),
]
scope = select_dominant_scope(children, level="subsection")
self.assertIsNotNone(scope)
assert scope is not None
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 2, 3))
def test_merge_retrieval_groups_dedup_max_score_and_trim_children(self) -> None:
groups = {
"children_direct": [
_mk_retrieved("u1", 0.5, 1, 1, 1),
_mk_retrieved("u2", 0.7, 1, 1, 1),
],
"children_expanded": [
_mk_retrieved("u1", 0.9, 1, 1, 1),
_mk_retrieved("u3", 0.6, 1, 1, 1),
],
"chapters": [],
"subsections": [],
"sections": [],
"neighbors": [_mk_retrieved("u2", 0.1, 1, 1, 1)],
}
merged = merge_retrieval_groups(groups, k=2)
self.assertEqual(sorted(item.uid for item in merged["children_direct"]), ["u1", "u2"])
score_u1 = [item.score for item in merged["children_direct"] if item.uid == "u1"][0]
self.assertEqual(score_u1, 0.9)
self.assertEqual(merged["children_expanded"], [])
self.assertEqual(merged["neighbors"], [])
def test_expand_neighbor_children_returns_empty_for_zero_expand(self) -> None:
children = [_mk_retrieved("u1", 0.8, 1, 1, 1, child_index=3)]
result = expand_neighbor_children("postgresql://unused", children, neighbor_expand=0)
self.assertEqual(result, [])
if __name__ == "__main__":
unittest.main()
services:
backend:
build:
context: ../backend
dockerfile: Dockerfile
env_file:
- ../backend/.env
expose:
- "8000"
restart: unless-stopped
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
depends_on:
- backend
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
VITE_BACKEND_URL="" VITE_FRONTEND_LANG="de"
\ No newline at end of file
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
...@@ -11,7 +11,8 @@ ...@@ -11,7 +11,8 @@
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
...@@ -1234,13 +1235,13 @@ ...@@ -1234,13 +1235,13 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
...@@ -1356,9 +1357,9 @@ ...@@ -1356,9 +1357,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
...@@ -1610,6 +1611,19 @@ ...@@ -1610,6 +1611,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
...@@ -3282,9 +3296,9 @@ ...@@ -3282,9 +3296,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
...@@ -3536,6 +3550,7 @@ ...@@ -3536,6 +3550,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
...@@ -3580,6 +3595,44 @@ ...@@ -3580,6 +3595,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/remark-parse": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
...@@ -3679,6 +3732,12 @@ ...@@ -3679,6 +3732,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
......
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"react-router-dom": "^7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
......
export type TaskItem = {
task_id: string;
statement: string;
full_text: string;
};
export type TaskFile = {
file_id: string;
title: string;
intro: string;
tasks: TaskItem[];
};
export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
};
export type SelectedTaskRef = {
fileId: string;
taskId: string;
};
export type SelectTaskResponse = {
status: string;
file_id: string;
task_id: string;
};
export async function fetchTasks(): Promise<TasksResponse> {
const response = await fetch("/api/tasks");
if (!response.ok) {
throw new Error(`Tasks failed: ${response.status}`);
}
return response.json();
}
export async function selectTask(input: {
draft: string;
fileId: string;
taskId: string;
}): Promise<SelectTaskResponse> {
const response = await fetch("/api/tasks/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: input.draft,
file_id: input.fileId,
task_id: input.taskId,
}),
});
if (!response.ok) {
throw new Error(`Task selection failed: ${response.status}`);
}
return response.json();
}
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import CanvasToggle from "./CanvasToggle"; import CanvasToggle from "./CanvasToggle";
import { t } from "../../i18n";
type CanvasDrawerProps = { type CanvasDrawerProps = {
isVisible: boolean; isVisible: boolean;
...@@ -37,8 +38,8 @@ export default function CanvasDrawer({ ...@@ -37,8 +38,8 @@ export default function CanvasDrawer({
} }
const ratio = window.devicePixelRatio || 1; const ratio = window.devicePixelRatio || 1;
const width = 720; const width = Math.min(560, window.innerWidth - 80);
const height = 320; const height = 150;
canvas.width = width * ratio; canvas.width = width * ratio;
canvas.height = height * ratio; canvas.height = height * ratio;
...@@ -134,7 +135,20 @@ export default function CanvasDrawer({ ...@@ -134,7 +135,20 @@ export default function CanvasDrawer({
return ( return (
<div className="canvas-zone"> <div className="canvas-zone">
<div className="canvas-header"> <div className="canvas-header">
<div className="canvas-title">Canvas</div> <div className="canvas-title">{t("canvas")}</div>
{isVisible ? (
<div className="canvas-actions canvas-actions-header">
<button className="btn" type="button" onClick={handleToggleEraser}>
{isErasing ? t("pen") : t("eraser")}
</button>
<button className="btn" type="button" onClick={handleClear}>
{t("clear")}
</button>
<button className="btn primary" type="button" onClick={handleSave}>
{t("saveAndConvert")}
</button>
</div>
) : null}
<CanvasToggle isVisible={isVisible} onToggle={onToggle} /> <CanvasToggle isVisible={isVisible} onToggle={onToggle} />
</div> </div>
{isVisible ? ( {isVisible ? (
...@@ -156,20 +170,9 @@ export default function CanvasDrawer({ ...@@ -156,20 +170,9 @@ export default function CanvasDrawer({
onPointerLeave={handlePointerUp} onPointerLeave={handlePointerUp}
/> />
</div> </div>
<div className="canvas-actions">
<button className="btn" type="button" onClick={handleToggleEraser}>
{isErasing ? "Pen" : "Eraser"}
</button>
<button className="btn" type="button" onClick={handleClear}>
Clear
</button>
<button className="btn primary" type="button" onClick={handleSave}>
Save + Convert
</button>
</div>
</div> </div>
) : ( ) : (
<div className="canvas-hidden">Canvas hidden</div> <div className="canvas-hidden">{t("canvasHidden")}</div>
)} )}
</div> </div>
); );
......
import { t } from "../../i18n";
type CanvasToggleProps = { type CanvasToggleProps = {
isVisible: boolean; isVisible: boolean;
onToggle: () => void; onToggle: () => void;
...@@ -9,7 +11,7 @@ export default function CanvasToggle({ ...@@ -9,7 +11,7 @@ export default function CanvasToggle({
}: CanvasToggleProps) { }: CanvasToggleProps) {
return ( return (
<button className="btn" type="button" onClick={onToggle}> <button className="btn" type="button" onClick={onToggle}>
{isVisible ? "Hide" : "Show"} {isVisible ? t("hide") : t("show")}
</button> </button>
); );
} }
...@@ -2,6 +2,7 @@ import MessageInput from "./MessageInput"; ...@@ -2,6 +2,7 @@ import MessageInput from "./MessageInput";
import MessageList from "./MessageList"; import MessageList from "./MessageList";
import type { ChatMessage } from "./MessageList"; import type { ChatMessage } from "./MessageList";
import type { RetrievedDoc } from "../Retrieval/DocPanel"; import type { RetrievedDoc } from "../Retrieval/DocPanel";
import { t } from "../../i18n";
type ChatWindowProps = { type ChatWindowProps = {
messages: ChatMessage[]; messages: ChatMessage[];
...@@ -26,7 +27,7 @@ export default function ChatWindow({ ...@@ -26,7 +27,7 @@ export default function ChatWindow({
}: ChatWindowProps) { }: ChatWindowProps) {
return ( return (
<div className="chat-window"> <div className="chat-window">
<div className="chat-title">Chat</div> <div className="chat-title">{t("chat")}</div>
<MessageList <MessageList
messages={messages} messages={messages}
onInspectDoc={onInspectDoc} onInspectDoc={onInspectDoc}
......
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import type { RetrievedDoc } from "../Retrieval/DocPanel"; import type { RetrievedDoc } from "../Retrieval/DocPanel";
import { t } from "../../i18n";
type MessageBubbleProps = { type MessageBubbleProps = {
role: "user" | "assistant"; role: "user" | "assistant";
...@@ -48,7 +49,9 @@ export default function MessageBubble({ ...@@ -48,7 +49,9 @@ export default function MessageBubble({
return ( return (
<div className={`message-bubble ${role}`} ref={bubbleRef}> <div className={`message-bubble ${role}`} ref={bubbleRef}>
<div className="message-role">{role}</div> <div className="message-role">
{role === "user" ? t("roleUser") : t("roleAssistant")}
</div>
<div className="message-text"> <div className="message-text">
<ReactMarkdown <ReactMarkdown
components={{ components={{
......
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