Commit b903391a authored by Kantz's avatar Kantz
Browse files

Merge branch 'tool_calling' into 'main'

Tool calling

See merge request kantz/tutor_react!1
parents 4ff58000 c58be6a8
......@@ -2,4 +2,5 @@
.env
__pycache__/
drawings/
markdown/
\ No newline at end of file
markdown/
logs/
\ No newline at end of file
import json
from app.deterministic_services import llm_client
CLASSIFIER_PROMPT = (
"Du bist ein Klassifikator. Entscheide, ob die vorhandenen Informationen "
"aus Historie und Kontextblatt ausreichen, um einen naechsten didaktisch "
"wertvollen Hinweis zu geben. Antworte ausschliesslich mit gueltigem JSON "
"im Format {\"needs_more_context\": true/false, \"reason\": \"...\"}."
)
def needs_more_context(history: str, context_sheet: str) -> dict:
prompt = (
"Historie:\n"
+ history
+ "\n\nKontextblatt:\n"
+ context_sheet
+ "\n\nAntwortformat: JSON."
)
result = llm_client.chat(
messages=[
{"role": "system", "content": CLASSIFIER_PROMPT},
{"role": "user", "content": prompt},
],
use_ollama=True,
)
content = llm_client.get_message_content(result)
try:
payload = json.loads(content)
return {
"needs_more_context": bool(payload.get("needs_more_context")),
"reason": payload.get("reason", ""),
}
except json.JSONDecodeError:
return {"needs_more_context": True, "reason": "classifier_parse_error"}
from app.deterministic_services import llm_client
def generate_hint(
task: str,
solution: str,
history: str | None = None,
retrival: str | None = None,
) -> str:
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"
"Aufgabe:\n"
+ task
+ "\n\n"
"Loesung (vom Mathe-Tool):\n"
+ 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."
result = llm_client.chat(
messages=[{"role": "user", "content": prompt}],
)
return llm_client.get_message_content(result)
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"],
},
},
}
import json
from app.deterministic_services import llm_client
MATH_INTENT_PROMPT = (
"Du bist ein Parser fuer Matheaufgaben. Entscheide, ob sympy_solve genutzt "
"werden soll. Wenn ja, gib ein JSON-Objekt mit {\"use_math\": true, "
"\"task\": \"solve|simplify|diff|integrate\", \"input\": \"...\", "
"\"symbols\": [\"x\", ...]} zurueck. Wenn nein, gib "
"{\"use_math\": false} zurueck. Antworte nur mit JSON."
)
def extract_math_request(user_text: str) -> dict | None:
result = llm_client.chat(
messages=[
{"role": "system", "content": MATH_INTENT_PROMPT},
{"role": "user", "content": user_text},
],
use_ollama=True,
)
content = llm_client.get_message_content(result)
try:
payload = json.loads(content)
except json.JSONDecodeError:
return None
if not payload.get("use_math"):
return None
task = payload.get("task")
input_text = payload.get("input")
symbols = payload.get("symbols") or []
if not task or not input_text:
return None
return {"task": task, "input": input_text, "symbols": symbols}
......@@ -6,25 +6,19 @@ import time
from pathlib import Path
from typing import Optional
from app import config
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from dotenv import load_dotenv
try:
from mpxpy.mathpix_client import MathpixClient
except ImportError: # pragma: no cover - optional dependency
MathpixClient = None
from mpxpy.mathpix_client import MathpixClient
router = APIRouter()
load_dotenv()
MATHPIX_APP_ID = os.getenv("MATHPIX_APP_ID")
MATHPIX_APP_KEY = os.getenv("MATHPIX_APP_KEY")
settings = config.get_mathpix_settings()
mathpix_client = None
if MathpixClient and MATHPIX_APP_ID and MATHPIX_APP_KEY:
mathpix_client = MathpixClient(app_id=MATHPIX_APP_ID, app_key=MATHPIX_APP_KEY)
if settings.app_id and settings.app_key:
mathpix_client = MathpixClient(app_id=settings.app_id, app_key=settings.app_key)
class CanvasSaveRequest(BaseModel):
......@@ -61,28 +55,43 @@ def save_canvas(request: CanvasSaveRequest) -> CanvasSaveResponse:
if not safe_hint:
safe_hint = "drawing"
filename = f"{safe_hint}-{timestamp}.png"
file_path = drawings_dir / filename
base_name = f"{safe_hint}-{timestamp}"
img_path = drawings_dir / f"{base_name}.png"
txt_path = drawings_dir / f"{base_name}.txt"
with file_path.open("wb") as handle:
with img_path.open("wb") as handle:
handle.write(raw)
latex = "\\frac{a}{b}"
if mathpix_client:
try:
image = mathpix_client.image_new(str(file_path))
mmd = image.mmd()
conversion = mathpix_client.conversion_new(
mmd=mmd,
convert_to_md=True,
try:
image = mathpix_client.image_new(file_path=str(img_path), include_line_data=True)
lines = image.lines_json()
line_type = lines[0]["type"] if lines else None
if line_type != "math":
nf_img_path = img_path.with_name(f"nf_{img_path.name}")
nf_txt_path = txt_path.with_name(f"nf_{txt_path.name}")
os.rename(img_path, nf_img_path)
nf_txt_path.write_text(
lines[0]["text"]
)
return CanvasSaveResponse(
status="error",
latex=f"Please provide a math-formula, you provided a {line_type}.",
saved_as=str(nf_img_path),
)
conversion.wait_until_complete()
latex = conversion.to_md_text()
except Exception as exc:
raise HTTPException(status_code=500, detail="mathpix failed") from exc
mmd = image.mmd()
conversion = mathpix_client.conversion_new(
mmd=mmd,
convert_to_md=True,
)
conversion.wait_until_complete()
latex = conversion.to_md_text()
except Exception as exc:
raise HTTPException(status_code=500, detail="mathpix failed") from exc
txt_path.write_text(latex, encoding="utf-8")
return CanvasSaveResponse(
status="ok",
latex=latex,
saved_as=str(file_path),
saved_as=str(img_path),
)
......@@ -2,19 +2,15 @@ from __future__ import annotations
from typing import List, Optional
import os
from fastapi import APIRouter, HTTPException
from dotenv import load_dotenv
import logging
from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field
from app.services import chat_engine
from app.deterministic_services import orchestrator, session_store
router = APIRouter()
load_dotenv()
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
logger = logging.getLogger(__name__)
class ChatMessage(BaseModel):
......@@ -32,29 +28,84 @@ class ChatResponse(BaseModel):
sources: List[str] = []
class ChatArchiveResponse(BaseModel):
status: str
chat_id: str
class ChatArchiveSummary(BaseModel):
chat_id: str
saved_at: str
message_count: int
preview: str
class ChatArchiveDetail(BaseModel):
chat_id: str
saved_at: str
history: List[ChatMessage]
@router.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
if not request.messages:
raise HTTPException(status_code=400, detail="messages required")
if not OPENAI_BASE_URL or not OPENAI_API_KEY:
last = request.messages[-1]
reply = f"Mock reply to: {last.text}"
return ChatResponse(reply=reply, sources=["doc:example"])
try:
context = chat_engine.retrieve_context(
pg_url=os.getenv("POSTGRES_URL") or "",
query_text=request.messages[-1].text,
)
messages_payload = chat_engine.build_messages(
result = orchestrator.run_chat(
[{"role": m.role, "content": m.text} for m in request.messages],
context,
draft=request.draft,
)
reply = chat_engine.call_chat_api(messages_payload)
reply = result["reply"]
sources = result["sources"]
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Chat request failed")
raise HTTPException(status_code=502, detail="chat provider failed") from exc
return ChatResponse(reply=reply, sources=["openai-compatible"])
return ChatResponse(reply=reply, sources=sources)
@router.get("/api/chat/archives", response_model=List[ChatArchiveSummary])
def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSummary]:
try:
return session_store.list_archives(limit=limit)
except Exception as exc:
logger.exception("Chat archive list failed")
raise HTTPException(status_code=502, detail="chat archive list failed") from exc
@router.get("/api/chat/archive/{chat_id}", response_model=ChatArchiveDetail)
def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
try:
record = session_store.load_archive(chat_id)
except Exception as exc:
logger.exception("Chat archive load failed")
raise HTTPException(status_code=502, detail="chat archive load failed") from exc
if not record:
raise HTTPException(status_code=404, detail="chat archive not found")
return ChatArchiveDetail(
chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
)
@router.post("/api/chat/archive", response_model=ChatArchiveResponse)
def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
if not request.messages:
return ChatArchiveResponse(status="skipped", chat_id="unknown")
try:
chat_id = session_store.archive_chat(
[{"role": m.role, "content": m.text} for m in request.messages],
draft=request.draft,
)
except Exception as exc:
logger.exception("Chat archive failed")
raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id)
from __future__ import annotations
import logging
import os
from typing import Any, Dict
import httpx
import psycopg
from fastapi import APIRouter
router = APIRouter()
logger = logging.getLogger(__name__)
def _check_ollama() -> dict:
base_url = os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/")
url = f"{base_url}/api/tags"
try:
with httpx.Client(timeout=5.0) as client:
response = client.get(url)
response.raise_for_status()
return {"status": "ok", "url": url}
except Exception as exc:
return {"status": "error", "url": url, "detail": str(exc)}
def _normalize_openai_models_url(base_url: str) -> str:
trimmed = base_url.rstrip("/")
if trimmed.endswith("/v1/embeddings"):
trimmed = trimmed[: -len("/embeddings")]
if trimmed.endswith("/v1"):
return f"{trimmed}/models"
return f"{trimmed}/v1/models"
def _check_openai() -> dict:
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
if not base_url or not api_key:
return {"status": "missing_config"}
url = _normalize_openai_models_url(base_url)
try:
headers = {"Authorization": f"Bearer {api_key}"}
with httpx.Client(timeout=5.0) as client:
response = client.get(url, headers=headers)
if response.status_code in (401, 403):
return {"status": "unauthorized", "url": url}
response.raise_for_status()
return {"status": "ok", "url": url}
except Exception as exc:
return {"status": "error", "url": url, "detail": str(exc)}
def _check_postgres() -> dict:
pg_url = os.getenv("POSTGRES_URL")
if not pg_url:
return {"status": "missing_config"}
try:
with psycopg.connect(pg_url, connect_timeout=5) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return {"status": "ok"}
except Exception as exc:
return {"status": "error", "detail": str(exc)}
@router.get("/api/health")
def health() -> Dict[str, Any]:
services = {
"ollama": _check_ollama(),
"openai": _check_openai(),
"postgres": _check_postgres(),
}
overall = "ok"
if any(value["status"] in ("error", "unauthorized") for value in services.values()):
overall = "degraded"
return {"status": overall, "services": services}
def run_startup_checks() -> Dict[str, Any]:
result = health()
status = result.get("status")
if status == "ok":
logger.info("Startup health check OK")
else:
logger.warning("Startup health check degraded: %s", result)
return result
......@@ -8,23 +8,21 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from dotenv import load_dotenv
from app.services.embeddings import OpenAILikeEmbeddings
from app.services import vector_store
from app import config
from app.deterministic_services.embeddings import OpenAILikeEmbeddings
from app.deterministic_services import vector_store
router = APIRouter()
load_dotenv()
def _get_embedder() -> OpenAILikeEmbeddings:
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-large")
if not base_url or not api_key:
raise HTTPException(status_code=500, detail="Missing OPENAI_BASE_URL or OPENAI_API_KEY")
return OpenAILikeEmbeddings(base_url=base_url, api_key=api_key, model=model, target_dim=1024)
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,
)
class InitDbRequest(BaseModel):
......@@ -39,7 +37,7 @@ class IngestRequest(BaseModel):
class QueryRequest(BaseModel):
pg_url: Optional[str] = None
query: str = Field(..., min_length=1)
k: int = 8
k: int = 4
expand_links: bool = True
section_index: Optional[int] = None
subsection_index: Optional[int] = None
......@@ -118,8 +116,3 @@ def subsections(pg_url: Optional[str] = None, section_index: Optional[int] = Non
if not url:
raise HTTPException(status_code=500, detail="Missing POSTGRES_URL")
return vector_store.list_subsections(url, section_index)
@router.get("/api/retrieval/health")
def retrieval_health() -> dict:
return {"status": "ok"}
import os
from dataclasses import dataclass
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class OllamaSettings:
base_url: str
model: str
timeout: float | None
keepalive: str | None
@dataclass(frozen=True)
class EmbeddingSettings:
base_url: str
api_key: str
model: str
target_dim: int
@dataclass(frozen=True)
class OpenAIChatSettings:
base_url: str
api_key: str
model: str
timeout: float | None
@dataclass(frozen=True)
class MathpixSettings:
app_id: str
app_key: str
def _read_float(value: str | None) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except ValueError:
return None
def get_mathpix_settings() -> MathpixSettings:
return MathpixSettings(
app_id=os.getenv("MATHPIX_APP_ID"),
app_key=os.getenv("MATHPIX_APP_KEY")
)
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"),
)
def get_embedding_settings() -> EmbeddingSettings:
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-large")
if not base_url or not api_key:
raise ValueError("Missing OPENAI_BASE_URL or OPENAI_API_KEY")
return EmbeddingSettings(
base_url=base_url,
api_key=api_key,
model=model,
target_dim=1024,
)
def get_openai_chat_settings() -> OpenAIChatSettings | None:
model = os.getenv("OPENAI_CHAT_MODEL")
if not model:
return None
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
if not base_url or not api_key:
raise ValueError("Missing OPENAI_BASE_URL or OPENAI_API_KEY for chat")
return OpenAIChatSettings(
base_url=base_url,
api_key=api_key,
model=model,
timeout=_read_float(os.getenv("OPENAI_CHAT_TIMEOUT")),
)
def get_postgres_url() -> str:
pg_url = os.getenv("POSTGRES_URL")
if not pg_url:
raise ValueError("Missing POSTGRES_URL")
return pg_url
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime
from threading import Lock
from typing import Any
_CACHE: dict[str, dict[str, Any]] = {}
_LOCK = Lock()
_LOG_DIR = os.path.join("logs", "context_sheets")
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}"
first_user = next((m for m in messages if m.get("role") == "user" and m.get("content")), None)
if not first_user:
return "unknown"
digest = hashlib.sha1(first_user["content"].encode("utf-8")).hexdigest()
return digest[:12]
def format_history(messages: list[dict]) -> str:
lines = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
lines.append(f"{role}: {content}")
return "\n".join(lines)
def init_sheet(chat_id: str, messages: list[dict]) -> dict[str, Any]:
timestamp = _utc_now()
return {
"chat_id": chat_id,
"created_at": timestamp,
"updated_at": timestamp,
"history": messages[:],
"retrieval_contexts": [],
"math_solutions": [],
"tool_outputs": [],
"decisions": [],
"sources": [],
"initialized": False,
}
def load_sheet(chat_id: str) -> dict[str, Any] | None:
with _LOCK:
if chat_id in _CACHE:
return _CACHE[chat_id]
latest_path = os.path.join(_LOG_DIR, f"{chat_id}_latest.json")
if os.path.exists(latest_path):
with open(latest_path, "r", encoding="utf-8") as f:
sheet = json.load(f)
with _LOCK:
_CACHE[chat_id] = sheet
return sheet
return None
def update_history(sheet: dict[str, Any], messages: list[dict]) -> None:
sheet["history"] = messages[:]
sheet["updated_at"] = _utc_now()
def add_retrieval_context(
sheet: dict[str, Any],
query: str,
context: str,
sources: list[str],
) -> None:
sheet["retrieval_contexts"].append(
{"query": query, "context": context, "sources": sources}
)
sheet["sources"] = list(dict.fromkeys(sheet["sources"] + sources))
sheet["updated_at"] = _utc_now()
def add_math_solution(
sheet: dict[str, Any],
task: str,
input_text: str,
symbols: list[str] | None,
solution: str,
) -> None:
sheet["math_solutions"].append(
{
"task": task,
"input": input_text,
"symbols": symbols or [],
"solution": solution,
}
)
sheet["updated_at"] = _utc_now()
def add_decision(sheet: dict[str, Any], decision: dict[str, Any]) -> None:
entry = {"timestamp": _utc_now(), **decision}
sheet["decisions"].append(entry)
sheet["updated_at"] = _utc_now()
# wird aktuell nicht benutzt
def latest_math_solution(sheet: dict[str, Any]) -> str:
if not sheet["math_solutions"]:
return ""
return sheet["math_solutions"][-1].get("solution", "")
def first_math_solution(sheet: dict[str, Any]) -> str:
if not sheet["math_solutions"]:
return ""
return sheet["math_solutions"][0].get("solution", "")
def format_sheet(sheet: dict[str, Any]) -> str:
parts = []
history = format_history(sheet.get("history", []))
parts.append("HISTORY:\n" + (history or "(leer)"))
retrievals = sheet.get("retrieval_contexts", [])
if retrievals:
blocks = []
for item in retrievals:
blocks.append(f"QUERY: {item.get('query', '')}\n{item.get('context', '')}")
parts.append("RETRIEVAL_CONTEXT:\n" + "\n\n".join(blocks))
else:
parts.append("RETRIEVAL_CONTEXT:\n(leer)")
math_solutions = sheet.get("math_solutions", [])
if math_solutions:
blocks = []
for item in math_solutions:
blocks.append(
"TASK: {task}\nINPUT: {input}\nSYMBOLS: {symbols}\nSOLUTION: {solution}".format(
task=item.get("task", ""),
input=item.get("input", ""),
symbols=",".join(item.get("symbols", [])),
solution=item.get("solution", ""),
)
)
parts.append("MATH_SOLUTIONS:\n" + "\n\n".join(blocks))
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:
retrievals = sheet.get("retrieval_contexts", [])
if not retrievals:
return ""
latest = retrievals[-1]
return latest.get("context", "")
def save_sheet(sheet: dict[str, Any]) -> None:
os.makedirs(_LOG_DIR, exist_ok=True)
sheet["updated_at"] = _utc_now()
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
chat_id = sheet.get("chat_id", "unknown")
snapshot_path = os.path.join(_LOG_DIR, f"{chat_id}_{timestamp}.json")
latest_path = os.path.join(_LOG_DIR, f"{chat_id}_latest.json")
payload = json.dumps(sheet, ensure_ascii=True, indent=2)
with open(snapshot_path, "w", encoding="utf-8") as f:
f.write(payload)
with open(latest_path, "w", encoding="utf-8") as f:
f.write(payload)
with _LOCK:
_CACHE[chat_id] = sheet
import inspect
import ollama
from openai import OpenAI
from app import config
def _filter_kwargs(func, kwargs: dict) -> dict:
try:
signature = inspect.signature(func)
except (TypeError, ValueError):
return kwargs
return {key: value for key, value in kwargs.items() if key in signature.parameters}
def _chat_openai(messages: list[dict]) -> dict:
settings = config.get_openai_chat_settings()
if not settings:
return {}
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,
)
message = response.choices[0].message if response.choices else {}
return {"raw": response, "message": message}
def chat(
messages: list[dict],
tools: list[dict] | None = None,
use_ollama: bool = False,
) -> dict:
if not tools and not use_ollama:
openai_result = _chat_openai(messages)
if openai_result:
return openai_result
settings = config.get_ollama_settings()
client = ollama.Client(host=settings.base_url, timeout=settings.timeout)
kwargs: dict = {"model": settings.model, "messages": messages}
if tools:
kwargs["tools"] = tools
if settings.keepalive:
kwargs["keep_alive"] = settings.keepalive
response = client.chat(**_filter_kwargs(client.chat, kwargs))
return {"raw": response, "message": _extract_message(response)}
def _extract_message(response) -> object:
if isinstance(response, dict) and "message" in response:
return response["message"]
if hasattr(response, "message"):
return response.message
return {}
def get_message_content(result: dict | object) -> str:
message = result.get("message") if isinstance(result, dict) else result
if isinstance(message, dict):
return message.get("content", "") or ""
if hasattr(message, "content"):
return getattr(message, "content") or ""
return ""
def get_tool_calls(result: dict | object) -> list:
message = result.get("message") if isinstance(result, dict) else result
if isinstance(message, dict):
return message.get("tool_calls") or []
if hasattr(message, "tool_calls"):
return getattr(message, "tool_calls") or []
return []
def normalize_tool_call(tool_call: object) -> dict:
if isinstance(tool_call, dict):
function = tool_call.get("function") or {}
return {"name": function.get("name"), "arguments": function.get("arguments")}
function = getattr(tool_call, "function", None)
if function:
return {"name": getattr(function, "name", None), "arguments": getattr(function, "arguments", None)}
return {"name": None, "arguments": None}
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
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})
math_request = math_intent_LLM.extract_math_request(query_text)
if math_request:
solution = math_tool.sympy_solve(**math_request)
context_store.add_math_solution(
sheet,
math_request["task"],
math_request["input"],
math_request.get("symbols"),
solution,
)
_append_tool_log(tool_log, "sympy_solve", math_request, solution)
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)
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)
sheet = context_store.load_sheet(chat_id)
if new_chat or not sheet:
sheet = context_store.init_sheet(chat_id, messages)
context_store.update_history(sheet, messages)
tool_log: list[dict] = []
if new_chat or not sheet.get("initialized"):
_bootstrap_context(sheet, last_user.get("content", ""), tool_log)
sheet["initialized"] = True
history_text = context_store.format_history(messages)
sheet_text = context_store.format_sheet(sheet)
decision = decision_LLM.needs_more_context(history_text, sheet_text)
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)
# 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),
}
reply = hint_LLM.generate_hint(**hint_args)
_append_tool_log(tool_log, "generate_hint", hint_args, reply)
if not reply:
reply = "Dazu steht nichts im Material"
context_store.save_sheet(sheet)
tool_logging.write_tool_log(
tool_log,
created_at=sheet.get("created_at"),
chat_id=sheet.get("chat_id"),
)
return {"reply": reply, "sources": sheet.get("sources", []), "tool_log": tool_log}
from __future__ import annotations
from typing import List
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(
base_url=settings.base_url,
api_key=settings.api_key,
model=settings.model,
target_dim=settings.target_dim,
)
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]]:
url = pg_url or config.get_postgres_url()
embedder = _get_embedder()
retrieval = vector_store.retrieve(
pg_url=url,
embedder=embedder,
query=query_text,
k=8,
expand_links=True,
)
return build_context(retrieval)
from __future__ import annotations
import json
import os
from datetime import datetime
from threading import Lock
from typing import Any
from collections import deque
from app.deterministic_services import context_store
_LOCK = Lock()
_LOG_DIR = os.path.join("logs", "chat_sessions")
_LOG_PATH = os.path.join(_LOG_DIR, "archive.jsonl")
def _utc_now() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str:
chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.init_sheet(chat_id, messages)
context_store.update_history(sheet, messages)
context_store.save_sheet(sheet)
record = {
"chat_id": chat_id,
"saved_at": _utc_now(),
"history": sheet.get("history", []),
"context_sheet": context_store.format_sheet(sheet),
"retrieval_contexts": sheet.get("retrieval_contexts", []),
"math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []),
}
os.makedirs(_LOG_DIR, exist_ok=True)
payload = json.dumps(record, ensure_ascii=True)
with _LOCK:
with open(_LOG_PATH, "a", encoding="utf-8") as f:
f.write(payload + "\n")
return chat_id
def _summarize_record(record: dict[str, Any]) -> dict[str, Any]:
history = record.get("history", [])
preview = ""
for entry in reversed(history):
if entry.get("role") == "user" and entry.get("content"):
preview = entry["content"][:120]
break
return {
"chat_id": record.get("chat_id", "unknown"),
"saved_at": record.get("saved_at", ""),
"message_count": len(history),
"preview": preview,
}
def list_archives(limit: int = 20) -> list[dict[str, Any]]:
if limit <= 0:
return []
if not os.path.exists(_LOG_PATH):
return []
recent: deque[dict[str, Any]] = deque(maxlen=limit)
with _LOCK:
with open(_LOG_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
recent.append(json.loads(line))
except json.JSONDecodeError:
continue
return [_summarize_record(item) for item in reversed(recent)]
def load_archive(chat_id: str) -> dict[str, Any] | None:
if not chat_id or not os.path.exists(_LOG_PATH):
return None
with _LOCK:
with open(_LOG_PATH, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("chat_id") == chat_id:
history = [
{"role": entry.get("role", "user"), "text": entry.get("content", "")}
for entry in record.get("history", [])
]
return {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"history": history,
}
return None
import json
import os
from datetime import datetime
def _format_log_timestamp(created_at: str | None) -> str:
if not created_at:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
try:
parsed = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
except ValueError:
return created_at
def _format_filename_timestamp(timestamp: str) -> str:
try:
parsed = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ")
return parsed.strftime("%Y%m%d_%H%M%S")
except ValueError:
return timestamp.replace("-", "").replace(":", "").replace("T", "_").replace("Z", "")
def write_tool_log(entries: list[dict], created_at: str | None = None, chat_id: str | None = None) -> str:
os.makedirs("logs", exist_ok=True)
timestamp = _format_log_timestamp(created_at)
filename_ts = _format_filename_timestamp(timestamp)
suffix = chat_id or "latest"
path = os.path.join("logs", f"tool_calls_{suffix}_{filename_ts}.json")
payload = {
"timestamp": timestamp,
"entries": entries,
}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=True, indent=2)
return path
......@@ -272,7 +272,7 @@ def retrieve(
pg_url: str,
embedder: EmbeddingLike,
query: str,
k: int = 8,
k: int = 4,
section_index: Optional[int] = None,
subsection_index: Optional[int] = None,
type_filter: Optional[List[str]] = None,
......
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, retrieval
from app.api import canvas, chat, health, retrieval
@asynccontextmanager
async def lifespan(_app: FastAPI):
health.run_startup_checks()
yield
app = FastAPI(title="Math Tutor API", version="0.1.0")
app = FastAPI(title="Math Tutor API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
......@@ -15,8 +23,4 @@ app.add_middleware(
app.include_router(chat.router)
app.include_router(canvas.router)
app.include_router(retrieval.router)
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}
app.include_router(health.router)
from __future__ import annotations
from typing import List
import os
import httpx
from openai import OpenAI
from app.services.embeddings import OpenAILikeEmbeddings
from app.services import vector_store
SYSTEM_PROMPT = """Du bist ein Mathe-Tutor. Antworte auf Deutsch, klar und korrekt.
Nutze ausschließlich den bereitgestellten Kontext. Wenn nichts zur Frage im Kontext steht, antworte mit "Dazu steht nichts im Material" und nichts weiter.
Gib wenn möglich eine kurze Struktur: (1) Idee,
(2) Definition,
(3) kurzer Begründungs-/Rechenweg,
(4) Mini-Beispiel.
Zitiere Quellen inline mit den eckigen Klammern, die im Kontext vorangestellt sind, z.B. [s2/ss1/c3 | definition | ...].
"""
CONTEXT_LIMITS = {
"direct": 8,
"indirect": 6,
"subsection": 2,
"section": 1,
}
def _get_embedder() -> OpenAILikeEmbeddings:
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-large")
if not base_url or not api_key:
raise ValueError("Missing OPENAI_BASE_URL or OPENAI_API_KEY")
return OpenAILikeEmbeddings(base_url=base_url, api_key=api_key, model=model, target_dim=1024)
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 build_context(result: dict) -> str:
blocks: List[str] = []
def add_group(label: str, items: List[vector_store.Retrieved], limit: int) -> None:
if not items:
return
for doc in items[:limit]:
blocks.append(f"{label} {_format_ref(doc)}\n{doc.markdown}")
add_group("DIRECT", result.get("children_direct", []), CONTEXT_LIMITS["direct"])
add_group("INDIRECT", result.get("children_expanded", []), CONTEXT_LIMITS["indirect"])
add_group("SUBSECTION", result.get("subsections", []), CONTEXT_LIMITS["subsection"])
add_group("SECTION", result.get("sections", []), CONTEXT_LIMITS["section"])
if not blocks:
return "KONTEXT: (leer)"
return "KONTEXT:\n" + "\n\n".join(blocks)
def build_messages(messages: list[dict], context: str) -> list[dict]:
return [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": context},
] + messages
def retrieve_context(pg_url: str, query_text: str) -> str:
embedder = _get_embedder()
retrieval = vector_store.retrieve(
pg_url=pg_url,
embedder=embedder,
query=query_text,
k=8,
expand_links=True,
)
return build_context(retrieval)
def call_chat_api(messages_payload: list[dict]) -> str:
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
if not base_url or not api_key:
raise ValueError("Missing OPENAI_BASE_URL or OPENAI_API_KEY")
url = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": messages_payload,
}
headers = {"Authorization": f"Bearer {api_key}"}
with httpx.Client(timeout=60.0) as client:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
def stream_chat(messages_payload: list[dict]):
base_url = os.getenv("OPENAI_BASE_URL")
api_key = os.getenv("OPENAI_API_KEY")
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
if not base_url or not api_key:
raise ValueError("Missing OPENAI_BASE_URL or OPENAI_API_KEY")
client = OpenAI(api_key=api_key, base_url=base_url)
return client.chat.completions.create(
model=model,
messages=messages_payload,
stream=True,
)
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