Commit 6cf9eb8c authored by Kantz's avatar Kantz
Browse files

simplifications

parent d9b6c146
......@@ -9,10 +9,11 @@ import app.config as config
from app.deterministic_services import session_store
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.deterministic_services import socratic_oranisator
from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator,
is_valid_orchestrator,
resolve_orchestrator,
from app.deterministic_services.orchestrators import (
orchestrator_qa,
orchestrator_socratic,
orchestrator_task,
orchestrator_tutor,
)
from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field
......@@ -20,6 +21,13 @@ from pydantic import BaseModel, Field
router = APIRouter()
logger = logging.getLogger(__name__)
DEFAULT_ORCHESTRATOR = "qa"
ORCHESTRATORS = {
"qa": orchestrator_qa,
"tutor": orchestrator_tutor,
"task": orchestrator_task,
"socratic": orchestrator_socratic,
}
class ChatMessage(BaseModel):
......@@ -87,12 +95,11 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=400, detail="messages required")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
if requested_orchestrator and requested_orchestrator not in ORCHESTRATORS:
raise HTTPException(status_code=422, detail="unsupported orchestrator")
orchestrator_name, orchestrator_impl = resolve_orchestrator(
requested_orchestrator or None
)
orchestrator_name = requested_orchestrator or DEFAULT_ORCHESTRATOR
orchestrator_impl = ORCHESTRATORS[orchestrator_name]
try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
......@@ -203,7 +210,7 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task,
selected_topic=selected_topic,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
orchestrator=record.get("orchestrator") or DEFAULT_ORCHESTRATOR,
)
......@@ -213,9 +220,9 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
return ChatArchiveResponse(status="skipped", chat_id="unknown")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
if requested_orchestrator and requested_orchestrator not in ORCHESTRATORS:
raise HTTPException(status_code=422, detail="unsupported orchestrator")
archive_orchestrator = requested_orchestrator or get_default_orchestrator()
archive_orchestrator = requested_orchestrator or DEFAULT_ORCHESTRATOR
try:
chat_id = session_store.archive_chat(
......
# Package marker for services
from app.deterministic_services.retrieval_store import Source, SourceID
__all__ = ["Source", "SourceID"]
\ No newline at end of file
# Package marker for deterministic services.
from app.deterministic_services.context_stores import context_store_base
from app.deterministic_services.context_stores import context_store_task
from app.deterministic_services.context_stores import context_store_open
__all__ = ["context_store_base", "context_store_open", "context_store_task"]
# Package marker for context store implementations.
......@@ -11,7 +11,7 @@ from zoneinfo import ZoneInfo
from threading import Lock
from typing import Any
from app.deterministic_services import Source
from app.deterministic_services.retrieval_store import Source
from app.deterministic_services.tool_logging import (
format_filename_timestamp,
format_log_timestamp,
......@@ -36,12 +36,12 @@ def _resolve_latest_path(chat_id: str) -> str | None:
# timekeeping
# ---
def _mez_now() -> str:
def berlin_now_iso() -> str:
return datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M:%SZ")
def _touch(sheet: dict[str, Any]) -> None:
sheet["updated_at"] = _mez_now()
sheet["updated_at"] = berlin_now_iso()
def touch_sheet(sheet: dict[str, Any]) -> None:
......@@ -66,7 +66,7 @@ def get_chat_id(messages: list[dict], draft: str | None = None) -> str:
def init_sheet_base(chat_id: str, messages: list[dict]) -> dict[str, Any]:
timestamp = _mez_now()
timestamp = berlin_now_iso()
return {
"chat_id": chat_id,
"created_at": timestamp,
......@@ -203,7 +203,7 @@ def get_decisions(sheet: dict[str, Any]) -> list[dict[str, Any]]:
def add_decision(sheet: dict[str, Any], decision: dict[str, Any]) -> None:
entry = {"timestamp": _mez_now(), **decision}
entry = {"timestamp": berlin_now_iso(), **decision}
sheet.setdefault("decisions", []).append(entry)
_touch(sheet)
......
......@@ -7,7 +7,7 @@ from threading import Lock
from typing import Any, Dict, Tuple
import app.config as config
from app.deterministic_services.embeddings import BaseEmbeddings, EmbeddingFactory
from app.deterministic_services.embeddings import BaseEmbeddings, create_embedder
_EMBEDDER_LOCK = Lock()
......@@ -35,7 +35,7 @@ def get_embedder() -> tuple[BaseEmbeddings, bool]:
if _CACHED_EMBEDDER is not None and _CACHED_KEY == key:
return _CACHED_EMBEDDER, True
_CACHED_EMBEDDER = EmbeddingFactory.create(settings)
_CACHED_EMBEDDER = create_embedder(settings)
_CACHED_KEY = key
return _CACHED_EMBEDDER, False
......
# Embeddings-Implementation with Factory-Pattern
from __future__ import annotations
import math
from typing import List, Optional, Union
from enum import Enum
from typing import List, Optional, Union
from openai import OpenAI
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer
# -----------------------------
# configuration model
# -----------------------------
class EmbeddingType(str, Enum):
OPENAI_LIKE = "openai-like"
SENTENCE_TRANSFORMER = "sentence-transformer"
class OpenAILikeConfig(BaseModel):
"""Konfiguration für OpenAI-ähnliche APIs."""
base_url: str = Field(...,
description="Base URL der API (z. B. http://localhost:11434/v1)")
api_key: str = Field(...,
description="API-Key (z. B. 'ollama' für Ollama)")
model: str = Field(...,
description="Modellname (z. B. 'nomic-embed-text')")
target_dim: int = Field(1024, description="Ziel-Dimension der Embeddings")
base_url: str = Field(..., description="Base URL of the embedding API.")
api_key: str = Field(..., description="API key for the embedding API.")
model: str = Field(..., description="Embedding model name.")
target_dim: int = Field(1024, description="Target embedding dimension.")
class SentenceTransformerConfig(BaseModel):
"""Konfiguration für lokale SentenceTransformer-Modelle."""
model: str = Field(...,
description="Name des SentenceTransformer-Modells (z. B. 'all-MiniLM-L6-v2')")
target_dim: int = Field(384, description="Ziel-Dimension der Embeddings")
model: str = Field(..., description="SentenceTransformer model name.")
target_dim: int = Field(384, description="Target embedding dimension.")
class EmbeddingConfig(BaseModel):
"""Gemeinsame Konfiguration für die Factory."""
embedding_type: EmbeddingType = Field(...,
description="Typ der Embeddings")
embedding_type: EmbeddingType = Field(..., description="Embedding backend type.")
config: Union[OpenAILikeConfig, SentenceTransformerConfig] = Field(
..., description="Spezifische Konfiguration")
# -----------------------------
# base class for embeddings
# -----------------------------
..., description="Backend-specific embedding config."
)
class BaseEmbeddings:
"""
Basisklasse für Embedding-Generierung mit gemeinsamen Methoden.
"""
def __init__(self, target_dim: int = 384) -> None:
self.target_dim = target_dim
def _normalize(self, vec: List[float]) -> List[float]:
"""Normalisiert einen Vektor auf L2-Norm."""
norm = math.sqrt(sum(x * x for x in vec))
if norm == 0.0:
return vec
return [x / norm for x in vec]
def _truncate(self, vec: List[float]) -> List[float]:
"""Trunziert oder füllt den Vektor auf die Ziel-Dimension."""
if len(vec) < self.target_dim:
raise ValueError(
f"Embedding dimension {len(vec)} < target {self.target_dim}")
raise ValueError(f"Embedding dimension {len(vec)} < target {self.target_dim}")
if len(vec) > self.target_dim:
vec = vec[: self.target_dim]
return self._normalize(vec)
def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""Generiert Embeddings für eine Liste von Texten."""
return self._embed(texts)
def embed_query(self, text: str) -> List[float]:
"""Generiert ein Embedding für einen einzelnen Text."""
return self._embed(text)[0]
def _embed(self, inputs: List[str] | str) -> List[List[float]]:
"""Abstrakte Methode – muss in Unterklassen implementiert werden."""
raise NotImplementedError("Subclass must implement _embed method.")
raise NotImplementedError
# -----------------------------
# subclass
# -----------------------------
class OpenAILikeEmbeddings(BaseEmbeddings):
"""
Embeddings-Wrapper für OpenAI-ähnliche APIs (z. B. OpenAI, Ollama, TogetherAI).
"""
def __init__(self, config: OpenAILikeConfig) -> None:
super().__init__(target_dim=config.target_dim)
self.base_url = config.base_url.rstrip("/")
......@@ -103,11 +68,7 @@ class OpenAILikeEmbeddings(BaseEmbeddings):
self.model = config.model
def _embed(self, inputs: List[str] | str) -> List[List[float]]:
"""Ruft die externe Embedding-API auf."""
client = OpenAI(
api_key=self.api_key,
base_url=self.base_url,
)
client = OpenAI(api_key=self.api_key, base_url=self.base_url)
response = client.embeddings.create(
input=inputs,
model=self.model,
......@@ -118,16 +79,12 @@ class OpenAILikeEmbeddings(BaseEmbeddings):
if not isinstance(data, list):
raise ValueError("Embedding response missing 'data' list.")
# Sortiere nach Index, falls nötig
data_sorted = sorted(data, key=_embedding_item_index)
embeddings: List[List[float]] = []
for item in data_sorted:
for item in sorted(data, key=_embedding_item_index):
emb = _embedding_item_vector(item)
if not isinstance(emb, list):
raise ValueError("Embedding item missing 'embedding' list.")
embeddings.append(self._truncate([float(x) for x in emb]))
return embeddings
......@@ -144,10 +101,6 @@ def _embedding_item_vector(item: object) -> object:
class SentenceTransformerEmbeddings(BaseEmbeddings):
"""
Embeddings-Wrapper für lokale SentenceTransformer Modelle.
"""
def __init__(self, config: SentenceTransformerConfig) -> None:
super().__init__(target_dim=config.target_dim)
self.model_name = config.model
......@@ -155,61 +108,31 @@ class SentenceTransformerEmbeddings(BaseEmbeddings):
@property
def model(self) -> SentenceTransformer:
"""Liefert das SentenceTransformer-Modell (lazy load)."""
if self._model is None:
self._model = SentenceTransformer(
self.model_name,
trust_remote_code=True,
)
self._model = SentenceTransformer(self.model_name, trust_remote_code=True)
self._model.max_seq_length = 512
return self._model
def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""Generiert Embeddings für eine Liste von Texten."""
passage_embeddings = self.model.encode(
vectors = self.model.encode(
sentences=texts,
task="retrieval",
prompt_name="document",
)
return [self._truncate([float(x) for x in emb]) for emb in passage_embeddings]
return [self._truncate([float(x) for x in emb]) for emb in vectors]
def embed_query(self, text: str) -> List[float]:
"""Generiert ein Embedding für einen einzelnen Text."""
query_embeddings = self.model.encode(
vectors = self.model.encode(
sentences=[text],
task="retrieval",
prompt_name="query",
)
return self._truncate([float(x) for x in query_embeddings[0]])
# -----------------------------
# Factory: creats embeddings based on config
# -----------------------------
class EmbeddingFactory:
"""
Factory-Klasse zur dynamischen Erzeugung von Embeddings-Instanzen.
"""
@staticmethod
def create(config: EmbeddingConfig) -> BaseEmbeddings:
"""
Erzeugt eine Embeddings-Instanz basierend auf der Konfiguration.
Args:
config (EmbeddingConfig): Die Konfiguration mit Typ und Details.
Returns:
BaseEmbeddings: Instanz der passenden Embeddings-Klasse.
Raises:
ValueError: Wenn der Typ nicht unterstützt wird.
"""
if config.embedding_type == EmbeddingType.OPENAI_LIKE:
return OpenAILikeEmbeddings(config=config)
elif config.embedding_type == EmbeddingType.SENTENCE_TRANSFORMER:
return SentenceTransformerEmbeddings(config=config)
else:
raise ValueError(
f"Unsupported embedding type: {config.embedding_type}")
return self._truncate([float(x) for x in vectors[0]])
def create_embedder(config: EmbeddingConfig) -> BaseEmbeddings:
if config.embedding_type == EmbeddingType.OPENAI_LIKE:
return OpenAILikeEmbeddings(config=config)
if config.embedding_type == EmbeddingType.SENTENCE_TRANSFORMER:
return SentenceTransformerEmbeddings(config=config)
raise ValueError(f"Unsupported embedding type: {config.embedding_type}")
......@@ -2,13 +2,10 @@ from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Any, Callable, List, TypeVar
import app.config as config
from app.deterministic_services import (
Source,
context_store,
embedding_provider,
referenz_decoder,
......@@ -16,6 +13,8 @@ from app.deterministic_services import (
tool_log_context,
tool_logging,
)
from app.deterministic_services.context_stores.context_store_base import berlin_now_iso
from app.deterministic_services.retrieval_store import Source
T = TypeVar("T")
......@@ -40,16 +39,12 @@ def is_new_chat(messages: list[dict]) -> bool:
# Timekeeping
# ---
def _utc_now_iso() -> str:
return datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%dT%H:%M:%SZ")
def _start_timing() -> tuple[str, float]:
return _utc_now_iso(), time.perf_counter()
return berlin_now_iso(), time.perf_counter()
def _finish_timing(started_perf: float) -> tuple[str, float]:
finished_at = _utc_now_iso()
finished_at = berlin_now_iso()
duration_ms = round((time.perf_counter() - started_perf) * 1000, 2)
return finished_at, duration_ms
......
from __future__ import annotations
from typing import Any
from app.deterministic_services.orchestrators import (
orchestrator_qa,
orchestrator_socratic,
orchestrator_task,
orchestrator_tutor,
)
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task", "socratic")
DEFAULT_ORCHESTRATOR = "qa"
_ORCHESTRATOR_MODULES: dict[str, Any] = {
"qa": orchestrator_qa,
"tutor": orchestrator_tutor,
"task": orchestrator_task,
"socratic": orchestrator_socratic,
}
def get_default_orchestrator() -> str:
return DEFAULT_ORCHESTRATOR
def is_valid_orchestrator(value: str) -> bool:
return value in _ORCHESTRATOR_MODULES
def resolve_orchestrator(value: str | None = None) -> tuple[str, Any]:
if value:
normalized = value.strip().lower()
if normalized in _ORCHESTRATOR_MODULES:
return normalized, _ORCHESTRATOR_MODULES[normalized]
default_name = get_default_orchestrator()
return default_name, _ORCHESTRATOR_MODULES[default_name]
......@@ -13,14 +13,6 @@ from pydantic import BaseModel
from app.deterministic_services.vector_store import EmbeddingLike, embed_query
def _load_subsection_store():
from app.deterministic_services import vector_store_subsection
return vector_store_subsection
class SourceID(BaseModel):
chapter_title: Optional[str] = None
section_title: Optional[str] = None
......@@ -29,16 +21,6 @@ class SourceID(BaseModel):
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,
"subsubsection_title": self.subsubsection_title,
"title": self.title,
"doc_type": self.doc_type,
}
def to_string(self) -> str:
string_rep = self.title
if self.subsubsection_title:
......@@ -59,15 +41,6 @@ class Source(BaseModel):
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"
......@@ -86,16 +59,6 @@ class Retrieved:
metadata: Dict[str, Any]
markdown: str
def to_dict(self) -> Dict[str, Any]:
return {
"uid": self.uid,
"doc_type": self.doc_type,
"score": self.score,
"metadata": self.metadata,
"markdown": self.markdown,
}
def _row_to_retrieved(row: Dict[str, Any], source_type: Optional[str] = None) -> Retrieved:
meta = {
"uid": row["uid"],
......
......@@ -5,22 +5,17 @@ from __future__ import annotations
import json
import os
from collections import deque
from datetime import datetime
from threading import Lock
from typing import Any
from zoneinfo import ZoneInfo
from app.deterministic_services import context_store
from app.deterministic_services.context_stores.context_store_base import berlin_now_iso
_LOCK = Lock()
_LOG_DIR = os.path.join("logs", "chat_sessions")
_LOG_PATH = os.path.join(_LOG_DIR, "archive.jsonl")
def _mez_now() -> str:
return datetime.now(ZoneInfo("Europe/Berlin")).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()
......@@ -51,7 +46,7 @@ def archive_chat(
record = {
"chat_id": chat_id,
"saved_at": _mez_now(),
"saved_at": berlin_now_iso(),
"orchestrator": str(orchestrator or "").strip().lower() or None,
"history": sheet.get("history", []),
"context_sheet": context_store.format_sheet(sheet),
......
......@@ -2,13 +2,11 @@ fastapi
uvicorn
python-dotenv
mpxpy
pillow
httpx
ollama
openai
mistralai
mcp
sympy
psycopg[binary]
pgvector
pyyaml
......
......@@ -3,7 +3,7 @@
from __future__ import annotations
from app.deterministic_services import retrieval_store, vector_store
from app.deterministic_services.embeddings import BaseEmbeddings, EmbeddingFactory
from app.deterministic_services.embeddings import BaseEmbeddings, create_embedder
import argparse
import os
......@@ -21,7 +21,7 @@ if str(ROOT_DIR) not in sys.path:
def build_embedder() -> BaseEmbeddings:
return EmbeddingFactory.create(config.get_embedding_settings())
return create_embedder(config.get_embedding_settings())
def cli_init_db(args: argparse.Namespace) -> None:
......
......@@ -5,7 +5,7 @@ os.environ.setdefault("EMBEDDING_PROVIDER", "sentence-transformer")
os.environ.setdefault("EMBEDDING_TYPE", "sentence-transformer")
from app import config
from app.deterministic_services.embeddings import EmbeddingFactory
from app.deterministic_services.embeddings import create_embedder
from app.deterministic_services import retrieval_store
......@@ -70,7 +70,7 @@ def main() -> None:
args = parser.parse_args()
pg_url = args.pg or config.get_postgres_url()
embedder = EmbeddingFactory.create(config.get_embedding_settings())
embedder = create_embedder(config.get_embedding_settings())
subsection_refs = _parse_subsections(
args.subsections, args.chapter_index, args.section_index
......
......@@ -7,8 +7,9 @@ from unittest.mock import patch
os.environ.setdefault("EMBEDDING_PROVIDER", "sentence-transformer")
os.environ.setdefault("EMBEDDING_TYPE", "sentence-transformer")
from app.deterministic_services import Source as PackageSource, SourceID as PackageSourceID
from app.deterministic_services.retrieval_store import (
Source as PackageSource,
SourceID as PackageSourceID,
Retrieved,
Source,
SourceID,
......
......@@ -12,7 +12,7 @@ import {
import { t } from "../i18n";
import {
DEFAULT_ORCHESTRATOR,
FALLBACK_ORCHESTRATORS,
ORCHESTRATORS,
isTaskCoupledOrchestrator,
type OrchestratorName,
} from "../utils/orchestrator";
......@@ -102,7 +102,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [selectedOrchestrator, setSelectedOrchestratorState] =
useState<OrchestratorName>(DEFAULT_ORCHESTRATOR);
const [availableOrchestrators, setAvailableOrchestrators] = useState<OrchestratorName[]>(
() => [...FALLBACK_ORCHESTRATORS]
() => [...ORCHESTRATORS]
);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
......@@ -313,7 +313,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const initTasks = useCallback(async () => {
try {
const available = [...FALLBACK_ORCHESTRATORS];
const available = [...ORCHESTRATORS];
const nextOrchestrator = available.includes(selectedOrchestrator)
? selectedOrchestrator
: DEFAULT_ORCHESTRATOR;
......@@ -322,7 +322,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedOrchestratorState(nextOrchestrator);
await loadSelectionData(nextOrchestrator);
} catch (error) {
setAvailableOrchestrators([...FALLBACK_ORCHESTRATORS]);
setAvailableOrchestrators([...ORCHESTRATORS]);
setSelectedOrchestratorState(DEFAULT_ORCHESTRATOR);
await loadSelectionData(DEFAULT_ORCHESTRATOR);
void error;
......
......@@ -4,8 +4,6 @@ export type OrchestratorName = (typeof ORCHESTRATORS)[number];
export const DEFAULT_ORCHESTRATOR: OrchestratorName = "qa";
export const FALLBACK_ORCHESTRATORS: OrchestratorName[] = [...ORCHESTRATORS];
export const normalizeOrchestrator = (
value: string | null | undefined
): OrchestratorName | null => {
......
export {
getSelectionRouteForOrchestrator,
isSocraticOrchestrator,
isTaskSelectionOrchestrator,
type OrchestratorName,
} from "./orchestrator";
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