nicht fertig; Stand Kessler Demo

This commit is contained in:
Ida Dittrich 2026-02-23 07:32:40 +01:00
parent d8fb3bf821
commit d3dbca7289
30 changed files with 3957 additions and 947 deletions

View file

@ -7,7 +7,8 @@ Implements LangChain BaseChatModel interface using AI center models.
import logging
import asyncio
from typing import Any, AsyncIterator, Dict, List, Optional
import time
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
from datetime import datetime
from langchain_core.language_models.chat_models import BaseChatModel
@ -28,6 +29,7 @@ from modules.datamodels.datamodelAi import (
AiModel,
AiModelCall,
AiModelResponse,
AiCallResponse,
AiCallOptions,
OperationTypeEnum,
ProcessingModeEnum,
@ -36,6 +38,15 @@ from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
# Workflow-level store for allowed_providers (survives LangGraph/bind_tools execution context
# where instance attributes may be lost when model is wrapped or serialized)
_workflow_allowed_providers: Dict[str, List[str]] = {}
def clear_workflow_allowed_providers(workflow_id: str) -> None:
"""Remove workflow from registry when stream completes to avoid memory growth."""
_workflow_allowed_providers.pop(workflow_id, None)
class AICenterChatModel(BaseChatModel):
"""
@ -48,6 +59,9 @@ class AICenterChatModel(BaseChatModel):
user: User,
operation_type: OperationTypeEnum = OperationTypeEnum.DATA_ANALYSE,
processing_mode: ProcessingModeEnum = ProcessingModeEnum.DETAILED,
billing_callback: Optional[Callable[[AiCallResponse], None]] = None,
workflow_id: Optional[str] = None,
allowed_providers: Optional[List[str]] = None,
**kwargs
):
"""
@ -57,6 +71,9 @@ class AICenterChatModel(BaseChatModel):
user: Current user for RBAC and model selection
operation_type: Operation type for model selection
processing_mode: Processing mode for model selection
billing_callback: Optional callback invoked after each _agenerate with AiCallResponse for billing
workflow_id: Optional workflow/conversation ID for billing context
allowed_providers: Optional list of allowed provider connector types (empty/None = all)
**kwargs: Additional arguments passed to BaseChatModel
"""
super().__init__(**kwargs)
@ -65,6 +82,12 @@ class AICenterChatModel(BaseChatModel):
object.__setattr__(self, "operation_type", operation_type)
object.__setattr__(self, "processing_mode", processing_mode)
object.__setattr__(self, "_selected_model", None)
object.__setattr__(self, "_billing_callback", billing_callback)
object.__setattr__(self, "_workflow_id", workflow_id)
object.__setattr__(self, "_allowed_providers", allowed_providers or [])
# Store in workflow-level registry so it survives when instance attrs are lost (e.g. bind_tools)
if workflow_id and allowed_providers:
_workflow_allowed_providers[workflow_id] = list(allowed_providers)
@property
def _llm_type(self) -> str:
@ -115,10 +138,24 @@ class AICenterChatModel(BaseChatModel):
rbacInstance=rbac_instance
)
# Create options for model selector
# Allowed providers: instance attr or workflow store (lost in LangGraph/bind_tools context)
workflow_id = getattr(self, '_workflow_id', None)
allowed = (
(_workflow_allowed_providers.get(workflow_id) if workflow_id else None)
or getattr(self, '_allowed_providers', None)
or []
)
if allowed:
logger.info(f"AICenterChatModel _select_model: applying allowedProviders={allowed}")
filtered = [m for m in available_models if m.connectorType in allowed]
if filtered:
available_models = filtered
else:
logger.warning(f"No models match allowedProviders {allowed}, using all RBAC-permitted models")
options = AiCallOptions(
operationType=self.operation_type,
processingMode=self.processing_mode
processingMode=self.processing_mode,
allowedProviders=allowed if allowed else None
)
# Select model
@ -239,6 +276,14 @@ class AICenterChatModel(BaseChatModel):
# Convert messages to AI center format
ai_messages = self._convert_messages_to_ai_format(messages)
# Compute input bytes for billing (sum of message content lengths)
input_bytes = sum(
len((m.get("content") or "").encode("utf-8"))
for m in ai_messages
if isinstance(m.get("content"), str)
)
start_time = time.time()
# If tools are bound, add tool definitions to the system message
# This ensures the model knows about available tools
# Some models need explicit tool definitions to enable tool calling
@ -282,12 +327,10 @@ class AICenterChatModel(BaseChatModel):
"content": tools_note.strip()
})
# Convert LangChain tools to OpenAI tool format for potential use
# Note: The actual tool calling is handled by the connector if it supports it
# This conversion is kept for potential future use or connector support
# Convert LangChain tools to OpenAI/function-calling format (used by OpenAI and Ollama-compatible APIs)
openai_tools = None
if tools and self._selected_model.connectorType == "openai":
# Convert LangChain tools to OpenAI tool format
if tools and self._selected_model.connectorType in ("openai", "privatellm"):
# Build tool schema in OpenAI format (Ollama uses same format for tool calling)
openai_tools = []
for tool in tools:
if hasattr(tool, "name") and hasattr(tool, "description"):
@ -355,51 +398,66 @@ class AICenterChatModel(BaseChatModel):
)
)
# If tools are bound and this is an OpenAI model, we need to call the API directly
# with tools included, since the connector interface doesn't support tools
if openai_tools and self._selected_model.connectorType == "openai":
# Call OpenAI API directly with tools (like legacy ChatAnthropic does)
# If tools are bound, use OpenAI-compatible API (OpenAI or Private-LLM Ollama endpoint)
if openai_tools and self._selected_model.connectorType in ("openai", "privatellm"):
import httpx
import json as _json
from modules.shared.configuration import APP_CONFIG
api_key = APP_CONFIG.get('Connector_AiOpenai_API_SECRET')
if not api_key:
raise ValueError("OpenAI API key not configured")
if self._selected_model.connectorType == "openai":
api_url = self._selected_model.apiUrl
api_key = APP_CONFIG.get("Connector_AiOpenai_API_SECRET")
if not api_key:
raise ValueError("OpenAI API key not configured")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
ollama_model = self._selected_model.name
else:
# privatellm: use Ollama OpenAI-compatible /v1/chat/completions (same service, same provider)
base_url = self._selected_model.apiUrl.replace("/api/analyze", "")
api_url = f"{base_url}/v1/chat/completions"
api_key = APP_CONFIG.get("Connector_AiPrivateLlm_API_SECRET")
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key
# Ollama needs the underlying model name (e.g. qwen2.5:7b), not poweron-text-general
ollama_model = getattr(self._selected_model, "version", None) or self._selected_model.name
payload = {
"model": self._selected_model.name,
"model": ollama_model,
"messages": ai_messages,
"tools": openai_tools,
"tool_choice": "auto", # Let model decide when to use tools
"tool_choice": "auto",
"temperature": self._selected_model.temperature,
"max_tokens": self._selected_model.maxTokens
"max_tokens": self._selected_model.maxTokens,
}
use_connector_fallback = False
async with httpx.AsyncClient(timeout=600.0) as client:
response_obj = await client.post(
self._selected_model.apiUrl,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json=payload
)
response_obj = await client.post(api_url, headers=headers, json=payload)
if response_obj.status_code != 200:
error_msg = f"OpenAI API error: {response_obj.status_code} - {response_obj.text}"
if response_obj.status_code == 404 and self._selected_model.connectorType == "privatellm":
logger.warning(
"Private-LLM /v1/chat/completions not found (404). Falling back to /api/analyze. "
"Tool calling will not work until the service exposes an OpenAI-compatible endpoint."
)
use_connector_fallback = True
elif response_obj.status_code != 200:
error_msg = f"AI API error ({self._selected_model.connectorType}): {response_obj.status_code} - {response_obj.text}"
logger.error(error_msg)
raise ValueError(error_msg)
if use_connector_fallback:
if not self._selected_model.functionCall:
raise ValueError(f"Model {self._selected_model.displayName} has no functionCall defined")
response = await self._selected_model.functionCall(model_call)
else:
response_json = response_obj.json()
choice = response_json["choices"][0]
message = choice["message"]
# Extract content and tool calls
content = message.get("content", "")
tool_calls_raw = message.get("tool_calls")
# Convert OpenAI tool_calls format to LangChain format
# LangChain expects: [{"id": "...", "name": "...", "args": {...}}]
tool_calls = None
if tool_calls_raw:
tool_calls = []
@ -407,29 +465,24 @@ class AICenterChatModel(BaseChatModel):
func_data = tc.get("function", {})
func_name = func_data.get("name")
func_args_str = func_data.get("arguments", "{}")
# Parse JSON arguments string to dict
import json
try:
func_args = json.loads(func_args_str) if isinstance(func_args_str, str) else func_args_str
except:
func_args = _json.loads(func_args_str) if isinstance(func_args_str, str) else func_args_str
except Exception:
func_args = {}
tool_calls.append({
"id": tc.get("id"),
"name": func_name,
"args": func_args
"args": func_args,
})
# Create response object
response = AiModelResponse(
content=content or "",
success=True,
modelId=self._selected_model.name,
metadata={
"response_id": response_json.get("id", ""),
"tool_calls": tool_calls
}
"tool_calls": tool_calls,
},
)
else:
# No tools or not OpenAI - use connector normally
@ -441,6 +494,34 @@ class AICenterChatModel(BaseChatModel):
if not response.success:
raise ValueError(f"AI model call failed: {response.error or 'Unknown error'}")
# Billing: compute price and invoke callback
output_bytes = len((response.content or "").encode("utf-8"))
processing_time = time.time() - start_time
price_chf = 0.0
if getattr(self._selected_model, "calculatepriceCHF", None):
try:
price_chf = self._selected_model.calculatepriceCHF(
processing_time, input_bytes, output_bytes
)
except Exception as e:
logger.warning(f"Billing: price calculation failed: {e}")
billing_callback = getattr(self, "_billing_callback", None)
if billing_callback:
try:
ai_response = AiCallResponse(
content=response.content or "",
modelName=self._selected_model.name,
provider=getattr(self._selected_model, "connectorType", "unknown") or "unknown",
priceCHF=price_chf,
processingTime=processing_time,
bytesSent=input_bytes,
bytesReceived=output_bytes,
errorCount=0,
)
billing_callback(ai_response)
except Exception as e:
logger.error(f"Billing callback error: {e}")
# Extract tool calls from response metadata if present
tool_calls = None
if response.metadata:
@ -471,7 +552,9 @@ class AICenterChatModel(BaseChatModel):
bound_model = AICenterChatModel(
user=self.user,
operation_type=self.operation_type,
processing_mode=self.processing_mode
processing_mode=self.processing_mode,
billing_callback=getattr(self, "_billing_callback", None),
workflow_id=getattr(self, "_workflow_id", None),
)
object.__setattr__(bound_model, "_selected_model", self._selected_model)
# Store tools for potential use in message conversion

View file

@ -23,31 +23,43 @@ class CheckpointTuple(NamedTuple):
pending_writes: Optional[List[Tuple[str, Any]]] = None
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage, ToolMessage
from modules.interfaces.interfaceDbChat import getInterface
from modules.datamodels.datamodelChat import ChatMessage, ChatWorkflow
from modules.features.chatbot.interfaceFeatureChatbot import getInterface as getChatbotInterface
from modules.features.chatbot.interfaceFeatureChatbot import ChatbotMessage
from modules.datamodels.datamodelUam import User
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
def _sanitize_llm_response(text: str) -> str:
"""Strip chat template tokens and trailing junk that some models leak."""
if not text or not isinstance(text, str):
return text or ""
for sentinel in ("<|im_start|>", "<|im_end|>", "<|endoftext|>", "<|user|>", "<|assistant|>"):
if sentinel in text:
text = text.split(sentinel)[0]
return text.strip()
class DatabaseCheckpointer(BaseCheckpointSaver):
"""
Custom LangGraph checkpointer that uses the existing database interface.
Maps LangGraph thread_id to workflow.id and stores messages in the existing format.
Custom LangGraph checkpointer that uses the chatbot's own database interface.
Maps LangGraph thread_id to conversation.id; stores messages via interface (workflowId maps to conversationId).
"""
def __init__(self, user: User, workflow_id: str):
def __init__(self, user: User, workflow_id: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""
Initialize the database checkpointer.
Args:
user: Current user for database access
workflow_id: Workflow ID (maps to LangGraph thread_id)
mandateId: Mandate ID for proper data isolation
featureInstanceId: Feature instance ID for proper data isolation
"""
self.user = user
self.workflow_id = workflow_id
self.interface = getInterface(user)
self.interface = getChatbotInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _convert_langchain_to_db_message(
self,
@ -101,7 +113,7 @@ class DatabaseCheckpointer(BaseCheckpointSaver):
def _convert_db_to_langchain_messages(
self,
messages: List[ChatMessage]
messages: List[ChatbotMessage]
) -> List[BaseMessage]:
"""
Convert database messages to LangChain messages.
@ -183,37 +195,40 @@ class DatabaseCheckpointer(BaseCheckpointSaver):
elif isinstance(msg, AIMessage):
# Check if this message has tool_calls
tool_calls = getattr(msg, "tool_calls", None)
# Skip messages with tool_calls - these are intermediate tool call requests
if tool_calls and len(tool_calls) > 0:
logger.debug(f"Skipping intermediate AIMessage with tool_calls for workflow {thread_id}")
continue
# Store all other AIMessages (final answers)
# Skip agent_sql_plan output (raw SQL block) - only store agent_formulate final answer
content = msg.content if isinstance(msg.content, str) else str(msg.content)
cu = (content or "").strip().upper()
if content and (
content.strip().startswith("```")
or (cu.startswith("SELECT") and ("FROM" in cu or "JOIN" in cu))
):
logger.debug(f"Skipping intermediate SQL AIMessage for workflow {thread_id}")
continue
checkpoint_user_assistant_messages.append(msg)
# Only store new messages that aren't already in the database
new_messages_to_store = []
for msg in checkpoint_user_assistant_messages:
# Determine role
role = "user" if isinstance(msg, HumanMessage) else "assistant"
content = msg.content if isinstance(msg.content, str) else str(msg.content)
# Skip empty messages (they might be status updates)
if isinstance(msg, AIMessage):
content = _sanitize_llm_response(content)
if not content or not content.strip():
continue
# Check if this message already exists
content_key = (role, content)
if content_key not in existing_content_set:
if isinstance(msg, AIMessage) and msg.content != content:
msg = AIMessage(content=content)
new_messages_to_store.append(msg)
existing_content_set.add(content_key) # Mark as seen to avoid duplicates in this batch
existing_content_set.add(content_key)
# Store only the new messages
if new_messages_to_store:
for i, msg in enumerate(new_messages_to_store, 1):
sequence_nr = existing_count + i
# Convert to database format
db_message_data = self._convert_langchain_to_db_message(
msg,

View file

@ -2,12 +2,14 @@
# All rights reserved.
"""Chatbot domain logic."""
import re
import logging
from dataclasses import dataclass, field
from typing import Annotated, AsyncIterator, Any, List, Optional, TYPE_CHECKING
from pydantic import BaseModel
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
@ -17,7 +19,6 @@ from langchain_core.messages import (
from langgraph.graph.message import add_messages
from langgraph.graph import StateGraph, START, END
from langgraph.graph.state import CompiledStateGraph
from langgraph.prebuilt import ToolNode
from modules.features.chatbot.bridges.ai import AICenterChatModel
from modules.features.chatbot.bridges.memory import DatabaseCheckpointer
@ -36,10 +37,136 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _tool_output_to_markdown_table(raw: str) -> str:
"""
Convert sqlite_query tool output to a markdown table for deterministic display.
Reduces model hallucination by providing a ready-to-copy table.
Format: "Query returned N rows:\\nColumns: A, B, C\\n1. A: x, B: y, C: z\\n..."
"""
if not raw or not raw.strip():
return raw
lines = [ln.strip() for ln in raw.strip().split("\n") if ln.strip()]
if len(lines) < 2:
return raw
# Parse header
row_count_line = lines[0] # "Query returned 20 rows:"
cols_line = next((ln for ln in lines if ln.lower().startswith("columns:")), None)
if not cols_line:
return raw
headers = [h.strip() for h in cols_line.replace("Columns:", "").split(",")]
if not headers:
return raw
# Parse data rows (1. Col: val, Col: val)
rows = []
for ln in lines:
if re.match(r"^\d+\.\s+", ln):
rest = re.sub(r"^\d+\.\s+", "", ln)
row = {}
for part in rest.split(", "):
if ": " in part:
k, v = part.split(": ", 1)
row[k.strip()] = str(v).strip()
if row:
rows.append([row.get(h, "") for h in headers])
if not rows:
return raw
# Build markdown table
sep = " | "
header_row = sep.join(headers)
div_row = sep.join(["---"] * len(headers))
data_rows = [sep.join(str(c) for c in r) for r in rows]
table = "\n".join([header_row, div_row] + data_rows)
suffix = ""
if "(Showing first" in raw or "of " in raw:
m = re.search(r"\(Showing first (\d+) of (\d+) rows\)", raw)
if m:
suffix = f"\n\nZeige {m.group(1)} von {m.group(2)} Artikeln."
return f"{row_count_line}\n\n{table}{suffix}"
def _sanitize_llm_response(text: str) -> str:
"""Strip chat template tokens and trailing junk that some models leak."""
if not text or not isinstance(text, str):
return text or ""
for sentinel in ("<|im_start|>", "<|im_end|>", "<|endoftext|>", "<|user|>", "<|assistant|>"):
if sentinel in text:
text = text.split(sentinel)[0]
return text.strip()
# Natural language markers to split system prompt into context sections
_SPLIT_MARKERS = {
"schema_start": "Die Datenbank enthält",
"schema_start_alt": "Die Datenbank enthält die Tabellen",
"response_structure_start": "Antwortstruktur ist strikt",
"response_structure_alt": "Antwortstruktur:",
"response_structure_fallback": "Antwortstruktur",
}
def _split_system_prompt(prompt: str) -> dict:
"""
Split system prompt by natural language section markers.
Returns: {intro, schema, response_structure}
- intro: Role, tools, general instructions (before schema)
- schema: Database tables, SQL rules, column definitions (for SQL generation)
- response_structure: Mandatory answer format (Einleitungssatz, Tabelle, etc.)
"""
if not prompt or not isinstance(prompt, str):
return {"intro": "", "schema": "", "response_structure": ""}
text = prompt.strip()
intro_end = len(text)
schema_start_idx = -1
schema_end = len(text)
response_start_idx = -1
# Find schema start
for marker in (_SPLIT_MARKERS["schema_start"], _SPLIT_MARKERS["schema_start_alt"]):
idx = text.find(marker)
if idx >= 0:
schema_start_idx = idx
intro_end = idx
break
# Find response structure start
for marker in (
_SPLIT_MARKERS["response_structure_start"],
_SPLIT_MARKERS["response_structure_alt"],
_SPLIT_MARKERS["response_structure_fallback"],
):
idx = text.find(marker)
if idx >= 0:
response_start_idx = idx
schema_end = idx if schema_start_idx >= 0 else len(text)
break
intro = text[:intro_end].strip() if intro_end > 0 else ""
schema = (
text[schema_start_idx:schema_end].strip()
if schema_start_idx >= 0 and schema_end > schema_start_idx
else ""
)
response_structure = (
text[response_start_idx:].strip()
if response_start_idx >= 0
else ""
)
# Fallback: if no markers found, use full prompt for intro
if not intro and not schema and not response_structure:
intro = text
elif not response_structure and intro:
response_structure = intro # Use intro's format hints as fallback
return {"intro": intro, "schema": schema, "response_structure": response_structure}
class ChatState(BaseModel):
"""Represents the state of a chat session."""
messages: Annotated[List[BaseMessage], add_messages]
plan: Optional[str] = None # Planner routing: "SQL", "TAVILY", "BOTH", "NONE"
@dataclass
@ -135,6 +262,8 @@ class Chatbot:
) -> CompiledStateGraph[ChatState, None, ChatState, ChatState]:
"""Builds the chatbot application workflow using LangGraph.
Supports small context windows via planning phase and tiered prompts.
Args:
memory: The chat memory to use.
tools: The list of tools the chatbot can use.
@ -142,83 +271,303 @@ class Chatbot:
Returns:
A compiled state graph representing the chatbot application.
"""
llm_with_tools = self.model.bind_tools(tools=tools)
# Build tool subsets per agent type
tools_by_name = {t.name: t for t in tools}
sql_tool = tools_by_name.get("sqlite_query")
tavily_tool = tools_by_name.get("tavily_search")
streaming_tool = tools_by_name.get("send_streaming_message")
tools_sql = [t for t in [sql_tool, tavily_tool, streaming_tool] if t is not None]
tools_tavily = [t for t in [tavily_tool, streaming_tool] if t is not None]
llm_plain = self.model
# SQL path uses structured prompts + parse/execute (no native tool calling) - fits /api/analyze
llm_tavily = self.model.bind_tools(tools=tools_tavily) if tools_tavily else self.model
def select_window(msgs: List[BaseMessage]) -> List[BaseMessage]:
"""Selects a window of messages that fit within the context window size.
# Minimal planner prompt (~250 tokens) - fits any 8K+ model
# Explicit: Lager, Bestand, Artikel, wie viele = SQL (Datenbank)
PLANNER_SYSTEM = (
"Du bist ein Assistent. Antworte NUR mit einem Wort: SQL, TAVILY, BOTH oder NONE.\n"
"SQL = Fragen zu Lager, Bestand, Artikel, Preisen, wie viele, Anzahl (Datenbankabfrage).\n"
"TAVILY = Internetsuche, Produktinfos außerhalb der DB, Markttrends.\n"
"BOTH = beides nötig. NONE = nur Begrüßung oder Danksagung, keine Daten nötig.\n"
"Beispiele: 'wie viele X auf Lager' -> SQL, 'Infos zu Produkt Y' -> TAVILY."
)
Args:
msgs: The list of messages to select from.
# Truncation suffix for schema when prompt is cut
SCHEMA_TRUNCATION_SUFFIX = (
"\n\n[... Schema gekürzt. Wichtige Tabellen: Artikel, Lagerplatz_Artikel, Einkaufspreis, Lagerplatz. "
"Artikel-Spalte: a.\"Artikelbezeichnung\". "
"JOIN: Artikel a, Lagerplatz_Artikel l ON a.I_ID = l.R_ARTIKEL, Lagerplatz lp ON l.R_LAGERPLATZ = lp.I_ID.]"
)
Returns:
A list of messages that fit within the context window size.
"""
# Structured output for /api/analyze (no tool calls): model outputs SQL in code block, we parse and execute
SQL_PLAN_SUFFIX = (
"\n\n--- AUSGABEFORMAT (PFLICHT) ---\n"
"Antworte NUR mit einer SQL SELECT-Abfrage in diesem Format:\n"
"```sql\nDEINE_SQL_QUERY\n```\n"
"KRITISCH bei 'wie viele X auf Lager': Liefere ARTIKELZEILEN (Artikelnummer, Artikelbezeichnung, Bestand) "
"mit LIMIT 20, NICHT nur SELECT COUNT(*). Ohne Detailzeilen kann keine Tabelle angezeigt werden. "
"Gesamtanzahl optional via Unterabfrage im SELECT."
)
bytes_per_token = 3 # Balanced estimate for mixed content
reserved_tokens = 3000 # Tools block + conversation overhead
def _get_context_length() -> int:
"""Get selected model's context length; pre-select if needed."""
if hasattr(self.model, "_selected_model") and self.model._selected_model:
return getattr(self.model._selected_model, "contextLength", 128000)
return 128000
def _truncate_system_prompt(full_prompt: str, max_chars: int, suffix: str = "") -> str:
"""Truncate system prompt to fit context budget."""
if len(full_prompt) <= max_chars:
return full_prompt
return full_prompt[: max_chars - len(suffix)] + suffix
# Split system prompt by natural language sections for targeted context
_prompt_sections = _split_system_prompt(self.system_prompt)
def select_window(msgs: List[BaseMessage], max_tokens_override: Optional[int] = None) -> List[BaseMessage]:
"""Selects a window of messages that fit within the context window size."""
def approx_counter(items: List[BaseMessage]) -> int:
"""Approximate token counter for messages.
Args:
items: List of messages to count tokens for.
Returns:
Approximate number of tokens in the messages.
"""
return sum(len(getattr(m, "content", "") or "") for m in items)
# Use model's context length if available, otherwise default
max_tokens = getattr(self.model._selected_model, "contextLength", 128000) if hasattr(self.model, "_selected_model") and self.model._selected_model else 128000
max_tokens = max_tokens_override or _get_context_length()
return trim_messages(
msgs,
strategy="last",
token_counter=approx_counter,
max_tokens=int(max_tokens * 0.8), # Use 80% of context window
max_tokens=int(max_tokens * 0.8),
start_on="human",
end_on=("human", "tool"),
include_system=True,
)
async def agent_node(state: ChatState) -> dict:
"""Agent node for the chatbot workflow.
async def planner_node(state: ChatState) -> dict:
"""Planner: minimal prompt, no tools. Outputs SQL/TAVILY/BOTH/NONE.
Does NOT add planner message to chat - only sets state.plan for routing."""
human_msgs = [m for m in state.messages if isinstance(m, HumanMessage)]
last_human = human_msgs[-1].content if human_msgs else ""
window = [
SystemMessage(content=PLANNER_SYSTEM),
HumanMessage(content=last_human),
]
plan = "SQL"
try:
response = await llm_plain.ainvoke(window)
except ValueError as exc:
if "No suitable model found" in str(exc):
logger.warning(f"Planner model selection failed: {exc}")
return {"plan": plan}
raise
content = (response.content or "").strip().upper()
for keyword in ("SQL", "TAVILY", "BOTH", "NONE"):
if keyword in content:
plan = keyword
break
return {"plan": plan}
Args:
state: The current chat state.
# Keywords that indicate database/inventory query - override NONE to SQL
_SQL_KEYWORDS = (
"lager", "bestand", "artikel", "wie viele", "anzahl", "preis",
"lieferant", "lieferanten", "bestellen", "verfügbar", "inventar"
)
Returns:
The updated chat state after processing.
"""
# Select the message window to fit in context (trim if needed)
window = select_window(state.messages)
def route_by_plan(state: ChatState) -> str:
"""Route from planner to agent_sql_plan, agent_tavily, or agent_answer."""
plan = (state.plan or "SQL").upper()
# Override NONE when user clearly asks for inventory/data (e.g. "wie viele LEDs auf Lager")
if plan == "NONE" and sql_tool:
last_user = ""
for m in reversed(state.messages):
if isinstance(m, HumanMessage):
last_user = (m.content or "").lower()
break
if any(kw in last_user for kw in _SQL_KEYWORDS):
logger.info("Planner returned NONE but user asked inventory question - routing to SQL")
plan = "SQL"
if plan in ("SQL", "BOTH") and sql_tool:
return "agent_sql_plan"
if plan == "TAVILY" and tavily_tool:
return "agent_tavily"
return "agent_answer"
# Ensure the system prompt is present at the start
if not window or not isinstance(window[0], SystemMessage):
window = [SystemMessage(content=self.system_prompt)] + window
# Call the LLM with tools (use ainvoke for async)
response = await llm_with_tools.ainvoke(window)
# Return the new state
async def _agent_common(
state: ChatState,
system_content: str,
llm,
node_name: str,
) -> dict:
"""Shared logic for agent nodes."""
msgs = select_window(state.messages)
if not msgs or not isinstance(msgs[0], SystemMessage):
window = [SystemMessage(content=system_content)] + msgs
else:
window = [SystemMessage(content=system_content)] + [m for m in msgs if not isinstance(m, SystemMessage)]
try:
response = await llm.ainvoke(window)
except ValueError as exc:
if "No suitable model found" in str(exc):
logger.warning(f"{node_name} model selection failed: {exc}")
response = AIMessage(
content=(
"Es tut mir leid, derzeit steht kein passendes KI-Modell für diese Anfrage zur Verfügung. "
"Bitte versuchen Sie es später erneut oder wenden Sie sich an den Administrator."
)
)
else:
raise
return {"messages": [response]}
def should_continue(state: ChatState) -> str:
"""Determines whether to continue the workflow or end it.
async def agent_sql_plan_node(state: ChatState) -> dict:
"""Generate SQL. Uses schema section + minimal intro. Output: ```sql...``` for parse/execute."""
ctx_len = _get_context_length()
max_system_chars = max(1000, int(ctx_len * 0.8 - reserved_tokens) * bytes_per_token) - len(SQL_PLAN_SUFFIX)
# Prefer schema section; add short intro if space allows
schema_part = _prompt_sections["schema"] or _prompt_sections["intro"]
intro_part = _prompt_sections["intro"][:400] if _prompt_sections["intro"] else ""
combined = f"{intro_part}\n\n{schema_part}" if intro_part else schema_part
system_content = _truncate_system_prompt(
combined, max_system_chars, SCHEMA_TRUNCATION_SUFFIX
) + SQL_PLAN_SUFFIX
return await _agent_common(state, system_content, llm_plain, "agent_sql_plan")
This conditional edge is called after the agent node to decide
whether to continue to the tools node (if the last message contains
tool calls) or to end the workflow (if no tool calls are present).
def _parse_sql_from_content(content: str) -> Optional[str]:
"""Extract SQL from ```sql...``` or ```...``` code block. Only allows SELECT."""
if not content:
return None
match = re.search(r"```(?:sql)?\s*([\s\S]*?)```", content)
if match:
sql = match.group(1).strip()
if sql and sql.upper().strip().startswith("SELECT"):
return sql
# Fallback: find line starting with SELECT
for line in content.split("\n"):
line = line.strip()
if line.upper().startswith("SELECT"):
return line
return None
Args:
state: The current chat state.
def _sanitize_sql_typos(sql: str) -> str:
"""Fix common LLM SQL typos that cause syntax errors."""
if not sql:
return sql
# Fix "CASE WHENLAGerplatz" - missing space after WHEN when followed directly by identifier
sql = re.sub(r"WHEN([A-Za-z_][A-Za-z0-9_.\"]*)", r"WHEN \1", sql, flags=re.IGNORECASE)
# Fix "LAGerplatz_Artikel" / "LAGerplatz" -> correct casing
sql = re.sub(r"\bLAGerplatz_Artikel\b", "Lagerplatz_Artikel", sql)
sql = re.sub(r"\bLAGerplatz\b", "Lagerplatz", sql)
# Preprocessor uses Einkaufspreis (not Einkaufspreis_neu) and m_Artikel (not ARTIKEL)
sql = sql.replace('"Einkaufspreis_neu"', '"Einkaufspreis"')
sql = sql.replace("Einkaufspreis_neu.", "Einkaufspreis.")
sql = re.sub(
r'"Einkaufspreis"\."ARTIKEL"',
'"Einkaufspreis"."m_Artikel"',
sql,
)
return sql
Returns:
The next node to transition to ("tools" or END).
"""
# Get the last message
last_message = state.messages[-1]
async def parse_execute_sql_node(state: ChatState) -> dict:
"""Parse SQL from last AIMessage, execute via preprocessor, add ToolMessage."""
last_msg = state.messages[-1] if state.messages else None
if not isinstance(last_msg, AIMessage):
return {"messages": [ToolMessage(content="Fehler: Keine AI-Antwort zum Parsen.", tool_call_id="parse_0", name="sqlite_query")]}
sql = _parse_sql_from_content(last_msg.content or "")
if not sql or not sql_tool:
return {"messages": [ToolMessage(content="Konnte keine SQL-Abfrage aus der Antwort extrahieren.", tool_call_id="parse_0", name="sqlite_query")]}
sql = _sanitize_sql_typos(sql)
try:
result = await sql_tool.ainvoke({"query": sql})
except Exception as e:
logger.error(f"SQL execution failed: {e}")
result = f"Fehler bei der Ausführung: {e}"
return {"messages": [ToolMessage(content=str(result), tool_call_id="parse_0", name="sqlite_query")]}
# Check if the last message contains tool calls
# If so, continue to the tools node; otherwise, end the workflow
return "tools" if getattr(last_message, "tool_calls", None) else END
FORMULATE_TASK = (
"\n\n--- AKTUELLE AUFGABE ---\n"
"Du erhältst eine Benutzerfrage und die exakten Datenbankergebnisse. "
"KRITISCH: Nutze NUR die gelieferten Daten. Erfinde NIEMALS Daten (keine LED-A01, LED Rot, etc.). "
"Wenn die Ergebnisse NUR eine Zahl enthalten (z.B. '1. COUNT(*): 806'): Reportiere NUR diese Zahl, KEINE erfundene Tabelle. "
"Eine Tabelle darf NUR erstellt werden, wenn echte Zeilen '1. Spalte: Wert, ...' in den Daten stehen. "
"Beachte die obige ANTWORTSTRUKTUR."
)
async def agent_formulate_node(state: ChatState) -> dict:
"""Formulate final answer. Uses intro + response_structure sections (not schema)."""
human_content = ""
tool_content = ""
for m in state.messages:
if isinstance(m, HumanMessage):
human_content = m.content or ""
if isinstance(m, ToolMessage) and getattr(m, "name", "") == "sqlite_query":
tool_content = m.content or ""
if not tool_content or not tool_content.strip():
logger.warning("agent_formulate: no tool_content (sqlite_query) in state.messages")
return {"messages": [AIMessage(content="Die Datenbankabfrage konnte keine Ergebnisse liefern. Bitte versuchen Sie es mit einer anderen Formulierung.")]}
# When SQL failed, return error directly - don't let model hallucinate success
if "Query failed" in tool_content or tool_content.strip().startswith("Error"):
err_summary = "Die Datenbankabfrage ist fehlgeschlagen."
if "no such column" in tool_content:
err_summary += " Ein Spaltenname scheint nicht zu passen. Bitte die Anfrage anders formulieren."
return {"messages": [AIMessage(content=err_summary)]}
# Convert to markdown table so model copies exact values instead of reformatting/hallucinating
formatted_data = _tool_output_to_markdown_table(tool_content)
logger.debug(f"agent_formulate: tool_content length={len(tool_content)}, formatted={len(formatted_data)}")
ctx_len = _get_context_length()
max_system_chars = max(3000, int(ctx_len * 0.5) * bytes_per_token) - len(FORMULATE_TASK)
# Use intro + response_structure (mandatory format)
resp_struct = _prompt_sections["response_structure"] or _prompt_sections["intro"]
intro_formulate = _prompt_sections["intro"]
combined = f"{intro_formulate}\n\n{resp_struct}" if intro_formulate != resp_struct else resp_struct
# Fit within context; prefer keeping response_structure intact
if len(combined) + len(FORMULATE_TASK) > max_system_chars:
combined = _truncate_system_prompt(combined, max_system_chars - len(FORMULATE_TASK), "")
system_content = combined + FORMULATE_TASK
prompt = (
f"Benutzerfrage: {human_content}\n\n"
"--- VORGEGEBENE DATEN (diese Tabelle/Zahlen UNVERÄNDERT in die Antwort übernehmen): ---\n"
f"{formatted_data}\n\n"
"Die obige Tabelle bzw. Zahlen sind die EINZIGEN erlaubten Daten. Kopiere sie 1:1. "
"Berechne keine eigenen Summen/Anzahlen - nutze die gelieferten Werte. Formuliere die Antwort:"
)
window = [SystemMessage(content=system_content), HumanMessage(content=prompt)]
try:
response = await llm_plain.ainvoke(window)
except ValueError as exc:
if "No suitable model found" in str(exc):
response = AIMessage(content="Es gab einen Fehler bei der Formulierung. Bitte versuchen Sie es erneut.")
else:
raise
# Sanitize: strip leaked chat template tokens (<|im_start|> etc.) and trailing junk
if response.content:
response = AIMessage(content=_sanitize_llm_response(response.content))
return {"messages": [response]}
async def agent_tavily_node(state: ChatState) -> dict:
"""Agent with Tavily only. Uses intro + response_structure (no schema)."""
resp_struct = _prompt_sections["response_structure"] or ""
intro_tavily = _prompt_sections["intro"]
combined = f"{intro_tavily}\n\n{resp_struct}" if resp_struct else intro_tavily
system_content = _truncate_system_prompt(combined, 6000, "")
return await _agent_common(state, system_content, llm_tavily, "agent_tavily")
async def agent_answer_node(state: ChatState) -> dict:
"""Agent with no tools. Uses intro + response_structure."""
resp_struct = _prompt_sections["response_structure"] or ""
intro_answer = _prompt_sections["intro"]
combined = f"{intro_answer}\n\n{resp_struct}" if resp_struct else intro_answer
system_content = _truncate_system_prompt(combined, 6000, "")
return await _agent_common(state, system_content, llm_plain, "agent_answer")
def should_continue_tavily(state: ChatState) -> str:
last = state.messages[-1]
return "tools" if getattr(last, "tool_calls", None) else END
def route_back(state: ChatState) -> str:
"""Route from tools back to agent_tavily (SQL path uses parse_execute_sql, no tools loop)."""
# Tools node is only reached from agent_tavily when it returns tool_calls
return "agent_tavily" if tavily_tool else "agent_answer"
async def tools_with_retry(state: ChatState) -> dict:
"""Tools node with parallel execution and retry logic.
@ -341,13 +690,24 @@ class Chatbot:
return result
# Compose the workflow
# Compose the workflow: planner -> route -> agent_* -> tools (Tavily only) or END
workflow = StateGraph(ChatState)
workflow.add_node("agent", agent_node)
workflow.add_node("planner", planner_node)
workflow.add_node("agent_sql_plan", agent_sql_plan_node)
workflow.add_node("parse_execute_sql", parse_execute_sql_node)
workflow.add_node("agent_formulate", agent_formulate_node)
workflow.add_node("agent_tavily", agent_tavily_node)
workflow.add_node("agent_answer", agent_answer_node)
workflow.add_node("tools", tools_with_retry)
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue)
workflow.add_edge("tools", "agent")
workflow.add_edge(START, "planner")
workflow.add_conditional_edges("planner", route_by_plan)
# SQL path: agent_sql_plan -> parse_execute_sql -> agent_formulate -> END (no tools, /api/analyze compatible)
workflow.add_edge("agent_sql_plan", "parse_execute_sql")
workflow.add_edge("parse_execute_sql", "agent_formulate")
workflow.add_edge("agent_formulate", END)
workflow.add_conditional_edges("agent_tavily", should_continue_tavily)
workflow.add_edge("agent_answer", END)
workflow.add_conditional_edges("tools", route_back)
return workflow.compile(checkpointer=memory)
async def chat(self, message: str, chat_id: str = "default") -> List[BaseMessage]:
@ -429,6 +789,8 @@ class Chatbot:
)
# Normalize for the frontend (only user/assistant with text content)
# Exclude planner-only and SQL-path intermediate messages from chat display
_planner_only = frozenset(("sql", "tavily", "both", "none"))
chat_history_payload: List[dict] = []
for m in final_msgs:
if isinstance(m, BaseMessage):
@ -437,8 +799,24 @@ class Chatbot:
d = ChatStreamingHelper.dict_message_to_dict(obj=m)
else:
continue
if d.get("role") in ("user", "assistant") and d.get("content"):
chat_history_payload.append(d)
if d.get("role") not in ("user", "assistant") or not d.get("content"):
continue
content = (d.get("content") or "").strip()
if d.get("role") == "assistant" and content.lower() in _planner_only:
continue # Skip planner routing message
# Skip agent_sql_plan output: ```sql block OR raw SQL (SELECT...FROM/JOIN)
if d.get("role") == "assistant":
cu = content.upper()
if content.startswith("```") or (
cu.startswith("SELECT") and ("FROM" in cu or "JOIN" in cu)
):
continue
# Strip leaked chat template tokens (<|im_start|> etc.) from assistant messages
content = _sanitize_llm_response(content)
if not content:
continue
d = {**d, "content": content}
chat_history_payload.append(d)
yield {
"type": "final",

View file

@ -36,7 +36,7 @@ async def generate_conversation_name(
# Check if AI service is available
if not hasattr(services, 'ai') or services.ai is None:
logger.warning("AI service not available, generating name from prompt")
return _generate_name_from_prompt(prompt)
return generate_name_from_prompt(prompt)
# Ensure AI service is initialized before use
await services.ai.ensureAiObjectsInitialized()
@ -80,7 +80,7 @@ Antworte NUR mit dem deutschen Titel, ohne Anführungszeichen oder Erklärungen.
if not response or not hasattr(response, 'content') or not response.content:
logger.warning("AI response invalid, generating name from prompt")
return _generate_name_from_prompt(prompt)
return generate_name_from_prompt(prompt)
logger.info(f"AI response received: {response.content[:100]}...")
@ -102,7 +102,7 @@ Antworte NUR mit dem deutschen Titel, ohne Anführungszeichen oder Erklärungen.
english_words = ["search", "find", "show", "display", "query", "article", "product", "item", "led articles", "product search"]
if any(word in name_lower for word in english_words):
logger.warning(f"AI generated English name '{name}', generating from prompt instead")
return _generate_name_from_prompt(prompt)
return generate_name_from_prompt(prompt)
# Limit to 50 characters
if len(name) > 50:
@ -114,14 +114,14 @@ Antworte NUR mit dem deutschen Titel, ohne Anführungszeichen oder Erklärungen.
return name
else:
logger.warning(f"Generated name is too short: '{name}', generating from prompt")
return _generate_name_from_prompt(prompt)
return generate_name_from_prompt(prompt)
except Exception as e:
logger.error(f"Error generating conversation name with AI: {e}", exc_info=True)
return _generate_name_from_prompt(prompt)
return generate_name_from_prompt(prompt)
def _generate_name_from_prompt(prompt: str) -> str:
def generate_name_from_prompt(prompt: str) -> str:
"""
Generate a conversation name directly from the German prompt.
Creates a concise title by extracting key words and formatting them.

View file

@ -7,7 +7,7 @@ Loads configuration from the database (FeatureInstance.config JSONB field).
import logging
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, TYPE_CHECKING
from typing import Optional, Dict, Any, List, TYPE_CHECKING
if TYPE_CHECKING:
from modules.datamodels.datamodelFeatures import FeatureInstance
@ -63,6 +63,7 @@ class ModelConfig:
"""Model configuration for a chatbot instance."""
operationType: str = "DATA_ANALYSE"
processingMode: str = "BASIC" # Changed from DETAILED for faster responses
allowedProviders: List[str] = field(default_factory=list) # Empty = all providers allowed
@dataclass
@ -119,9 +120,11 @@ class ChatbotConfig:
# Parse model config with defaults
model_data = data.get("model", {})
allowed_providers = model_data.get("allowedProviders") or data.get("allowedProviders", [])
model_config = ModelConfig(
operationType=model_data.get("operationType", "DATA_ANALYSE"),
processingMode=model_data.get("processingMode", "DETAILED")
processingMode=model_data.get("processingMode", "DETAILED"),
allowedProviders=allowed_providers if isinstance(allowed_providers, list) else []
)
return cls(
@ -184,7 +187,8 @@ class ChatbotConfig:
# Model config defaults - use BASIC for faster responses
converted["model"] = {
"operationType": "DATA_ANALYSE",
"processingMode": "BASIC"
"processingMode": "BASIC",
"allowedProviders": data.get("allowedProviders", [])
}
# Copy other fields
@ -213,7 +217,8 @@ class ChatbotConfig:
},
"model": {
"operationType": self.model.operationType,
"processingMode": self.model.processingMode
"processingMode": self.model.processingMode,
"allowedProviders": self.model.allowedProviders
}
}
@ -249,8 +254,11 @@ def load_chatbot_config_from_instance(instance: 'FeatureInstance') -> ChatbotCon
logger.warning(f"Instance {instance_id} has no config, using minimal defaults")
config_data = {}
logger.debug(f"Instance {instance_id} raw config keys: {list(config_data.keys()) if config_data else []}, allowedProviders: {config_data.get('allowedProviders')}")
# Create config from dictionary
config = ChatbotConfig.from_dict(config_data, config_id=instance_id)
logger.debug(f"Instance {instance_id} parsed config.model.allowedProviders: {config.model.allowedProviders}")
# Cache the config
_config_cache[cache_key] = config

File diff suppressed because it is too large Load diff

View file

@ -18,15 +18,10 @@ FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbot.chat",
"label": {"en": "Chat", "de": "Chat", "fr": "Chat"},
"meta": {"area": "chat"}
},
{
"objectKey": "ui.feature.chatbot.threads",
"label": {"en": "Threads", "de": "Threads", "fr": "Threads"},
"meta": {"area": "threads"}
},
"objectKey": "ui.feature.chatbot.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
"meta": {"area": "conversations"}
}
]
# Resource Objects for RBAC catalog
@ -81,7 +76,7 @@ TEMPLATE_ROLES = [
},
"accessRules": [
# UI: full access to all views
{"context": "UI", "item": "ui.feature.chatbot.chat", "view": True},
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
{"context": "UI", "item": "ui.feature.chatbot.threads", "view": True},
# Resource access: can start/stop chats, view threads, delete own
{"context": "RESOURCE", "item": "resource.feature.chatbot.startStream", "view": True},

View file

@ -25,8 +25,9 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
# Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelChat import UserInputRequest
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.features.chatbot.interfaceFeatureChatbot import ChatbotConversation
# Import chatbot feature
from modules.features.chatbot import chatProcess
@ -101,6 +102,119 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
return str(instance.mandateId)
# =============================================================================
# List threads - MUST be first to avoid /{instanceId}/{workflowId} matching
# GET /api/chatbot/{instanceId}/threads before DELETE /api/chatbot/{instanceId}/{workflowId}
# =============================================================================
@router.get("/{instanceId}/threads")
@limiter.limit("120/minute")
def get_chatbot_threads(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
context: RequestContext = Depends(getRequestContext)
) -> Union[PaginatedResponse[ChatbotConversation], Dict[str, Any]]:
"""
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
- If workflowId is provided: Returns the workflow details and all chat data (messages, logs, stats)
- If workflowId is not provided: Returns a paginated list of all workflows
"""
mandateId = _validateInstanceAccess(instanceId, context)
try:
interfaceDbChat = _getServiceChat(context, instanceId)
if workflowId:
workflow = interfaceDbChat.getWorkflow(workflowId)
if not workflow:
raise HTTPException(
status_code=404,
detail=f"Workflow with ID {workflowId} not found"
)
if hasattr(workflow, 'model_dump'):
workflow_dict = workflow.model_dump()
elif hasattr(workflow, 'dict'):
workflow_dict = workflow.dict()
elif isinstance(workflow, dict):
workflow_dict = dict(workflow)
else:
workflow_dict = workflow
if workflow_dict.get("maxSteps") is None:
workflow_dict["maxSteps"] = 10
chatData = interfaceDbChat.getUnifiedChatData(workflowId, None)
return {
"workflow": workflow_dict,
"chatData": chatData
}
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
all_workflows = interfaceDbChat.getWorkflows(pagination=None)
chatbot_workflows_data = [
wf for wf in all_workflows
if (wf.get("workflowMode") or getattr(wf, "workflowMode", None)) == "Chatbot"
]
if paginationParams:
if paginationParams.sort:
chatbot_workflows_data = interfaceDbChat._applySorting(chatbot_workflows_data, paginationParams.sort)
totalItems = len(chatbot_workflows_data)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
workflows = chatbot_workflows_data[startIdx:endIdx]
else:
workflows = chatbot_workflows_data
totalItems = len(chatbot_workflows_data)
totalPages = 1
normalized_workflows = []
for wf in workflows:
normalized_wf = dict(wf)
if normalized_wf.get("maxSteps") is None:
normalized_wf["maxSteps"] = 10
normalized_workflows.append(normalized_wf)
metadata = PaginationMetadata(
currentPage=paginationParams.page if paginationParams else 1,
pageSize=paginationParams.pageSize if paginationParams else len(workflows),
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
return PaginatedResponse(
items=normalized_workflows,
pagination=metadata
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting chatbot threads: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Error getting chatbot threads: {str(e)}"
)
# Chatbot streaming endpoint (SSE)
@router.post("/{instanceId}/start/stream")
@limiter.limit("120/minute")
@ -189,7 +303,7 @@ async def stream_chatbot_start(
serializable_item = {
"type": item.get("type"),
"createdAt": item.get("createdAt"),
"item": item.get("item").dict() if hasattr(item.get("item"), "dict") else item.get("item")
"item": item.get("item").model_dump() if hasattr(item.get("item"), "model_dump") else (item.get("item").dict() if hasattr(item.get("item"), "dict") else item.get("item"))
}
# Emit item directly in exact chatData format: {type, createdAt, item}
yield f"data: {json.dumps(serializable_item)}\n\n"
@ -257,7 +371,10 @@ async def stream_chatbot_start(
# Ensure item field is serializable (convert Pydantic models to dicts)
if isinstance(chatdata_item, dict) and "item" in chatdata_item:
item_obj = chatdata_item.get("item")
if hasattr(item_obj, "dict"):
if hasattr(item_obj, "model_dump"):
chatdata_item = chatdata_item.copy()
chatdata_item["item"] = item_obj.model_dump()
elif hasattr(item_obj, "dict"):
chatdata_item = chatdata_item.copy()
chatdata_item["item"] = item_obj.dict()
yield f"data: {json.dumps(chatdata_item)}\n\n"
@ -310,14 +427,14 @@ async def stream_chatbot_start(
# Workflow stop endpoint
@router.post("/{instanceId}/stop/{workflowId}", response_model=ChatWorkflow)
@router.post("/{instanceId}/stop/{workflowId}", response_model=ChatbotConversation)
@limiter.limit("120/minute")
async def stop_chatbot(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: str = Path(..., description="ID of the workflow to stop"),
context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
) -> ChatbotConversation:
"""Stops a running chatbot workflow."""
# Validate instance access
_validateInstanceAccess(instanceId, context)
@ -384,138 +501,6 @@ async def stop_chatbot(
detail=str(e)
)
# List chatbot threads/workflows or get specific thread details
# NOTE: This route MUST be defined BEFORE /{instanceId}/{workflowId} routes
# to prevent "threads" from being matched as a workflowId
@router.get("/{instanceId}/threads")
@limiter.limit("120/minute")
def get_chatbot_threads(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
context: RequestContext = Depends(getRequestContext)
) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]:
"""
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
- If workflowId is provided: Returns the workflow details and all chat data (messages, logs, stats)
- If workflowId is not provided: Returns a paginated list of all workflows
"""
# Validate instance access
mandateId = _validateInstanceAccess(instanceId, context)
try:
interfaceDbChat = _getServiceChat(context, instanceId)
# If workflowId is provided, return single workflow with chat data
if workflowId:
workflow = interfaceDbChat.getWorkflow(workflowId)
if not workflow:
raise HTTPException(
status_code=404,
detail=f"Workflow with ID {workflowId} not found"
)
# Normalize workflow data to match ChatWorkflow model requirements
# Convert workflow object to dict if needed, and normalize None values
if hasattr(workflow, 'model_dump'):
workflow_dict = workflow.model_dump()
elif hasattr(workflow, 'dict'):
workflow_dict = workflow.dict()
elif isinstance(workflow, dict):
workflow_dict = dict(workflow)
else:
workflow_dict = workflow
# Set maxSteps to default value of 10 if None (as per ChatWorkflow model)
if workflow_dict.get("maxSteps") is None:
workflow_dict["maxSteps"] = 10
# Get unified chat data for this workflow
chatData = interfaceDbChat.getUnifiedChatData(workflowId, None)
return {
"workflow": workflow_dict,
"chatData": chatData
}
# Otherwise, return paginated list of workflows
# Parse pagination parameter
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
# Get all workflows filtered by mandateId (RBAC handles this automatically)
# We get all workflows first to filter by workflowMode before pagination
all_workflows = interfaceDbChat.getWorkflows(pagination=None)
# Filter to only include chatbot workflows
chatbot_workflows_data = [
wf for wf in all_workflows
if wf.get("workflowMode") == WorkflowModeEnum.WORKFLOW_CHATBOT.value
]
# Apply pagination if requested
if paginationParams:
# Apply sorting if provided
if paginationParams.sort:
chatbot_workflows_data = interfaceDbChat._applySorting(chatbot_workflows_data, paginationParams.sort)
# Count total items after filtering
totalItems = len(chatbot_workflows_data)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
workflows = chatbot_workflows_data[startIdx:endIdx]
else:
workflows = chatbot_workflows_data
totalItems = len(chatbot_workflows_data)
totalPages = 1
# Normalize workflow data to match ChatWorkflow model requirements
# Convert None values to defaults before response validation
normalized_workflows = []
for wf in workflows:
normalized_wf = dict(wf) # Create a copy
# Set maxSteps to default value of 10 if None (as per ChatWorkflow model)
if normalized_wf.get("maxSteps") is None:
normalized_wf["maxSteps"] = 10
normalized_workflows.append(normalized_wf)
# Create paginated response
metadata = PaginationMetadata(
currentPage=paginationParams.page if paginationParams else 1,
pageSize=paginationParams.pageSize if paginationParams else len(workflows),
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
return PaginatedResponse(
items=normalized_workflows,
pagination=metadata
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting chatbot threads: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Error getting chatbot threads: {str(e)}"
)
# Delete chatbot workflow endpoint
# NOTE: This catch-all route MUST be defined AFTER more specific routes like /threads
@router.delete("/{instanceId}/{workflowId}", response_model=Dict[str, Any])
@ -543,7 +528,7 @@ def delete_chatbot(
)
# Check if workflow is a chatbot workflow
if workflow.workflowMode != WorkflowModeEnum.WORKFLOW_CHATBOT.value:
if (workflow.workflowMode or getattr(workflow, "workflowMode", None)) != "Chatbot":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workflow {workflowId} is not a chatbot workflow"

View file

@ -13,7 +13,8 @@ import asyncio
import re
from typing import Optional, Dict, Any, List
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument
from modules.datamodels.datamodelChat import UserInputRequest
from modules.features.chatbot.interfaceFeatureChatbot import ChatbotConversation, ChatbotDocument
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
@ -21,7 +22,7 @@ from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.services import getInterface as getServices
from modules.features.chatbot.streaming.events import get_event_manager
from modules.features.chatbot.chatbot import Chatbot
from modules.features.chatbot.bridges.ai import AICenterChatModel
from modules.features.chatbot.bridges.ai import AICenterChatModel, clear_workflow_allowed_providers
from modules.features.chatbot.bridges.memory import DatabaseCheckpointer
from modules.features.chatbot.config import (
load_chatbot_config_from_instance,
@ -30,7 +31,7 @@ from modules.features.chatbot.config import (
from modules.datamodels.datamodelAi import OperationTypeEnum, ProcessingModeEnum
from modules.workflows.methods.methodAi.methodAi import MethodAi
from modules.connectors.connectorPreprocessor import PreprocessorConnector
from modules.features.chatbot.chatbotConstants import generate_conversation_name
from modules.features.chatbot.chatbotConstants import generate_conversation_name, generate_name_from_prompt
import base64
logger = logging.getLogger(__name__)
@ -69,7 +70,7 @@ async def chatProcess(
userInput: UserInputRequest,
workflowId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> ChatWorkflow:
) -> ChatbotConversation:
"""
Simple chatbot processing - analyze user input and generate queries.
@ -87,14 +88,21 @@ async def chatProcess(
featureInstanceId: Feature instance ID for loading instance-specific config
Returns:
ChatWorkflow instance
ChatbotConversation instance
"""
try:
# Get services with mandate and feature instance context
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
services.featureCode = 'chatbot'
interfaceDbChat = services.interfaceDbChat
# Load instance config and apply allowedProviders for AI calls (conversation name + main chat)
chatbot_config = await _load_chatbot_config(featureInstanceId)
if chatbot_config.model.allowedProviders:
services.allowedProviders = chatbot_config.model.allowedProviders
logger.info(f"Chatbot instance {featureInstanceId}: restricting to providers {chatbot_config.model.allowedProviders}")
from modules.features.chatbot.interfaceFeatureChatbot import getInterface as getChatbotInterface
interfaceDbChat = getChatbotInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
# Get event manager and create queue if needed
event_manager = get_event_manager()
@ -119,39 +127,53 @@ async def chatProcess(
if not event_manager.has_queue(workflowId):
event_manager.create_queue(workflowId)
else:
# Generate conversation name based on user's prompt
conversation_name = await generate_conversation_name(
services,
userInput.prompt,
userInput.userLanguage
)
# Use placeholder name immediately - don't block on AI call
prompt_stripped = (userInput.prompt or "").strip()
trivial_prompts = {"test", "hi", "hallo", "hello", "hey"}
is_short = len(prompt_stripped) < 30 or prompt_stripped.lower() in trivial_prompts
# Create new workflow
workflowData = {
if is_short:
conversation_name = generate_name_from_prompt(userInput.prompt)
run_name_task = False
else:
conversation_name = "Neue Unterhaltung"
run_name_task = True
# Create new conversation (per feature instance)
conversationData = {
"id": str(uuid.uuid4()),
"mandateId": mandateId,
"featureInstanceId": featureInstanceId, # Store feature instance for RBAC
"featureInstanceId": featureInstanceId,
"status": "running",
"name": conversation_name,
"currentRound": 1,
"currentTask": 0,
"currentAction": 0,
"totalTasks": 0,
"totalActions": 0,
"workflowMode": WorkflowModeEnum.WORKFLOW_CHATBOT.value,
"workflowMode": "Chatbot",
"startedAt": getUtcTimestamp(),
"lastActivity": getUtcTimestamp()
}
workflow = interfaceDbChat.createWorkflow(workflowData)
workflow = interfaceDbChat.createConversation(conversationData)
logger.info(f"Created new chatbot workflow: {workflow.id} with name: {conversation_name}")
# Run AI name generation in background (for longer prompts only)
if run_name_task:
asyncio.create_task(_update_conversation_name_async(
services=services,
currentUser=currentUser,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
workflowId=workflow.id,
prompt=userInput.prompt,
userLanguage=userInput.userLanguage,
interfaceDbChat=interfaceDbChat,
event_manager=event_manager
))
# Create event queue for new workflow (for streaming)
event_manager.create_queue(workflow.id)
# Reload workflow to get current message count
workflow = interfaceDbChat.getWorkflow(workflow.id)
# Process uploaded files and create ChatDocuments
# Process uploaded files and create ChatbotDocuments
user_documents = []
if userInput.listFileId and len(userInput.listFileId) > 0:
logger.info(f"Processing {len(userInput.listFileId)} uploaded file(s) for user message")
@ -167,8 +189,8 @@ async def chatProcess(
originalMimeType = fileInfo.get("mimeType", "application/octet-stream")
fileSizeToUse = fileInfo.get("size", 0)
# Create ChatDocument for the file
document = ChatDocument(
# Create ChatbotDocument for the file
document = ChatbotDocument(
id=str(uuid.uuid4()),
messageId="", # Will be set when message is created
fileId=fileId,
@ -180,14 +202,14 @@ async def chatProcess(
actionNumber=0
)
user_documents.append(document)
logger.info(f"Created ChatDocument for file {fileId} -> {originalFileName}")
logger.info(f"Created ChatbotDocument for file {fileId} -> {originalFileName}")
except Exception as e:
logger.error(f"Error processing file ID {fileId}: {e}", exc_info=True)
# Store user message
userMessageData = {
userMessageData: Dict[str, Any] = {
"id": f"msg_{uuid.uuid4()}",
"workflowId": workflow.id,
"conversationId": workflow.id,
"message": userInput.prompt,
"role": "user",
"status": "first" if workflowId is None else "step",
@ -197,7 +219,8 @@ async def chatProcess(
"taskNumber": 0,
"actionNumber": 0
}
if user_documents:
userMessageData["documents"] = [d.model_dump() for d in user_documents]
userMessage = interfaceDbChat.createMessage(userMessageData)
logger.info(f"Stored user message: {userMessage.id} with {len(user_documents)} document(s)")
@ -209,7 +232,7 @@ async def chatProcess(
data={
"type": "message",
"createdAt": message_timestamp,
"item": userMessage.dict()
"item": userMessage.model_dump()
},
event_category="chat"
)
@ -220,6 +243,10 @@ async def chatProcess(
"lastActivity": getUtcTimestamp()
})
# Pre-flight billing check before starting LangGraph (if mandateId present)
if mandateId:
_preflight_billing_check(services, mandateId, featureInstanceId)
# Process in background using LangGraph (async)
asyncio.create_task(_processChatbotMessageLangGraph(
services,
@ -227,7 +254,8 @@ async def chatProcess(
workflow.id,
userInput,
userMessage.id,
featureInstanceId=featureInstanceId
featureInstanceId=featureInstanceId,
config=chatbot_config
))
# Reload workflow to include new message
@ -239,6 +267,46 @@ async def chatProcess(
raise
async def _update_conversation_name_async(
services,
currentUser: User,
mandateId: Optional[str],
featureInstanceId: Optional[str],
workflowId: str,
prompt: str,
userLanguage: str,
interfaceDbChat,
event_manager,
) -> None:
"""
Background task: generate conversation name via AI and update workflow.
Runs in parallel to LangGraph so it doesn't block the first response.
"""
try:
new_name = await generate_conversation_name(services, prompt, userLanguage)
if new_name:
interfaceDbChat.updateWorkflow(workflowId, {"name": new_name, "lastActivity": getUtcTimestamp()})
logger.info(f"Updated workflow {workflowId} name to: {new_name}")
# Emit stat event so frontend can refresh thread list/title
workflow = interfaceDbChat.getWorkflow(workflowId)
if workflow:
wf_dict = workflow.model_dump() if hasattr(workflow, "model_dump") else workflow.dict()
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
data={
"type": "stat",
"createdAt": getUtcTimestamp(),
"item": wf_dict
},
event_category="chat",
message="Workflow name updated",
step="workflow_update"
)
except Exception as e:
logger.warning(f"Background conversation name update failed for workflow {workflowId}: {e}")
async def _execute_queries_parallel(queries: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Execute multiple SQL queries in parallel with shared connector.
@ -345,8 +413,7 @@ async def _emit_log_and_event(
# Emit event directly for streaming (using correct signature)
if created_log and event_manager:
try:
from modules.datamodels.datamodelChat import ChatLog
# Convert to dict if it's a Pydantic model
# Convert to dict if it's a Pydantic model (ChatbotLog from createLog)
if hasattr(created_log, "model_dump"):
log_dict = created_log.model_dump()
elif hasattr(created_log, "dict"):
@ -670,7 +737,7 @@ async def _convert_file_ids_to_document_references(
"""
references = []
# Get workflow to search for ChatDocuments
# Get workflow to search for ChatbotDocuments
workflow = services.workflow
if not workflow:
logger.error("Cannot convert file IDs to document references: workflow not set in services")
@ -684,7 +751,7 @@ async def _convert_file_ids_to_document_references(
logger.warning(f"File {file_id} not found, skipping")
continue
# Find ChatDocument that has this fileId
# Find ChatbotDocument that has this fileId
document_id = None
if workflow.messages:
for message in workflow.messages:
@ -696,14 +763,16 @@ async def _convert_file_ids_to_document_references(
if document_id:
break
# Search database if not found in messages
# Search chatbot database if not found in messages
if not document_id:
try:
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.features.chatbot.interfaceFeatureChatbot import getInterface as getChatbotInterface
chatbotInterface = getChatbotInterface(services.user, mandateId=services.mandateId, featureInstanceId=services.featureInstanceId)
documents = getRecordsetWithRBAC(
services.interfaceDbChat.db,
ChatDocument,
services.currentUser,
chatbotInterface.db,
ChatbotDocument,
services.user,
recordFilter={"fileId": file_id}
)
if documents:
@ -715,7 +784,7 @@ async def _convert_file_ids_to_document_references(
except Exception:
pass # Fallback to fileId
# Use ChatDocument ID if found, otherwise use fileId as fallback
# Use ChatbotDocument ID if found, otherwise use fileId as fallback
ref = DocumentItemReference(documentId=document_id if document_id else file_id)
references.append(ref)
except Exception as e:
@ -779,9 +848,9 @@ async def _create_chat_document_from_action_document(
message_id: str,
workflow_id: str,
round_number: int
) -> ChatDocument:
) -> ChatbotDocument:
"""
Create a ChatDocument from an ActionDocument by storing the file data.
Create a ChatbotDocument from an ActionDocument by storing the file data.
Args:
services: Services instance
@ -791,7 +860,7 @@ async def _create_chat_document_from_action_document(
round_number: Round number
Returns:
ChatDocument instance
ChatbotDocument instance
"""
try:
# Get file data (could be bytes or string)
@ -838,8 +907,8 @@ async def _create_chat_document_from_action_document(
if not success:
logger.warning(f"Failed to store file data for {file_item.id}, but continuing...")
# Create ChatDocument
chat_document = ChatDocument(
# Create ChatbotDocument
chat_document = ChatbotDocument(
id=str(uuid.uuid4()),
messageId=message_id,
fileId=file_item.id,
@ -851,11 +920,11 @@ async def _create_chat_document_from_action_document(
actionNumber=0
)
logger.info(f"Created ChatDocument {chat_document.id} from ActionDocument {file_name} (size: {len(file_bytes)} bytes)")
logger.info(f"Created ChatbotDocument {chat_document.id} from ActionDocument {file_name} (size: {len(file_bytes)} bytes)")
return chat_document
except Exception as e:
logger.error(f"Error creating ChatDocument from ActionDocument: {e}", exc_info=True)
logger.error(f"Error creating ChatbotDocument from ActionDocument: {e}", exc_info=True)
raise
@ -977,6 +1046,7 @@ async def _bridge_chatbot_events(
message="Chatbot-Verarbeitung abgeschlossen",
step="complete"
)
clear_workflow_allowed_providers(workflow_id)
# Update workflow status
try:
@ -1001,6 +1071,7 @@ async def _bridge_chatbot_events(
message=f"Fehler beim Verarbeiten: {error_msg}",
step="error"
)
clear_workflow_allowed_providers(workflow_id)
# Update workflow status
try:
@ -1059,6 +1130,7 @@ async def _bridge_chatbot_events(
message="Chatbot-Verarbeitung abgeschlossen",
step="complete"
)
clear_workflow_allowed_providers(workflow_id)
# Update workflow status
try:
@ -1081,6 +1153,7 @@ async def _bridge_chatbot_events(
message=f"Fehler beim Verarbeiten: {str(e)}",
step="error"
)
clear_workflow_allowed_providers(workflow_id)
async def _load_chatbot_config(featureInstanceId: Optional[str]) -> ChatbotConfig:
@ -1121,13 +1194,85 @@ async def _load_chatbot_config(featureInstanceId: Optional[str]) -> ChatbotConfi
raise
def _preflight_billing_check(services, mandateId: str, featureInstanceId: Optional[str]) -> None:
"""
Pre-flight billing check before starting chatbot AI processing.
Raises if mandate has insufficient balance or no providers allowed.
"""
from modules.services.serviceBilling.mainServiceBilling import (
getService as getBillingService,
InsufficientBalanceException,
ProviderNotAllowedException,
BillingContextError,
)
user = services.user
featureCode = "chatbot"
try:
billingService = getBillingService(user, mandateId, featureInstanceId, featureCode)
balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed:
raise InsufficientBalanceException(
currentBalance=balanceCheck.currentBalance or 0.0,
requiredAmount=0.01,
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}"
)
rbacAllowedProviders = billingService.getallowedProviders()
if not rbacAllowedProviders:
raise ProviderNotAllowedException(
provider="any",
message="Keine AI-Provider fuer Ihre Rolle freigegeben. Kontaktieren Sie Ihren Administrator."
)
except (InsufficientBalanceException, ProviderNotAllowedException):
raise
except Exception as e:
logger.error(f"Billing pre-flight failed: {e}")
raise BillingContextError(f"Billing check failed: {e}")
def _create_chatbot_billing_callback(services, workflow_id: str):
"""
Create billing callback for AICenterChatModel. Records each AI call to poweron_billing.
"""
from modules.services.serviceBilling.mainServiceBilling import getService as getBillingService
from modules.datamodels.datamodelAi import AiCallResponse
user = services.user
mandateId = services.mandateId
featureInstanceId = getattr(services, "featureInstanceId", None)
featureCode = "chatbot"
billingService = getBillingService(user, mandateId, featureInstanceId, featureCode)
def _billing_callback(response: AiCallResponse) -> None:
if not response or getattr(response, "errorCount", 0) > 0:
return
priceCHF = getattr(response, "priceCHF", 0.0)
if not priceCHF or priceCHF <= 0:
return
provider = getattr(response, "provider", None) or "unknown"
modelName = getattr(response, "modelName", None) or "unknown"
try:
billingService.recordUsage(
priceCHF=priceCHF,
workflowId=workflow_id,
aicoreProvider=provider,
aicoreModel=modelName,
description=f"AI: {modelName}"
)
logger.debug(f"Chatbot billed: {priceCHF:.4f} CHF, provider={provider}, model={modelName}")
except Exception as e:
logger.error(f"Chatbot billing failed: {e}")
return _billing_callback
async def _processChatbotMessageLangGraph(
services,
currentUser: User,
workflowId: str,
userInput: UserInputRequest,
userMessageId: str,
featureInstanceId: Optional[str] = None
featureInstanceId: Optional[str] = None,
config: Optional[ChatbotConfig] = None
):
"""
Process chatbot message using LangGraph.
@ -1144,7 +1289,8 @@ async def _processChatbotMessageLangGraph(
event_manager = get_event_manager()
try:
interfaceDbChat = services.interfaceDbChat
from modules.features.chatbot.interfaceFeatureChatbot import getInterface as getChatbotInterface
interfaceDbChat = getChatbotInterface(currentUser, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
# Reload workflow to get current messages
workflow = interfaceDbChat.getWorkflow(workflowId)
@ -1165,8 +1311,19 @@ async def _processChatbotMessageLangGraph(
logger.info(f"Workflow {workflowId} was stopped, aborting processing")
return
# Load configuration from FeatureInstance (database) or fall back to file
config = await _load_chatbot_config(featureInstanceId)
# Emit synthetic status for real-time UI feedback
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
data={"type": "status", "label": "Lade Konfiguration..."},
event_category="chat",
message="Status update",
step="status"
)
# Load configuration if not passed (e.g. when resuming)
if config is None:
config = await _load_chatbot_config(featureInstanceId)
# Replace {{DATE}} placeholder in system prompt
from datetime import datetime
@ -1179,14 +1336,39 @@ async def _processChatbotMessageLangGraph(
operation_type = OperationTypeEnum[config.model.operationType]
processing_mode = ProcessingModeEnum[config.model.processingMode]
billing_callback = None
if services.mandateId:
billing_callback = _create_chatbot_billing_callback(services, workflowId)
allowed_providers = config.model.allowedProviders or None
if allowed_providers:
logger.info(f"Chatbot AICenterChatModel: restricting to providers {allowed_providers}")
model = AICenterChatModel(
user=currentUser,
operation_type=operation_type,
processing_mode=processing_mode
processing_mode=processing_mode,
billing_callback=billing_callback,
workflow_id=workflowId,
allowed_providers=allowed_providers
)
# Create memory/checkpointer
memory = DatabaseCheckpointer(user=currentUser, workflow_id=workflowId)
# Emit synthetic status for real-time UI feedback
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
data={"type": "status", "label": "Bereite Chat vor..."},
event_category="chat",
message="Status update",
step="status"
)
# Create memory/checkpointer (uses chatbot's own DB via interfaceFeatureChatbot)
memory = DatabaseCheckpointer(
user=currentUser,
workflow_id=workflowId,
mandateId=services.mandateId,
featureInstanceId=featureInstanceId
)
# Create chatbot instance with config for dynamic tool configuration
chatbot = await Chatbot.create(
@ -1197,6 +1379,16 @@ async def _processChatbotMessageLangGraph(
config=config
)
# Emit synthetic status for real-time UI feedback
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
data={"type": "status", "label": "Analysiere Anfrage..."},
event_category="chat",
message="Status update",
step="status"
)
# Stream events using chatbot
event_stream = chatbot.stream_events(
message=userInput.prompt,

View file

@ -0,0 +1,3 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Chatbot V2 feature - context-aware chat with file upload and extraction."""

View file

@ -0,0 +1,8 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Chatbot V2 bridges - AI and memory."""
from modules.features.chatbot.bridges.ai import AICenterChatModel, clear_workflow_allowed_providers
from .memory import ChatbotV2Checkpointer
__all__ = ["AICenterChatModel", "clear_workflow_allowed_providers", "ChatbotV2Checkpointer"]

View file

@ -0,0 +1,187 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot V2 checkpointer - maps LangGraph state to ChatbotV2 message storage.
"""
import logging
import uuid
from typing import Any, Dict, List, Optional, Tuple, NamedTuple
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint, CheckpointMetadata
class CheckpointTuple(NamedTuple):
"""Tuple containing config, checkpoint, metadata, parent_config, and pending_writes."""
config: Dict[str, Any]
checkpoint: Checkpoint
metadata: CheckpointMetadata
parent_config: Optional[Dict[str, Any]] = None
pending_writes: Optional[List[Tuple[str, Any]]] = None
from modules.features.chatbotV2.interfaceFeatureChatbotV2 import getInterface as getChatbotV2Interface
from modules.features.chatbotV2.datamodelFeatureChatbotV2 import ChatbotV2Message
from modules.datamodels.datamodelUam import User
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
def _sanitize_llm_response(text: str) -> str:
"""Strip chat template tokens and trailing junk."""
if not text or not isinstance(text, str):
return text or ""
for sentinel in ("<|im_start|>", "<|im_end|>", "<|endoftext|>", "<|user|>", "<|assistant|>"):
if sentinel in text:
text = text.split(sentinel)[0]
return text.strip()
class ChatbotV2Checkpointer(BaseCheckpointSaver):
"""Checkpointer that stores messages via ChatbotV2 interface."""
def __init__(
self,
user: User,
workflow_id: str,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
):
self.user = user
self.workflow_id = workflow_id
self.interface = getChatbotV2Interface(
user,
mandateId=mandateId,
featureInstanceId=featureInstanceId
)
def _to_db_message(
self,
msg: BaseMessage,
sequence_nr: int,
round_number: int
) -> Dict[str, Any]:
role = "user"
content = ""
if isinstance(msg, HumanMessage):
role = "user"
content = msg.content if isinstance(msg.content, str) else str(msg.content or "")
elif isinstance(msg, AIMessage):
role = "assistant"
content = msg.content if isinstance(msg.content, str) else str(msg.content or "")
content = _sanitize_llm_response(content)
elif isinstance(msg, SystemMessage):
role = "system"
content = msg.content if isinstance(msg.content, str) else str(msg.content or "")
return {
"id": str(uuid.uuid4()),
"conversationId": self.workflow_id,
"message": content,
"role": role,
"status": "step" if sequence_nr > 1 else "first",
"sequenceNr": sequence_nr,
"publishedAt": getUtcTimestamp(),
"roundNumber": round_number
}
def _to_langchain(self, messages: List[ChatbotV2Message]) -> List[BaseMessage]:
result = []
for m in messages:
if m.role == "user":
result.append(HumanMessage(content=m.message or ""))
elif m.role == "assistant":
result.append(AIMessage(content=m.message or ""))
elif m.role == "system":
result.append(SystemMessage(content=m.message or ""))
return result
def put(
self,
config: Dict[str, Any],
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: Dict[str, int],
) -> None:
thread_id = config.get("configurable", {}).get("thread_id", self.workflow_id)
conv = self.interface.getConversation(thread_id)
if not conv:
logger.warning(f"Conversation {thread_id} not found")
return
round_number = conv.currentRound or 1
state = checkpoint.get("channel_values", {})
messages = state.get("messages", [])
if not messages:
return
existing = self.interface.getMessages(thread_id)
existing_set = {(m.role, m.message) for m in existing}
existing_count = len(existing)
for i, msg in enumerate(messages):
if not isinstance(msg, (HumanMessage, AIMessage)):
continue
role = "user" if isinstance(msg, HumanMessage) else "assistant"
content = msg.content if isinstance(msg.content, str) else str(msg.content or "")
if isinstance(msg, AIMessage):
content = _sanitize_llm_response(content)
if not content or not content.strip():
continue
if (role, content) in existing_set:
continue
existing_set.add((role, content))
existing_count += 1
db_msg = self._to_db_message(msg, existing_count, round_number)
self.interface.createMessage(db_msg)
self.interface.updateConversation(thread_id, {"lastActivity": getUtcTimestamp()})
def get(self, config: Dict[str, Any]) -> Optional[Checkpoint]:
thread_id = config.get("configurable", {}).get("thread_id", self.workflow_id)
conv = self.interface.getConversation(thread_id)
if not conv:
return None
messages = self.interface.getMessages(thread_id)
lc_messages = self._to_langchain(messages)
return {
"id": str(uuid.uuid4()),
"v": 1,
"ts": getUtcTimestamp(),
"channel_values": {"messages": lc_messages},
"channel_versions": {},
"versions_seen": {}
}
# Async methods required for LangGraph ainvoke/astream
async def aget_tuple(
self,
config: Dict[str, Any],
) -> Optional[CheckpointTuple]:
"""Async version of get that returns tuple of (config, checkpoint, metadata)."""
checkpoint = self.get(config)
if checkpoint:
metadata: CheckpointMetadata = {"step": 0}
return CheckpointTuple(
config=config,
checkpoint=checkpoint,
metadata=metadata,
parent_config=None,
pending_writes=None,
)
return None
async def aput(
self,
config: Dict[str, Any],
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: Dict[str, int],
) -> None:
"""Async version of put."""
self.put(config, checkpoint, metadata, new_versions)
async def aput_writes(
self,
config: Dict[str, Any],
writes: List[Tuple[str, Any]],
task_id: str,
) -> None:
"""Async version of put_writes. No-op - writes are handled through aput()."""
pass

View file

@ -0,0 +1,210 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot V2 domain logic - simple chat LangGraph with context injection.
Uses chunk-based retrieval with retry: when AI says "nicht enthalten",
tries the next chunk batch until content is found or all chunks searched.
"""
import asyncio
import logging
import re
from typing import Annotated, Optional, TYPE_CHECKING, List, Dict, Any
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langgraph.graph.message import add_messages
from langgraph.graph import StateGraph, START, END
from langgraph.graph.state import CompiledStateGraph
from pydantic import BaseModel
from modules.features.chatbotV2.bridges import AICenterChatModel, ChatbotV2Checkpointer
from modules.features.chatbotV2.contextChunkRetrieval import (
chunk_sections,
chunk_text_blocks,
get_ordered_chunks_for_question,
get_chunk_batch,
response_indicates_not_found,
DEFAULT_CHUNK_SIZE,
DEFAULT_CHUNK_OVERLAP,
)
if TYPE_CHECKING:
from modules.features.chatbotV2.config import ChatbotV2Config
logger = logging.getLogger(__name__)
class ChatState(BaseModel):
"""State for Chatbot V2 chat session."""
messages: Annotated[list[BaseMessage], add_messages]
# Optional context for chunk retrieval (passed at invoke, not persisted)
chatbotv2_context: Optional[Dict[str, Any]] = None
# Default max context chars (~20k tokens) - fits GPT 25k limit with room for prompt + response
DEFAULT_MAX_CONTEXT_CHARS = 60_000
def _build_system_prompt_from_chunks(
base_prompt: str,
chunks: List[Dict[str, Any]]
) -> str:
"""Build system prompt from a list of chunks."""
if not chunks:
return base_prompt
header = "\n\n--- DOCUMENT CONTEXT (use this to answer user questions) ---\n"
parts = [base_prompt, header]
current_file = None
for chunk in chunks:
fn = chunk.get("fileName", "document")
if fn != current_file:
parts.append(f"\n### {fn}\n")
current_file = fn
parts.append(chunk.get("text", ""))
return "\n".join(parts)
def build_context_system_prompt(
base_prompt: str,
extracted_context: dict,
user_question: str,
max_context_chars: Optional[int] = None,
chunk_size: Optional[int] = None,
chunk_overlap: Optional[int] = None,
) -> str:
"""
Build system prompt with first chunk batch (for single-call use).
For retry loop, use get_ordered_chunks_for_question + get_chunk_batch + _build_system_prompt_from_chunks.
"""
chunks = _get_all_chunks(extracted_context, chunk_size, chunk_overlap)
if not chunks:
return base_prompt
ordered = get_ordered_chunks_for_question(chunks, user_question or "")
selected = get_chunk_batch(ordered, 0, max_context_chars or DEFAULT_MAX_CONTEXT_CHARS)
return _build_system_prompt_from_chunks(base_prompt, selected)
def _get_all_chunks(
extracted_context: dict,
chunk_size: Optional[int],
chunk_overlap: Optional[int],
) -> List[Dict[str, Any]]:
"""Get all chunks from extracted context."""
sections = extracted_context.get("sections", [])
text_blocks = extracted_context.get("textBlocks", [])
if not sections and not text_blocks:
return []
cs = chunk_size if chunk_size and chunk_size > 0 else DEFAULT_CHUNK_SIZE
co = chunk_overlap if chunk_overlap is not None and chunk_overlap >= 0 else DEFAULT_CHUNK_OVERLAP
if sections:
return chunk_sections(sections, chunk_size=cs, chunk_overlap=co)
return chunk_text_blocks(text_blocks, chunk_size=cs, chunk_overlap=co)
def create_chat_graph(
model: AICenterChatModel,
memory: ChatbotV2Checkpointer,
) -> CompiledStateGraph:
"""
Create chat graph with retry loop: when AI says content not found,
tries next chunk batch until found or exhausted.
Context params passed via state.chatbotv2_context at invoke time.
"""
async def chat_node(state: ChatState) -> dict:
# State can be dict (LangGraph) or Pydantic model
state_dict = state if isinstance(state, dict) else (state.model_dump() if hasattr(state, "model_dump") else {})
msgs = state_dict.get("messages", [])
if not msgs:
return {}
ctx = state_dict.get("chatbotv2_context") or {}
ctx_dict = ctx.get("ctx_dict", {})
user_question = ctx.get("user_question", "")
base_prompt = ctx.get("base_prompt", "Answer based on the provided context.")
max_chars = ctx.get("max_context_chars") or DEFAULT_MAX_CONTEXT_CHARS
chunk_size = ctx.get("chunk_size") or DEFAULT_CHUNK_SIZE
chunk_overlap = ctx.get("chunk_overlap")
if max_chars <= 0:
max_chars = DEFAULT_MAX_CONTEXT_CHARS
if chunk_overlap is None or chunk_overlap < 0:
chunk_overlap = DEFAULT_CHUNK_OVERLAP
user_msgs = [m for m in msgs if not isinstance(m, SystemMessage)]
if not user_msgs:
return {}
# Get chunks - use DOCUMENT ORDER for retry (batch 0 = start, batch 1 = next part, etc.)
chunks = _get_all_chunks(ctx_dict, chunk_size, chunk_overlap)
if not chunks:
logger.warning("No chunks from ctx_dict - sections=%s, textBlocks=%s",
len(ctx_dict.get("sections", [])), len(ctx_dict.get("textBlocks", [])))
# Always use document order (chunkIndex) for systematic search through entire document
ordered = sorted(chunks, key=lambda c: c.get("chunkIndex", 0))
batch_index = 0
last_response = None
logger.info("Chunk retrieval: %d chunks total, max_chars=%d, will try batches until found or exhausted",
len(ordered), max_chars)
while True:
batch = get_chunk_batch(ordered, batch_index, max_chars) if ordered else []
if not batch:
# No more chunks - return last response or final message
if last_response:
return {"messages": [last_response]}
return {"messages": [AIMessage(
content="Ich habe das gesamte Dokument durchsucht, konnte aber keine "
"passende Information zu Ihrer Frage finden. Bitte formulieren Sie die Frage "
"ggf. anders oder prüfen Sie, ob das Dokument die gewünschten Angaben enthält."
)]}
system_prompt = _build_system_prompt_from_chunks(base_prompt, batch)
window = [SystemMessage(content=system_prompt)] + user_msgs
response = None
for attempt in range(3): # Max 3 attempts (initial + 2 retries on rate limit)
try:
response = await model.ainvoke(window)
break
except Exception as exc:
err_str = str(exc).lower()
if ("429" in err_str or "rate limit" in err_str) and attempt < 2:
wait_secs = 6
match = re.search(r"try again in ([\d.]+)s", err_str, re.IGNORECASE)
if match:
wait_secs = max(6, int(float(match.group(1))) + 1)
logger.warning("Rate limit hit on chunk batch %d, waiting %ds before retry (attempt %d/3)",
batch_index, wait_secs, attempt + 1)
await asyncio.sleep(wait_secs)
else:
if "No suitable model found" in str(exc):
return {"messages": [AIMessage(
content="Es tut mir leid, derzeit steht kein passendes KI-Modell zur Verfügung. "
"Bitte versuchen Sie es später erneut."
)]}
raise
if response is None:
return {"messages": [AIMessage(
content="Ein Fehler ist aufgetreten. Bitte versuchen Sie es später erneut."
)]}
content = response.content if hasattr(response, "content") else str(response)
if response_indicates_not_found(content):
logger.info("Chunk batch %d: AI said not found (%.0f chars), trying next batch",
batch_index, len(content))
batch_index += 1
last_response = response
await asyncio.sleep(5) # Pause before next batch to avoid rate limits
continue
logger.info("Chunk batch %d: Found answer (%.0f chars)", batch_index, len(content))
return {"messages": [response]}
workflow = StateGraph(ChatState)
workflow.add_node("chat", chat_node)
workflow.add_edge(START, "chat")
workflow.add_edge("chat", END)
return workflow.compile(checkpointer=memory)

View file

@ -0,0 +1,98 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Configuration system for Chatbot V2 instances.
Loads configuration from FeatureInstance.config JSONB field.
"""
import logging
from dataclasses import dataclass
from typing import Optional, Dict, Any, List, TYPE_CHECKING
if TYPE_CHECKING:
from modules.datamodels.datamodelFeatures import FeatureInstance
logger = logging.getLogger(__name__)
_config_cache: Dict[str, 'ChatbotV2Config'] = {}
DEFAULT_SYSTEM_PROMPT = (
"You are a helpful assistant. Answer questions based on the provided context documents. "
"When the user asks about the documents, use the extracted content to provide accurate answers. "
"If the context does not contain relevant information, say so."
)
@dataclass
class ModelConfig:
"""Model configuration for Chatbot V2."""
operationType: str = "DATA_ANALYSE"
processingMode: str = "BASIC"
allowedProviders: List[str] = None
def __post_init__(self):
if self.allowedProviders is None:
self.allowedProviders = []
def _parse_int(val: Optional[Any], default: Optional[int] = None) -> Optional[int]:
"""Parse int from config value."""
if val is None:
return default
if isinstance(val, int):
return val
try:
return int(val)
except (TypeError, ValueError):
return default
@dataclass
class ChatbotV2Config:
"""Configuration for a Chatbot V2 instance."""
id: str
name: str
systemPrompt: str
model: ModelConfig
maxContextChars: Optional[int] = None # Max document chars in system prompt (~60k ≈ 20k tokens). None = default 60k.
chunkSize: Optional[int] = None # Chunk size in chars (~15k). None = default.
chunkOverlap: Optional[int] = None # Overlap between chunks in chars (~500). None = default.
@classmethod
def from_dict(cls, data: Dict[str, Any], config_id: str = "default") -> 'ChatbotV2Config':
"""Create ChatbotV2Config from dictionary."""
system_prompt = data.get("systemPrompt") or DEFAULT_SYSTEM_PROMPT
model_data = data.get("model", {})
allowed_providers = model_data.get("allowedProviders") or data.get("allowedProviders", [])
model_config = ModelConfig(
operationType=model_data.get("operationType", "DATA_ANALYSE"),
processingMode=model_data.get("processingMode", "BASIC"),
allowedProviders=allowed_providers if isinstance(allowed_providers, list) else []
)
return cls(
id=data.get("id", config_id),
name=data.get("name", "Chatbot V2"),
systemPrompt=system_prompt,
model=model_config,
maxContextChars=_parse_int(data.get("maxContextChars")),
chunkSize=_parse_int(data.get("chunkSize")),
chunkOverlap=_parse_int(data.get("chunkOverlap"))
)
def load_chatbotv2_config_from_instance(instance: 'FeatureInstance') -> ChatbotV2Config:
"""Load Chatbot V2 configuration from a FeatureInstance's config field."""
instance_id = instance.id
cache_key = f"instance_{instance_id}"
if cache_key in _config_cache:
return _config_cache[cache_key]
config_data = instance.config or {}
config = ChatbotV2Config.from_dict(config_data, config_id=instance_id)
_config_cache[cache_key] = config
logger.info(f"Loaded chatbotv2 config from instance {instance_id}")
return config

View file

@ -0,0 +1,272 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chunk-based context retrieval for Chatbot V2.
Splits documents into chunks and selects relevant chunks per user question.
If no relevant chunk is found, falls back to next chunks in document order - no context is lost.
"""
import re
import logging
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
# Default chunk size (~5k tokens each), overlap for context continuity
DEFAULT_CHUNK_SIZE = 15_000
DEFAULT_CHUNK_OVERLAP = 500
# Stopwords for relevance scoring (DE/EN/FR - minimal set)
STOPWORDS = {
"der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "eines",
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
"of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
"le", "la", "les", "un", "une", "des", "du", "de", "et", "ou",
"was", "wer", "wie", "wo", "wann", "warum", "welche", "welcher",
}
def _extract_keywords(text: str) -> set:
"""Extract significant words for relevance scoring."""
text_lower = text.lower()
words = re.findall(r"\b[a-zàâäéèêëïîôùûüÿæœçß]{2,}\b", text_lower)
return {w for w in words if w not in STOPWORDS and len(w) > 1}
def chunk_sections(
sections: List[Dict[str, Any]],
chunk_size: int = DEFAULT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP
) -> List[Dict[str, Any]]:
"""
Split sections into overlapping chunks.
Each chunk has: chunkIndex, text, fileName, fileId, sectionIndex.
"""
chunks = []
chunk_index = 0
for sec_idx, section in enumerate(sections):
text = section.get("text", "")
file_name = section.get("fileName", "document")
file_id = section.get("fileId", "")
if not text:
continue
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunk_text = text[start:end]
chunks.append({
"chunkIndex": chunk_index,
"text": chunk_text,
"fileName": file_name,
"fileId": file_id,
"sectionIndex": sec_idx,
"startChar": start,
"endChar": end,
})
chunk_index += 1
start = end - chunk_overlap if end < len(text) else len(text)
return chunks
def chunk_text_blocks(
text_blocks: List[Dict[str, Any]],
chunk_size: int = DEFAULT_CHUNK_SIZE,
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP
) -> List[Dict[str, Any]]:
"""Split textBlocks (blocks per file) into chunks."""
chunks = []
chunk_index = 0
for doc in text_blocks:
blocks = doc.get("blocks", [])
file_name = doc.get("fileName", "document")
file_id = doc.get("fileId", "")
text_parts = []
for b in blocks:
text_parts.append(b.get("text", ""))
full_text = "\n".join(text_parts)
if not full_text:
continue
start = 0
while start < len(full_text):
end = min(start + chunk_size, len(full_text))
chunk_text = full_text[start:end]
chunks.append({
"chunkIndex": chunk_index,
"text": chunk_text,
"fileName": file_name,
"fileId": file_id,
"startChar": start,
"endChar": end,
})
chunk_index += 1
start = end - chunk_overlap if end < len(full_text) else len(full_text)
return chunks
def score_chunk_relevance(chunk_text: str, question: str) -> float:
"""
Score how relevant a chunk is to the user question.
Uses keyword overlap with simple IDF-like weighting.
Returns 0 if no overlap.
"""
if not question or not chunk_text:
return 0.0
q_words = _extract_keywords(question)
if not q_words:
return 0.0
chunk_lower = chunk_text.lower()
score = 0.0
for w in q_words:
count = chunk_lower.count(w)
if count > 0:
score += 1.0 + 0.5 * min(count - 1, 3)
return score
def get_ordered_chunks_for_question(
chunks: List[Dict[str, Any]],
question: str
) -> List[Dict[str, Any]]:
"""
Return chunks ordered by relevance (or doc order if no match).
Does NOT limit by max_context_chars - used for iterative batch retrieval.
"""
if not chunks:
return []
scored = []
for c in chunks:
score = score_chunk_relevance(c.get("text", ""), question)
scored.append((score, c))
max_score = max(s for s, _ in scored)
if max_score > 0:
scored.sort(key=lambda x: (-x[0], x[1].get("chunkIndex", 0)))
else:
scored.sort(key=lambda x: x[1].get("chunkIndex", 0))
logger.debug("No chunk matched question keywords - using chunks in document order")
return [c for _, c in scored]
def get_chunk_batch(
ordered_chunks: List[Dict[str, Any]],
batch_index: int,
max_context_chars: int
) -> List[Dict[str, Any]]:
"""
Get the Nth batch of chunks that fits in max_context_chars.
Batch 0 = first chunk(s) that fit, batch 1 = next chunk(s), etc.
Returns [] when batch_index is beyond available chunks.
"""
if not ordered_chunks or batch_index < 0:
return []
used_chars = 0
chunks_in_batch = 0
start_idx = 0
# Find start index for this batch (skip chunks from previous batches)
for _ in range(batch_index):
batch_chars = 0
i = start_idx
while i < len(ordered_chunks):
text = ordered_chunks[i].get("text", "")
if batch_chars + len(text) <= max_context_chars:
batch_chars += len(text)
i += 1
else:
if batch_chars < max_context_chars and len(text) > 0:
batch_chars += min(len(text), max_context_chars - batch_chars)
i += 1
break
start_idx = i
if start_idx >= len(ordered_chunks):
return []
# Collect chunks for this batch
selected = []
for i in range(start_idx, len(ordered_chunks)):
chunk = ordered_chunks[i]
text = chunk.get("text", "")
if not text:
continue
if used_chars + len(text) <= max_context_chars:
selected.append(chunk)
used_chars += len(text)
else:
if used_chars < max_context_chars:
remaining = max_context_chars - used_chars
selected.append({**chunk, "text": text[:remaining]})
break
return selected
def select_chunks_for_question(
chunks: List[Dict[str, Any]],
question: str,
max_context_chars: int
) -> List[Dict[str, Any]]:
"""
Select first batch of chunks (for backward compatibility).
For iterative retry, use get_ordered_chunks_for_question + get_chunk_batch.
"""
ordered = get_ordered_chunks_for_question(chunks, question)
return get_chunk_batch(ordered, 0, max_context_chars)
# Phrases that indicate the AI found no relevant content (DE/EN/FR)
NOT_FOUND_PHRASES = [
"nicht enthalten",
"nicht im kontext",
"nicht im dokument",
"nicht im bereitgestellten",
"nicht auffindbar",
"keine information",
"keine angaben",
"nicht gefunden",
"nicht verfügbar",
"kein hinweis",
"enthalten nicht",
"artikel nicht enthalten",
"nicht im vorliegenden",
"bereitgestellten kontext enthält nicht",
"im kontext nicht",
"not contained",
"not found",
"no information",
"not in the context",
"not in the document",
"pas dans le contexte",
"non trouvé",
]
def response_indicates_not_found(response_content: str) -> bool:
"""
Check if the AI response indicates that the requested content was not found.
When True, we should try the next chunk batch.
"""
if not response_content or not isinstance(response_content, str):
return False
text_lower = response_content.lower().strip()
# Only treat short/medium responses as "not found" - long answers may mention it incidentally
if len(text_lower) > 600:
return False
for phrase in NOT_FOUND_PHRASES:
if phrase in text_lower:
return True
return False

View file

@ -0,0 +1,160 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
LangGraph-based pipeline for extracting context from uploaded documents.
Creates chat context from PDF and text files (no domain-specific goals).
"""
import logging
from typing import TypedDict, List, Dict, Any, Optional
from langgraph.graph import StateGraph, START, END
logger = logging.getLogger(__name__)
class ContextExtractionState(TypedDict):
"""State for context extraction pipeline."""
# Input: list of {fileId, bytes, mimeType, fileName}
files: List[Dict[str, Any]]
# Extracted text blocks per file: [{fileId, fileName, blocks: [{page, text, block_id}]}]
textBlocks: List[Dict[str, Any]]
# Structured sections for chat context (simplified articles/sections)
sections: List[Dict[str, Any]]
# Optional summaries (empty for now - no LLM in extraction)
summaries: List[Dict[str, Any]]
errors: List[str]
def extract_text_node(state: ContextExtractionState) -> ContextExtractionState:
"""Extract text from each file. PDF via BZOPdfExtractor, TXT as plain text."""
text_blocks = []
errors = list(state.get("errors", []))
for idx, file_info in enumerate(state.get("files", [])):
file_id = file_info.get("fileId", f"file_{idx}")
file_bytes = file_info.get("bytes")
mime_type = (file_info.get("mimeType") or "").lower()
file_name = file_info.get("fileName", f"document_{idx}")
if not file_bytes:
errors.append(f"No content for file {file_name} ({file_id})")
continue
blocks = []
try:
if "pdf" in mime_type or file_name.lower().endswith(".pdf"):
from modules.features.realEstate.bzoPdfExtractor import BZOPdfExtractor
extractor = BZOPdfExtractor()
tb_list = extractor.extract_text_blocks(file_bytes, file_id)
for tb in tb_list:
blocks.append({
"page": tb.page,
"text": tb.text,
"block_id": tb.block_id,
"bbox": tb.bbox
})
logger.info(f"Extracted {len(blocks)} blocks from PDF {file_name}")
elif "text" in mime_type or file_name.lower().endswith(".txt"):
text = file_bytes.decode("utf-8", errors="replace")
lines = text.split("\n")
for i, line in enumerate(lines):
if line.strip():
blocks.append({
"page": 1,
"text": line.strip(),
"block_id": f"{file_id}_line_{i}",
"bbox": None
})
logger.info(f"Extracted {len(blocks)} lines from text file {file_name}")
else:
errors.append(f"Unsupported format for {file_name}: {mime_type}")
except Exception as e:
logger.error(f"Error extracting {file_name}: {e}", exc_info=True)
errors.append(f"Extraction failed for {file_name}: {str(e)}")
if blocks:
text_blocks.append({
"fileId": file_id,
"fileName": file_name,
"blocks": blocks
})
return {
**state,
"textBlocks": text_blocks,
"errors": errors
}
def structure_content_node(state: ContextExtractionState) -> ContextExtractionState:
"""Assemble text blocks into sections for chat context."""
sections = []
for doc in state.get("textBlocks", []):
file_name = doc.get("fileName", "document")
blocks = doc.get("blocks", [])
if not blocks:
continue
# Build section: combine blocks with page awareness
text_parts = []
current_page = 0
for b in blocks:
page = b.get("page", 1)
if page != current_page and text_parts:
text_parts.append("\n\n")
text_parts.append(b.get("text", ""))
current_page = page
full_text = "".join(text_parts).strip()
if full_text:
sections.append({
"fileId": doc.get("fileId"),
"fileName": file_name,
"text": full_text,
"blockCount": len(blocks)
})
return {
**state,
"sections": sections
}
def create_context_extraction_graph():
"""Create and compile the context extraction LangGraph."""
workflow = StateGraph(ContextExtractionState)
workflow.add_node("extract_text", extract_text_node)
workflow.add_node("structure_content", structure_content_node)
workflow.add_edge(START, "extract_text")
workflow.add_edge("extract_text", "structure_content")
workflow.add_edge("structure_content", END)
return workflow.compile()
def run_extraction(files: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Run the context extraction pipeline on uploaded files.
Args:
files: List of {fileId, bytes, mimeType, fileName}
Returns:
{
"textBlocks": [...],
"sections": [...],
"summaries": [],
"errors": [...]
}
"""
state: ContextExtractionState = {
"files": files,
"textBlocks": [],
"sections": [],
"summaries": [],
"errors": []
}
graph = create_context_extraction_graph()
final_state = graph.invoke(state)
return {
"textBlocks": final_state.get("textBlocks", []),
"sections": final_state.get("sections", []),
"summaries": final_state.get("summaries", []),
"errors": final_state.get("errors", [])
}

View file

@ -0,0 +1,85 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Pydantic models for Chatbot V2 feature.
Stores context per chat: uploaded files, extracted content, and conversation history.
"""
import uuid
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
from modules.shared.timeUtils import getUtcTimestamp
class ChatbotV2ContextFile(BaseModel):
"""Uploaded file metadata for context extraction."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
fileId: str = Field(description="Foreign key to file in central Files table")
fileName: str = Field(description="Original file name")
mimeType: str = Field(default="application/octet-stream", description="MIME type")
fileSize: int = Field(default=0, description="File size in bytes")
uploadOrder: int = Field(default=0, description="Order of upload (0-based)")
class ChatbotV2ExtractedContext(BaseModel):
"""Extracted content per conversation - text blocks and summaries for chat context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
textBlocks: List[Dict[str, Any]] = Field(default_factory=list, description="Extracted text blocks per file (page, text, block_id)")
summaries: List[Dict[str, Any]] = Field(default_factory=list, description="Optional per-document or per-section summaries")
extractionStatus: str = Field(default="pending", description="pending|running|completed|failed")
errors: List[str] = Field(default_factory=list, description="Extraction errors if any")
createdAt: float = Field(default_factory=getUtcTimestamp, description="When extraction completed")
class ChatbotV2Document(BaseModel):
"""Documents attached to chatbot V2 messages."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
messageId: str = Field(description="Foreign key to message")
fileId: str = Field(description="Foreign key to file")
fileName: str = Field(description="Name of the file")
fileSize: int = Field(default=0, description="Size of the file")
mimeType: str = Field(default="application/octet-stream", description="MIME type")
class ChatbotV2Message(BaseModel):
"""Messages in Chatbot V2 conversations."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
parentMessageId: Optional[str] = Field(None, description="Parent message ID for threading")
message: Optional[str] = Field(None, description="Message content")
role: str = Field(description="Role: user or assistant")
status: str = Field(default="step", description="Status: first, step, last")
sequenceNr: int = Field(default=0, description="Sequence number of the message")
publishedAt: Optional[float] = Field(default=None, description="When the message was published (UTC timestamp)")
roundNumber: int = Field(default=1, description="Round number in conversation")
class ChatbotV2Log(BaseModel):
"""Log entries for Chatbot V2 conversations."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation")
message: str = Field(description="Log message")
type: str = Field(default="info", description="Log type: info, warning, error")
timestamp: float = Field(default_factory=getUtcTimestamp, description="When the log entry was created")
status: Optional[str] = Field(None, description="Status of the log entry")
progress: Optional[float] = Field(None, description="Progress indicator (0.0 to 1.0)")
class ChatbotV2Conversation(BaseModel):
"""Chatbot V2 conversation - stores context information per chat."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation")
mandateId: Optional[str] = Field(None, description="Mandate ID for RBAC")
name: Optional[str] = Field(None, description="Name of the conversation")
status: str = Field(default="extracting", description="extracting|ready|running|stopped")
currentRound: int = Field(default=0, description="Current round number")
lastActivity: float = Field(default_factory=getUtcTimestamp, description="Timestamp of last activity")
startedAt: float = Field(default_factory=getUtcTimestamp, description="When the conversation started")
extractedContextId: Optional[str] = Field(None, description="FK to ChatbotV2ExtractedContext when ready")
maxSteps: int = Field(default=10, description="Maximum number of chat rounds")
# Hydrated from child tables (not stored in DB as columns)
contextFiles: List[ChatbotV2ContextFile] = Field(default_factory=list, description="Uploaded context files")
messages: List[ChatbotV2Message] = Field(default_factory=list, description="Conversation messages")

View file

@ -0,0 +1,440 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface to Chatbot V2 database.
Manages context-aware conversations with file upload and extraction.
"""
import logging
import math
import uuid
from typing import Dict, Any, List, Optional, Union
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import User, AccessLevel
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from .datamodelFeatureChatbotV2 import (
ChatbotV2Conversation,
ChatbotV2ContextFile,
ChatbotV2ExtractedContext,
ChatbotV2Message,
ChatbotV2Document,
ChatbotV2Log,
)
logger = logging.getLogger(__name__)
_chatbotV2Interfaces: Dict[str, "ChatbotV2Objects"] = {}
FEATURE_CODE = "chatbotv2"
def getInterface(
currentUser: User,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> "ChatbotV2Objects":
"""Get or create a ChatbotV2Objects instance for the given user context."""
if not currentUser or not currentUser.id:
raise ValueError("Valid user context required")
key = f"{currentUser.id}_{mandateId or ''}_{featureInstanceId or ''}"
if key not in _chatbotV2Interfaces:
_chatbotV2Interfaces[key] = ChatbotV2Objects(
currentUser,
mandateId=mandateId,
featureInstanceId=featureInstanceId
)
else:
_chatbotV2Interfaces[key].setUserContext(
currentUser,
mandateId=mandateId,
featureInstanceId=featureInstanceId
)
return _chatbotV2Interfaces[key]
class ChatbotV2Objects:
"""Interface to Chatbot V2 database."""
def __init__(
self,
currentUser: Optional[User] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
):
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.featureCode = FEATURE_CODE
self.rbac = None
self._initializeDatabase()
if currentUser:
self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def setUserContext(
self,
currentUser: User,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
):
"""Set user context for the interface."""
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId:
raise ValueError("Invalid user context: id is required")
from modules.security.rootAccess import getRootDbAppConnector
dbApp = getRootDbAppConnector()
self.rbac = RbacClass(self.db, dbApp=dbApp)
self.db.updateContext(self.userId)
def __del__(self):
if hasattr(self, "db") and self.db is not None:
try:
self.db.close()
except Exception as e:
logger.error(f"Error closing database connection: {e}")
def _initializeDatabase(self):
try:
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_chatbotv2"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId
)
logger.info("ChatbotV2 database initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize ChatbotV2 database: {e}")
raise
def checkRbacPermission(
self,
modelClass: type,
operation: str,
recordId: Optional[str] = None
) -> bool:
"""Check RBAC permission for an operation on a table."""
if not self.rbac or not self.currentUser:
return False
from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(modelClass.__name__, featureCode=self.featureCode)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
if operation == "create":
return permissions.create != AccessLevel.NONE
if operation == "update":
return permissions.update != AccessLevel.NONE
if operation == "delete":
return permissions.delete != AccessLevel.NONE
if operation == "read":
return permissions.read != AccessLevel.NONE
return False
def _separateObjectFields(self, model_class, data: Dict[str, Any]) -> tuple:
"""Separate simple fields from object fields (contextFiles, messages)."""
simple_fields = {}
object_fields = {}
model_fields = model_class.model_fields
for field_name, value in data.items():
if field_name in model_fields:
if field_name in ("contextFiles", "messages"):
object_fields[field_name] = value
continue
if field_name.startswith("_"):
simple_fields[field_name] = value
elif isinstance(value, (str, int, float, bool, type(None))):
simple_fields[field_name] = value
elif field_name in model_fields:
field_info = model_fields[field_name]
if hasattr(field_info, "annotation"):
from typing import get_origin, get_args
origin = get_origin(field_info.annotation)
if origin in (dict, list):
simple_fields[field_name] = value
else:
object_fields[field_name] = value
else:
object_fields[field_name] = value
return simple_fields, object_fields
# ===== Conversation CRUD =====
def getConversations(
self,
pagination: Optional[PaginationParams] = None
) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""Get conversations for current feature instance."""
record_filter = {}
if self.featureInstanceId:
record_filter["featureInstanceId"] = self.featureInstanceId
records = getRecordsetWithRBAC(
self.db,
ChatbotV2Conversation,
self.currentUser,
recordFilter=record_filter if record_filter else None,
orderBy="lastActivity",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.featureCode
)
if pagination is None:
return records
total = len(records)
page = pagination.page or 1
page_size = pagination.pageSize or 20
start = (page - 1) * page_size
items = records[start : start + page_size]
total_pages = math.ceil(total / page_size) if total > 0 else 0
return PaginatedResult(items=items, totalItems=total, totalPages=total_pages)
def getConversation(self, conversationId: str) -> Optional[ChatbotV2Conversation]:
"""Get a conversation by ID with hydrated context files and messages."""
records = getRecordsetWithRBAC(
self.db,
ChatbotV2Conversation,
self.currentUser,
recordFilter={"id": conversationId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.featureCode
)
if not records:
return None
r = records[0]
context_files = self.getContextFiles(conversationId)
messages = self.getMessages(conversationId)
max_steps = r.get("maxSteps")
if max_steps is None:
max_steps = 10
return ChatbotV2Conversation(
id=r["id"],
featureInstanceId=r.get("featureInstanceId", "") or self.featureInstanceId or "",
mandateId=r.get("mandateId"),
name=r.get("name"),
status=r.get("status", "extracting"),
currentRound=r.get("currentRound", 0),
lastActivity=r.get("lastActivity", getUtcTimestamp()),
startedAt=r.get("startedAt", getUtcTimestamp()),
extractedContextId=r.get("extractedContextId"),
maxSteps=max_steps,
contextFiles=context_files,
messages=messages
)
def createConversation(self, data: Dict[str, Any]) -> ChatbotV2Conversation:
"""Create a new conversation."""
if not self.checkRbacPermission(ChatbotV2Conversation, "create"):
raise PermissionError("No permission to create conversations")
data["featureInstanceId"] = data.get("featureInstanceId") or self.featureInstanceId or ""
data["mandateId"] = data.get("mandateId") or self.mandateId
simple, obj = self._separateObjectFields(ChatbotV2Conversation, data)
if "maxSteps" not in simple or simple["maxSteps"] is None:
simple["maxSteps"] = 10
created = self.db.recordCreate(ChatbotV2Conversation, simple)
max_steps = created.get("maxSteps")
if max_steps is None:
max_steps = 10
return ChatbotV2Conversation(
id=created["id"],
featureInstanceId=created.get("featureInstanceId", ""),
mandateId=created.get("mandateId"),
name=created.get("name"),
status=created.get("status", "extracting"),
currentRound=created.get("currentRound", 0),
lastActivity=created.get("lastActivity", getUtcTimestamp()),
startedAt=created.get("startedAt", getUtcTimestamp()),
extractedContextId=created.get("extractedContextId"),
maxSteps=max_steps,
contextFiles=[],
messages=[]
)
def updateConversation(self, conversationId: str, data: Dict[str, Any]) -> Optional[ChatbotV2Conversation]:
"""Update a conversation."""
conv = self.getConversation(conversationId)
if not conv:
return None
if not self.checkRbacPermission(ChatbotV2Conversation, "update"):
raise PermissionError("No permission to update conversation")
simple, _ = self._separateObjectFields(ChatbotV2Conversation, data)
simple["lastActivity"] = getUtcTimestamp()
updated = self.db.recordModify(ChatbotV2Conversation, conversationId, simple)
if not updated:
return None
return self.getConversation(conversationId)
def deleteConversation(self, conversationId: str) -> bool:
"""Delete a conversation and all related data."""
conv = self.getConversation(conversationId)
if not conv:
return False
if not self.checkRbacPermission(ChatbotV2Conversation, "delete"):
raise PermissionError("No permission to delete conversation")
for cf in self.getContextFiles(conversationId):
self.db.recordDelete(ChatbotV2ContextFile, cf.id)
ctx = self.getExtractedContextByConversation(conversationId)
if ctx:
self.db.recordDelete(ChatbotV2ExtractedContext, ctx.id)
for msg in self.getMessages(conversationId):
for doc in self.getDocuments(msg.id):
self.db.recordDelete(ChatbotV2Document, doc.id)
self.db.recordDelete(ChatbotV2Message, msg.id)
for log in self.getLogs(conversationId):
self.db.recordDelete(ChatbotV2Log, log.id)
return self.db.recordDelete(ChatbotV2Conversation, conversationId)
# ===== Context File CRUD =====
def getContextFiles(self, conversationId: str) -> List[ChatbotV2ContextFile]:
"""Get context files for a conversation."""
records = getRecordsetWithRBAC(
self.db,
ChatbotV2ContextFile,
self.currentUser,
recordFilter={"conversationId": conversationId},
orderBy="uploadOrder",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.featureCode
)
return [ChatbotV2ContextFile(**r) for r in records]
def createContextFile(self, data: Dict[str, Any]) -> ChatbotV2ContextFile:
"""Create a context file record."""
return ChatbotV2ContextFile(**self.db.recordCreate(ChatbotV2ContextFile, data))
# ===== Extracted Context CRUD =====
def getExtractedContext(self, extractedContextId: str) -> Optional[ChatbotV2ExtractedContext]:
"""Get extracted context by ID."""
records = self.db.getRecordset(
ChatbotV2ExtractedContext,
recordFilter={"id": extractedContextId}
)
if not records:
return None
return ChatbotV2ExtractedContext(**records[0])
def getExtractedContextByConversation(self, conversationId: str) -> Optional[ChatbotV2ExtractedContext]:
"""Get extracted context for a conversation."""
records = self.db.getRecordset(
ChatbotV2ExtractedContext,
recordFilter={"conversationId": conversationId}
)
if not records:
return None
return ChatbotV2ExtractedContext(**records[0])
def createExtractedContext(self, data: Dict[str, Any]) -> ChatbotV2ExtractedContext:
"""Create extracted context record."""
created = self.db.recordCreate(ChatbotV2ExtractedContext, data)
return ChatbotV2ExtractedContext(**created)
def updateExtractedContext(self, extractedContextId: str, data: Dict[str, Any]) -> Optional[ChatbotV2ExtractedContext]:
"""Update extracted context."""
updated = self.db.recordModify(ChatbotV2ExtractedContext, extractedContextId, data)
return ChatbotV2ExtractedContext(**updated) if updated else None
# ===== Message CRUD =====
def getMessages(self, conversationId: str) -> List[ChatbotV2Message]:
"""Get messages for a conversation."""
records = getRecordsetWithRBAC(
self.db,
ChatbotV2Message,
self.currentUser,
recordFilter={"conversationId": conversationId},
orderBy="sequenceNr",
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.featureCode
)
return [ChatbotV2Message(**r) for r in records]
def createMessage(self, data: Dict[str, Any]) -> ChatbotV2Message:
"""Create a message."""
if "id" not in data or not data["id"]:
data["id"] = str(uuid.uuid4())
created = self.db.recordCreate(ChatbotV2Message, data)
return ChatbotV2Message(**created)
# ===== Document CRUD =====
def getDocuments(self, messageId: str) -> List[ChatbotV2Document]:
"""Get documents for a message."""
records = self.db.getRecordset(
ChatbotV2Document,
recordFilter={"messageId": messageId}
)
return [ChatbotV2Document(**r) for r in records]
def createDocument(self, data: Dict[str, Any]) -> ChatbotV2Document:
"""Create a document record."""
created = self.db.recordCreate(ChatbotV2Document, data)
return ChatbotV2Document(**created)
# ===== Log CRUD =====
def getLogs(self, conversationId: str) -> List[ChatbotV2Log]:
"""Get logs for a conversation."""
records = self.db.getRecordset(
ChatbotV2Log,
recordFilter={"conversationId": conversationId}
)
# Sort by timestamp (connector doesn't support orderBy)
logs = [ChatbotV2Log(**r) for r in records]
logs.sort(key=lambda log: parseTimestamp(log.timestamp, default=0))
return logs
def createLog(self, data: Dict[str, Any]) -> ChatbotV2Log:
"""Create a log entry."""
if "timestamp" not in data:
data["timestamp"] = getUtcTimestamp()
created = self.db.recordCreate(ChatbotV2Log, data)
return ChatbotV2Log(**created)
# ===== Unified Chat Data (for streaming/polling) =====
def getUnifiedChatData(
self,
conversationId: str,
afterTimestamp: Optional[float] = None
) -> Dict[str, Any]:
"""Get unified chat data (messages, logs) in chronological order."""
conv = self.getConversation(conversationId)
if not conv:
return {"items": []}
items = []
for msg in conv.messages:
ts = parseTimestamp(msg.publishedAt, default=getUtcTimestamp())
if afterTimestamp is not None and ts <= afterTimestamp:
continue
items.append({"type": "message", "createdAt": ts, "item": msg})
for log in self.getLogs(conversationId):
ts = parseTimestamp(log.timestamp, default=getUtcTimestamp())
if afterTimestamp is not None and ts <= afterTimestamp:
continue
items.append({"type": "log", "createdAt": ts, "item": log})
items.sort(key=lambda x: x.get("createdAt", 0))
return {"items": items}

View file

@ -0,0 +1,250 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot V2 Feature Container - Main Module.
Handles feature initialization and RBAC catalog registration.
Context-aware chat with file upload and extraction.
"""
import logging
from typing import Dict, List, Any
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "chatbotv2"
FEATURE_LABEL = {"en": "Chatbot V2", "de": "Chatbot V2", "fr": "Chatbot V2"}
FEATURE_ICON = "mdi-robot-outline"
# UI Objects for RBAC catalog - single "conversations" view (upload + chat in one page)
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbotv2.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
"meta": {"area": "conversations"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.chatbotv2.upload",
"label": {"en": "Upload Files", "de": "Dateien hochladen", "fr": "Télécharger fichiers"},
"meta": {"endpoint": "/api/chatbotv2/{instanceId}/upload", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbotv2.startStream",
"label": {"en": "Start Chat (Stream)", "de": "Chat starten (Stream)", "fr": "Démarrer chat (Stream)"},
"meta": {"endpoint": "/api/chatbotv2/{instanceId}/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbotv2.stop",
"label": {"en": "Stop Chat", "de": "Chat stoppen", "fr": "Arrêter chat"},
"meta": {"endpoint": "/api/chatbotv2/{instanceId}/stop/{workflowId}", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbotv2.threads",
"label": {"en": "Get Threads", "de": "Threads abrufen", "fr": "Récupérer threads"},
"meta": {"endpoint": "/api/chatbotv2/{instanceId}/threads", "method": "GET"}
},
{
"objectKey": "resource.feature.chatbotv2.delete",
"label": {"en": "Delete Chat", "de": "Chat löschen", "fr": "Supprimer chat"},
"meta": {"endpoint": "/api/chatbotv2/{instanceId}/conversations/{workflowId}", "method": "DELETE"}
},
]
# Template roles for this feature
TEMPLATE_ROLES = [
{
"roleLabel": "chatbotv2-viewer",
"description": {
"en": "Chatbot V2 Viewer - View threads (read-only)",
"de": "Chatbot V2 Betrachter - Threads ansehen (nur lesen)",
"fr": "Visualiseur Chatbot V2 - Consulter les threads (lecture seule)"
},
"accessRules": [
{"context": "UI", "item": "ui.feature.chatbotv2.conversations", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.threads", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
},
{
"roleLabel": "chatbotv2-user",
"description": {
"en": "Chatbot V2 User - Upload, extract, and chat with own threads",
"de": "Chatbot V2 Benutzer - Hochladen, extrahieren und chatten mit eigenen Threads",
"fr": "Utilisateur Chatbot V2 - Upload, extraction et chat avec ses threads"
},
"accessRules": [
{"context": "UI", "item": "ui.feature.chatbotv2.conversations", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.upload", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.startStream", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.stop", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.threads", "view": True},
{"context": "RESOURCE", "item": "resource.feature.chatbotv2.delete", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
]
},
{
"roleLabel": "chatbotv2-admin",
"description": {
"en": "Chatbot V2 Admin - Full access to all features",
"de": "Chatbot V2 Admin - Vollzugriff auf alle Funktionen",
"fr": "Administrateur Chatbot V2 - Accès complet"
},
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
]
},
]
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
}
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""
Register this feature's RBAC objects in the catalog.
"""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
_syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
def _syncTemplateRolesToDb() -> int:
"""Sync template roles and their AccessRules to the database."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
templateRoles = [r for r in existingRoles if r.mandateId is None]
existingRoleLabels = {r.roleLabel: str(r.id) for r in templateRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingRoleLabels:
roleId = existingRoleLabels[roleLabel]
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
isSystemRole=False
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
"""Ensure AccessRules exist for a role based on templates."""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
existingRules = rootInterface.getAccessRulesByRole(roleId)
existingSignatures = set()
for rule in existingRules:
sig = (rule.context.value if rule.context else None, rule.item)
existingSignatures.add(sig)
createdCount = 0
for template in ruleTemplates:
context = template.get("context", "UI")
item = template.get("item")
sig = (context, item)
if sig in existingSignatures:
continue
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
if createdCount > 0:
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount

View file

@ -0,0 +1,350 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot V2 routes - context-aware chat with file upload and extraction.
"""
import asyncio
import json
import math
import logging
import uuid
from typing import Optional, Any, Dict, Union
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, status, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.datamodels.datamodelChat import UserInputRequest
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.shared.timeUtils import getUtcTimestamp
from . import interfaceFeatureChatbotV2 as interfaceDbChat
from .interfaceFeatureChatbotV2 import getInterface as getChatbotV2Interface
from .datamodelFeatureChatbotV2 import ChatbotV2Conversation
from .serviceChatbotV2 import uploadAndExtract, chatProcessV2
from modules.features.chatbot.streaming.events import get_event_manager
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/chatbotv2",
tags=["Chatbot V2"],
responses={404: {"description": "Not found"}}
)
class UploadRequest(BaseModel):
"""Request body for file upload - files must be uploaded to central storage first."""
listFileId: list[str] = Field(default_factory=list, description="List of file IDs from central storage")
def _getServiceChat(context: RequestContext, instanceId: Optional[str] = None):
"""Get ChatbotV2 interface with instance context."""
mandateId = str(context.mandateId) if context.mandateId else None
return getChatbotV2Interface(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId
)
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""Validate that the user has access to the feature instance."""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=404,
detail=f"Feature instance '{instanceId}' not found"
)
if instance.featureCode != "chatbotv2":
raise HTTPException(
status_code=400,
detail=f"Instance '{instanceId}' is not a chatbotv2 instance"
)
if not context.hasSysAdminRole:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess:
raise HTTPException(
status_code=403,
detail=f"Access denied to feature instance '{instanceId}'"
)
return str(instance.mandateId)
# =============================================================================
# Upload - start extraction
# =============================================================================
@router.post("/{instanceId}/upload")
@limiter.limit("60/minute")
async def upload_files(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
body: UploadRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Upload files as context and start extraction.
Files must be uploaded to central storage first; pass their file IDs in listFileId.
Returns conversationId. Extraction runs in background; poll threads or use SSE for status.
"""
mandateId = _validateInstanceAccess(instanceId, context)
if not body.listFileId:
raise HTTPException(status_code=400, detail="listFileId is required and must not be empty")
try:
conversation = await uploadAndExtract(
context.user,
mandateId=mandateId,
instanceId=instanceId,
listFileId=body.listFileId
)
return {
"conversationId": conversation.id,
"status": conversation.status,
"message": "Extraction started. Poll GET /threads?workflowId={} for status.".format(conversation.id)
}
except Exception as e:
logger.error(f"Error in upload_files: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# List threads - MUST be first to avoid /{instanceId}/{workflowId} matching
# =============================================================================
@router.get("/{instanceId}/threads")
@limiter.limit("120/minute")
def get_threads(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: Optional[str] = Query(None, description="Optional workflow/conversation ID for details"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
context: RequestContext = Depends(getRequestContext)
) -> Union[PaginatedResponse, Dict[str, Any]]:
"""List conversations or get details for a specific one."""
_validateInstanceAccess(instanceId, context)
interfaceDbChat = _getServiceChat(context, instanceId)
if workflowId:
conv = interfaceDbChat.getConversation(workflowId)
if not conv:
raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found")
workflow_dict = conv.model_dump()
chatData = interfaceDbChat.getUnifiedChatData(workflowId, None)
return {"workflow": workflow_dict, "chatData": chatData}
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
all_convs = interfaceDbChat.getConversations(pagination=None)
# all_convs from getConversations can be list of dicts (from getRecordsetWithRBAC)
items = [c if isinstance(c, dict) else c.model_dump() for c in all_convs]
if paginationParams:
totalItems = len(items)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
workflows = items[startIdx:endIdx]
else:
workflows = items
totalItems = len(items)
totalPages = 1
metadata = PaginationMetadata(
currentPage=paginationParams.page if paginationParams else 1,
pageSize=paginationParams.pageSize if paginationParams else len(workflows),
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
return PaginatedResponse(items=workflows, pagination=metadata)
# =============================================================================
# Start/continue chat (SSE stream)
# =============================================================================
@router.post("/{instanceId}/start/stream")
@limiter.limit("120/minute")
async def stream_chat_start(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: Optional[str] = Query(None, description="Optional conversation ID to continue"),
userInput: UserInputRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> StreamingResponse:
"""Start or continue a chat with SSE streaming."""
mandateId = _validateInstanceAccess(instanceId, context)
event_manager = get_event_manager()
final_workflow_id = workflowId or userInput.workflowId
try:
workflow = await chatProcessV2(
context.user,
mandateId=mandateId,
userInput=userInput,
conversationId=final_workflow_id,
instanceId=instanceId
)
if not workflow:
raise HTTPException(status_code=500, detail="Failed to create or load workflow")
queue = event_manager.get_queue(workflow.id)
if not queue:
queue = event_manager.create_queue(workflow.id)
async def event_stream():
try:
interfaceDbChat = _getServiceChat(context, instanceId)
chatData = interfaceDbChat.getUnifiedChatData(workflow.id, None)
if chatData.get("items"):
for item in chatData["items"]:
ser = {
"type": item.get("type"),
"createdAt": item.get("createdAt"),
"item": item.get("item").model_dump() if hasattr(item.get("item"), "model_dump") else item.get("item")
}
yield f"data: {json.dumps(ser)}\n\n"
keepalive_interval = 30.0
last_keepalive = asyncio.get_event_loop().time()
status_check_interval = 5.0
last_status_check = asyncio.get_event_loop().time()
timeout = 300.0
start_time = asyncio.get_event_loop().time()
while True:
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > timeout:
break
if await request.is_disconnected():
break
current_time = asyncio.get_event_loop().time()
if current_time - last_status_check >= status_check_interval:
try:
cw = interfaceDbChat.getConversation(workflow.id)
if cw and cw.status == "stopped":
break
except Exception:
pass
last_status_check = current_time
try:
event = await asyncio.wait_for(queue.get(), timeout=1.0)
event_type = event.get("type")
event_data = event.get("data", {})
if event_type == "chatdata" and event_data:
if event_data.get("type") == "status":
yield f"data: {json.dumps({'type': 'status', 'label': event_data.get('label', '')})}\n\n"
else:
item = event_data
if isinstance(item, dict) and "item" in item:
obj = item.get("item")
if hasattr(obj, "model_dump"):
item = {**item, "item": obj.model_dump()}
yield f"data: {json.dumps(item)}\n\n"
elif event_type in ("complete", "stopped"):
break
elif event_type == "error" and event.get("step") == "error":
break
last_keepalive = current_time
except asyncio.TimeoutError:
if current_time - last_keepalive >= keepalive_interval:
yield ": keepalive\n\n"
last_keepalive = current_time
except Exception as e:
logger.error(f"Error in event stream: {e}")
break
except Exception as e:
logger.error(f"Error in event stream generator: {e}", exc_info=True)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in stream_chat_start: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# Stop chat
# =============================================================================
@router.post("/{instanceId}/stop/{workflowId}")
@limiter.limit("120/minute")
async def stop_chat(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: str = Path(..., description="Conversation ID to stop"),
context: RequestContext = Depends(getRequestContext)
) -> ChatbotV2Conversation:
"""Stop a running chat."""
_validateInstanceAccess(instanceId, context)
interfaceDbChat = _getServiceChat(context, instanceId)
conv = interfaceDbChat.getConversation(workflowId)
if not conv:
raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found")
interfaceDbChat.updateConversation(workflowId, {"status": "stopped", "lastActivity": getUtcTimestamp()})
interfaceDbChat.createLog({
"conversationId": workflowId,
"message": "Workflow stopped by user",
"type": "warning",
"status": "stopped",
"timestamp": getUtcTimestamp()
})
event_manager = get_event_manager()
await event_manager.emit_event(
context_id=workflowId,
event_type="stopped",
data={"workflowId": workflowId},
event_category="workflow",
message="Workflow stopped by user",
step="stopped"
)
return interfaceDbChat.getConversation(workflowId)
# =============================================================================
# Delete conversation - use /conversations/{workflowId} to avoid
# /{instanceId}/{workflowId} matching GET /threads (workflowId="threads")
# =============================================================================
@router.delete("/{instanceId}/conversations/{workflowId}")
@limiter.limit("120/minute")
def delete_conversation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
workflowId: str = Path(..., description="Conversation ID to delete"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a conversation and its data."""
_validateInstanceAccess(instanceId, context)
interfaceDbChat = _getServiceChat(context, instanceId)
conv = interfaceDbChat.getConversation(workflowId)
if not conv:
raise HTTPException(status_code=404, detail=f"Conversation {workflowId} not found")
success = interfaceDbChat.deleteConversation(workflowId)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete conversation")
return {"id": workflowId, "message": "Conversation deleted successfully"}

View file

@ -0,0 +1,258 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot V2 service - orchestration for upload/extraction and chat.
"""
import logging
import uuid
from typing import Optional, List
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelChat import UserInputRequest
from modules.datamodels.datamodelAi import OperationTypeEnum, ProcessingModeEnum
from modules.shared.timeUtils import getUtcTimestamp
from modules.services import getInterface as getServices
from .interfaceFeatureChatbotV2 import getInterface as getChatbotV2Interface
from .datamodelFeatureChatbotV2 import ChatbotV2Conversation
from .contextExtractionLangGraph import run_extraction
from .chatbotV2 import create_chat_graph
from .config import load_chatbotv2_config_from_instance
from .bridges import AICenterChatModel, clear_workflow_allowed_providers, ChatbotV2Checkpointer
from modules.features.chatbot.streaming.events import get_event_manager
logger = logging.getLogger(__name__)
async def _load_config(instance_id: Optional[str]):
"""Load ChatbotV2 config from feature instance."""
if not instance_id:
return None
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
root = getRootInterface()
feat = getFeatureInterface(root.db)
instance = feat.getFeatureInstance(instance_id)
if not instance:
return None
return load_chatbotv2_config_from_instance(instance)
async def uploadAndExtract(
currentUser: User,
mandateId: Optional[str],
instanceId: str,
listFileId: List[str]
) -> ChatbotV2Conversation:
"""
Create conversation, store context files, run extraction, save result.
"""
interface = getChatbotV2Interface(currentUser, mandateId=mandateId, featureInstanceId=instanceId)
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=instanceId)
conversation_id = str(uuid.uuid4())
conv_data = {
"id": conversation_id,
"featureInstanceId": instanceId,
"mandateId": mandateId,
"status": "extracting",
"name": "Context Chat",
"currentRound": 0,
"maxSteps": 10,
"startedAt": getUtcTimestamp(),
"lastActivity": getUtcTimestamp()
}
conv = interface.createConversation(conv_data)
files_for_extraction = []
for idx, file_id in enumerate(listFileId):
try:
file_info = services.chat.getFileInfo(file_id)
if not file_info:
logger.warning(f"File {file_id} not found")
continue
file_bytes = services.chat.getFileData(file_id)
if not file_bytes:
logger.warning(f"No data for file {file_id}")
continue
interface.createContextFile({
"conversationId": conversation_id,
"fileId": file_id,
"fileName": file_info.get("fileName", "document"),
"mimeType": file_info.get("mimeType", "application/octet-stream"),
"fileSize": file_info.get("size", 0),
"uploadOrder": idx
})
files_for_extraction.append({
"fileId": file_id,
"bytes": file_bytes,
"mimeType": file_info.get("mimeType", "application/octet-stream"),
"fileName": file_info.get("fileName", "document")
})
except Exception as e:
logger.error(f"Error loading file {file_id}: {e}", exc_info=True)
if not files_for_extraction:
interface.updateConversation(conversation_id, {"status": "ready"})
interface.createExtractedContext({
"conversationId": conversation_id,
"textBlocks": [],
"summaries": [],
"extractionStatus": "failed",
"errors": ["No files could be loaded"]
})
return interface.getConversation(conversation_id)
result = run_extraction(files_for_extraction)
# Store sections in summaries for chat context (build_context_system_prompt uses sections)
summaries = result.get("summaries", []) or result.get("sections", [])
extracted = interface.createExtractedContext({
"conversationId": conversation_id,
"textBlocks": result.get("textBlocks", []),
"summaries": summaries,
"extractionStatus": "completed",
"errors": result.get("errors", []),
"createdAt": getUtcTimestamp()
})
interface.updateConversation(conversation_id, {
"status": "ready",
"extractedContextId": extracted.id,
"lastActivity": getUtcTimestamp()
})
return interface.getConversation(conversation_id)
async def chatProcessV2(
currentUser: User,
mandateId: Optional[str],
userInput: UserInputRequest,
conversationId: Optional[str],
instanceId: str
) -> Optional[ChatbotV2Conversation]:
"""
Run chat with extracted context. Creates or resumes conversation.
"""
interface = getChatbotV2Interface(currentUser, mandateId=mandateId, featureInstanceId=instanceId)
event_manager = get_event_manager()
config = await _load_config(instanceId)
base_prompt = config.systemPrompt if config else "You are a helpful assistant. Answer based on the provided context."
if conversationId:
conv = interface.getConversation(conversationId)
if not conv:
raise ValueError(f"Conversation {conversationId} not found")
if conv.status == "extracting":
raise ValueError("Conversation not ready for chat (status: extracting). Wait for extraction to complete.")
# Reset stale "running" from previous failed/interrupted request
if conv.status == "running":
logger.info("Resetting stale conversation status from 'running' to 'ready'")
interface.updateConversation(conversationId, {"status": "ready"})
new_round = conv.currentRound + 1
interface.updateConversation(conversationId, {
"status": "running",
"currentRound": new_round,
"lastActivity": getUtcTimestamp()
})
conv = interface.getConversation(conversationId)
if not event_manager.has_queue(conversationId):
event_manager.create_queue(conversationId)
else:
raise ValueError("conversationId is required for Chatbot V2 chat")
extracted_ctx = interface.getExtractedContextByConversation(conversationId)
ctx_dict = {"textBlocks": [], "sections": []}
if extracted_ctx:
tb = extracted_ctx.textBlocks or []
ctx_dict["textBlocks"] = tb
# summaries hold sections from extraction (fileName, text, blockCount)
ctx_dict["sections"] = extracted_ctx.summaries if isinstance(extracted_ctx.summaries, list) else []
if not ctx_dict["sections"] and tb:
# Build sections from textBlocks if summaries empty
for doc in tb:
blocks = doc.get("blocks", [])
text_parts = [b.get("text", "") for b in blocks]
ctx_dict["sections"].append({
"fileId": doc.get("fileId"),
"fileName": doc.get("fileName", "document"),
"text": "\n".join(text_parts),
"blockCount": len(blocks)
})
user_msg = userInput.prompt or ""
if not user_msg.strip():
raise ValueError("Prompt is required")
max_context_chars = config.maxContextChars if config else None
chunk_size = config.chunkSize if config else None
chunk_overlap = config.chunkOverlap if config else None
# Resolve to concrete values (chat node reads from configurable)
max_ctx = max_context_chars if max_context_chars and max_context_chars > 0 else 60_000
cs = chunk_size if chunk_size and chunk_size > 0 else 15_000
co = chunk_overlap if chunk_overlap is not None and chunk_overlap >= 0 else 500
allowed_providers = config.model.allowedProviders if config else []
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=instanceId)
if allowed_providers:
services.allowedProviders = allowed_providers # type: ignore[attr-defined]
model = AICenterChatModel(
user=currentUser,
operation_type=OperationTypeEnum.DATA_ANALYSE,
processing_mode=ProcessingModeEnum.BASIC,
workflow_id=conversationId,
allowed_providers=allowed_providers if allowed_providers else None
)
memory = ChatbotV2Checkpointer(
user=currentUser,
workflow_id=conversationId,
mandateId=mandateId,
featureInstanceId=instanceId
)
app = create_chat_graph(model, memory)
chatbotv2_context = {
"ctx_dict": ctx_dict,
"user_question": user_msg,
"base_prompt": base_prompt,
"max_context_chars": max_ctx,
"chunk_size": cs,
"chunk_overlap": co,
}
interface.createMessage({
"id": str(uuid.uuid4()),
"conversationId": conversationId,
"message": user_msg,
"role": "user",
"status": "first",
"sequenceNr": len(interface.getMessages(conversationId)) + 1,
"publishedAt": getUtcTimestamp(),
"roundNumber": conv.currentRound
})
try:
result = await app.ainvoke(
{
"messages": [{"role": "user", "content": user_msg}],
"chatbotv2_context": chatbotv2_context,
},
config={"configurable": {"thread_id": conversationId}}
)
# Assistant message is stored by ChatbotV2Checkpointer.put() - do NOT create again here.
# The stream sends chatData at start (from DB), so no need to emit chatdata event.
await event_manager.emit_event(
context_id=conversationId,
event_type="complete",
data={},
event_category="workflow"
)
finally:
clear_workflow_allowed_providers(conversationId)
interface.updateConversation(conversationId, {
"status": "ready",
"lastActivity": getUtcTimestamp()
})
return interface.getConversation(conversationId)

View file

@ -1206,14 +1206,20 @@ class ComponentObjects:
return False
def getFileData(self, fileId: str) -> Optional[bytes]:
"""Returns the binary data of a file if user has access."""
# Check file access
"""Returns the binary data of a file if user has access.
File access is user-scoped (same as routeDataFiles): getFile() verifies the user
owns the FileItem via _createdBy. Once ownership is confirmed, we read FileData
directly to ensure the uploader can always access their file content.
"""
# Check file access (user-scoped via _createdBy - same logic as routeDataFiles)
file = self.getFile(fileId)
if not file:
logger.warning(f"No access to file ID {fileId}")
return None
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId}, mandateId=self.mandateId)
# User owns the file - read FileData directly (bypass RBAC for owned files)
fileDataEntries = self.db.getRecordset(FileData, recordFilter={"id": fileId})
if not fileDataEntries:
logger.warning(f"No data found for file ID {fileId}")
return None

View file

@ -61,6 +61,11 @@ TABLE_NAMESPACE = {
"ChatStat": "chat",
"ChatDocument": "chat",
"Prompt": "chat",
# Chatbot (poweron_chatbot) - per feature-instance isolation
"ChatbotConversation": "chatbot",
"ChatbotMessage": "chatbot",
"ChatbotDocument": "chatbot",
"ChatbotLog": "chatbot",
# Files - benutzer-eigen
"FileItem": "files",
"FileData": "files",
@ -70,7 +75,7 @@ TABLE_NAMESPACE = {
}
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
USER_OWNED_NAMESPACES = {"chat", "files", "automation"}
USER_OWNED_NAMESPACES = {"chat", "chatbot", "files", "automation"}
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:

View file

@ -671,6 +671,11 @@ def updateFeatureInstance(
detail="Failed to update feature instance"
)
# Clear chatbot config cache when config was updated for chatbot instances
if "config" in updateData and instance.featureCode == "chatbot":
from modules.features.chatbot.config import clear_config_cache
clear_config_cache(instanceId)
logger.info(f"User {context.user.id} updated feature instance {instanceId}: {updateData}")
return updated.model_dump()

View file

@ -116,6 +116,11 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
return UI_OBJECTS
elif featureCode == "neutralization":
from modules.features.neutralization.mainNeutralization import UI_OBJECTS
elif featureCode == "chatbot":
from modules.features.chatbot.mainChatbot import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "chatbotv2":
from modules.features.chatbotV2.mainChatbotV2 import UI_OBJECTS
return UI_OBJECTS
else:
logger.warning(f"Unknown feature code: {featureCode}")

View file

@ -489,18 +489,20 @@ detectedIntent-Werte:
balanceCheck = billingService.checkBalance(estimatedCost)
if not balanceCheck.allowed:
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
logger.warning(
f"Billing check failed for user {user.id}: "
f"Balance {balanceCheck.currentBalance:.2f} CHF, "
f"Balance {balance_str} CHF, "
f"Reason: {balanceCheck.reason}"
)
raise InsufficientBalanceException(
currentBalance=balanceCheck.currentBalance or 0.0,
requiredAmount=estimatedCost,
message=f"Ungenugendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}"
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}"
)
logger.debug(f"Billing check passed: Balance {balanceCheck.currentBalance:.2f} CHF")
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
logger.debug(f"Billing check passed: Balance {balance_str} CHF")
# Check if at least one provider is allowed (RBAC check)
rbacAllowedProviders = billingService.getallowedProviders()

View file

@ -36,6 +36,16 @@ def discoverFeatureContainers() -> List[str]:
return sorted(containers)
def _router_load_order_key(filepath: str) -> tuple:
"""
Sort key for router loading. Longer/ Lexicographically later prefixes first
so that /api/chatbotv2 is registered before /api/chatbot (avoids prefix collision).
"""
featureDir = os.path.basename(os.path.dirname(filepath))
# chatbotV2 before chatbot - use negative length then name so longer names sort first
return (-len(featureDir), featureDir)
def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
"""
Dynamically load and register routers from all discovered feature containers.
@ -48,8 +58,9 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
"""
results = {}
pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py")
filepaths = sorted(glob.glob(pattern), key=_router_load_order_key)
for filepath in glob.glob(pattern):
for filepath in filepaths:
featureDir = os.path.basename(os.path.dirname(filepath))
routerFile = os.path.basename(filepath)[:-3] # Remove .py

View file

@ -11,6 +11,7 @@ erstellt: <dateiname>_structure.json
Datenbanken:
- poweron_app (User, Mandate, RBAC, Features, etc.)
- poweron_chat (Chat-Konversationen und Nachrichten)
- poweron_chatbot (Chatbot-Feature: Konversationen, Nachrichten, Logs)
- poweron_management (Workflows, Prompts, Connections, etc.)
- poweron_realestate (Real Estate Daten)
- poweron_trustee (Trustee Daten)
@ -102,6 +103,7 @@ except Exception as e:
ALL_DATABASES = [
"poweron_app", # Haupt-App: User, Mandate, RBAC, Features
"poweron_chat", # Chat-Konversationen
"poweron_chatbot", # Chatbot-Feature: Konversationen, Nachrichten, Logs
"poweron_management", # Workflows, Prompts, Connections
"poweron_realestate", # Real Estate
"poweron_trustee", # Trustee
@ -112,6 +114,7 @@ ALL_DATABASES = [
DATABASE_CONFIG = {
"poweron_app": "DB_APP", # DB_APP_HOST, DB_APP_USER, DB_APP_PASSWORD_SECRET, etc.
"poweron_chat": "DB_CHAT", # DB_CHAT_HOST, DB_CHAT_USER, etc.
"poweron_chatbot": "DB_CHATBOT", # DB_CHATBOT_* (fallsback to DB_*)
"poweron_management": "DB_MANAGEMENT",
"poweron_realestate": "DB_REALESTATE",
"poweron_trustee": "DB_TRUSTEE",
@ -749,6 +752,7 @@ def main():
Datenbanken:
poweron_app - User, Mandate, RBAC, Features
poweron_chat - Chat-Konversationen
poweron_chatbot - Chatbot-Feature
poweron_management - Workflows, Prompts, Connections
poweron_realestate - Real Estate Daten
poweron_trustee - Trustee Daten
@ -757,7 +761,7 @@ Beispiele:
python script_db_export_migration.py
python script_db_export_migration.py --pretty
python script_db_export_migration.py -o backup.json --pretty
python script_db_export_migration.py --db poweron_app,poweron_chat
python script_db_export_migration.py --db poweron_app,poweron_chat,poweron_chatbot
python script_db_export_migration.py --exclude Token,AuthEvent --include-meta
python script_db_export_migration.py --summary
"""

View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""
Initialize poweron_chatbot database for the Chatbot feature.
Creates the poweron_chatbot database if it does not exist.
Uses DB_CHATBOT_* config (falls back to DB_*).
Tables (ChatbotConversation, ChatbotMessage, ChatbotDocument, ChatbotLog) are
auto-created by the connector on first use.
Usage:
python script_db_init_chatbot.py [--dry-run]
"""
import os
import sys
import argparse
import logging
from pathlib import Path
scriptPath = Path(__file__).resolve()
gatewayPath = scriptPath.parent.parent
sys.path.insert(0, str(gatewayPath))
os.chdir(str(gatewayPath))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
import psycopg2
from modules.shared.configuration import APP_CONFIG
DB_NAME = "poweron_chatbot"
CONFIG_PREFIX = "DB_CHATBOT"
def _get_config():
"""Get DB config: DB_CHATBOT_* with fallback to DB_*."""
host = APP_CONFIG.get(f"{CONFIG_PREFIX}_HOST") or APP_CONFIG.get("DB_HOST", "localhost")
port = int(APP_CONFIG.get(f"{CONFIG_PREFIX}_PORT") or APP_CONFIG.get("DB_PORT", "5432"))
user = APP_CONFIG.get(f"{CONFIG_PREFIX}_USER") or APP_CONFIG.get("DB_USER")
password = (
APP_CONFIG.get(f"{CONFIG_PREFIX}_PASSWORD_SECRET")
or APP_CONFIG.get(f"{CONFIG_PREFIX}_PASSWORD")
or APP_CONFIG.get("DB_PASSWORD_SECRET")
or APP_CONFIG.get("DB_PASSWORD")
)
return {"host": host, "port": port, "user": user, "password": password}
def init_chatbot_db(dry_run: bool = False) -> bool:
"""Create poweron_chatbot database if it does not exist."""
config = _get_config()
if not config["user"] or not config["password"]:
logger.error("DB_USER and DB_PASSWORD (or DB_CHATBOT_*) required")
return False
try:
conn = psycopg2.connect(
host=config["host"],
port=config["port"],
database="postgres",
user=config["user"],
password=config["password"],
)
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM pg_database WHERE datname = %s",
(DB_NAME,),
)
exists = cur.fetchone() is not None
if exists:
logger.info(f"Database {DB_NAME} already exists")
else:
if dry_run:
logger.info(f"[DRY-RUN] Would create database {DB_NAME}")
else:
cur.execute(f'CREATE DATABASE "{DB_NAME}"')
logger.info(f"Created database {DB_NAME}")
conn.close()
return True
except Exception as e:
logger.error(f"Failed to init {DB_NAME}: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Initialize poweron_chatbot database")
parser.add_argument("--dry-run", action="store_true", help="Do not create, only report")
args = parser.parse_args()
ok = init_chatbot_db(dry_run=args.dry_run)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Initialize poweron_chatbotv2 database for the Chatbot V2 feature.
Creates the poweron_chatbotv2 database if it does not exist.
Uses DB_CHATBOTV2_* config (falls back to DB_*).
Tables (ChatbotV2Conversation, ChatbotV2ContextFile, ChatbotV2ExtractedContext,
ChatbotV2Message, ChatbotV2Document, ChatbotV2Log) are auto-created by the
connector on first use.
Usage:
python script_db_init_chatbotv2.py [--dry-run]
"""
import os
import sys
import argparse
import logging
from pathlib import Path
scriptPath = Path(__file__).resolve()
gatewayPath = scriptPath.parent.parent
sys.path.insert(0, str(gatewayPath))
os.chdir(str(gatewayPath))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
import psycopg2
from modules.shared.configuration import APP_CONFIG
DB_NAME = "poweron_chatbotv2"
CONFIG_PREFIX = "DB_CHATBOTV2"
def _get_config():
"""Get DB config: DB_CHATBOTV2_* with fallback to DB_*."""
host = APP_CONFIG.get(f"{CONFIG_PREFIX}_HOST") or APP_CONFIG.get("DB_HOST", "localhost")
port = int(APP_CONFIG.get(f"{CONFIG_PREFIX}_PORT") or APP_CONFIG.get("DB_PORT", "5432"))
user = APP_CONFIG.get(f"{CONFIG_PREFIX}_USER") or APP_CONFIG.get("DB_USER")
password = (
APP_CONFIG.get(f"{CONFIG_PREFIX}_PASSWORD_SECRET")
or APP_CONFIG.get(f"{CONFIG_PREFIX}_PASSWORD")
or APP_CONFIG.get("DB_PASSWORD_SECRET")
or APP_CONFIG.get("DB_PASSWORD")
)
return {"host": host, "port": port, "user": user, "password": password}
def init_chatbotv2_db(dry_run: bool = False) -> bool:
"""Create poweron_chatbotv2 database if it does not exist."""
config = _get_config()
if not config["user"] or not config["password"]:
logger.error("DB_USER and DB_PASSWORD (or DB_CHATBOTV2_*) required")
return False
try:
conn = psycopg2.connect(
host=config["host"],
port=config["port"],
database="postgres",
user=config["user"],
password=config["password"],
)
conn.autocommit = True
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM pg_database WHERE datname = %s",
(DB_NAME,),
)
exists = cur.fetchone() is not None
if exists:
logger.info(f"Database {DB_NAME} already exists")
else:
if dry_run:
logger.info(f"[DRY-RUN] Would create database {DB_NAME}")
else:
cur.execute(f'CREATE DATABASE "{DB_NAME}"')
logger.info(f"Created database {DB_NAME}")
conn.close()
return True
except Exception as e:
logger.error(f"Failed to init {DB_NAME}: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Initialize poweron_chatbotv2 database")
parser.add_argument("--dry-run", action="store_true", help="Do not create, only report")
args = parser.parse_args()
ok = init_chatbotv2_db(dry_run=args.dry_run)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()