LLM Integration Layer
Status: v1. Complete LLM integration architecture for SkillNet. Covers provider abstraction, LangGraph integration, prompt management, streaming, cost/token management, error handling, and local LLM support. Aligns with architecture.md, backend-api.md, and rag-retrieval.md.
1. Provider Abstraction
SkillNet does not lock into any LLM provider. The backend talks to a single interface – base URL + API key + model name – using the openai Python SDK. Any OpenAI-compatible API works out of the box (OpenAI, DeepSeek, Groq, Together, Mistral, local via Ollama/LM Studio, etc.). No provider-specific code exists in business logic.
1.1 Environment Variables
Two groups: one for chat/completion models, one for embedding models. Per-use-case model overrides allow running different models for different tasks (e.g., a cheap model for tutoring, a stronger model for generation).
Chat / Completion
| Variable | Default | Description |
|---|---|---|
LLM_BASE_URL |
https://api.openai.com/v1 |
Base URL for the OpenAI-compatible API |
LLM_API_KEY |
"" |
API key for authentication |
LLM_MODEL |
gpt-4o-mini |
Default model for all use cases |
LLM_GENERATION_MODEL |
(falls back to LLM_MODEL) |
Model for course/manual generation (may want stronger reasoning) |
LLM_TUTOR_MODEL |
(falls back to LLM_MODEL) |
Model for tutor chat (may want faster/cheaper) |
LLM_EVAL_MODEL |
(falls back to LLM_MODEL) |
Model for exercise evaluation (may want structured output reliability) |
Embeddings
| Variable | Default | Description |
|---|---|---|
EMBEDDING_BASE_URL |
(falls back to LLM_BASE_URL) |
Separate base URL for embeddings (different provider or local) |
EMBEDDING_API_KEY |
(falls back to LLM_API_KEY) |
Separate API key for embedding provider |
EMBEDDING_MODEL |
intfloat/multilingual-e5-small |
Embedding model name |
EMBEDDING_DIMENSIONS |
384 |
Expected embedding dimensions. Must match pgvector column width |
E5 prefix requirement: The
intfloat/multilingual-e5-smallmodel (and all E5-family models) requires specific prefixes on input text: prepend"query: "for search queries and"passage: "for documents being indexed. SeeLLMClient.embed_query()andLLMClient.embed_passages()helpers in section 1.3.
The separation between chat and embedding configuration exists because organizations commonly use different providers for each: a cloud API for chat (OpenAI, DeepSeek) and a local model for embeddings (multilingual-e5-small via Ollama), or vice versa.
1.2 LLMSettings Configuration
Pydantic Settings class that reads environment variables, provides fallback chains, and resolves the correct model for each use case.
# src/llm/config.py
from enum import Enum
from pydantic_settings import BaseSettings
class LLMUseCase(str, Enum):
"""Use cases that may have dedicated model overrides."""
TUTOR = "tutor"
GENERATION = "generation"
EVALUATION = "evaluation"
ADMIN = "admin" # admin assistant chat
INGESTION = "ingestion" # document processing (summarization, extraction)
class LLMSettings(BaseSettings):
"""LLM configuration. Reads from environment variables with LLM_ prefix."""
# --- Chat / Completion ---
LLM_BASE_URL: str = "https://api.openai.com/v1"
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
# Per-use-case overrides (empty = fall back to LLM_MODEL)
LLM_GENERATION_MODEL: str = ""
LLM_TUTOR_MODEL: str = ""
LLM_EVAL_MODEL: str = ""
# --- Embeddings ---
EMBEDDING_BASE_URL: str = ""
EMBEDDING_API_KEY: str = ""
EMBEDDING_MODEL: str = "intfloat/multilingual-e5-small"
EMBEDDING_DIMENSIONS: int = 384
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
def model_for(self, use_case: LLMUseCase) -> str:
"""Resolve the model name for a given use case.
Priority: per-use-case env var > LLM_MODEL default.
"""
overrides = {
LLMUseCase.GENERATION: self.LLM_GENERATION_MODEL,
LLMUseCase.TUTOR: self.LLM_TUTOR_MODEL,
LLMUseCase.EVALUATION: self.LLM_EVAL_MODEL,
}
override = overrides.get(use_case, "")
return override if override else self.LLM_MODEL
@property
def embed_base_url(self) -> str:
"""Embedding base URL, falls back to chat base URL."""
return self.EMBEDDING_BASE_URL or self.LLM_BASE_URL
@property
def embed_api_key(self) -> str:
"""Embedding API key, falls back to chat API key."""
return self.EMBEDDING_API_KEY or self.LLM_API_KEY
llm_settings = LLMSettings()
Why Pydantic Settings: Consistent with the rest of SkillNet’s config pattern (see src/config.py in backend-api.md). Validates types at startup, reads .env files, and provides typed access without parsing strings manually.
1.3 LLMClient
A thin wrapper around AsyncOpenAI that provides three methods: chat(), chat_stream(), and embed(). The wrapper exists to centralize model resolution, default parameters, and error handling. Services never instantiate AsyncOpenAI directly.
# src/llm/client.py
from collections.abc import AsyncGenerator
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
from src.llm.config import LLMSettings, LLMUseCase
class LLMClient:
"""Provider-agnostic LLM client.
Uses the openai Python SDK which works with any OpenAI-compatible API:
OpenAI, DeepSeek, Groq, Together, Ollama, LM Studio, vLLM, etc.
"""
def __init__(self, settings: LLMSettings) -> None:
self._settings = settings
# Chat client
self._client = AsyncOpenAI(
base_url=settings.LLM_BASE_URL,
api_key=settings.LLM_API_KEY,
)
# Embedding client (may point to a different provider)
self._embed_client = AsyncOpenAI(
base_url=settings.embed_base_url,
api_key=settings.embed_api_key,
)
async def chat(
self,
messages: list[ChatCompletionMessageParam],
*,
use_case: LLMUseCase = LLMUseCase.TUTOR,
temperature: float = 0.7,
max_tokens: int = 2048,
response_format: dict | None = None,
) -> str:
"""Single-shot chat completion. Returns the full response text.
Used for: evaluations, generation steps, admin queries,
any case where the full response is needed before processing.
"""
model = self._settings.model_for(use_case)
kwargs: dict = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if response_format is not None:
kwargs["response_format"] = response_format
response = await self._client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
async def chat_stream(
self,
messages: list[ChatCompletionMessageParam],
*,
use_case: LLMUseCase = LLMUseCase.TUTOR,
temperature: float = 0.7,
max_tokens: int = 2048,
) -> AsyncGenerator[str, None]:
"""Streaming chat completion. Yields tokens as they arrive.
Used for: tutor chat, admin chat -- any user-facing conversational
endpoint where perceived latency matters.
"""
model = self._settings.model_for(use_case)
stream = await self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
async def embed(
self,
texts: list[str],
) -> list[list[float]]:
"""Generate embeddings for a list of texts.
Uses the embedding-specific client/model, which may point to a
different provider than the chat model.
Returns a list of embedding vectors. Each vector has exactly
EMBEDDING_DIMENSIONS floats.
Note: For E5-family models (intfloat/multilingual-e5-*), the caller
must prepend "query: " or "passage: " to inputs. Use the
embed_query() and embed_passages() helpers instead of calling
this method directly.
"""
response = await self._embed_client.embeddings.create(
model=self._settings.EMBEDDING_MODEL,
input=texts,
dimensions=self._settings.EMBEDDING_DIMENSIONS,
)
return [item.embedding for item in response.data]
async def embed_query(self, text: str) -> list[float]:
"""Embed a search query with the E5 "query: " prefix."""
return (await self.embed([f"query: {text}"]))[0]
async def embed_passages(self, texts: list[str]) -> list[list[float]]:
"""Embed documents/passages with the E5 "passage: " prefix."""
return await self.embed([f"passage: {t}" for t in texts])
Why not use AsyncOpenAI directly in services: Three reasons. First, model resolution per use case would be scattered across every call site. Second, retry logic and error translation (section 6) wrap the client. Third, testability – services depend on LLMClient which can be replaced with a fake in tests.
1.4 Dependency Injection
The LLMClient is injected into route handlers via FastAPI’s Depends() system, consistent with the existing pattern for database sessions and auth.
# src/deps/llm.py
from src.llm.client import LLMClient
from src.llm.config import llm_settings
_llm_client: LLMClient | None = None
async def get_llm_client() -> LLMClient:
"""FastAPI dependency that provides the LLM client.
The client is created once and reused (the underlying AsyncOpenAI
client manages its own connection pool).
"""
global _llm_client
if _llm_client is None:
_llm_client = LLMClient(llm_settings)
return _llm_client
# Type alias for route signatures
from typing import Annotated
from fastapi import Depends
LLM = Annotated[LLMClient, Depends(get_llm_client)]
Usage in routes:
@router.post("/chat")
async def tutor_chat(user: EmployeeUser, db: DBSession, llm: LLM, body: ChatMessage):
service = ChatService(db, llm)
return StreamingResponse(
service.tutor_stream(user, body.message, body.context),
media_type="text/event-stream",
)
2. LangGraph Integration
LangGraph manages agent state machines. Each agent type (tutor, content generator, evaluator) is a graph with defined nodes and edges. Graph nodes call LLMClient methods – they do not instantiate their own LLM connections.
2.1 State Dataclasses
Each agent type has a typed state that flows through the graph. States are dataclasses (not dicts) for type safety and IDE support.
State definitions are in their respective agent documents: see content-generation.md (GenerationState) and chat-agents.md (TutorState).
# src/agents/states.py
from dataclasses import dataclass, field
from typing import Annotated
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
@dataclass
class EvaluationState:
"""State for the exercise evaluation agent."""
messages: Annotated[list[BaseMessage], add_messages]
exercise_id: str
exercise_type: str
student_answer: dict
rubric: dict = field(default_factory=dict)
score: float | None = None
feedback: str = ""
criteria_scores: dict = field(default_factory=dict)
2.2 Graph Node Pattern
Graph nodes are async functions that receive state, call LLMClient, and return state updates. Dependencies (LLMClient, database session) are injected via closures when building the graph – not through config["configurable"] and not through state (which must remain serializable).
# src/agents/tutor_agent.py
from langgraph.graph import StateGraph, END
from src.agents.states import TutorState
from src.llm.client import LLMClient
async def retrieve_context(state: TutorState, llm: LLMClient, db) -> dict:
"""Node: retrieve relevant chunks from knowledge layer."""
if not state.should_retrieve:
return {}
query = state.messages[-1].content
query_embedding = await llm.embed_query(query)
# Semantic search (see rag-retrieval.md section 3.2.2)
chunks = await similarity_search(db, query_embedding, top_k=5)
return {
"retrieved_chunks": chunks,
"should_retrieve": False,
}
async def generate_response(state: TutorState, llm: LLMClient) -> dict:
"""Node: generate tutor response from context + history."""
context_block = assemble_context_block(state.retrieved_chunks)
system_prompt = build_tutor_system_prompt(state)
messages = [
{"role": "system", "content": system_prompt},
*[{"role": m.type, "content": m.content} for m in state.messages],
]
# Insert context before the latest user message
if context_block:
messages.insert(-1, {
"role": "system",
"content": f"Contexto relevante:\n\n{context_block}",
})
response = await llm.chat(
messages,
use_case=LLMUseCase.TUTOR,
temperature=0.3,
)
citations = extract_citations(response, state.retrieved_chunks)
return {
"messages": [AIMessage(content=response)],
"citations": citations,
}
def build_tutor_graph(llm: LLMClient, db) -> StateGraph:
"""Build the tutor agent graph.
Dependencies are injected via closures -- each node lambda
captures llm and db from the enclosing scope.
"""
graph = StateGraph(TutorState)
graph.add_node("retrieve", lambda state: retrieve_context(state, llm, db))
graph.add_node("respond", lambda state: generate_response(state, llm))
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "respond")
graph.add_edge("respond", END)
return graph.compile()
2.3 Streaming from LangGraph to SSE
For user-facing chat, the tutor graph uses chat_stream() instead of chat(). The async generator flows from LangGraph through FastAPI’s StreamingResponse to the client as Server-Sent Events.
# src/services/chat_service.py
from fastapi import Request
from src.llm.client import LLMClient, LLMUseCase
class ChatService:
def __init__(self, db, llm: LLMClient) -> None:
self._db = db
self._llm = llm
async def tutor_stream(
self,
user,
message: str,
context: dict | None,
request: Request,
):
"""Async generator that yields SSE events for tutor chat.
Flow: retrieve context -> stream LLM response -> emit citations.
"""
# 1. Retrieve context (non-streaming, fast)
chunks = await self._retrieve_context(user, message, context)
messages = self._build_messages(user, message, chunks)
# 2. Stream LLM response token by token
full_response = ""
try:
async for token in self._llm.chat_stream(
messages,
use_case=LLMUseCase.TUTOR,
temperature=0.3,
):
# Check if client disconnected
if await request.is_disconnected():
return
full_response += token
yield f"event: token\ndata: {json.dumps({'content': token})}\n\n"
except Exception as e:
yield f"event: error\ndata: {json.dumps({'message': str(e)})}\n\n"
return
# 3. Extract citations and emit done event
citations = extract_citations(full_response, chunks)
yield f"event: done\ndata: {json.dumps({'message_id': str(uuid4()), 'citations': citations})}\n\n"
# 4. Save to chat history (fire-and-forget, does not block the stream)
await self._save_history(user, message, full_response, citations)
3. Prompt Management
3.1 Design Decisions
| Decision | Rationale |
|---|---|
| Prompts in Python files, not database | Version controlled with git. Code review on prompt changes. No migration needed to update a prompt. Rollback = git revert. |
| f-strings for templating | No extra template engine dependency. Python’s f-strings are readable and type-checkable. Jinja2 would add complexity without benefit for this scale. |
| One directory per agent type | Clear ownership. A developer looking for the tutor’s system prompt goes to agents/prompts/tutor.py. |
| Shared module for cross-cutting instructions | Safety guardrails, citation format, and language detection are the same across all agents. DRY. |
3.2 Directory Structure
src/agents/prompts/
__init__.py
tutor.py # Tutor system prompt, follow-up prompts
generator.py # Course generation: outline, module, lesson, exercise
evaluator.py # Exercise grading: practical_case, dialogue
admin.py # Admin assistant system prompt
shared.py # Safety guardrails, citation instructions, language detection
3.3 Prompt Templates
Shared Safety and Citation Instructions
# src/agents/prompts/shared.py
SAFETY_GUARDRAILS = """
REGLAS DE SEGURIDAD (cumplir SIEMPRE):
- Responde SOLO con informacion del contexto proporcionado.
- Si la informacion no esta en el contexto, di exactamente: "No tengo informacion \
sobre esto en los documentos disponibles."
- NUNCA inventes datos, cifras, plazos o procedimientos.
- NUNCA reveles informacion personal de otros empleados.
- NUNCA menciones condiciones medicas, discapacidades o neurodivergencia.
- NUNCA proporciones consejo legal, medico o financiero.
- Si la pregunta es sobre un tema ajeno al ambito laboral, redirige: \
"Mi funcion es ayudarte con los procesos y conocimientos de la empresa."
"""
# SUPERSEDED 2026-07-27 for the tutor: the second rule ("di exactamente: No tengo
# informacion...") is the one that made the tutor refuse every question in the demo.
# The live prompts are in src/llm/prompts/tutor.py, where the persona holds across
# three grounding states and the bottom one answers from general knowledge and says
# so. The other five rules survive there, reworded. See docs/design/chat-agents.md.
CITATION_INSTRUCTIONS = """
INSTRUCCIONES DE CITACION:
- Cita la fuente usando [Fuente N] al final de cada afirmacion basada en el contexto.
- Cada fuente esta etiquetada como [Fuente 1], [Fuente 2], etc.
- Si combinas informacion de varias fuentes, cita todas: [Fuente 1][Fuente 3].
- No cites fuentes para conocimiento general o saludos.
"""
LANGUAGE_DETECTION = """
IDIOMA:
- Responde en el mismo idioma que la pregunta del usuario.
- Si el contexto esta en un idioma diferente al de la pregunta, traduce \
la informacion relevante al idioma de la pregunta.
"""
Tutor System Prompt
# src/agents/prompts/tutor.py
from src.agents.prompts.shared import (
SAFETY_GUARDRAILS,
CITATION_INSTRUCTIONS,
LANGUAGE_DETECTION,
)
def tutor_system_prompt(
user_name: str,
course_title: str | None = None,
lesson_title: str | None = None,
) -> str:
"""Build the tutor system prompt.
Temperature: 0.3 (grounded RAG responses need low temperature).
Format: plain text with [Fuente N] citations.
"""
context_section = ""
if course_title:
context_section += f"El empleado esta en el curso: '{course_title}'."
if lesson_title:
context_section += f" Leccion actual: '{lesson_title}'."
return f"""Eres un tutor de formacion corporativa de SkillNet. Tu nombre es Tutor.
Ayudas a empleados a entender los procesos y conocimientos de su empresa.
EMPLEADO: {user_name}
{context_section}
COMPORTAMIENTO:
- Se claro, directo y profesional.
- Adapta tus explicaciones al nivel del empleado.
- Si el empleado parece confundido, ofrece una explicacion alternativa o un ejemplo.
- Usa listas y estructura cuando la respuesta tiene multiples pasos.
- Mantén las respuestas concisas (maximo 3-4 parrafos) a menos que el empleado \
pida una explicacion detallada.
{SAFETY_GUARDRAILS}
{CITATION_INSTRUCTIONS}
{LANGUAGE_DETECTION}
"""
Generator Prompts
# src/agents/prompts/generator.py
def outline_prompt(document_summary: str, target_outcome: str) -> str:
"""Prompt for generating a course outline from a document.
Temperature: 0.3 (structured, deterministic output).
Format: JSON (response_format={"type": "json_object"}).
"""
return f"""Eres un disenador instruccional experto. Analiza el siguiente resumen \
de documento y genera una estructura de curso.
DOCUMENTO:
{document_summary}
OBJETIVO DEL CURSO:
{target_outcome}
Genera un JSON con esta estructura exacta:
{{
"title": "Titulo del curso",
"modules": [
{{
"title": "Titulo del modulo",
"summary": "Resumen en 1-2 oraciones",
"lessons": [
{{
"title": "Titulo de la leccion",
"key_concepts": ["concepto1", "concepto2"],
"estimated_duration_minutes": 15
}}
]
}}
]
}}
REGLAS:
- Maximo 5 modulos, 3-4 lecciones por modulo.
- Cada modulo debe cubrir un tema coherente.
- Ordenar de lo basico a lo avanzado.
- Los conceptos clave deben ser especificos al documento, no genericos.
"""
def lesson_content_prompt(
lesson_title: str,
key_concepts: list[str],
source_chunks: str,
) -> str:
"""Prompt for generating lesson content from source chunks.
Temperature: 0.3 (faithful to source material).
Format: Markdown text.
"""
concepts = ", ".join(key_concepts)
return f"""Genera el contenido de la leccion "{lesson_title}" basandote \
EXCLUSIVAMENTE en el material fuente proporcionado.
CONCEPTOS CLAVE A CUBRIR: {concepts}
MATERIAL FUENTE:
{source_chunks}
FORMATO DE SALIDA:
- Escribe en Markdown.
- Empieza con una introduccion breve (2-3 oraciones).
- Desarrolla cada concepto con explicaciones claras.
- Incluye ejemplos practicos del contexto del documento.
- Termina con un resumen de puntos clave en lista.
- Extension: 500-1000 palabras.
REGLAS:
- Usa SOLO informacion del material fuente.
- No inventes ejemplos que no esten en el documento.
- Adapta el lenguaje al ambito corporativo.
"""
def exercise_prompt(
lesson_title: str,
lesson_content: str,
exercise_type: str,
) -> str:
"""Prompt for generating exercises for a lesson.
Temperature: 0.3 (consistent with generation pipeline).
Format: JSON (response_format={"type": "json_object"}).
"""
type_instructions = {
"test": """Genera una pregunta de opcion multiple con 4 opciones.
JSON: {"question": "...", "options": ["a", "b", "c", "d"], "correct": 0, "explanation": "..."}""",
"true_false": """Genera una afirmacion verdadera o falsa.
JSON: {"statement": "...", "correct": true|false, "explanation": "..."}""",
"fill_blank": """Genera un texto con huecos a rellenar (marca los huecos con ___).
JSON: {"text_with_blanks": "El plazo de ___ es de ___ dias.", "answers": ["devolucion", "30"], "explanation": "..."}""",
"order_steps": """Genera una secuencia de pasos a ordenar.
JSON: {"instruction": "Ordena los pasos de...", "steps": ["paso1", "paso2", "paso3", "paso4"], "correct_order": [0, 1, 2, 3], "explanation": "..."}""",
"practical_case": """Genera un caso practico con rubrica de evaluacion.
JSON: {"scenario": "...", "question": "...", "rubric": {"criterio1": {"description": "...", "max_score": 5}, "criterio2": {"description": "...", "max_score": 5}}, "model_answer": "..."}""",
}
instruction = type_instructions.get(exercise_type, type_instructions["test"])
return f"""Genera un ejercicio de tipo "{exercise_type}" para la leccion "{lesson_title}".
CONTENIDO DE LA LECCION:
{lesson_content}
TIPO DE EJERCICIO:
{instruction}
REGLAS:
- El ejercicio debe evaluar comprension del contenido, no memorizacion.
- Las opciones incorrectas deben ser plausibles.
- La explicacion debe ser educativa.
- Basa todo en el contenido de la leccion.
"""
Evaluator Prompts
# src/agents/prompts/evaluator.py
def practical_case_eval_prompt(
scenario: str,
question: str,
rubric: dict,
student_answer: str,
model_answer: str,
) -> str:
"""Prompt for evaluating a practical case exercise.
Temperature: 0.2 (consistent evaluation).
Format: JSON (response_format={"type": "json_object"}).
"""
rubric_text = "\n".join(
f"- {name}: {criteria['description']} (max {criteria['max_score']} puntos)"
for name, criteria in rubric.items()
)
return f"""Evalua la respuesta de un empleado a un caso practico.
ESCENARIO:
{scenario}
PREGUNTA:
{question}
RUBRICA:
{rubric_text}
RESPUESTA MODELO (referencia, no la unica respuesta correcta):
{model_answer}
RESPUESTA DEL EMPLEADO:
{student_answer}
Evalua segun la rubrica. Devuelve JSON:
{{
"total_score": <float>,
"max_score": <float>,
"passed": <bool>,
"criteria": {{
"<criterio>": {{
"score": <float>,
"max_score": <float>,
"feedback": "Explicacion especifica"
}}
}},
"general_feedback": "Retroalimentacion general constructiva en 2-3 oraciones."
}}
REGLAS:
- Se justo pero exigente. La respuesta no necesita ser identica al modelo.
- Valora la comprension sobre la memorizacion.
- El feedback debe ser constructivo y especifico.
- passed = true si total_score >= 60% de max_score.
"""
Admin Assistant Prompt
# src/agents/prompts/admin.py
from src.agents.prompts.shared import SAFETY_GUARDRAILS, LANGUAGE_DETECTION
def admin_system_prompt() -> str:
"""Build the admin assistant system prompt.
Temperature: 0.5 (helpful but precise).
Format: plain text.
"""
return f"""Eres un asistente administrativo de SkillNet. Ayudas a administradores \
a gestionar la formacion de su equipo.
CAPACIDADES:
- Responder preguntas sobre el estado de la formacion (progreso, brechas, alertas).
- Sugerir acciones basadas en datos (asignaciones, revisiones, mentoria).
- Explicar metricas y reportes del sistema.
- Guiar en la configuracion del sistema.
DATOS DISPONIBLES:
- Los datos del contexto provienen de la base de datos de la organizacion.
- Incluyen: empleados, cursos, progreso, habilidades, alertas.
COMPORTAMIENTO:
- Se directo y orientado a la accion.
- Cuando sugieras acciones, se especifico ("Asignar el curso X a Carlos").
- Presenta datos en formato tabular cuando haya multiples items.
- Destaca situaciones criticas (deadlines proximos, brechas de habilidad).
{SAFETY_GUARDRAILS}
{LANGUAGE_DETECTION}
"""
3.4 Temperature and Format Summary
| Agent | Use Case | Temperature | Output Format |
|---|---|---|---|
| Tutor | Chat response | 0.3 | Plain text + citations |
| Tutor | Follow-up suggestion | 0.8 | Plain text |
| Generator | Course outline | 0.3 | JSON |
| Generator | Lesson content | 0.3 | Markdown |
| Generator | Exercise creation | 0.3 | JSON |
| Evaluator | Practical case grading | 0.2 | JSON |
| Evaluator | Dialogue assessment | 0.2 | JSON |
| Admin | Assistant response | 0.5 | Plain text |
| Ingestion | Document summarization | 0.2 | Plain text |
3.5 Prompt Testing
Prompts can be tested in isolation without spinning up the full application. Each prompt function returns a string – no side effects, no I/O.
# tests/test_prompts.py
def test_tutor_prompt_includes_safety():
prompt = tutor_system_prompt("Maria", "Devoluciones", "Plazos")
assert "NUNCA inventes" in prompt
assert "Maria" in prompt
assert "Devoluciones" in prompt
def test_outline_prompt_produces_valid_instruction():
prompt = outline_prompt("Manual de devoluciones...", "Dominar el proceso")
assert "JSON" in prompt
assert "modules" in prompt
def test_eval_prompt_includes_rubric():
rubric = {"claridad": {"description": "Respuesta clara", "max_score": 5}}
prompt = practical_case_eval_prompt(
"Un cliente...", "Que harias?", rubric, "Yo haria...", "Lo correcto es..."
)
assert "claridad" in prompt
assert "max 5 puntos" in prompt
4. Streaming Architecture
4.1 End-to-End Flow
Token-by-token streaming from LLM to browser:
LLM Provider
| (OpenAI-compatible streaming API)
v
AsyncOpenAI.chat.completions.create(stream=True)
| (async iterator of ChatCompletionChunk)
v
LLMClient.chat_stream()
| (async generator yielding str tokens)
v
ChatService.tutor_stream()
| (async generator yielding SSE-formatted strings)
v
FastAPI StreamingResponse(media_type="text/event-stream")
| (HTTP chunked transfer encoding)
v
Browser EventSource / ReadableStream
| (parsed SSE events)
v
React state update (append token to message)
4.2 SSE Event Types
Three event types, kept minimal:
event: token
data: {"content": "The"}
event: token
data: {"content": " return"}
event: token
data: {"content": " policy"}
event: done
data: {"message_id": "a1b2c3d4", "citations": [{"document": "Manual_Devoluciones.pdf", "section": "Plazos", "page": 3}]}
event: error
data: {"message": "Model unavailable. Please try again."}
| Event | When | Payload |
|---|---|---|
token |
Each token from the LLM | {"content": "<token text>"} |
done |
LLM finished generating | {"message_id": "<uuid>", "citations": [...]} |
error |
Any error during streaming | {"message": "<user-facing message>"} |
4.3 Server-Side Implementation
# src/routes/chat.py
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
router = APIRouter()
@router.post("/chat")
async def tutor_chat(
request: Request,
user: EmployeeUser,
db: DBSession,
llm: LLM,
body: ChatMessageRequest,
):
"""Tutor chat endpoint. Returns SSE stream."""
service = ChatService(db, llm)
return StreamingResponse(
service.tutor_stream(user, body.message, body.context, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
},
)
4.4 Client-Side Implementation
The React frontend uses ReadableStream (not EventSource) because the initial request is a POST with a body, which EventSource does not support.
// src/hooks/useTutorChat.ts
interface ChatEvent {
type: 'token' | 'done' | 'error'
data: {
content?: string
message_id?: string
citations?: Citation[]
message?: string
}
}
async function streamChat(
message: string,
context: { course_id?: string; lesson_id?: string },
onEvent: (event: ChatEvent) => void,
signal: AbortSignal,
): Promise<void> {
const res = await fetch('/api/v1/chat', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, context }),
signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }))
onEvent({ type: 'error', data: { message: err.detail } })
return
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()! // Keep incomplete line in buffer
let eventType = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7)
} else if (line.startsWith('data: ') && eventType) {
const data = JSON.parse(line.slice(6))
onEvent({ type: eventType as ChatEvent['type'], data })
eventType = ''
}
}
}
}
4.5 Client Disconnection Detection
If the user navigates away or closes the tab while the LLM is still generating, the server detects this and stops the stream. This prevents wasting LLM tokens on abandoned requests.
# Inside ChatService.tutor_stream()
async for token in self._llm.chat_stream(messages, use_case=LLMUseCase.TUTOR):
# Check if client disconnected before yielding each token
if await request.is_disconnected():
# Client is gone -- stop generating
return
full_response += token
yield f"event: token\ndata: {json.dumps({'content': token})}\n\n"
The request.is_disconnected() call is cheap (checks the ASGI connection state). It does not add meaningful latency to the stream.
4.6 When NOT to Stream
Streaming is used only for user-facing conversational output. For structured JSON output, the full response is needed before processing:
| Endpoint | Streaming? | Why |
|---|---|---|
| Tutor chat | Yes | User sees tokens as they arrive |
| Admin chat | Yes | Same rationale |
| Exercise evaluation | No | Response must be valid JSON, parsed as a whole |
| Course generation | No | Each step produces structured data for the next step |
| Document summarization | No | Summary is consumed by the system, not displayed incrementally |
5. Cost and Token Management
5.1 Token Counting
SkillNet uses tiktoken with the cl100k_base encoding as a universal approximation for token counting. This encoding is close enough for OpenAI models and serves as a reasonable estimate for other providers (DeepSeek, Groq, etc.). Exact token counts are provider-specific, but the approximation is sufficient for budget management and truncation decisions.
# src/llm/tokens.py
import tiktoken
# Singleton encoder -- loaded once, thread-safe
_encoder = tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str) -> int:
"""Count tokens using cl100k_base encoding.
This is an approximation. Different models use different tokenizers,
but cl100k_base is close enough for:
- Context window budget checks (off by <5%)
- Cost estimation (off by <10%)
- Truncation decisions (safe because we truncate conservatively)
"""
return len(_encoder.encode(text))
def count_messages_tokens(messages: list[dict]) -> int:
"""Count tokens across a list of chat messages.
Accounts for the per-message overhead (role, name, delimiters).
Based on OpenAI's token counting guide.
"""
tokens = 0
for message in messages:
tokens += 4 # every message: <|start|>{role}\n ... \n<|end|>
for key, value in message.items():
tokens += count_tokens(str(value))
if key == "name":
tokens += -1 # role is omitted when name is present
tokens += 2 # reply priming: <|start|>assistant
return tokens
5.2 Context Window Management
The most critical use of token counting is fitting messages into the model’s context window. The fit_messages_to_context function ensures that the system prompt is never truncated, while conversation history is trimmed from oldest messages first.
# src/llm/context.py
from src.llm.tokens import count_tokens, count_messages_tokens
# Known context window sizes. Conservative estimates (leave margin).
CONTEXT_WINDOWS = {
"gpt-4o": 128_000,
"gpt-4o-mini": 128_000,
"gpt-4-turbo": 128_000,
"gpt-3.5-turbo": 16_385,
"deepseek-chat": 64_000,
"deepseek-reasoner": 64_000,
"llama3.1": 128_000,
"llama3.2": 128_000,
"mistral": 32_000,
"qwen2.5": 32_000,
}
DEFAULT_CONTEXT_WINDOW = 8_192 # Conservative fallback for unknown models
RESPONSE_RESERVE = 2_048 # Tokens reserved for the model's response
def get_context_window(model: str) -> int:
"""Get context window size for a model.
Checks exact match first, then prefix match (for versioned names
like 'gpt-4o-2024-08-06'), then falls back to conservative default.
"""
if model in CONTEXT_WINDOWS:
return CONTEXT_WINDOWS[model]
# Prefix match: 'gpt-4o-2024-08-06' matches 'gpt-4o'
for known_model, window in CONTEXT_WINDOWS.items():
if model.startswith(known_model):
return window
return DEFAULT_CONTEXT_WINDOW
def fit_messages_to_context(
messages: list[dict],
model: str,
response_reserve: int = RESPONSE_RESERVE,
) -> list[dict]:
"""Truncate message history to fit within the model's context window.
Rules:
1. The system message (messages[0]) is NEVER truncated.
2. The most recent user message (messages[-1]) is NEVER truncated.
3. History messages are removed from OLDEST first.
4. At minimum, the result contains [system, latest_user_message].
Returns a new list (does not modify the input).
"""
max_tokens = get_context_window(model) - response_reserve
# Check if everything fits
total = count_messages_tokens(messages)
if total <= max_tokens:
return list(messages)
# System prompt is always first and always kept
system_msg = messages[0] if messages[0]["role"] == "system" else None
latest_msg = messages[-1]
# Calculate fixed cost (system + latest message)
fixed_messages = []
if system_msg:
fixed_messages.append(system_msg)
fixed_cost = count_messages_tokens(fixed_messages + [latest_msg])
if fixed_cost >= max_tokens:
# Even system + latest don't fit. Return them anyway (model will
# truncate internally, but we preserve the most important context).
result = []
if system_msg:
result.append(system_msg)
result.append(latest_msg)
return result
# Budget for history messages
history_budget = max_tokens - fixed_cost
# History: everything between system and latest message
start = 1 if system_msg else 0
history = messages[start:-1]
# Keep as many recent history messages as fit
kept_history = []
history_tokens = 0
for msg in reversed(history):
msg_tokens = count_messages_tokens([msg])
if history_tokens + msg_tokens > history_budget:
break
kept_history.insert(0, msg)
history_tokens += msg_tokens
# Assemble result
result = []
if system_msg:
result.append(system_msg)
result.extend(kept_history)
result.append(latest_msg)
return result
5.3 Usage Tracking
LLM usage is logged to a database table for monitoring and cost analysis. This is not real-time billing – it is an observability tool for the admin.
-- llm_usage_log table
CREATE TABLE llm_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
user_id uuid REFERENCES users(id),
use_case text NOT NULL, -- 'tutor', 'generation', 'evaluation', etc.
model text NOT NULL, -- actual model used
prompt_tokens integer NOT NULL,
completion_tokens integer NOT NULL,
total_tokens integer NOT NULL,
estimated_cost numeric(10, 6), -- USD, estimated from token counts
duration_ms integer, -- wall clock time for the API call
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_llm_usage_org ON llm_usage_log (org_id, created_at DESC);
CREATE INDEX idx_llm_usage_case ON llm_usage_log (use_case, created_at DESC);
# src/llm/usage.py
from datetime import datetime
from uuid import UUID
# Cost per 1M tokens (approximate, varies by provider)
COST_TABLE = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"deepseek-chat": {"input": 0.14, "output": 0.28},
"deepseek-reasoner": {"input": 0.55, "output": 2.19},
"llama3.1": {"input": 0.0, "output": 0.0}, # Local, no API cost
}
def estimate_cost(
model: str,
prompt_tokens: int,
completion_tokens: int,
) -> float:
"""Estimate USD cost for an LLM call.
Returns 0.0 for unknown models (conservative -- better to
under-report than over-report for local models).
"""
rates = COST_TABLE.get(model)
if not rates:
return 0.0
input_cost = (prompt_tokens / 1_000_000) * rates["input"]
output_cost = (completion_tokens / 1_000_000) * rates["output"]
return round(input_cost + output_cost, 6)
async def log_usage(
db,
*,
org_id: UUID,
user_id: UUID | None,
use_case: str,
model: str,
prompt_tokens: int,
completion_tokens: int,
duration_ms: int,
) -> None:
"""Record LLM usage to the database."""
cost = estimate_cost(model, prompt_tokens, completion_tokens)
await db.execute(
text("""
INSERT INTO llm_usage_log
(org_id, user_id, use_case, model, prompt_tokens,
completion_tokens, total_tokens, estimated_cost, duration_ms)
VALUES
(:org_id, :user_id, :use_case, :model, :prompt_tokens,
:completion_tokens, :total_tokens, :cost, :duration_ms)
"""),
{
"org_id": org_id,
"user_id": user_id,
"use_case": use_case,
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"cost": cost,
"duration_ms": duration_ms,
},
)
5.4 Cost Estimation for Generation Jobs
Before starting a course generation job, the system estimates the total token cost so the admin can make an informed decision.
# src/llm/cost.py
from src.llm.tokens import count_tokens
from src.llm.usage import estimate_cost
def estimate_generation_cost(
document_text: str,
model: str,
num_modules: int = 4,
lessons_per_module: int = 3,
exercises_per_lesson: int = 2,
) -> dict:
"""Estimate the token cost of generating a course from a document.
Returns a breakdown by step so the admin knows where the cost is.
"""
doc_tokens = count_tokens(document_text)
# Step 1: Outline generation (reads document summary, outputs JSON)
outline_input = doc_tokens // 3 + 500 # summary + prompt
outline_output = 800 # JSON outline
# Step 2: Lesson content (one call per lesson, reads relevant chunks)
num_lessons = num_modules * lessons_per_module
lesson_input = 1500 * num_lessons # chunks + prompt per lesson
lesson_output = 1000 * num_lessons # ~1000 tokens per lesson
# Step 3: Exercise generation (one call per exercise)
num_exercises = num_lessons * exercises_per_lesson
exercise_input = 800 * num_exercises # lesson content + prompt
exercise_output = 300 * num_exercises # JSON per exercise
total_input = outline_input + lesson_input + exercise_input
total_output = outline_output + lesson_output + exercise_output
total_cost = estimate_cost(model, total_input, total_output)
return {
"estimated_input_tokens": total_input,
"estimated_output_tokens": total_output,
"estimated_total_tokens": total_input + total_output,
"estimated_cost_usd": total_cost,
"breakdown": {
"outline": estimate_cost(model, outline_input, outline_output),
"lessons": estimate_cost(model, lesson_input, lesson_output),
"exercises": estimate_cost(model, exercise_input, exercise_output),
},
"model": model,
}
6. Error Handling
6.1 Retry Strategy
LLM API calls are inherently unreliable (network issues, rate limits, provider outages). SkillNet uses the tenacity library for retry logic with exponential backoff.
# src/llm/resilience.py
import logging
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception,
before_sleep_log,
)
from openai import (
APIConnectionError,
APITimeoutError,
InternalServerError,
RateLimitError,
AuthenticationError,
NotFoundError,
)
logger = logging.getLogger("skillnet.llm")
def _is_retryable(exc: BaseException) -> bool:
"""Determine if an exception is worth retrying.
Retryable:
- APIConnectionError: network issue, might resolve
- APITimeoutError: server slow, might recover
- InternalServerError (500): provider hiccup
- RateLimitError (429): will resolve after backoff
Not retryable:
- AuthenticationError (401): wrong API key, won't fix itself
- NotFoundError (404): wrong model name, won't fix itself
- BadRequestError (400): malformed request, won't fix itself
"""
return isinstance(exc, (
APIConnectionError,
APITimeoutError,
InternalServerError,
RateLimitError,
))
def with_retry(func):
"""Decorator that adds retry logic to LLM calls.
Strategy:
- Max 3 attempts
- Exponential backoff: 1s, 2s, 4s (multiplier=1, min=1, max=10)
- Only retries on transient errors
- Logs each retry attempt
"""
return retry(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)(func)
Applied to the LLMClient methods:
# src/llm/client.py (with retry)
class LLMClient:
@with_retry
async def chat(self, messages, *, use_case, temperature, max_tokens, response_format=None):
...
# Note: chat_stream is NOT retried at the method level.
# If the stream fails mid-way, the error is emitted as an SSE event
# and the client can retry the full request.
async def chat_stream(self, messages, **kwargs):
...
@with_retry
async def embed(self, texts):
...
6.2 Rate Limit Handling
When the LLM provider returns 429 (rate limited), the Retry-After header indicates how long to wait. The openai SDK handles this automatically via RateLimitError, and tenacity’s exponential backoff covers the retry timing. For cases where the Retry-After header specifies a longer wait:
# src/llm/resilience.py (continued)
from tenacity import wait_exponential, wait_combine, wait_fixed
def _get_retry_after(exc: BaseException) -> float | None:
"""Extract Retry-After from a RateLimitError, if present."""
if isinstance(exc, RateLimitError):
retry_after = getattr(exc, "response", None)
if retry_after and hasattr(retry_after, "headers"):
header = retry_after.headers.get("Retry-After")
if header:
try:
return float(header)
except ValueError:
pass
return None
class WaitRespectRetryAfter(wait_exponential):
"""Custom wait strategy that respects the Retry-After header
when present, falling back to exponential backoff otherwise."""
def __call__(self, retry_state):
exc = retry_state.outcome.exception()
retry_after = _get_retry_after(exc)
if retry_after is not None:
return retry_after
return super().__call__(retry_state)
6.3 Error Translation
LLM errors are translated into SkillNet’s AppError hierarchy (see backend-api.md section 4.4). The user never sees raw provider errors.
# src/llm/errors.py
from openai import (
AuthenticationError,
NotFoundError,
BadRequestError,
APIConnectionError,
APITimeoutError,
InternalServerError,
RateLimitError,
)
from src.core.exceptions import AppError
class LLMUnavailableError(AppError):
"""LLM provider is temporarily unavailable."""
def __init__(self, detail: str = "LLM service is temporarily unavailable"):
super().__init__(detail, "LLM_UNAVAILABLE", 502)
class LLMConfigError(AppError):
"""LLM configuration is invalid (wrong API key, model not found)."""
def __init__(self, detail: str = "LLM configuration error"):
super().__init__(detail, "LLM_CONFIG_ERROR", 503)
class LLMRateLimitError(AppError):
"""LLM provider rate limit exceeded."""
def __init__(self, retry_after: float | None = None):
detail = "LLM rate limit exceeded. Please try again shortly."
super().__init__(detail, "LLM_RATE_LIMITED", 429)
self.retry_after = retry_after
def translate_llm_error(exc: Exception) -> AppError:
"""Convert an openai SDK exception to a SkillNet AppError.
This function is used AFTER retries are exhausted. The error
reaching here is the final failure.
Key principle: errors are 502/503 (upstream failure), not 500
(our fault). Non-LLM features continue working.
"""
if isinstance(exc, AuthenticationError):
return LLMConfigError(
"LLM API key is invalid. Check LLM_API_KEY in environment variables."
)
if isinstance(exc, NotFoundError):
return LLMConfigError(
"LLM model not found. Check LLM_MODEL in environment variables."
)
if isinstance(exc, BadRequestError):
return LLMConfigError(
f"Invalid request to LLM provider: {exc.message}"
)
if isinstance(exc, RateLimitError):
retry_after = _get_retry_after(exc)
return LLMRateLimitError(retry_after)
if isinstance(exc, (APIConnectionError, APITimeoutError)):
return LLMUnavailableError(
"Cannot connect to LLM provider. Check network and LLM_BASE_URL."
)
if isinstance(exc, InternalServerError):
return LLMUnavailableError(
"LLM provider returned an internal error. The issue is on their end."
)
# Unknown error
return LLMUnavailableError(f"Unexpected LLM error: {type(exc).__name__}")
6.4 Graceful Degradation
When the LLM is unavailable, SkillNet does not crash. Non-LLM features continue working normally:
| Feature | When LLM is down |
|---|---|
| Login, navigation, settings | Work normally |
| Course listing, enrollment | Work normally |
| Deterministic exercises (test, true/false, fill blank) | Grade normally (no LLM needed) |
| Progress tracking, spaced repetition scheduling | Work normally |
| Skills matrix, reports | Work normally |
| Tutor chat | Returns error: “El tutor no esta disponible en este momento” |
| Practical case evaluation | Returns error: “La evaluacion no esta disponible” |
| Course generation | Fails gracefully: job status = “failed”, admin notified |
| Document processing | Embedding step fails, status = “error”, admin can retry |
The principle: 502/503 (upstream failure), never 500 (our bug). The client receives a clear error message and knows the issue is the LLM provider, not SkillNet.
7. Local LLM Support
SkillNet’s provider-agnostic design means local LLMs work with the same interface – change the URL, keep the code.
7.1 Configuration
| Provider | LLM_BASE_URL |
Model example | Notes |
|---|---|---|---|
| Ollama | http://localhost:11434/v1 |
llama3.1, qwen2.5, mistral |
Ollama exposes an OpenAI-compatible /v1 endpoint. No API key needed (set LLM_API_KEY=ollama). |
| LM Studio | http://localhost:1234/v1 |
lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF |
LM Studio’s local server is OpenAI-compatible out of the box. No API key needed (set LLM_API_KEY=lm-studio). |
| vLLM | http://localhost:8000/v1 |
meta-llama/Meta-Llama-3.1-8B-Instruct |
vLLM for production-grade local serving with batching. |
| text-generation-webui | http://localhost:5000/v1 |
(loaded model) | Oobabooga’s web UI also exposes an OpenAI-compatible endpoint. |
Example .env for a fully local setup:
# Chat model via Ollama
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama
LLM_MODEL=llama3.1
# Embeddings via Ollama
EMBEDDING_BASE_URL=http://localhost:11434/v1
EMBEDDING_API_KEY=ollama
EMBEDDING_MODEL=intfloat/multilingual-e5-small
EMBEDDING_DIMENSIONS=384
7.2 Model Considerations
Local models have different characteristics than cloud APIs. The LLM integration layer handles these differences:
Context Window
Local models typically have smaller context windows (4K-8K for GGUF quantized models, though some support 32K-128K). The fit_messages_to_context function (section 5.2) handles this automatically – if the configured model has a known smaller window, history is truncated more aggressively.
For unknown local models, the conservative default of 8,192 tokens is used. The admin can override by adding the model to CONTEXT_WINDOWS in the configuration.
JSON Output Reliability
Cloud APIs (OpenAI, DeepSeek) support structured output modes (response_format={"type": "json_object"}). Local models may not support this parameter, or may produce malformed JSON.
SkillNet handles this with a fallback JSON extraction function:
# src/llm/parsing.py
import json
import re
def extract_json_from_response(text: str) -> dict | list | None:
"""Extract JSON from an LLM response, handling common local model issues.
Tries, in order:
1. Direct parse (response is pure JSON)
2. Extract from markdown code block (```json ... ```)
3. Find the first { ... } or [ ... ] block in the text
4. Return None if no valid JSON found
This function exists because local models (Ollama, LM Studio) may:
- Wrap JSON in markdown code blocks
- Add explanatory text before/after the JSON
- Produce JSON with trailing commas (cleaned up)
"""
if not text or not text.strip():
return None
text = text.strip()
# 1. Direct parse
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 2. Markdown code block
code_block = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL)
if code_block:
try:
return json.loads(code_block.group(1).strip())
except json.JSONDecodeError:
pass
# 3. Find first JSON object or array
# Look for balanced braces/brackets
for start_char, end_char in [('{', '}'), ('[', ']')]:
start = text.find(start_char)
if start == -1:
continue
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == start_char:
depth += 1
elif c == end_char:
depth -= 1
if depth == 0:
candidate = text[start:i + 1]
# Clean trailing commas before closing brace/bracket
candidate = re.sub(r',\s*([}\]])', r'\1', candidate)
try:
return json.loads(candidate)
except json.JSONDecodeError:
break
return None
Embedding Dimension Validation
When using local embedding models, the configured EMBEDDING_DIMENSIONS must match what the model actually produces. A mismatch causes pgvector insertion failures (vector dimension mismatch). SkillNet validates this at startup:
# src/llm/startup.py
import logging
from src.llm.client import LLMClient
from src.llm.config import llm_settings
logger = logging.getLogger("skillnet.llm")
async def validate_embedding_config(llm: LLMClient) -> None:
"""Validate that the embedding model produces vectors matching
the configured EMBEDDING_DIMENSIONS.
Called during application startup (in the FastAPI lifespan handler).
Raises RuntimeError if dimensions don't match, preventing the app
from starting with a misconfigured embedding model.
"""
try:
test_embeddings = await llm.embed(["test embedding dimension validation"])
except Exception as e:
logger.warning(
"Could not validate embedding dimensions at startup: %s. "
"Embedding features may fail at runtime.",
e,
)
return
actual_dim = len(test_embeddings[0])
expected_dim = llm_settings.EMBEDDING_DIMENSIONS
if actual_dim != expected_dim:
raise RuntimeError(
f"Embedding dimension mismatch: model '{llm_settings.EMBEDDING_MODEL}' "
f"produces {actual_dim}-dimensional vectors, but EMBEDDING_DIMENSIONS "
f"is set to {expected_dim}. Update EMBEDDING_DIMENSIONS={actual_dim} "
f"in your .env file, and ensure the document_chunks.embedding column "
f"matches (vector({actual_dim}))."
)
logger.info(
"Embedding config validated: model=%s, dimensions=%d",
llm_settings.EMBEDDING_MODEL,
actual_dim,
)
Integration with the FastAPI lifespan:
# src/main.py (lifespan handler addition)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Existing startup...
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
# Validate embedding configuration
llm = LLMClient(llm_settings)
await validate_embedding_config(llm)
yield
await engine.dispose()
7.3 Performance Comparison
Approximate performance for a tutor chat response (single question, 5 chunks in context, ~2000 token prompt, ~500 token response):
| Setup | Time to First Token | Full Response | Cost per Query |
|---|---|---|---|
| Cloud API (GPT-4o-mini) | ~300ms | ~2s | ~$0.001 |
| Cloud API (DeepSeek-chat) | ~500ms | ~3s | ~$0.0003 |
| Local GPU (RTX 3060, Llama 3.1 8B) | ~200ms | ~4s | $0.00 |
| Local GPU (RTX 4090, Llama 3.1 8B) | ~100ms | ~2s | $0.00 |
| Local CPU (16GB RAM, Llama 3.1 8B Q4) | ~2s | ~30s | $0.00 |
| Local CPU (32GB RAM, Qwen 2.5 7B Q4) | ~1.5s | ~25s | $0.00 |
Recommendation:
- Development / testing: Local via Ollama on CPU. Slow but free and offline.
- Small deployment (< 10 concurrent users): DeepSeek or local with a mid-range GPU.
- Production deployment: Cloud API (GPT-4o-mini or DeepSeek) for reliability and speed. Local GPU for organizations that cannot send data externally.
CPU-only local LLMs are viable for testing and low-traffic deployments. For concurrent users, a GPU or cloud API is strongly recommended.
8. File Layout
All LLM integration code lives under src/llm/. Agent definitions (LangGraph graphs) live under src/agents/. Prompts are nested under agents because they are agent-specific.
src/
├── llm/ # LLM integration layer
│ ├── __init__.py
│ ├── config.py # LLMSettings, LLMUseCase enum
│ ├── client.py # LLMClient (chat, chat_stream, embed)
│ ├── tokens.py # count_tokens, count_messages_tokens (tiktoken)
│ ├── context.py # fit_messages_to_context, CONTEXT_WINDOWS
│ ├── cost.py # estimate_generation_cost
│ ├── usage.py # log_usage, estimate_cost, COST_TABLE
│ ├── resilience.py # with_retry, WaitRespectRetryAfter
│ ├── errors.py # LLMUnavailableError, LLMConfigError, translate_llm_error
│ ├── parsing.py # extract_json_from_response
│ └── startup.py # validate_embedding_config
│
├── deps/
│ ├── llm.py # get_llm_client FastAPI dependency
│ └── ...
│
├── agents/ # LangGraph agent definitions
│ ├── __init__.py
│ ├── prompts/ # All prompts (tutor, generator, evaluator, admin, shared)
│ │ ├── __init__.py
│ │ ├── tutor.py # tutor_system_prompt()
│ │ ├── generator.py # outline_prompt(), lesson_content_prompt(), exercise_prompt()
│ │ ├── evaluator.py # practical_case_eval_prompt(), dialogue_eval_prompt()
│ │ ├── admin.py # admin_system_prompt()
│ │ └── shared.py # SAFETY_GUARDRAILS, CITATION_INSTRUCTIONS, LANGUAGE_DETECTION
│ ├── content/ # Content generation pipeline (graph, nodes, state)
│ ├── tutor/ # Tutor chat agent
│ └── admin/ # Admin chat agent
│
├── services/
│ ├── chat_service.py # Tutor/admin chat (uses LLMClient, yields SSE)
│ ├── generation_service.py # Course generation (uses LLMClient, updates job status)
│ ├── exercise_service.py # Exercise grading (uses LLMClient for practical/dialogue)
│ └── ...
│
└── routes/
├── chat.py # SSE streaming endpoints
└── ...
Module Responsibilities
| Module | Responsibility | Dependencies |
|---|---|---|
llm/config.py |
Read env vars, resolve model per use case | pydantic-settings |
llm/client.py |
OpenAI SDK wrapper, 3 methods | openai, config |
llm/tokens.py |
Token counting | tiktoken |
llm/context.py |
Fit messages to context window | tokens |
llm/cost.py |
Estimate generation job cost | tokens, usage |
llm/usage.py |
Log usage to database, estimate cost | sqlalchemy |
llm/resilience.py |
Retry decorator, backoff strategy | tenacity, openai errors |
llm/errors.py |
Translate openai errors to AppError | openai, core.exceptions |
llm/parsing.py |
Extract JSON from messy LLM output | json, re |
llm/startup.py |
Validate embedding config at boot | client, config |
What’s decided vs what’s deferred
| Decided | Deferred |
|---|---|
| OpenAI-compatible SDK for all providers | Fine-tuning / LoRA support |
| Per-use-case model overrides via env vars | Admin UI for model selection (env vars only for now) |
| tiktoken cl100k_base for token counting | Provider-specific tokenizer integration |
| Prompts in Python files (version controlled) | Prompt A/B testing framework |
| SSE streaming for chat, no streaming for structured output | WebSocket fallback for SSE |
| Retry with exponential backoff (tenacity) | Circuit breaker pattern |
| 502/503 for LLM failures, not 500 | Multi-provider failover (try provider B if A is down) |
| Embedding dimension validation at startup | Automatic dimension detection from pgvector column |
| Usage logging to PostgreSQL | Usage dashboard in admin UI |
| Cost estimation for generation jobs | Budget limits / spending caps |
| Local LLM support via same OpenAI-compatible interface | GPU detection / automatic model selection |