Commit 069b5f3c authored by Kantz's avatar Kantz
Browse files

Merge branch 'old_retrival' into 'main'

Old retrival

See merge request kantz/tutor_react!2
parents b903391a 8c452e91
......@@ -39,7 +39,7 @@ Ingest markdown docs (expects `markdown/sections`, `markdown/subsections`, `mark
```powershell
cd math-tutor/backend
.\.venv\Scripts\Activate.ps1
python .\scripts\retrieval_cli.py ingest --base markdown
python .\scripts\retrieval_cli.py ingest --base markdown --clear
```
Query via CLI:
......@@ -61,3 +61,7 @@ Retrieval settings:
- `neighbor_expand`
- Backend defaults are in `math-tutor/backend/app/api/retrieval.py` (`QueryRequest`).
- Core retrieval logic is in `math-tutor/backend/app/services/vector_store.py` (`retrieve`).
## Testing
python -m test.hint_test ...
python -m test.vector_store_test --query "Was ist eine Teilmenge?" --k 8 --expand
MATHPIX_APP_ID=""
MATHPIX_APP_KEY=""
OPENAI_BASE_URL=""
OPENAI_API_KEY=""
OPENAI_CHAT_MODEL=""
OPENAI_CHAT_TEMPERATURE=""
OPENAI_EMBED_MODEL=""
POSTGRES_URL=""
OLLAMA_URL=""
OLLAMA_MODEL= ""
OLLAMA_TEMPERATURE=""
FRONTEND_URL=""
\ No newline at end of file
......@@ -13,16 +13,16 @@ CLASSIFIER_PROMPT = (
def needs_more_context(history: str, context_sheet: str) -> dict:
prompt = (
"Historie:\n"
+ history
+ "\n\nKontextblatt:\n"
"Kontextblatt:\n"
+ context_sheet
+ "\n\nHistorie:\n"
+ history
+ "\n\nAntwortformat: JSON."
)
result = llm_client.chat(
messages=[
{"role": "system", "content": CLASSIFIER_PROMPT},
{"role": "user", "content": prompt},
{"role": "system", "content": CLASSIFIER_PROMPT},
],
use_ollama=True,
)
......@@ -31,7 +31,7 @@ def needs_more_context(history: str, context_sheet: str) -> dict:
payload = json.loads(content)
return {
"needs_more_context": bool(payload.get("needs_more_context")),
"reason": payload.get("reason", ""),
"reason": content,
}
except json.JSONDecodeError:
return {"needs_more_context": True, "reason": "classifier_parse_error"}
return {"needs_more_context": True, "reason": content}
......@@ -3,60 +3,35 @@ from app.deterministic_services import llm_client
def generate_hint(
task: str,
solution: str,
LLM_solution: str,
math_solution: str | None = None,
history: str | None = None,
retrival: str | None = None,
sources: str | None = None,
) -> str:
prompt = (
system_prompt = (
"Du bist ein didaktischer Tutor. "
"Schaue dir Aufgabe, die dazugehörige Lösung und die bisherige Historie an."
"Entscheide basierend darauf was der nächste Schritt ist den der Nutzer machen muss um zur Lösung zu kommne"
"Schreibe einen kurzen und klaren Tipp zum nächsten Schritt"
"Gebe dem Nutzer hilfreiche Tipps um mathematische Aufgaben zu lösen. "
"Antworte nur mit dem Tipp, ohne weitere Erklärungen."
"Gibe keine komplette Lösung der Aufgabe."
"Die Mathematische Lösung hat immer Vorrang vor der LLM Lösung."
"Halte dich kurz und prägnant."
)
prompt = (
"Aufgabe:\n"
+ task
+ "\n\n"
"Loesung (vom Mathe-Tool):\n"
+ solution
"Loesung (von einem LLM):\n"
+ LLM_solution
+ "\n"
)
if math_solution:
prompt += "Mathematische Loesung:\n" + math_solution + "\n"
if history:
prompt = "\nHistorie:\n" + history + "\n" + prompt
if retrival:
prompt = "\nKontext:\n" + retrival + "\n" + prompt
prompt += "Halte dich kurz und klar. Gibt nicht die Lösung aus."
prompt += "\nHistorie:\n" + history + "\n"
if sources:
prompt = "\nKontext:\n" + sources + "\n" + prompt
prompt += "\nGebe einen hilfreichen Tipp zur Lösung der Aufgabe. Halte dich kurz und prägnant."
result = llm_client.chat(
messages=[{"role": "user", "content": prompt}],
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}],
)
return llm_client.get_message_content(result)
\ No newline at end of file
TOOL_SPEC = {
"type": "function",
"function": {
"name": "generate_hint",
"description": (
"Gibt einen didaktisch wertvollen naechsten Hinweis "
"auf Basis der Aufgabe und der berechneten Loesung."
),
"parameters": {
"type": "object",
"properties": {
"task": {"type": "string", "description": "Die gegebene Aufgabe"},
"solution": {
"type": "string",
"description": "Loesung aus dem Mathe-Tool",
},
"history": {
"type": "string",
"description": "Optionaler Verlauf, kann leer sein",
},
"retrival": {
"type": "string",
"description": "Optionales Kontextblatt mit Werkzeug- und Retrieval-Infos",
},
},
"required": ["task", "solution"],
},
},
}
from app.deterministic_services import llm_client
SOLVER_PROMPT = (
"Du bist ein Mathe-Aufgaben Löser. Schreibe ein ausführliche genaue Lösung für die gestellt Fragen. "
"In der du alle Zwischenschritte aufführst und möglich Fehlerquellen auflistet."
"Deine Lösung soll korrekt sein und didaktisch gut formuliert."
"Benutzt die Begriffsbezeichungen aus dem Kontext"
)
def solve_question(question: str, context_sheet: str) -> str:
prompt = (
"Frage:\n"
+ question
+ "\nKontex:\n"
+ context_sheet
)
result = llm_client.chat(
messages=[
{"role": "system", "content": SOLVER_PROMPT},
{"role": "user", "content": prompt},
],
)
solution = llm_client.get_message_content(result)
return solution
\ No newline at end of file
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.deterministic_services import context_store
from app.deterministic_services import Source
router = APIRouter()
class ChatMessage(BaseModel):
role: str = Field(..., pattern="^(user|assistant)$")
text: str = Field(..., min_length=1)
class ContextRequest(BaseModel):
messages: List[ChatMessage]
draft: Optional[str] = None
@router.post("/api/context/retrieval")
def get_retrieval_context(request: ContextRequest) -> List[dict]:
if not request or not request.messages:
raise HTTPException(status_code=400, detail="messages required")
messages = [{"role": m.role, "content": m.text} for m in request.messages]
chat_id = context_store.get_chat_id(messages, draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
raise HTTPException(status_code=404, detail="context sheet not found")
sources = context_store.get_retrieval(sheet)
if not sources:
return []
return [source.model_dump() for source in sources]
......@@ -41,7 +41,7 @@ class QueryRequest(BaseModel):
expand_links: bool = True
section_index: Optional[int] = None
subsection_index: Optional[int] = None
type_filter: Optional[List[str]] = None
source_type_filter: Optional[List[str]] = None
neighbor_expand: int = 0
......@@ -83,23 +83,11 @@ def query(request: QueryRequest) -> dict:
k=request.k,
section_index=request.section_index,
subsection_index=request.subsection_index,
type_filter=request.type_filter,
source_type_filter=request.source_type_filter,
expand_links=request.expand_links,
neighbor_expand=request.neighbor_expand,
)
def pack(items: list[vector_store.Retrieved]) -> list[dict]:
return [item.to_dict() for item in items]
return {
"query": result["query"],
"children": pack(result["children"]),
"children_direct": pack(result["children_direct"]),
"children_expanded": pack(result["children_expanded"]),
"subsections": pack(result["subsections"]),
"sections": pack(result["sections"]),
"neighbors": pack(result["neighbors"]),
}
return result
@router.get("/api/retrieval/sections")
......
......@@ -12,6 +12,7 @@ class OllamaSettings:
model: str
timeout: float | None
keepalive: str | None
temperature: float | None
@dataclass(frozen=True)
......@@ -27,6 +28,7 @@ class OpenAIChatSettings:
api_key: str
model: str
timeout: float | None
temperature: float | None
@dataclass(frozen=True)
class MathpixSettings:
......@@ -48,12 +50,19 @@ def get_mathpix_settings() -> MathpixSettings:
app_key=os.getenv("MATHPIX_APP_KEY")
)
def get_frontend_url() -> str:
url = os.getenv("FRONTEND_URL")
if not url:
raise ValueError("Missing FRONTEND_URL")
return url
def get_ollama_settings() -> OllamaSettings:
return OllamaSettings(
base_url=os.getenv("OLLAMA_URL", "http://localhost:11434"),
model=os.getenv("OLLAMA_MODEL", "qwen3"),
timeout=_read_float(os.getenv("OLLAMA_TIMEOUT")),
keepalive=os.getenv("OLLAMA_KEEPALIVE"),
temperature=_read_float(os.getenv("OLLAMA_TEMPERATURE")),
)
......@@ -83,6 +92,7 @@ def get_openai_chat_settings() -> OpenAIChatSettings | None:
api_key=api_key,
model=model,
timeout=_read_float(os.getenv("OPENAI_CHAT_TIMEOUT")),
temperature=_read_float(os.getenv("OPENAI_CHAT_TEMPERATURE")),
)
......
# Package marker for services
from app.deterministic_services.vector_store import Source, SourceID
__all__ = ["Source", "SourceID"]
\ No newline at end of file
......@@ -7,6 +7,8 @@ from datetime import datetime
from threading import Lock
from typing import Any
from app.deterministic_services import Source
_CACHE: dict[str, dict[str, Any]] = {}
_LOCK = Lock()
......@@ -17,10 +19,6 @@ def _utc_now() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
def is_new_chat(messages: list[dict]) -> bool:
return not any(m.get("role") == "assistant" for m in messages)
def get_chat_id(messages: list[dict], draft: str | None = None) -> str:
if draft:
return f"draft_{draft}"
......@@ -49,9 +47,8 @@ def init_sheet(chat_id: str, messages: list[dict]) -> dict[str, Any]:
"history": messages[:],
"retrieval_contexts": [],
"math_solutions": [],
"tool_outputs": [],
"LLM_solutions":[],
"decisions": [],
"sources": [],
"initialized": False,
}
......@@ -79,13 +76,23 @@ def update_history(sheet: dict[str, Any], messages: list[dict]) -> None:
def add_retrieval_context(
sheet: dict[str, Any],
query: str,
context: str,
sources: list[str],
sources: list[Source],
) -> None:
sheet["retrieval_contexts"].append(
{"query": query, "context": context, "sources": sources}
)
sheet["sources"] = list(dict.fromkeys(sheet["sources"] + sources))
retrieval_entry = {"query": query, "sources": [source.model_dump() for source in sources]}
sheet["retrieval_contexts"].append(retrieval_entry)
sheet["updated_at"] = _utc_now()
def update_retrieval_context(
sheet: dict[str, Any],
query: str,
sources: list[Source],
) -> None:
retrievals = sheet.get("retrieval_contexts", [])
if retrievals:
latest_retrieval = retrievals[-1]
latest_retrieval["sources"] = [source.model_dump() for source in sources]
latest_retrieval["query"] = query
sheet["updated_at"] = _utc_now()
......@@ -106,6 +113,17 @@ def add_math_solution(
)
sheet["updated_at"] = _utc_now()
def add_LLM_solution(
sheet: dict[str, Any],
solution: str,
) -> None:
sheet["LLM_solutions"].append(
{
"solution": solution,
}
)
sheet["updated_at"] = _utc_now()
def add_decision(sheet: dict[str, Any], decision: dict[str, Any]) -> None:
entry = {"timestamp": _utc_now(), **decision}
......@@ -119,6 +137,19 @@ def latest_math_solution(sheet: dict[str, Any]) -> str:
return ""
return sheet["math_solutions"][-1].get("solution", "")
def last_LLM_solution(sheet: dict[str, Any]) -> str:
if not sheet["LLM_solutions"]:
return ""
return sheet["LLM_solutions"][-1].get("solution", "")
def get_task(sheet: dict[str, Any]) -> str:
history = sheet.get("history", [])
if history:
return history[0].get("content", "")
return ""
def get_history(sheet: dict[str, Any]) -> str:
return format_history(sheet.get("history", []))
def first_math_solution(sheet: dict[str, Any]) -> str:
if not sheet["math_solutions"]:
......@@ -135,7 +166,16 @@ def format_sheet(sheet: dict[str, Any]) -> str:
if retrievals:
blocks = []
for item in retrievals:
blocks.append(f"QUERY: {item.get('query', '')}\n{item.get('context', '')}")
query = item.get('query', '')
sources = item.get('sources', [])
# Erstelle eine formatierte Liste der Quellen mit ihren Scores
source_blocks = []
for source in sources:
source_blocks.append(Source.model_validate(source).to_string())
source_info = "\n".join(source_blocks) if source_blocks else ""
blocks.append(f"QUERY: {query}\nSOURCES:\n{source_info}")
parts.append("RETRIEVAL_CONTEXT:\n" + "\n\n".join(blocks))
else:
parts.append("RETRIEVAL_CONTEXT:\n(leer)")
......@@ -156,19 +196,16 @@ def format_sheet(sheet: dict[str, Any]) -> str:
else:
parts.append("MATH_SOLUTIONS:\n(leer)")
if sheet.get("tool_outputs"):
parts.append("TOOL_OUTPUTS:\n" + json.dumps(sheet["tool_outputs"], ensure_ascii=True))
else:
parts.append("TOOL_OUTPUTS:\n(leer)")
return "\n\n".join(parts)
def get_retrival(sheet: dict[str, Any]) -> str:
def get_retrieval(sheet: dict[str, Any]) -> list[Source]:
retrievals = sheet.get("retrieval_contexts", [])
if not retrievals:
return ""
latest = retrievals[-1]
return latest.get("context", "")
return []
latest_retrieval = retrievals[-1]
sources= latest_retrieval.get("sources", [])
return [Source.model_validate(source) for source in sources]
def save_sheet(sheet: dict[str, Any]) -> None:
os.makedirs(_LOG_DIR, exist_ok=True)
......
......@@ -21,10 +21,10 @@ def _chat_openai(messages: list[dict]) -> dict:
timeout = settings.timeout or 60.0
client = OpenAI(api_key=settings.api_key, base_url=settings.base_url, timeout=timeout)
response = client.chat.completions.create(
messages=messages,
model=settings.model,
)
kwargs = {"messages": messages, "model": settings.model}
if settings.temperature is not None:
kwargs["temperature"] = settings.temperature
response = client.chat.completions.create(**kwargs)
message = response.choices[0].message if response.choices else {}
return {"raw": response, "message": message}
......@@ -47,6 +47,8 @@ def chat(
kwargs["tools"] = tools
if settings.keepalive:
kwargs["keep_alive"] = settings.keepalive
if settings.temperature is not None:
kwargs["options"] = {"temperature": settings.temperature}
response = client.chat(**_filter_kwargs(client.chat, kwargs))
return {"raw": response, "message": _extract_message(response)}
......
from app.deterministic_services import context_store, retrieval_service, tool_logging
from app.tools import math_tool
from app.LLM_services import hint_LLM, decision_LLM, math_intent_LLM
from app.LLM_services import hint_LLM, decision_LLM, math_intent_LLM, solver_LLM
from typing import List
def _is_new_chat(messages: list[dict]) -> bool:
return not any(m.get("role") == "assistant" for m in messages)
def _append_tool_log(tool_log: list[dict], name: str, args: dict, response: object) -> None:
tool_log.append({"name": name, "arguments": args, "response": response})
def _bootstrap_context(sheet: dict, query_text: str, tool_log: list[dict]) -> None:
context, sources = retrieval_service.retrieve_context(query_text=query_text)
context_store.add_retrieval_context(sheet, query_text, context, sources)
_append_tool_log(tool_log, "retrieve_context", {"query": query_text}, {"context": context, "sources": sources})
# Erstelle einen neuen Retrieval-Block oder aktualisiere den bestehenden
sources = retrieval_service.retrieve_context(query_text=query_text)
retrievals = sheet.get("retrieval_contexts", [])
if retrievals:
context_store.update_retrieval_context(sheet, query_text, sources)
_append_tool_log(tool_log, "update_retrieve_context", {"query": query_text}, {"sources": [source.to_string() for source in sources]})
else:
context_store.add_retrieval_context(sheet, query_text, sources)
_append_tool_log(tool_log, "retrieve_context", {"query": query_text}, {"sources": [source.to_string() for source in sources]})
math_request = math_intent_LLM.extract_math_request(query_text)
if math_request:
......@@ -24,19 +35,32 @@ def _bootstrap_context(sheet: dict, query_text: str, tool_log: list[dict]) -> No
)
_append_tool_log(tool_log, "sympy_solve", math_request, solution)
def _extract_user_messages(messages: list[dict]) -> list[str]:
"""
Extrahiert alle Inhalte von Messages mit der Rolle 'user' und gibt sie als Liste von Strings zurück.
Der letzte user-Content wird als letztes Element in der Liste enthalten sein.
"""
user_contents = []
for message in messages:
if message.get("role") == "user":
content = message.get("content", "")
if content: # Nur hinzufügen, wenn Inhalt vorhanden ist
user_contents.append(content)
return user_contents
def run_chat(messages: list[dict], draft: str | None = None) -> dict:
# Input-Fehlerbehandlung
if not messages:
raise ValueError("messages required")
last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
last_user = _extract_user_messages(messages)[-1] if _extract_user_messages(messages) else None
if not last_user:
raise ValueError("last user message required")
# Context Store Mangement
chat_id = context_store.get_chat_id(messages, draft=draft)
new_chat = context_store.is_new_chat(messages)
new_chat = _is_new_chat(messages)
sheet = context_store.load_sheet(chat_id)
if new_chat or not sheet:
sheet = context_store.init_sheet(chat_id, messages)
......@@ -45,24 +69,32 @@ def run_chat(messages: list[dict], draft: str | None = None) -> dict:
tool_log: list[dict] = []
if new_chat or not sheet.get("initialized"):
_bootstrap_context(sheet, last_user.get("content", ""), tool_log)
_bootstrap_context(sheet, last_user, tool_log)
sheet["initialized"] = True
history_text = context_store.format_history(messages)
sheet_text = context_store.format_sheet(sheet)
if new_chat:
llm_solution = solver_LLM.solve_question(last_user, sheet_text)
_append_tool_log(tool_log, "LLM_Solution", {"question": last_user, "sheet": sheet_text}, llm_solution)
context_store.add_LLM_solution(sheet, llm_solution)
else:
decision = decision_LLM.needs_more_context(history_text, sheet_text)
_append_tool_log(tool_log, "decision", {"sheet": sheet_text}, decision)
context_store.add_decision(sheet, decision)
if decision.get("needs_more_context"):
_bootstrap_context(sheet, last_user.get("content", ""), tool_log)
sheet_text = context_store.format_sheet(sheet)
full_query = ("\n").join(_extract_user_messages(messages))
_bootstrap_context(sheet, full_query, tool_log)
# Hinweis und Ausgaben generierung
hint_args = {
"task": last_user.get("content", ""),
"solution": context_store.first_math_solution(sheet),
"history": history_text,
"retrival": context_store.get_retrival(sheet),
"task": context_store.get_task(sheet),
"LLM_solution": context_store.last_LLM_solution(sheet),
"math_solution": context_store.first_math_solution(sheet),
"history": context_store.format_history(messages),
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(sheet)]),
}
reply = hint_LLM.generate_hint(**hint_args)
_append_tool_log(tool_log, "generate_hint", hint_args, reply)
......
from __future__ import annotations
from typing import List
from collections import defaultdict
from typing import Dict, List
from app.deterministic_services import Source
from app import config
from app.deterministic_services.embeddings import OpenAILikeEmbeddings
from app.deterministic_services import vector_store
CONTEXT_LIMITS = {
"direct": 4,
"indirect": 6,
"subsection": 2,
"section": 1,
}
def _get_embedder() -> OpenAILikeEmbeddings:
settings = config.get_embedding_settings()
return OpenAILikeEmbeddings(
......@@ -24,68 +18,14 @@ def _get_embedder() -> OpenAILikeEmbeddings:
)
def _format_ref(doc: vector_store.Retrieved) -> str:
meta = doc.metadata
sec = meta.get("section_index")
sub = meta.get("subsection_index")
child = meta.get("child_index")
ref = []
if sec is not None:
ref.append(f"s{sec}")
if sub is not None:
ref.append(f"ss{sub}")
if child is not None:
ref.append(f"c{child}")
ref_id = "/".join(ref) if ref else "unknown"
doc_type = meta.get("type") or doc.doc_type
title = (
meta.get("title")
or meta.get("subsection_title")
or meta.get("section_title")
or meta.get("path")
or "Untitled"
)
return f"[{ref_id} | {doc_type} | {title}]"
def _append_group(
label: str,
items: List[vector_store.Retrieved],
limit: int,
blocks: List[str],
sources: List[str],
) -> None:
if not items:
return
for doc in items[:limit]:
ref = _format_ref(doc)
blocks.append(f"{label} {ref}\n{doc.markdown}")
sources.append(ref)
def build_context(result: dict) -> tuple[str, List[str]]:
blocks: List[str] = []
sources: List[str] = []
_append_group("DIRECT", result.get("children_direct", []), CONTEXT_LIMITS["direct"], blocks, sources)
_append_group("INDIRECT", result.get("children_expanded", []), CONTEXT_LIMITS["indirect"], blocks, sources)
_append_group("SUBSECTION", result.get("subsections", []), CONTEXT_LIMITS["subsection"], blocks, sources)
_append_group("SECTION", result.get("sections", []), CONTEXT_LIMITS["section"], blocks, sources)
if not blocks:
return "KONTEXT: (leer)", sources
return "KONTEXT:\n" + "\n\n".join(blocks), sources
def retrieve_context(query_text: str, pg_url: str | None = None) -> tuple[str, List[str]]:
def retrieve_context(query_text: str, pg_url: str | None = None) -> List[Source]:
url = pg_url or config.get_postgres_url()
embedder = _get_embedder()
retrieval = vector_store.retrieve(
sources = vector_store.retrieve(
pg_url=url,
embedder=embedder,
query=query_text,
k=8,
expand_links=True,
)
return build_context(retrieval)
return sources
......@@ -29,8 +29,17 @@ def write_tool_log(entries: list[dict], created_at: str | None = None, chat_id:
path = os.path.join("logs", f"tool_calls_{suffix}_{filename_ts}.json")
payload = {
"timestamp": timestamp,
"entries": entries,
"entries": list(entries),
}
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
existing = json.load(f)
if isinstance(existing, dict) and isinstance(existing.get("entries"), list):
payload["entries"] = existing["entries"] + payload["entries"]
payload["timestamp"] = existing.get("timestamp", payload["timestamp"])
except (OSError, json.JSONDecodeError):
pass
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=True, indent=2)
return path
......@@ -10,6 +10,7 @@ from psycopg.rows import dict_row
from pgvector import Vector
from pgvector.psycopg import register_vector
import yaml
from pydantic import BaseModel
@dataclass
......@@ -129,8 +130,7 @@ CREATE TABLE IF NOT EXISTS docs (
section_title TEXT NULL,
subsection_title TEXT NULL,
title TEXT NULL,
type TEXT NULL,
box_hint TEXT NULL,
source_type TEXT NULL,
path TEXT NOT NULL,
markdown TEXT NOT NULL,
......@@ -141,7 +141,7 @@ CREATE TABLE IF NOT EXISTS docs (
CREATE INDEX IF NOT EXISTS docs_embedding_cos_idx
ON docs USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX IF NOT EXISTS docs_type_idx ON docs(type);
CREATE INDEX IF NOT EXISTS docs_source_type_idx ON docs(source_type);
CREATE INDEX IF NOT EXISTS docs_doc_type_idx ON docs(doc_type);
CREATE INDEX IF NOT EXISTS docs_section_idx ON docs(section_index);
CREATE INDEX IF NOT EXISTS docs_subsection_idx ON docs(section_index, subsection_index);
......@@ -151,12 +151,12 @@ UPSERT_SQL = """
INSERT INTO docs (
uid, doc_type,
section_index, subsection_index, child_index,
section_title, subsection_title, title, type, box_hint,
section_title, subsection_title, title, source_type,
path, markdown, embedding
) VALUES (
%(uid)s, %(doc_type)s,
%(section_index)s, %(subsection_index)s, %(child_index)s,
%(section_title)s, %(subsection_title)s, %(title)s, %(type)s, %(box_hint)s,
%(section_title)s, %(subsection_title)s, %(title)s, %(source_type)s,
%(path)s, %(markdown)s, %(embedding)s
)
ON CONFLICT (uid) DO UPDATE SET
......@@ -167,8 +167,7 @@ ON CONFLICT (uid) DO UPDATE SET
section_title = EXCLUDED.section_title,
subsection_title = EXCLUDED.subsection_title,
title = EXCLUDED.title,
type = EXCLUDED.type,
box_hint = EXCLUDED.box_hint,
source_type = EXCLUDED.source_type,
path = EXCLUDED.path,
markdown = EXCLUDED.markdown,
embedding = EXCLUDED.embedding
......@@ -211,8 +210,7 @@ def upsert_docs(pg_url: str, docs: List[DocRecord], embeddings: List[List[float]
"section_title": m.get("section_title"),
"subsection_title": m.get("subsection_title"),
"title": m.get("title"),
"type": m.get("type"),
"box_hint": m.get("box_hint"),
"source_type": m.get("source_type"),
"path": doc.path,
"markdown": doc.markdown,
"embedding": Vector(emb),
......@@ -227,6 +225,13 @@ def upsert_docs(pg_url: str, docs: List[DocRecord], embeddings: List[List[float]
return len(rows)
def clear_docs(pg_url: str) -> None:
with psycopg.connect(pg_url) as conn:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE docs;")
conn.commit()
@dataclass
class Retrieved:
uid: str
......@@ -244,6 +249,57 @@ class Retrieved:
"markdown": self.markdown,
}
class Source(BaseModel):
source_id: SourceID
retrieved_as: str
source_type: str
score: float
markdown: str
def to_dict(self) -> Dict[str, Any]:
return {
"source_id": self.source_id.to_dict(),
"retrieved_as": self.retrieved_as,
"source_type": self.source_type,
"score": self.score,
"markdown": self.markdown,
}
def to_string(self) -> str:
return (
f"Source(source_id={self.source_id.to_string()},\n"
f" retrieved_as={self.retrieved_as},\n"
f" source_type={self.source_type},\n"
f" score={self.score},\n"
f" markdown_length={len(self.markdown)})"
)
class SourceID(BaseModel):
chapter_title: Optional[str] = None
section_title: Optional[str] = None
subsection_title: Optional[str] = None
title: str
doc_type: str
def to_dict(self) -> Dict[str, Any]:
return {
"chapter_title": self.chapter_title,
"section_title": self.section_title,
"subsection_title": self.subsection_title,
"title": self.title,
"doc_type": self.doc_type,
}
def to_string(self) -> str:
string_rep = self.title
if self.subsection_title:
string_rep = f"{self.subsection_title}|{string_rep}"
if self.section_title:
string_rep = f"{self.section_title}|{string_rep}"
if self.chapter_title:
string_rep = f"{self.chapter_title}|{string_rep}"
return f"[{string_rep}|{self.doc_type}]"
def _row_to_retrieved(row: Dict[str, Any]) -> Retrieved:
meta = {
......@@ -255,8 +311,7 @@ def _row_to_retrieved(row: Dict[str, Any]) -> Retrieved:
"section_title": row["section_title"],
"subsection_title": row["subsection_title"],
"title": row["title"],
"type": row["type"],
"box_hint": row["box_hint"],
"source_type": row["source_type"],
"path": row["path"],
}
return Retrieved(
......@@ -268,6 +323,26 @@ def _row_to_retrieved(row: Dict[str, Any]) -> Retrieved:
)
def _retrivla_to_sources(retrievd : Dict[str, List[Retrieved]]) -> List[Source]:
sources = []
for name, retrieved_grouep in retrievd.items():
for retrival in retrieved_grouep:
sources.append(Source(
source_id=SourceID(
chapter_title = "none",
section_title=retrival.metadata.get("section_title"),
subsection_title=retrival.metadata.get("subsection_title"),
title=retrival.metadata.get("title"),
doc_type=retrival.doc_type
),
retrieved_as=name,
source_type=retrival.metadata.get("source_type") or "unknown",
score=retrival.score,
markdown=retrival.markdown
))
return sources
def retrieve(
pg_url: str,
embedder: EmbeddingLike,
......@@ -275,10 +350,10 @@ def retrieve(
k: int = 4,
section_index: Optional[int] = None,
subsection_index: Optional[int] = None,
type_filter: Optional[List[str]] = None,
source_type_filter: Optional[List[str]] = None,
expand_links: bool = True,
neighbor_expand: int = 0,
) -> Dict[str, Any]:
) -> List[Source]:
qvec = Vector(embed_query(embedder, query))
where = ["doc_type = 'child'"]
......@@ -292,9 +367,9 @@ def retrieve(
where.append("subsection_index = %(subsection_index)s")
params["subsection_index"] = subsection_index
if type_filter:
where.append("type = ANY(%(type_filter)s)")
params["type_filter"] = type_filter
if source_type_filter:
where.append("source_type = ANY(%(source_type_filter)s)")
params["source_type_filter"] = source_type_filter
where_sql = " AND ".join(where)
......@@ -302,7 +377,7 @@ def retrieve(
SELECT
uid, doc_type,
section_index, subsection_index, child_index,
section_title, subsection_title, title, type, box_hint,
section_title, subsection_title, title, source_type,
path, markdown,
1 - (embedding <=> %(qvec)s) AS score
FROM docs
......@@ -344,7 +419,7 @@ def retrieve(
SELECT
d.uid, d.doc_type,
d.section_index, d.subsection_index, d.child_index,
d.section_title, d.subsection_title, d.title, d.type, d.box_hint,
d.section_title, d.subsection_title, d.title, d.source_type,
d.path, d.markdown,
1.0 AS score
FROM docs d
......@@ -361,7 +436,7 @@ def retrieve(
SELECT
uid, doc_type,
section_index, subsection_index, child_index,
section_title, subsection_title, title, type, box_hint,
section_title, subsection_title, title, source_type,
path, markdown,
1.0 AS score
FROM docs
......@@ -377,7 +452,7 @@ def retrieve(
SELECT
d.uid, d.doc_type,
d.section_index, d.subsection_index, d.child_index,
d.section_title, d.subsection_title, d.title, d.type, d.box_hint,
d.section_title, d.subsection_title, d.title, d.source_type,
d.path, d.markdown,
1 - (d.embedding <=> %(qvec)s) AS score
FROM docs d
......@@ -420,9 +495,9 @@ def retrieve(
SELECT
d.uid, d.doc_type,
d.section_index, d.subsection_index, d.child_index,
d.section_title, d.subsection_title, d.title, d.type, d.box_hint,
d.section_title, d.subsection_title, d.title, d.source_type,
d.path, d.markdown,
0.9 AS score
0 AS score
FROM docs d
JOIN unnest(%(sec_arr)s::int[], %(sub_arr)s::int[], %(child_arr)s::int[]) AS u(sec, sub, child)
ON d.section_index = u.sec AND d.subsection_index = u.sub AND d.child_index = u.child
......@@ -434,16 +509,15 @@ def retrieve(
child_uids = {child.uid for child in children}
neighbors = [neighbor for neighbor in neighbors if neighbor.uid not in child_uids]
return {
"children": children,
retrivla_dict = {
"children_direct": children_direct,
"children_expanded": children_expanded,
"subsections": subsections,
"sections": sections_docs,
"neighbors": neighbors,
"query": query,
}
sources = _retrivla_to_sources(retrivla_dict)
return sources
def list_sections(pg_url: str) -> List[Dict[str, Any]]:
......
......@@ -2,7 +2,9 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, retrieval
from app.api import canvas, chat, health, retrieval, context
from app.config import get_frontend_url
@asynccontextmanager
async def lifespan(_app: FastAPI):
......@@ -14,7 +16,7 @@ app = FastAPI(title="Math Tutor API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_origins=["http://localhost:5173", get_frontend_url()],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
......@@ -23,4 +25,5 @@ app.add_middleware(
app.include_router(chat.router)
app.include_router(canvas.router)
app.include_router(retrieval.router)
app.include_router(context.router)
app.include_router(health.router)
......@@ -43,6 +43,8 @@ def cli_ingest(args: argparse.Namespace) -> None:
pg_url = args.pg or os.getenv("POSTGRES_URL")
if not pg_url:
raise ValueError("Missing POSTGRES_URL")
if args.clear:
vector_store.clear_docs(pg_url)
upserted = vector_store.upsert_docs(pg_url, docs, embeddings)
print(f"Ingested/upserted docs: {upserted}")
......@@ -92,6 +94,7 @@ def main() -> int:
ap_ing = sub.add_parser("ingest", help="Embed and upsert markdown docs")
ap_ing.add_argument("--pg", default=None, help="Postgres URL (or set POSTGRES_URL)")
ap_ing.add_argument("--base", default="markdown", help="Base folder with sections/subsections/childs")
ap_ing.add_argument("--clear", action="store_true", help="Clear docs table before ingest")
ap_ing.set_defaults(func=cli_ingest)
ap_q = sub.add_parser("query", help="Run retrieval")
......
import argparse
import json
import os
from typing import Any
from app.LLM_services import hint_LLM
from app.deterministic_services import context_store
def _load_sheet_from_path(path: str) -> dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _resolve_sheet(args: argparse.Namespace) -> dict[str, Any]:
if args.sheet:
return _load_sheet_from_path(args.sheet)
if args.chat_id:
sheet = context_store.load_sheet(args.chat_id)
if sheet is None:
raise FileNotFoundError(f"Kein Context-Sheet gefunden fuer chat_id={args.chat_id}")
return sheet
raise ValueError("Bitte --sheet oder --chat-id angeben.")
def main() -> None:
parser = argparse.ArgumentParser(description="Isolierter Hint-LLM Test mit Context Sheet.")
parser.add_argument("--sheet", help="Pfad zu einem Context Sheet JSON.")
parser.add_argument("--chat-id", help="Chat-ID zum Laden aus logs/context_sheets.")
args = parser.parse_args()
sheet = _resolve_sheet(args)
hint_args = {
"task": context_store.get_task(sheet),
"LLM_solution": context_store.last_LLM_solution(sheet),
"math_solution": context_store.first_math_solution(sheet),
"history": context_store.get_history(sheet),
"retrival": context_store.get_retrieval(sheet),
}
reply = hint_LLM.generate_hint(**hint_args)
print(reply)
if __name__ == "__main__":
main()
import argparse
from app import config
from app.deterministic_services.embeddings import OpenAILikeEmbeddings
from app.deterministic_services import vector_store
def _get_embedder() -> OpenAILikeEmbeddings:
settings = config.get_embedding_settings()
return OpenAILikeEmbeddings(
base_url=settings.base_url,
api_key=settings.api_key,
model=settings.model,
target_dim=settings.target_dim,
)
def _normalize_sources(result: object) -> list[vector_store.Source]:
if isinstance(result, list):
return result
if isinstance(result, dict):
groups = {k: v for k, v in result.items() if isinstance(v, list)}
return vector_store._retrivla_to_sources(groups)
raise TypeError("Unexpected retrieval result type")
def main() -> None:
parser = argparse.ArgumentParser(description="Isolierter Vector-Store Retrieval Test.")
parser.add_argument("--query", required=True, help="Query text")
parser.add_argument("--pg", default=None, help="Postgres URL (oder set POSTGRES_URL)")
parser.add_argument("--k", type=int, default=8)
parser.add_argument("--expand", action="store_true")
parser.add_argument("--section-index", type=int, default=None)
parser.add_argument("--subsection-index", type=int, default=None)
parser.add_argument("--source-type-filter", nargs="*", default=None)
parser.add_argument("--neighbor-expand", type=int, default=0)
args = parser.parse_args()
pg_url = args.pg or config.get_postgres_url()
embedder = _get_embedder()
result = vector_store.retrieve(
pg_url=pg_url,
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,
)
sources = _normalize_sources(result)
if not sources:
print("Keine Quellen gefunden.")
return
print(f"Gefundene Quellen: {len(sources)}")
for source in sources:
print(source.to_string())
if __name__ == "__main__":
main()
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