althause update

This commit is contained in:
Ida Dittrich 2026-01-30 11:24:24 +01:00
parent 14ec8b7007
commit e4662b19e2
12 changed files with 1846 additions and 1092 deletions

View file

@ -3,7 +3,7 @@
"""Feature models: Feature, FeatureInstance."""
import uuid
from typing import Optional
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
@ -68,6 +68,11 @@ class FeatureInstance(BaseModel):
description="Whether this feature instance is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
config: Optional[Dict[str, Any]] = Field(
default=None,
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
@ -79,5 +84,6 @@ registerModelLabels(
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"config": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"},
},
)

View file

@ -0,0 +1,170 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Adapter to use AI Center as a LangChain-compatible chat model.
Maps LangChain message format to AI Center requests and responses.
"""
import logging
from typing import Any, AsyncIterator, Iterator, List, Optional
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
)
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.callbacks import AsyncCallbackHandlerForLLMRun, CallbackManagerForLLMRun
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
logger = logging.getLogger(__name__)
class AICenterChatModel(BaseChatModel):
"""
Adapter to use AI center as LangChain chat model.
Converts LangChain messages to AI center format and back.
"""
def __init__(
self,
services,
system_prompt: str = "",
temperature: float = 0.2,
**kwargs
):
"""
Initialize AI Center chat model adapter.
Args:
services: Services instance with AI access
system_prompt: System prompt to use
temperature: Temperature for AI calls
"""
super().__init__(**kwargs)
self.services = services
self.system_prompt = system_prompt
self.temperature = temperature
@property
def _llm_type(self) -> str:
"""Return identifier of LLM type."""
return "ai_center"
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> ChatResult:
"""
Synchronous generation - not supported, use async version.
"""
raise NotImplementedError("Use async version: _agenerate")
async def _agenerate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackHandlerForLLMRun] = None,
**kwargs: Any,
) -> ChatResult:
"""
Generate chat response using AI center.
Args:
messages: List of LangChain messages
stop: Optional list of stop sequences
run_manager: Optional callback manager
**kwargs: Additional arguments
Returns:
ChatResult with generated message
"""
# Convert LangChain messages to AI center prompt format
prompt_parts = []
# Add system prompt if present
if self.system_prompt:
prompt_parts.append(self.system_prompt)
# Convert messages to text format
for msg in messages:
if isinstance(msg, SystemMessage):
# System messages are already in system_prompt or can be added here
if not self.system_prompt:
prompt_parts.append(f"System: {msg.content}")
elif isinstance(msg, HumanMessage):
prompt_parts.append(f"User: {msg.content}")
elif isinstance(msg, AIMessage):
prompt_parts.append(f"Assistant: {msg.content}")
else:
# Generic message
prompt_parts.append(str(msg.content))
# Combine into single prompt
full_prompt = "\n\n".join(prompt_parts)
# Create AI center request
ai_request = AiCallRequest(
prompt=full_prompt,
options=AiCallOptions(
resultFormat="txt",
operationType=OperationTypeEnum.DATA_ANALYSE,
processingMode=ProcessingModeEnum.DETAILED,
temperature=self.temperature
)
)
# Call AI center
try:
await self.services.ai.ensureAiObjectsInitialized()
ai_response = await self.services.ai.callAi(ai_request)
# Extract content
content = ai_response.content if hasattr(ai_response, 'content') else str(ai_response)
# Create AIMessage from response
ai_message = AIMessage(content=content)
# Create ChatGeneration
generation = ChatGeneration(message=ai_message)
# Return ChatResult
return ChatResult(generations=[generation])
except Exception as e:
logger.error(f"Error calling AI center: {e}", exc_info=True)
# Return error message
error_message = AIMessage(content=f"Error: {str(e)}")
generation = ChatGeneration(message=error_message)
return ChatResult(generations=[generation])
async def astream(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackHandlerForLLMRun] = None,
**kwargs: Any,
) -> AsyncIterator[BaseMessage]:
"""
Stream chat response (not fully supported by AI center, returns single chunk).
Args:
messages: List of LangChain messages
stop: Optional list of stop sequences
run_manager: Optional callback manager
**kwargs: Additional arguments
Yields:
BaseMessage chunks
"""
# For now, just return the full response as a single chunk
# TODO: Implement proper streaming if AI center supports it
result = await self._agenerate(messages, stop, run_manager, **kwargs)
if result.generations:
yield result.generations[0].message

View file

@ -0,0 +1,231 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Chatbot instance configuration management.
Handles loading and applying instance-specific configurations.
"""
import logging
from typing import Optional, Dict, Any, List
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.interfaces.interfaceDbApp import getRootInterface
logger = logging.getLogger(__name__)
class ChatbotConfig:
"""
Chatbot instance configuration structure.
Provides defaults and validation for chatbot instance configs.
"""
# Default configuration
DEFAULT_CONFIG = {
"connector": {
"types": ["preprocessor"], # Array of database connector types: "preprocessor", "custom"
"type": "preprocessor", # Legacy: single connector type (for backward compatibility)
"customConnectorClass": None # For custom connectors
},
"prompts": {
"useCustomPrompts": False,
"customAnalysisPrompt": None,
"customFinalAnswerPrompt": None,
"customSystemPrompt": None # For LangGraph workflow (single system prompt)
},
"behavior": {
"maxQueries": 5,
"enableWebResearch": True,
"enableRetryOnEmpty": True,
"maxRetryAttempts": 2
},
"database": {
"schema": None, # Custom schema info if needed
"tablePrefix": None # Custom table prefix if needed
}
}
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""
Initialize chatbot config with defaults and overrides.
Args:
config: Instance-specific config dict (from FeatureInstance.config)
"""
self.config = self._merge_config(config or {})
def _merge_config(self, instance_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Merge instance config with defaults, handling nested dicts.
Args:
instance_config: Instance-specific config
Returns:
Merged configuration dict
"""
merged = self.DEFAULT_CONFIG.copy()
# Deep merge nested dicts
for key, value in instance_config.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = {**merged[key], **value}
else:
merged[key] = value
return merged
@property
def connector_types(self) -> List[str]:
"""Get connector types as list (supports multiple connectors)."""
connector_config = self.config.get("connector", {})
# Support new array format
types = []
if "types" in connector_config and isinstance(connector_config["types"], list):
types = connector_config["types"]
# Fallback to legacy single type format
elif "type" in connector_config:
types = [connector_config["type"]]
else:
types = ["preprocessor"]
# Filter out 'websearch' (not a database connector, handled separately via enableWebResearch)
types = [t for t in types if t != "websearch"]
# Ensure at least one connector
if not types:
types = ["preprocessor"]
return types
@property
def connector_type(self) -> str:
"""Get primary connector type (preprocessor, custom)."""
# For backward compatibility, return first connector type
types = self.connector_types
return types[0] if types else "preprocessor"
@property
def custom_connector_class(self) -> Optional[str]:
"""Get custom connector class name if using custom connector."""
return self.config.get("connector", {}).get("customConnectorClass")
@property
def use_custom_prompts(self) -> bool:
"""Check if custom prompts should be used. Always true since prompts are required."""
# Prompts are now required, so this is always true if prompts are configured
return bool(self.config.get("prompts", {}).get("customAnalysisPrompt") or
self.config.get("prompts", {}).get("customFinalAnswerPrompt"))
@property
def custom_analysis_prompt(self) -> Optional[str]:
"""Get custom analysis prompt (required for chatbot instances)."""
prompt = self.config.get("prompts", {}).get("customAnalysisPrompt")
if not prompt:
logger.warning("custom_analysis_prompt is not configured - this is required for chatbot instances")
return prompt
@property
def custom_final_answer_prompt(self) -> Optional[str]:
"""Get custom final answer prompt (required for chatbot instances)."""
prompt = self.config.get("prompts", {}).get("customFinalAnswerPrompt")
if not prompt:
logger.warning("custom_final_answer_prompt is not configured - this is required for chatbot instances")
return prompt
@property
def custom_system_prompt(self) -> Optional[str]:
"""Get custom system prompt for LangGraph workflow."""
# Prefer customSystemPrompt, fallback to customAnalysisPrompt
prompt = self.config.get("prompts", {}).get("customSystemPrompt")
if not prompt:
prompt = self.config.get("prompts", {}).get("customAnalysisPrompt")
return prompt
@property
def max_queries(self) -> int:
"""Get maximum number of queries allowed."""
return self.config.get("behavior", {}).get("maxQueries", 5)
@property
def enable_web_research(self) -> bool:
"""Check if web research is enabled."""
return self.config.get("behavior", {}).get("enableWebResearch", True)
@property
def enable_retry_on_empty(self) -> bool:
"""Check if retry on empty results is enabled."""
return self.config.get("behavior", {}).get("enableRetryOnEmpty", True)
@property
def max_retry_attempts(self) -> int:
"""Get maximum retry attempts."""
return self.config.get("behavior", {}).get("maxRetryAttempts", 2)
def get_connector_instance(self):
"""
Get connector instance based on configuration.
Uses the primary (first) connector type from the configured connectors.
Returns:
Connector instance (PreprocessorConnector, or custom connector if configured)
"""
# Use primary connector type (first in the list)
connector_type = self.connector_type.lower()
if connector_type == "preprocessor":
from modules.connectors.connectorPreprocessor import PreprocessorConnector
return PreprocessorConnector()
elif connector_type == "custom" and self.custom_connector_class:
# Dynamic import for custom connectors
try:
module_path, class_name = self.custom_connector_class.rsplit(".", 1)
module = __import__(module_path, fromlist=[class_name])
connector_class = getattr(module, class_name)
return connector_class()
except Exception as e:
logger.error(f"Failed to load custom connector {self.custom_connector_class}: {e}")
raise ValueError(f"Invalid custom connector: {self.custom_connector_class}")
else:
# Default to PreprocessorConnector
logger.warning(f"Unknown connector type '{connector_type}', using PreprocessorConnector")
from modules.connectors.connectorPreprocessor import PreprocessorConnector
return PreprocessorConnector()
def get_chatbot_config(instance_id: Optional[str]) -> ChatbotConfig:
"""
Load chatbot configuration for a feature instance.
Args:
instance_id: FeatureInstance ID (None for default config)
Returns:
ChatbotConfig instance with merged defaults and instance config
"""
if not instance_id:
# Return default config if no instance ID provided
return ChatbotConfig()
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instance_id)
if not instance:
logger.warning(f"Feature instance {instance_id} not found, using default config")
return ChatbotConfig()
# Verify it's a chatbot instance
if instance.featureCode != "chatbot":
logger.warning(f"Instance {instance_id} is not a chatbot instance, using default config")
return ChatbotConfig()
# Load config from instance
instance_config = instance.config if hasattr(instance, 'config') and instance.config else {}
return ChatbotConfig(instance_config)
except Exception as e:
logger.error(f"Error loading chatbot config for instance {instance_id}: {e}")
# Return default config on error
return ChatbotConfig()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,160 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Utility functions for the chatbot module.
Contains conversation name generation and other utilities.
"""
import logging
import re
from typing import Optional
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
logger = logging.getLogger(__name__)
async def generate_conversation_name(
services,
userPrompt: str,
userLanguage: str = "en"
) -> str:
"""
Generate a short, descriptive conversation name based on user's prompt.
Args:
services: Services instance with AI access
userPrompt: The user's input prompt
userLanguage: User's preferred language (for prompt localization)
Returns:
Short conversation name (max 60 characters)
"""
try:
truncated_prompt = userPrompt[:200] if len(userPrompt) > 200 else userPrompt
name_prompt = f"""Create a professional conversation title in THE SAME LANGUAGE as the user's question.
Question: "{truncated_prompt}"
Rules:
- Title MUST be in the same language as the question (GermanGerman, FrenchFrench, EnglishEnglish)
- Max 60 characters, no punctuation (?, !, .)
- Professional and concise
- Respond ONLY with the title, nothing else"""
await services.ai.ensureAiObjectsInitialized()
nameRequest = AiCallRequest(
prompt=name_prompt,
options=AiCallOptions(
resultFormat="txt",
operationType=OperationTypeEnum.DATA_GENERATE,
processingMode=ProcessingModeEnum.DETAILED,
temperature=0.7
)
)
nameResponse = await services.ai.callAi(nameRequest)
generated_name = nameResponse.content.strip()
# Extract first line and clean up
generated_name = generated_name.split('\n')[0].strip()
generated_name = re.sub(r'^(Title|Titel|Titre|Name|Name:):\s*', '', generated_name, flags=re.IGNORECASE)
generated_name = re.sub(r'^["\']|["\']$', '', generated_name)
generated_name = re.sub(r'[?!.]+$', '', generated_name) # Remove trailing punctuation
# Apply title case
if generated_name:
words = generated_name.split()
capitalized_words = []
for word in words:
if word.isupper() and len(word) > 1:
capitalized_words.append(word) # Keep acronyms
else:
capitalized_words.append(word.capitalize())
generated_name = " ".join(capitalized_words).strip()
# Validate and truncate if needed
if not generated_name or len(generated_name) < 3:
if userLanguage == "de":
generated_name = "Chatbot Konversation"
elif userLanguage == "fr":
generated_name = "Conversation Chatbot"
else:
generated_name = "Chatbot Conversation"
if len(generated_name) > 60:
truncated = generated_name[:57]
last_space = truncated.rfind(' ')
generated_name = truncated[:last_space] + "..." if last_space > 30 else truncated + "..."
logger.info(f"Generated conversation name: '{generated_name}'")
return generated_name
except Exception as e:
logger.error(f"Error generating conversation name: {e}", exc_info=True)
if userLanguage == "de":
return "Chatbot Konversation"
elif userLanguage == "fr":
return "Conversation Chatbot"
else:
return "Chatbot Conversation"
def get_empty_results_retry_instructions(empty_count: int) -> str:
"""
Get retry instructions when empty results are detected.
Args:
empty_count: Number of queries that returned empty results
Returns:
Formatted instructions string
"""
if empty_count == 0:
return ""
return f"""
LEERE ERGEBNISSE ERKANNT
Es wurden {empty_count} Query(s) ausgeführt, die 0 Zeilen zurückgegeben haben. Versuche alternative Strategien.
WICHTIG - MAXIMAL 5 QUERIES FÜR PERFORMANCE
Erstelle MAXIMAL 5 alternative SQL-Queries mit komplett anderen Strategien:
1. **Breitere Suche ohne Zertifizierung**: Entferne Zertifizierungsfilter komplett
- Beispiel: Suche nur nach Netzgerät + einphasig + 10A (ohne UL)
- Suche in Artikelbezeichnung, Artikelbeschrieb, Keywords
2. **Erweiterte Suche nach Netzgeräten mit Ampere-Angaben**: Breitere Ampere-Patterns
- Beispiel: (Netzteil OR Netzgerät) AND (10A OR 15A OR 20A OR Ampere)
- Suche auch nach "Ampere" als Begriff, nicht nur Zahlen
3. **Breitere UL-Suche bei Netzgeräten**: Suche UL in allen Feldern
- Beispiel: (UL OR UL-zertifiziert) AND (Netzgerät OR Netzteil OR Power Supply)
- Suche auch in Keywords-Feld
4. **Netzgeräte mit 10A ohne weitere Filter**: Minimaler Filter
- Beispiel: (Netzgerät OR Netzteil) AND (10A OR 15A OR 20A)
- Keine Filter auf einphasig oder Zertifizierung
5. **Zertifizierte Netzgeräte allgemein**: Breite Zertifizierungs-Suche
- Beispiel: (UL OR CE OR TÜV OR certified OR zertifiziert) AND (Netzgerät OR Netzteil)
6. **COUNT-Abfrage für Statistik**: Prüfe ob überhaupt Artikel existieren
- SELECT COUNT(*) WHERE (Netzgerät OR Netzteil) AND (10A OR 15A OR 20A)
7. **Spezifische Suche nach einphasigen Netzgeräten**: Ohne Zertifizierung
- Beispiel: (einphasig OR 1-phasig OR single phase) AND (Netzgerät OR Netzteil)
8. **Fallback mit minimalen Filtern**: Nur Hauptkriterien
- Beispiel: Netzgerät AND (10A OR 15A OR 20A) - keine weiteren Filter
WICHTIG:
- Erstelle MAXIMAL 5 Queries mit unterschiedlichen Strategien (für Performance)
- Verwende breitere OR-Bedingungen für alternative Begriffe
- Entferne zu spezifische Filter, die möglicherweise keine Treffer finden
- Suche in Artikelbezeichnung, Artikelbeschrieb UND Keywords-Feld
"""

View file

@ -0,0 +1,345 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
LangGraph-based chatbot implementation.
Uses LangGraph workflow with AI Center integration and connector tools.
"""
import logging
from dataclasses import dataclass
from typing import Annotated, AsyncIterator, Any, Optional, List
from pydantic import BaseModel
from langchain_core.messages import (
BaseMessage,
HumanMessage,
SystemMessage,
trim_messages,
)
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 langgraph.checkpoint.memory import MemorySaver
from modules.features.chatbot.aiCenterAdapter import AICenterChatModel
from modules.features.chatbot.langgraphTools import (
send_streaming_message,
create_sql_tool,
create_tavily_tools,
)
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
class ChatState(BaseModel):
"""Represents the state of a chat session."""
messages: Annotated[List[BaseMessage], add_messages]
@dataclass
class LangGraphChatbot:
"""LangGraph-based chatbot with AI Center integration."""
model: AICenterChatModel
memory: Any
app: Optional[CompiledStateGraph] = None
system_prompt: str = "You are a helpful assistant."
@classmethod
async def create(
cls,
services,
system_prompt: str,
connector_instance,
enable_web_research: bool = True,
tavily_api_key: Optional[str] = None,
context_window_size: int = 8000,
) -> "LangGraphChatbot":
"""
Factory method to create and configure a LangGraphChatbot instance.
Args:
services: Services instance with AI access
system_prompt: The system prompt to initialize the chatbot
connector_instance: Database connector instance (PreprocessorConnector)
enable_web_research: Whether to enable web research tools
tavily_api_key: Tavily API key for web research (if None, uses APP_CONFIG)
context_window_size: Maximum context window size in tokens
Returns:
A configured LangGraphChatbot instance
"""
# Get Tavily API key from config if not provided
if tavily_api_key is None:
tavily_api_key = APP_CONFIG.get("Connector_AiTavily_API_SECRET")
# Create AI Center chat model adapter
model = AICenterChatModel(
services=services,
system_prompt=system_prompt,
temperature=0.2
)
# Create memory/checkpointer
memory = MemorySaver()
instance = LangGraphChatbot(
model=model,
memory=memory,
system_prompt=system_prompt,
)
# Configure tools
configured_tools = await instance._configure_tools(
connector_instance,
enable_web_research,
tavily_api_key
)
# Build LangGraph app
instance.app = instance._build_app(memory, configured_tools, context_window_size)
return instance
async def _configure_tools(
self,
connector_instance,
enable_web_research: bool,
tavily_api_key: Optional[str]
) -> List:
"""
Configure tools for the chatbot.
Args:
connector_instance: Database connector instance
enable_web_research: Whether web research is enabled
tavily_api_key: Tavily API key
Returns:
List of configured tools
"""
tools = []
# SQL tool using connector
sql_tool = create_sql_tool(connector_instance)
tools.append(sql_tool)
# Streaming message tool
tools.append(send_streaming_message)
# Tavily tools (if enabled)
if enable_web_research:
tavily_tools = create_tavily_tools(tavily_api_key, enable_web_research)
tools.extend(tavily_tools)
logger.info(f"Configured {len(tools)} tools for LangGraph chatbot")
return tools
def _build_app(
self,
memory: Any,
tools: List,
context_window_size: int
) -> CompiledStateGraph[ChatState, None, ChatState, ChatState]:
"""
Builds the chatbot application workflow using LangGraph.
Args:
memory: The chat memory/checkpointer to use
tools: The list of tools the chatbot can use
context_window_size: Maximum context window size
Returns:
A compiled state graph representing the chatbot application
"""
# Bind tools to model
llm_with_tools = self.model.bind_tools(tools=tools)
def select_window(msgs: List[BaseMessage]) -> List[BaseMessage]:
"""Selects a window of messages that fit within the context window size.
Args:
msgs: The list of messages to select from.
Returns:
A list 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)
return trim_messages(
msgs,
strategy="last",
token_counter=approx_counter,
max_tokens=context_window_size,
start_on="human",
end_on=("human", "tool"),
include_system=True,
)
def agent_node(state: ChatState) -> dict:
"""Agent node for the chatbot workflow.
Args:
state: The current chat state.
Returns:
The updated chat state after processing.
"""
# Select the message window to fit in context (trim if needed)
window = select_window(state.messages)
# 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
response = llm_with_tools.invoke(window)
# Return the new state
return {"messages": [response]}
def should_continue(state: ChatState) -> str:
"""Determines whether to continue the workflow or end it.
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).
Args:
state: The current chat state.
Returns:
The next node to transition to ("tools" or END).
"""
# Get the last message
last_message = state.messages[-1]
# 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
# Compose the workflow
workflow = StateGraph(ChatState)
workflow.add_node("agent", agent_node)
workflow.add_node("tools", ToolNode(tools=tools))
workflow.add_edge(START, "agent")
workflow.add_conditional_edges("agent", should_continue)
workflow.add_edge("tools", "agent")
return workflow.compile(checkpointer=memory)
async def chat(self, message: str, chat_id: str = "default") -> List[BaseMessage]:
"""
Process a chat message by calling the LLM and tools and returns the chat history.
Args:
message: The user message to process
chat_id: The chat thread ID
Returns:
The list of messages in the chat history
"""
if not self.app:
raise RuntimeError("Chatbot app not initialized. Call create() first.")
# Set the right thread ID for memory
config = {"configurable": {"thread_id": chat_id}}
# Single-turn chat (non-streaming)
result = await self.app.ainvoke(
{"messages": [HumanMessage(content=message)]}, config=config
)
# Extract and return the messages from the result
return result["messages"]
async def stream_events(
self, *, message: str, chat_id: str = "default"
) -> AsyncIterator[dict]:
"""
Stream UI-focused events using astream_events v2.
Args:
message: The user message to process
chat_id: Logical thread identifier; forwarded in the runnable config so
memory and tools are scoped per thread
Yields:
dict: One of:
- ``{"type": "status", "label": str}`` for short progress updates.
- ``{"type": "final", "response": {"thread": str, "chat_history": list[dict]}}``
where ``chat_history`` only includes ``user``/``assistant`` roles.
- ``{"type": "error", "message": str}`` if an exception occurs.
"""
if not self.app:
raise RuntimeError("Chatbot app not initialized. Call create() first.")
# Thread-aware config for LangGraph/LangChain
config = {"configurable": {"thread_id": chat_id}}
def _is_root(ev: dict) -> bool:
"""Return True if the event is from the root run (v2: empty parent_ids)."""
return not ev.get("parent_ids")
try:
async for event in self.app.astream_events(
{"messages": [HumanMessage(content=message)]},
config=config,
version="v2",
):
etype = event.get("event")
ename = event.get("name") or ""
edata = event.get("data") or {}
# Stream human-readable progress via the special send_streaming_message tool
if etype == "on_tool_start" and ename == "send_streaming_message":
tool_in = edata.get("input") or {}
msg = tool_in.get("message")
if isinstance(msg, str) and msg.strip():
yield {"type": "status", "label": msg.strip()}
continue
# Emit the final payload when the root run finishes
if etype == "on_chain_end" and _is_root(event):
output_obj = edata.get("output")
# Extract message list from the graph's final output
final_msgs = output_obj.get("messages", []) if isinstance(output_obj, dict) else []
# Normalize for the frontend (only user/assistant with text content)
chat_history_payload: List[dict] = []
for m in final_msgs:
if isinstance(m, BaseMessage):
role = "user" if isinstance(m, HumanMessage) else "assistant" if isinstance(m, BaseMessage) else None
content = getattr(m, "content", "")
if role and content:
chat_history_payload.append({
"role": role,
"content": content
})
yield {
"type": "final",
"response": {
"thread": chat_id,
"chat_history": chat_history_payload,
},
}
return
except Exception as exc:
# Emit a single error envelope and end the stream
logger.error(f"Exception in stream_events: {exc}", exc_info=True)
yield {"type": "error", "message": f"Fehler beim Verarbeiten: {exc}"}

View file

@ -0,0 +1,166 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
LangGraph-compatible tools for chatbot.
Wraps connectors and external services as LangGraph tools.
"""
import logging
import json
from typing import Optional
from langchain_core.tools import tool
logger = logging.getLogger(__name__)
@tool
def send_streaming_message(message: str) -> str:
"""Send a streaming message to the user to provide updates during processing.
Use this tool to send short status updates to the user while you are working
on their request. This helps keep the user informed about what you are doing.
Args:
message: A short German message describing what you are currently doing.
Examples: "Durchsuche Datenbank nach Lampen, LED, Leuchten, und Ähnlichem."
"Suche im Internet nach Produktinformationen."
"Analysiere Suchergebnisse."
Returns:
A confirmation that the message was sent.
"""
# This tool doesn't actually do anything - it's just for the AI to signal
# what it's doing to the frontend via the tool call mechanism
return f"Status-Update gesendet: {message}"
def create_sql_tool(connector_instance):
"""
Create a LangGraph-compatible SQL tool using a connector instance.
Args:
connector_instance: PreprocessorConnector or similar connector instance
Returns:
LangChain tool for SQL queries
"""
# Store connector in closure
connector = connector_instance
@tool
async def execute_sql_query(query: str) -> str:
"""Execute a SQL SELECT query on the database.
This tool allows you to query the database to find articles, prices,
inventory levels, and other information.
Args:
query: A valid SQL SELECT query. Only SELECT queries are allowed.
Use double quotes for column names with spaces or special characters.
Example: SELECT "Artikelnummer", "Artikelbezeichnung" FROM Artikel
WHERE "Artikelbezeichnung" LIKE '%Lampe%' LIMIT 20
Returns:
Query results as formatted string with data rows
"""
try:
logger.info(f"Executing SQL query via connector: {query[:100]}...")
# Ensure connector is initialized
if connector is None:
return "Error: Database connector not initialized"
# Execute query
result = await connector.executeQuery(query, return_json=True)
if isinstance(result, dict):
# Return formatted text result
text_result = result.get("text", "Query executed successfully but returned no results.")
# Also include data count if available
data = result.get("data", [])
if data:
text_result += f"\n\nFound {len(data)} row(s)."
return text_result
else:
# Return string result directly
return str(result)
except Exception as e:
error_msg = f"Error executing SQL query: {str(e)}"
logger.error(error_msg, exc_info=True)
return error_msg
# Set tool metadata for better AI understanding
execute_sql_query.name = "execute_sql_query"
execute_sql_query.description = """Execute a SQL SELECT query on the database.
Use this tool to search for articles, check prices, inventory levels, suppliers, etc.
Only SELECT queries are allowed. Use double quotes for column names with spaces.
Database tables: Artikel, Einkaufspreis_neu, Lagerplatz_Artikel, Lagerplatz
Example queries:
- SELECT "Artikelnummer", "Artikelbezeichnung" FROM Artikel WHERE "Artikelbezeichnung" LIKE '%Lampe%'
- SELECT a."Artikelnummer", e."EP_CHF" FROM Artikel a LEFT JOIN Einkaufspreis_neu e ON a."I_ID" = e."ARTIKEL"
- SELECT a."Artikelnummer", l."S_IST_BESTAND" FROM Artikel a LEFT JOIN Lagerplatz_Artikel l ON a."I_ID" = l."R_ARTIKEL"
"""
return execute_sql_query
def create_tavily_tools(tavily_api_key: Optional[str] = None, enable_web_research: bool = True):
"""
Create Tavily search tools for web research.
Args:
tavily_api_key: Tavily API key (if None, tools will return error messages)
enable_web_research: Whether web research is enabled
Returns:
List of Tavily tools (search and extract)
"""
tools = []
if not enable_web_research or not tavily_api_key:
# Return dummy tools that explain web research is disabled
@tool
def tavily_search_disabled(query: str) -> str:
"""Web research is disabled for this chatbot instance."""
return "Web research is not enabled for this chatbot instance."
@tool
def tavily_extract_disabled(urls: str) -> str:
"""Web research is disabled for this chatbot instance."""
return "Web research is not enabled for this chatbot instance."
return [tavily_search_disabled, tavily_extract_disabled]
try:
from langchain_tavily import TavilySearchResults, TavilyExtract
# Create Tavily search tool
tavily_search = TavilySearchResults(
tavily_api_key=tavily_api_key,
max_results=5
)
# Create Tavily extract tool
tavily_extract = TavilyExtract(tavily_api_key=tavily_api_key)
return [tavily_search, tavily_extract]
except ImportError:
logger.warning("langchain_tavily not available, creating dummy tools")
@tool
def tavily_search_fallback(query: str) -> str:
"""Tavily search tool (not available - langchain_tavily not installed)."""
return "Tavily search is not available. Please install langchain_tavily package."
@tool
def tavily_extract_fallback(urls: str) -> str:
"""Tavily extract tool (not available - langchain_tavily not installed)."""
return "Tavily extract is not available. Please install langchain_tavily package."
return [tavily_search_fallback, tavily_extract_fallback]

View file

@ -392,15 +392,12 @@ from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.services import getInterface as getServices
from modules.features.chatbot import interfaceFeatureChatbot
from modules.features.chatbot.eventManager import get_event_manager
from modules.workflows.methods.methodAi.methodAi import MethodAi
from modules.connectors.connectorPreprocessor import PreprocessorConnector
from modules.features.chatbot.chatbotConstants import (
get_initial_analysis_prompt,
from modules.features.chatbot.chatbotUtils import (
generate_conversation_name,
get_final_answer_system_prompt,
get_final_answer_prompt_with_results,
get_empty_results_retry_instructions
)
from modules.features.chatbot.chatbotConfig import get_chatbot_config, ChatbotConfig
from modules.features.chatbot.langgraphChatbot import LangGraphChatbot
from langchain_core.messages import HumanMessage
import base64
logger = logging.getLogger(__name__)
@ -460,6 +457,16 @@ async def chatProcess(
ChatWorkflow instance
"""
try:
# Load chatbot configuration for this instance
chatbot_config = get_chatbot_config(featureInstanceId)
logger.info(f"Loaded chatbot config for instance {featureInstanceId}: connector={chatbot_config.connector_type}, maxQueries={chatbot_config.max_queries}")
# Validate that required system prompt is configured
if not chatbot_config.custom_system_prompt:
error_msg = f"Chatbot instance {featureInstanceId} is missing required customSystemPrompt configuration"
logger.error(error_msg)
raise ValueError(error_msg)
# Get services normally (for other services like chat, ai, etc.)
services = getServices(currentUser, None, mandateId=mandateId)
@ -612,7 +619,8 @@ async def chatProcess(
services,
workflow.id,
userInput,
userMessage.id
userMessage.id,
chatbot_config
))
# Reload workflow to include new message
@ -624,7 +632,7 @@ async def chatProcess(
raise
async def _execute_queries_parallel(queries: List[Dict[str, Any]]) -> Dict[str, Any]:
async def _execute_queries_parallel(queries: List[Dict[str, Any]], chatbot_config: ChatbotConfig) -> Dict[str, Any]:
"""
Execute multiple SQL queries in parallel with shared connector.
@ -633,6 +641,7 @@ async def _execute_queries_parallel(queries: List[Dict[str, Any]]) -> Dict[str,
- "query": SQL query string
- "purpose": Description of what the query retrieves
- "table": Primary table name
chatbot_config: ChatbotConfig instance for connector selection
Returns:
Dictionary mapping query indices to results:
@ -640,8 +649,8 @@ async def _execute_queries_parallel(queries: List[Dict[str, Any]]) -> Dict[str,
- "query_1_data", "query_2_data", etc.: Raw data arrays
- "query_1_error", "query_2_error", etc.: Error messages if query failed
"""
# Create single connector instance to reuse across all queries
connector = PreprocessorConnector()
# Create connector instance based on configuration
connector = chatbot_config.get_connector_instance()
try:
async def execute_single_query(idx: int, query_info: Dict[str, Any]):
"""Execute a single query using shared connector."""
@ -775,6 +784,248 @@ async def _check_workflow_stopped(interfaceDbChat, workflowId: str) -> bool:
return False
def _build_final_answer_prompt_with_results(
system_prompt: str,
user_prompt: str,
context: str,
db_results_part: str,
web_results_part: str,
is_resumed: bool = False,
has_db_results: bool = False,
has_web_results: bool = False
) -> str:
"""
Build the complete prompt for generating the final answer with database and web results.
Uses the provided system_prompt from configuration instead of hardcoded prompts.
Args:
system_prompt: System prompt from chatbot configuration
user_prompt: User's original prompt
context: Conversation context
db_results_part: Formatted database results section
web_results_part: Formatted web research results section
is_resumed: If True, exclude system prompt (already in context from previous messages)
has_db_results: Whether database results are available
has_web_results: Whether web research results are available
Returns:
Complete formatted prompt string
"""
if is_resumed:
# System prompt already in context, don't repeat it
# Emphasize that the current question is primary
if context:
context_section = f"""
KONTEXT (NUR FÜR REFERENZ - IGNORIEREN WENN NICHT BENÖTIGT)
{context}
ENDE KONTEXT
"""
else:
context_section = ""
# Build instructions based on what data sources are available
if has_web_results and not has_db_results:
# Only web research - emphasize web research
instructions = f"""⚠️⚠️⚠️ WICHTIG - NUR INTERNET-RECHERCHE VERFÜGBAR ⚠️⚠️⚠️
- Die AKTUELLE FRAGE OBEN ist die einzige Frage, die beantwortet werden muss
- Ignoriere den Kontext komplett, es sei denn die aktuelle Frage bezieht sich explizit darauf
- Antworte NUR auf die aktuelle Frage, nicht auf Fragen aus dem Kontext
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten aus der INTERNET-RECHERCHE. Erfinde KEINE Werte.
WICHTIG - INTERNET-RECHERCHE VERWENDEN
- OBLIGATORISCH: Verwende die Informationen aus der INTERNET-RECHERCHE oben
- OBLIGATORISCH: Beginne mit "Aus meiner Web-Recherche..." oder "Aus meiner Internet-Recherche..."
- OBLIGATORISCH: Gib Quellen an: [Info] ([Quelle: Name](URL))
- OBLIGATORISCH: Präsentiere die Informationen ausführlich und strukturiert
- ABSOLUT VERBOTEN: Erwähne Datenbank-Ergebnisse, wenn keine vorhanden sind
- ABSOLUT VERBOTEN: Daten erfinden
WICHTIG:
- Beginne DIREKT mit "Aus meiner Web-Recherche..." oder "Aus meiner Internet-Recherche..."
- Klare, strukturierte Antwort mit Quellenangaben
- Präsentiere die gefundenen Informationen ausführlich"""
elif has_db_results and not has_web_results:
# Only database - use existing database-focused instructions
instructions = f"""⚠️⚠️⚠️ WICHTIG ⚠️⚠️⚠️
- Die AKTUELLE FRAGE OBEN ist die einzige Frage, die beantwortet werden muss
- Ignoriere den Kontext komplett, es sei denn die aktuelle Frage bezieht sich explizit darauf
- Antworte NUR auf die aktuelle Frage, nicht auf Fragen aus dem Kontext
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten. Erfinde KEINE Werte. Wenn Daten fehlen, schreibe "Nicht verfügbar".
ABSOLUT KRITISCH - ALLE ARTIKEL ZURÜCKGEBEN
- OBLIGATORISCH: Du MUSST ALLE Artikel zurückgeben, die die Kriterien erfüllen
- OBLIGATORISCH: Kombiniere Ergebnisse aus ALLEN erfolgreichen Abfragen
- OBLIGATORISCH: Zähle ALLE Artikel in den DATENBANK-ERGEBNISSEN oben
- OBLIGATORISCH: Zeige ALLE gefundenen Artikel in deiner Antwort (bis zu 20 in der Tabelle)
- ABSOLUT VERBOTEN: Nur einen Artikel zurückgeben, wenn mehrere gefunden wurden
- ABSOLUT VERBOTEN: Nur den ersten Artikel zeigen
- ABSOLUT VERBOTEN: Artikel auslassen, die in den DATENBANK-ERGEBNISSEN stehen
WICHTIG:
- Beginne DIREKT mit "Aus der Datenbank habe ich..." (keine Planungsschritte!)
- Klare, strukturierte Antwort
- Markdown-Tabellen (max 20 Zeilen)
- Artikelnummern als Link: [ARTIKELNUMMER](/details/ARTIKELNUMMER)"""
elif not has_db_results and not has_web_results:
# No results from either source - but database query was executed
instructions = f"""⚠️⚠️⚠️ KRITISCH - DATENBANKABFRAGE WURDE AUSGEFÜHRT ⚠️⚠️⚠️
- Die AKTUELLE FRAGE OBEN ist die einzige Frage, die beantwortet werden muss
- Ignoriere den Kontext komplett, es sei denn die aktuelle Frage bezieht sich explizit darauf
- Antworte NUR auf die aktuelle Frage, nicht auf Fragen aus dem Kontext
{db_results_part}{web_results_part}
ABSOLUT KRITISCH - DATENBANKABFRAGE WURDE AUSGEFÜHRT
Die Datenbankabfrage wurde AUSGEFÜHRT, hat aber keine Ergebnisse zurückgegeben.
DU HAST ZUGRIFF AUF DIE DATENBANK - die Abfrage wurde durchgeführt!
VERBOTEN - NIEMALS SAGEN:
- "Ich habe keinen Zugriff auf die Datenbank"
- "Ich kann nicht auf die Datenbank zugreifen"
- "Es tut mir leid, aber ich habe keinen Zugriff"
- "Ich habe keinen Zugriff auf Echtzeit-Datenbanken"
- Jede andere Formulierung, die suggeriert, dass du keinen Zugriff hast!
RICHTIG - SAGE STATTDESSEN:
- "Es wurden keine Artikel gefunden"
- "Keine passenden Artikel in der Datenbank gefunden"
- "Die Datenbanksuche ergab keine Treffer"
- "Ich habe in der Datenbank gesucht, aber keine passenden Artikel gefunden"
WICHTIG: Die Datenbank wurde durchsucht - es wurden nur keine passenden Artikel gefunden!
Beginne deine Antwort mit: "Ich habe in der Datenbank gesucht, aber..." oder "Es wurden keine Artikel gefunden..." oder ähnlich."""
else:
# Both database and web research
instructions = f"""⚠️⚠️⚠️ WICHTIG ⚠️⚠️⚠️
- Die AKTUELLE FRAGE OBEN ist die einzige Frage, die beantwortet werden muss
- Ignoriere den Kontext komplett, es sei denn die aktuelle Frage bezieht sich explizit darauf
- Antworte NUR auf die aktuelle Frage, nicht auf Fragen aus dem Kontext
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten. Erfinde KEINE Werte. Wenn Daten fehlen, schreibe "Nicht verfügbar".
WICHTIG - BEIDE QUELLEN VERWENDEN
- OBLIGATORISCH: Verwende sowohl DATENBANK-ERGEBNISSE als auch INTERNET-RECHERCHE
- OBLIGATORISCH: Beginne mit "Aus der Datenbank habe ich..." für Datenbank-Ergebnisse
- OBLIGATORISCH: Verwende "Aus meiner Web-Recherche..." für Internet-Informationen
- OBLIGATORISCH: Gib Quellen für Web-Informationen an: [Info] ([Quelle: Name](URL))
- OBLIGATORISCH: Zeige ALLE Artikel aus den DATENBANK-ERGEBNISSEN (bis zu 20 in Tabelle)
WICHTIG:
- Beginne DIREKT mit "Aus der Datenbank habe ich..." für Datenbank-Ergebnisse
- Dann "Aus meiner Web-Recherche..." für Internet-Informationen
- Klare, strukturierte Antwort mit Quellenangaben"""
return f"""⚠️⚠️⚠️ AKTUELLE FRAGE (PRIMÄR - DIESE MUSS BEANTWORTET WERDEN) ⚠️⚠️⚠️
Antworte auf die folgende Frage des Nutzers: {user_prompt}
{context_section}{instructions}"""
else:
# New chat: include system prompt
# Build instructions based on what data sources are available
if has_web_results and not has_db_results:
# Only web research - emphasize web research
return f"""{system_prompt}
Antworte auf die folgende Frage des Nutzers: {user_prompt}{context}
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten aus der INTERNET-RECHERCHE. Erfinde KEINE Werte.
WICHTIG - INTERNET-RECHERCHE VERWENDEN
- OBLIGATORISCH: Verwende die Informationen aus der INTERNET-RECHERCHE oben
- OBLIGATORISCH: Beginne mit "Aus meiner Web-Recherche..." oder "Aus meiner Internet-Recherche..."
- OBLIGATORISCH: Gib Quellen an: [Info] ([Quelle: Name](URL))
- OBLIGATORISCH: Präsentiere die Informationen ausführlich und strukturiert
- ABSOLUT VERBOTEN: Erwähne Datenbank-Ergebnisse, wenn keine vorhanden sind
- ABSOLUT VERBOTEN: Daten erfinden
WICHTIG:
- Beginne DIREKT mit "Aus meiner Web-Recherche..." oder "Aus meiner Internet-Recherche..."
- Klare, strukturierte Antwort mit Quellenangaben
- Präsentiere die gefundenen Informationen ausführlich"""
elif has_db_results and not has_web_results:
# Only database - use existing database-focused instructions
return f"""{system_prompt}
Antworte auf die folgende Frage des Nutzers: {user_prompt}{context}
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten. Erfinde KEINE Werte. Wenn Daten fehlen, schreibe "Nicht verfügbar".
ABSOLUT KRITISCH - ALLE ARTIKEL ZURÜCKGEBEN
- OBLIGATORISCH: Du MUSST ALLE Artikel zurückgeben, die die Kriterien erfüllen
- OBLIGATORISCH: Kombiniere Ergebnisse aus ALLEN erfolgreichen Abfragen
- OBLIGATORISCH: Zähle ALLE Artikel in den DATENBANK-ERGEBNISSEN oben
- OBLIGATORISCH: Zeige ALLE gefundenen Artikel in deiner Antwort (bis zu 20 in der Tabelle)
- ABSOLUT VERBOTEN: Nur einen Artikel zurückgeben, wenn mehrere gefunden wurden
- ABSOLUT VERBOTEN: Nur den ersten Artikel zeigen
- ABSOLUT VERBOTEN: Artikel auslassen, die in den DATENBANK-ERGEBNISSEN stehen
WICHTIG:
- Beginne DIREKT mit "Aus der Datenbank habe ich..." (keine Planungsschritte!)
- Klare, strukturierte Antwort
- Markdown-Tabellen (max 20 Zeilen)
- Artikelnummern als Link: [ARTIKELNUMMER](/details/ARTIKELNUMMER)"""
elif not has_db_results and not has_web_results:
# No results from either source - but database query was executed
return f"""{system_prompt}
Antworte auf die folgende Frage des Nutzers: {user_prompt}{context}
{db_results_part}{web_results_part}
KRITISCH - DATENBANKABFRAGE WURDE AUSGEFÜHRT
Die Datenbankabfrage wurde AUSGEFÜHRT, hat aber keine Ergebnisse zurückgegeben.
DU HAST ZUGRIFF AUF DIE DATENBANK - die Abfrage wurde durchgeführt!
VERBOTEN - NIEMALS SAGEN:
- "Ich habe keinen Zugriff auf die Datenbank"
- "Ich kann nicht auf die Datenbank zugreifen"
- "Es tut mir leid, aber ich habe keinen Zugriff"
- "Ich habe keinen Zugriff auf Echtzeit-Datenbanken"
- Jede andere Formulierung, die suggeriert, dass du keinen Zugriff hast!
RICHTIG - SAGE STATTDESSEN:
- "Es wurden keine Artikel gefunden"
- "Keine passenden Artikel in der Datenbank gefunden"
- "Die Datenbanksuche ergab keine Treffer"
- "Ich habe in der Datenbank gesucht, aber keine passenden Artikel gefunden"
WICHTIG: Die Datenbank wurde durchsucht - es wurden nur keine passenden Artikel gefunden!
Beginne deine Antwort mit: "Ich habe in der Datenbank gesucht, aber..." oder "Es wurden keine Artikel gefunden..." oder ähnlich."""
else:
# Both database and web research
return f"""{system_prompt}
Antworte auf die folgende Frage des Nutzers: {user_prompt}{context}
{db_results_part}{web_results_part}
KRITISCH: Verwende NUR die oben angegebenen Daten. Erfinde KEINE Werte. Wenn Daten fehlen, schreibe "Nicht verfügbar".
WICHTIG - BEIDE QUELLEN VERWENDEN
- OBLIGATORISCH: Verwende sowohl DATENBANK-ERGEBNISSE als auch INTERNET-RECHERCHE
- OBLIGATORISCH: Beginne mit "Aus der Datenbank habe ich..." für Datenbank-Ergebnisse
- OBLIGATORISCH: Verwende "Aus meiner Web-Recherche..." für Internet-Informationen
- OBLIGATORISCH: Gib Quellen für Web-Informationen an: [Info] ([Quelle: Name](URL))
- OBLIGATORISCH: Zeige ALLE Artikel aus den DATENBANK-ERGEBNISSEN (bis zu 20 in Tabelle)
WICHTIG:
- Beginne DIREKT mit "Aus der Datenbank habe ich..." für Datenbank-Ergebnisse
- Dann "Aus meiner Web-Recherche..." für Internet-Informationen
- Klare, strukturierte Antwort mit Quellenangaben"""
def _buildWebResearchQuery(userPrompt: str, workflowMessages: List, queryResults: Optional[Dict[str, Any]] = None) -> str:
"""
Build enriched web research query by extracting product context from conversation history and current prompt.
@ -1248,11 +1499,12 @@ async def _processChatbotMessage(
services,
workflowId: str,
userInput: UserInputRequest,
userMessageId: str
userMessageId: str,
chatbot_config: ChatbotConfig
):
"""
Process chatbot message in background.
Analyzes user input and generates list of queries, then streams them back.
Process chatbot message using LangGraph workflow.
Uses LangGraph to handle the conversation flow with tools (SQL, Tavily, streaming).
"""
event_manager = get_event_manager()
@ -1278,39 +1530,177 @@ async def _processChatbotMessage(
logger.info(f"Workflow {workflowId} was stopped, aborting processing")
return
# Build conversation context from history
# Only include context if the new question might need it (e.g., references to previous messages)
context = ""
is_resumed = len(workflow.messages) > 0 if workflow.messages else False
# Check if the current question might need context (references like "it", "that", "previous", "earlier", etc.)
needs_context = False
if is_resumed:
current_prompt_lower = userInput.prompt.lower()
context_keywords = ["es", "das", "dieses", "jenes", "vorherige", "frühere", "vorhin", "oben",
"it", "that", "this", "previous", "earlier", "above", "mentioned", "before",
"davor", "dazu", "darauf", "damit", "davon"]
needs_context = any(keyword in current_prompt_lower for keyword in context_keywords)
if is_resumed and needs_context:
recent_messages = workflow.messages[-3:] # Reduced from 5 to 3 for less distraction
context = "\n\n⚠️ WICHTIG - KONTEXT NUR FÜR REFERENZ ⚠️\n"
context += "Die folgende Konversation ist nur als Referenz, falls die aktuelle Frage darauf Bezug nimmt.\n"
context += "FOKUSSIERE AUF DIE AKTUELLE FRAGE OBEN!\n\n"
context += "Vorherige Konversation:\n"
for msg in recent_messages:
if msg.role == "user":
context += f"User: {msg.message}\n"
elif msg.role == "assistant":
context += f"Assistant: {msg.message}\n"
await services.ai.ensureAiObjectsInitialized()
# Step 1: Analyze user input to generate queries
# Get connector instance
connector = chatbot_config.get_connector_instance()
# Get system prompt
system_prompt = chatbot_config.custom_system_prompt
if not system_prompt:
raise ValueError(f"System prompt not configured for chatbot instance")
# Create LangGraph chatbot instance
logger.info(f"Creating LangGraph chatbot for workflow {workflowId}")
chatbot = await LangGraphChatbot.create(
services=services,
system_prompt=system_prompt,
connector_instance=connector,
enable_web_research=chatbot_config.enable_web_research,
context_window_size=8000
)
# Process message using LangGraph streaming
logger.info(f"Processing message with LangGraph for workflow {workflowId}")
final_answer = None
chat_history = []
async for event in chatbot.stream_events(message=userInput.prompt, chat_id=workflowId):
# Check if workflow was stopped
if await _check_workflow_stopped(interfaceDbChat, workflowId):
logger.info(f"Workflow {workflowId} was stopped during processing")
return
event_type = event.get("type")
if event_type == "status":
# Emit status update
label = event.get("label", "")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, label, log_type="info")
elif event_type == "final":
# Final response received
response_data = event.get("response", {})
chat_history = response_data.get("chat_history", [])
# Extract final answer from chat history (last assistant message)
for msg in reversed(chat_history):
if msg.get("role") == "assistant":
final_answer = msg.get("content", "")
break
elif event_type == "error":
# Error occurred
error_msg = event.get("message", "Unknown error")
logger.error(f"LangGraph error: {error_msg}")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Fehler: {error_msg}", log_type="error")
final_answer = f"Entschuldigung, ein Fehler ist aufgetreten: {error_msg}"
# Close connector
try:
await connector.close()
except Exception as e:
logger.warning(f"Error closing connector: {e}")
# Check if workflow was stopped before storing answer
if await _check_workflow_stopped(interfaceDbChat, workflowId):
logger.info(f"Workflow {workflowId} was stopped, not storing final message")
return
# Store final answer if we have one
if final_answer:
workflow = interfaceDbChat.getWorkflow(workflowId)
message_id = f"msg_{uuid.uuid4()}"
assistantMessageData = {
"id": message_id,
"workflowId": workflowId,
"parentMessageId": userMessageId,
"message": final_answer,
"role": "assistant",
"status": "last",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": getUtcTimestamp(),
"success": True,
"roundNumber": workflow.currentRound,
"taskNumber": 0,
"actionNumber": 0
}
assistantMessage = interfaceDbChat.createMessage(assistantMessageData)
logger.info(f"Stored assistant message: {assistantMessage.id}")
# Emit message event for streaming
message_timestamp = parseTimestamp(assistantMessage.publishedAt, default=getUtcTimestamp())
await event_manager.emit_event(
context_id=workflowId,
event_type="chatdata",
data={
"type": "message",
"createdAt": message_timestamp,
"item": assistantMessage.dict()
},
event_category="chat"
)
# Update workflow status to completed
if not await _check_workflow_stopped(interfaceDbChat, workflowId):
interfaceDbChat.updateWorkflow(workflowId, {
"status": "completed",
"lastActivity": getUtcTimestamp()
})
await event_manager.emit_event(
context_id=workflowId,
event_type="complete",
data={"workflowId": workflowId},
event_category="workflow",
message="Chatbot-Verarbeitung abgeschlossen",
step="complete"
)
# Schedule cleanup
await event_manager.cleanup(workflowId, delay=300.0)
logger.info(f"LangGraph processing completed for workflow {workflowId}")
except Exception as e:
logger.info("Analyzing user input to generate queries...")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, "Analysiere Benutzeranfrage...")
analysisPrompt = get_initial_analysis_prompt(userInput.prompt, context, is_resumed)
# Use custom prompt from configuration (already validated at start of chatProcess)
analysisPrompt = chatbot_config.custom_analysis_prompt.replace("{userPrompt}", userInput.prompt).replace("{context}", context or "")
# CRITICAL: Add explicit JSON format requirement to ensure AI returns JSON
json_format_instruction = """
ABSOLUT KRITISCH - JSON-FORMAT ERFORDERLICH
DU MUSST DEINE ANTWORT AUSSCHLIESSLICH IM JSON-FORMAT GEBEN!
ANTWORTE NICHT MIT NORMALEM TEXT ODER EINER CHAT-ANTWORT!
DEINE ANTWORT MUSS EIN GÜLTIGES JSON-OBJEKT SEIN!
Erforderliches JSON-Format:
{
"needsDatabaseQuery": true/false,
"needsWebResearch": true/false,
"sqlQueries": [
{
"query": "SQL-Abfrage hier",
"purpose": "Zweck der Abfrage",
"table": "Haupttabelle"
}
],
"reasoning": "Begründung für die Abfragen"
}
KRITISCH - WANN DATENBANKABFRAGE ERFORDERLICH
SETZE "needsDatabaseQuery": true, WENN:
- Der Nutzer nach Artikeln, Produkten, Preisen, Lagerbeständen, Lieferanten fragt
- Der Nutzer nach Informationen aus der Datenbank fragt (auch allgemeine Fragen!)
- Der Nutzer eine Frage stellt, die mit Datenbank-Daten beantwortet werden kann
- Du dir nicht sicher bist - dann setze "needsDatabaseQuery": true und führe eine allgemeine Abfrage durch!
VERBOTEN:
- "needsDatabaseQuery": false setzen, nur weil die Frage allgemein klingt
- "needsDatabaseQuery": false setzen, ohne zu prüfen, ob Datenbank-Daten helfen könnten
- Chat-Antworten geben statt Datenbankabfragen durchzuführen
WICHTIG:
- Antworte NUR mit dem JSON-Objekt, KEIN zusätzlicher Text davor oder danach!
- KEINE Erklärungen, KEINE Begrüßungen, KEINE Chat-Antworten!
- NUR das JSON-Objekt!
- Bei Unsicherheit: IMMER "needsDatabaseQuery": true setzen!
"""
analysisPrompt = analysisPrompt + json_format_instruction
logger.info("Using custom analysis prompt from instance config with JSON format requirement")
# AI call for analysis
method_ai = MethodAi(services)
@ -1326,6 +1716,13 @@ async def _processChatbotMessage(
logger.info(f"Workflow {workflowId} was stopped during analysis, aborting processing")
return
# Retry logic for failed analysis (max 3 attempts)
max_analysis_retries = 3
analysis_retry_count = 0
analysis = None
analysis_content = None
while analysis_retry_count < max_analysis_retries:
# Extract content from ActionResult
analysis_content = None
if analysis_result.success and analysis_result.documents:
@ -1333,35 +1730,164 @@ async def _processChatbotMessage(
if isinstance(analysis_content, bytes):
analysis_content = analysis_content.decode('utf-8')
# Validate analysis was successful
if not analysis_content:
logger.warning("Analysis failed, using fallback")
analysis = {}
analysis_retry_count += 1
if analysis_retry_count < max_analysis_retries:
logger.warning(f"Analysis failed (attempt {analysis_retry_count}/{max_analysis_retries}): No content returned from AI, retrying...")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Analyse fehlgeschlagen, Versuch {analysis_retry_count}/{max_analysis_retries}...", log_type="warning")
# Retry analysis
analysis_result = await method_ai.process({
"aiPrompt": analysisPrompt,
"documentList": None,
"resultType": "json",
"simpleMode": True
})
continue
else:
error_msg = "Die Analyse Ihrer Anfrage ist nach mehreren Versuchen fehlgeschlagen. Bitte versuchen Sie es später erneut oder formulieren Sie Ihre Frage anders."
logger.error(f"Analysis failed after {max_analysis_retries} attempts: No content returned from AI")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Fehler: Analyse nach {max_analysis_retries} Versuchen fehlgeschlagen", log_type="error")
# Store error message as assistant response
workflow = interfaceDbChat.getWorkflow(workflowId)
message_id = f"msg_{uuid.uuid4()}"
assistantMessageData = {
"id": message_id,
"workflowId": workflowId,
"parentMessageId": userMessageId,
"message": error_msg,
"role": "assistant",
"status": "last",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": getUtcTimestamp(),
"success": False,
"roundNumber": workflow.currentRound,
"taskNumber": 0,
"actionNumber": 0
}
assistantMessage = interfaceDbChat.createMessage(assistantMessageData)
logger.info(f"Stored error message due to failed analysis after {max_analysis_retries} attempts: {assistantMessage.id}")
return
analysis = _extractJsonFromResponse(analysis_content)
if analysis is None:
analysis_retry_count += 1
if analysis_retry_count < max_analysis_retries:
logger.warning(f"Failed to extract JSON from analysis response (attempt {analysis_retry_count}/{max_analysis_retries}), retrying...")
logger.debug(f"Analysis content: {analysis_content[:500] if analysis_content else 'None'}")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"JSON-Extraktion fehlgeschlagen, Versuch {analysis_retry_count}/{max_analysis_retries}...", log_type="warning")
# Retry analysis
analysis_result = await method_ai.process({
"aiPrompt": analysisPrompt,
"documentList": None,
"resultType": "json",
"simpleMode": True
})
continue
else:
error_msg = "Die Analyse Ihrer Anfrage konnte nach mehreren Versuchen nicht verarbeitet werden. Bitte versuchen Sie es später erneut oder formulieren Sie Ihre Frage anders."
logger.error(f"Failed to extract JSON from analysis response after {max_analysis_retries} attempts. Content: {analysis_content[:500] if analysis_content else 'None'}")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Fehler: JSON-Extraktion nach {max_analysis_retries} Versuchen fehlgeschlagen", log_type="error")
# Store error message as assistant response
workflow = interfaceDbChat.getWorkflow(workflowId)
message_id = f"msg_{uuid.uuid4()}"
assistantMessageData = {
"id": message_id,
"workflowId": workflowId,
"parentMessageId": userMessageId,
"message": error_msg,
"role": "assistant",
"status": "last",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": getUtcTimestamp(),
"success": False,
"roundNumber": workflow.currentRound,
"taskNumber": 0,
"actionNumber": 0
}
assistantMessage = interfaceDbChat.createMessage(assistantMessageData)
logger.info(f"Stored error message due to failed JSON extraction after {max_analysis_retries} attempts: {assistantMessage.id}")
return
# Successfully extracted analysis, break retry loop
break
# Extract analysis results
needsDatabaseQuery = analysis.get("needsDatabaseQuery", False) if analysis else False
needsWebResearch = analysis.get("needsWebResearch", False) if analysis else False
sql_queries = analysis.get("sqlQueries", [])
sql_queries = analysis.get("sqlQueries", []) if analysis else []
# Support legacy single query format for backward compatibility
if not sql_queries and analysis.get("sqlQuery"):
if not sql_queries and analysis and analysis.get("sqlQuery"):
sql_queries = [{
"query": analysis.get("sqlQuery", ""),
"purpose": "Database query",
"table": "Unknown"
}]
reasoning = analysis.get("reasoning", "")
reasoning = analysis.get("reasoning", "") if analysis else ""
# Check if we need web research for certifications
# CRITICAL: If connectors are configured, ALWAYS use database if user asks about products/articles/inventory
# Override AI decision if it says "no database query" but connectors are available
if chatbot_config.connector_types and len(chatbot_config.connector_types) > 0:
user_prompt_lower = userInput.prompt.lower()
# Keywords that indicate database query is needed
db_keywords = [
"artikel", "produkt", "ware", "lager", "bestand", "preis", "lieferant",
"led", "lampe", "motor", "kabel", "schraube", "sensor", "netzteil",
"wie viele", "zeig mir", "suche", "finde", "gibt es", "haben wir",
"article", "product", "inventory", "stock", "price", "supplier",
"how many", "show me", "search", "find", "do we have"
]
has_db_intent = any(keyword in user_prompt_lower for keyword in db_keywords)
# If user asks about database-related topics but AI said no query needed, force it
if has_db_intent and not needsDatabaseQuery:
logger.warning(f"User asked about database-related topic but AI returned needsDatabaseQuery=false. Forcing needsDatabaseQuery=true because connectors are configured.")
needsDatabaseQuery = True
# Generate a default query if none were provided
if not sql_queries:
# Extract main search term from user prompt
search_terms = []
for keyword in db_keywords:
if keyword in user_prompt_lower:
# Try to extract the actual product/article name
words = user_prompt_lower.split()
keyword_idx = words.index(keyword) if keyword in words else -1
if keyword_idx >= 0 and keyword_idx < len(words) - 1:
# Take next word as potential product name
next_word = words[keyword_idx + 1]
if len(next_word) > 2: # Ignore short words like "die", "der", etc.
search_terms.append(next_word)
# Create a general search query
if search_terms:
search_term = search_terms[0]
else:
# Use the whole prompt as search term (limited)
search_term = userInput.prompt[:50] # Limit length
sql_queries = [{
"query": f'SELECT a."Artikelnummer", a."Artikelbezeichnung", a."Lieferant", a."Artikelkürzel" FROM Artikel a WHERE a."Artikelbezeichnung" LIKE \'%{search_term}%\' OR a."Artikelnummer" LIKE \'%{search_term}%\' OR a."Artikelkürzel" LIKE \'%{search_term}%\' LIMIT 20',
"purpose": f"Suche nach Artikeln die '{search_term}' enthalten",
"table": "Artikel"
}]
logger.info(f"Generated default database query for search term: {search_term}")
# Check if we need web research for certifications (only if enabled in config)
if chatbot_config.enable_web_research:
user_prompt_lower = userInput.prompt.lower()
certification_keywords = ["ul", "ce", "tüv", "vde", "iec", "iso", "zertifiziert", "certified", "certification"]
has_certification = any(keyword in user_prompt_lower for keyword in certification_keywords)
if has_certification and not needsWebResearch:
logger.warning("Certification detected but needsWebResearch is false - forcing to true")
needsWebResearch = True
else:
# Web research disabled in config
if needsWebResearch:
logger.info("Web research disabled in instance config, skipping")
needsWebResearch = False
# Limit query count to maximum 5 for performance
max_queries_allowed = 5
# Limit query count based on configuration
max_queries_allowed = chatbot_config.max_queries
if needsDatabaseQuery and len(sql_queries) > max_queries_allowed:
logger.info(f"Limiting queries from {len(sql_queries)} to {max_queries_allowed} for performance")
sql_queries = sql_queries[:max_queries_allowed]
@ -1369,8 +1895,9 @@ async def _processChatbotMessage(
logger.info(f"Analysis: DB={needsDatabaseQuery}, Web={needsWebResearch}, SQL queries={len(sql_queries)}")
# Build initial enriched web research query if needed (for logging, will be rebuilt after DB queries)
# Only if web research is enabled in config
enriched_web_query = None
if needsWebResearch:
if needsWebResearch and chatbot_config.enable_web_research:
enriched_web_query = _buildWebResearchQuery(userInput.prompt, workflow.messages)
# Build list of queries to stream back
@ -1386,7 +1913,7 @@ async def _processChatbotMessage(
"reasoning": reasoning
})
if needsWebResearch:
if needsWebResearch and chatbot_config.enable_web_research:
queries.append({
"type": "web",
"query": enriched_web_query or userInput.prompt,
@ -1426,9 +1953,9 @@ async def _processChatbotMessage(
queryResults = {}
webResearchResults = ""
# Start web research early in parallel with DB queries if needed
# Start web research early in parallel with DB queries if needed (only if enabled)
web_research_task = None
if needsWebResearch:
if needsWebResearch and chatbot_config.enable_web_research:
# Start with basic query (will enrich later with DB results if available)
basic_web_query = _buildWebResearchQuery(userInput.prompt, workflow.messages, None)
logger.info(f"Starting web research in parallel with DB queries using basic query: '{basic_web_query}'")
@ -1452,13 +1979,76 @@ async def _processChatbotMessage(
web_research_task = asyncio.create_task(perform_web_research())
# Check if connector is working before executing queries
if needsDatabaseQuery and sql_queries:
logger.info(f"Checking database connector before executing {len(sql_queries)} queries...")
try:
# Test connector with a simple query
test_connector = chatbot_config.get_connector_instance()
try:
# Try a simple test query to verify connector works
test_result = await test_connector.executeQuery("SELECT 1", return_json=True)
await test_connector.close()
if not test_result or test_result.get("text", "").startswith(("Error:", "Query failed:")):
raise Exception("Connector test query failed")
logger.info("Database connector test successful")
except Exception as connector_error:
await test_connector.close()
error_msg = f"Die Datenbankverbindung funktioniert derzeit nicht. Bitte versuchen Sie es später erneut. Fehler: {str(connector_error)}"
logger.error(f"Database connector test failed: {connector_error}", exc_info=True)
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Fehler: Datenbankverbindung fehlgeschlagen", log_type="error")
# Store error message as assistant response
workflow = interfaceDbChat.getWorkflow(workflowId)
message_id = f"msg_{uuid.uuid4()}"
assistantMessageData = {
"id": message_id,
"workflowId": workflowId,
"parentMessageId": userMessageId,
"message": error_msg,
"role": "assistant",
"status": "last",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": getUtcTimestamp(),
"success": False,
"roundNumber": workflow.currentRound,
"taskNumber": 0,
"actionNumber": 0
}
assistantMessage = interfaceDbChat.createMessage(assistantMessageData)
logger.info(f"Stored error message due to connector failure: {assistantMessage.id}")
return
except Exception as e:
error_msg = f"Die Datenbankverbindung konnte nicht hergestellt werden. Bitte versuchen Sie es später erneut. Fehler: {str(e)}"
logger.error(f"Failed to initialize database connector: {e}", exc_info=True)
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Fehler: Datenbankverbindung konnte nicht hergestellt werden", log_type="error")
# Store error message as assistant response
workflow = interfaceDbChat.getWorkflow(workflowId)
message_id = f"msg_{uuid.uuid4()}"
assistantMessageData = {
"id": message_id,
"workflowId": workflowId,
"parentMessageId": userMessageId,
"message": error_msg,
"role": "assistant",
"status": "last",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": getUtcTimestamp(),
"success": False,
"roundNumber": workflow.currentRound,
"taskNumber": 0,
"actionNumber": 0
}
assistantMessage = interfaceDbChat.createMessage(assistantMessageData)
logger.info(f"Stored error message due to connector initialization failure: {assistantMessage.id}")
return
# Execute database queries in parallel
if needsDatabaseQuery and sql_queries:
logger.info(f"Executing {len(sql_queries)} database queries in parallel...")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, f"Führe {len(sql_queries)} Datenbankabfrage(n) parallel aus...")
try:
queryResults = await _execute_queries_parallel(sql_queries)
queryResults = await _execute_queries_parallel(sql_queries, chatbot_config)
# Log results summary
successful_queries = [k for k in queryResults.keys() if k.startswith("query_") and not k.endswith("_error") and not k.endswith("_data")]
@ -1518,15 +2108,17 @@ async def _processChatbotMessage(
# Trigger retry if: no results AND we have database queries AND we executed at least one query
# Also trigger if all successful queries returned empty results
# Only retry if enabled in config
should_retry = (
chatbot_config.enable_retry_on_empty and
not has_any_results and
needsDatabaseQuery and
len(sql_queries) > 0 and
(len(successful_queries) > 0 or len(failed_queries) == 0) # Either we have successful queries or no failures (queries executed but empty)
)
# Iterative retry loop: try up to 2 times with different strategies
max_empty_retry_attempts = 2
# Iterative retry loop: try up to configured max attempts with different strategies
max_empty_retry_attempts = chatbot_config.max_retry_attempts if chatbot_config.enable_retry_on_empty else 0
empty_retry_attempt = 0
original_sql_queries_count = len(sql_queries)
previous_retry_rows = 0
@ -1583,8 +2175,51 @@ async def _processChatbotMessage(
retry_context += "- COUNT-Query: Wie viele Netzgeräte gibt es insgesamt?\n"
retry_context += "- Suche nach ALLEN verfügbaren Netzgeräten\n"
# Retry analysis is always part of an ongoing chat, so use is_resumed=True
retry_analysis_prompt = get_initial_analysis_prompt(userInput.prompt, retry_context, is_resumed=True)
# Retry analysis - use custom prompt from configuration (already validated at start of chatProcess)
retry_analysis_prompt = chatbot_config.custom_analysis_prompt.replace("{userPrompt}", userInput.prompt).replace("{context}", retry_context or "")
# CRITICAL: Add explicit JSON format requirement to ensure AI returns JSON
json_format_instruction = """
ABSOLUT KRITISCH - JSON-FORMAT ERFORDERLICH
DU MUSST DEINE ANTWORT AUSSCHLIESSLICH IM JSON-FORMAT GEBEN!
ANTWORTE NICHT MIT NORMALEM TEXT ODER EINER CHAT-ANTWORT!
DEINE ANTWORT MUSS EIN GÜLTIGES JSON-OBJEKT SEIN!
Erforderliches JSON-Format:
{
"needsDatabaseQuery": true/false,
"needsWebResearch": true/false,
"sqlQueries": [
{
"query": "SQL-Abfrage hier",
"purpose": "Zweck der Abfrage",
"table": "Haupttabelle"
}
],
"reasoning": "Begründung für die Abfragen"
}
KRITISCH - WANN DATENBANKABFRAGE ERFORDERLICH
SETZE "needsDatabaseQuery": true, WENN:
- Der Nutzer nach Artikeln, Produkten, Preisen, Lagerbeständen, Lieferanten fragt
- Der Nutzer nach Informationen aus der Datenbank fragt (auch allgemeine Fragen!)
- Der Nutzer eine Frage stellt, die mit Datenbank-Daten beantwortet werden kann
- Du dir nicht sicher bist - dann setze "needsDatabaseQuery": true und führe eine allgemeine Abfrage durch!
VERBOTEN:
- "needsDatabaseQuery": false setzen, nur weil die Frage allgemein klingt
- "needsDatabaseQuery": false setzen, ohne zu prüfen, ob Datenbank-Daten helfen könnten
- Chat-Antworten geben statt Datenbankabfragen durchzuführen
WICHTIG:
- Antworte NUR mit dem JSON-Objekt, KEIN zusätzlicher Text davor oder danach!
- KEINE Erklärungen, KEINE Begrüßungen, KEINE Chat-Antworten!
- NUR das JSON-Objekt!
- Bei Unsicherheit: IMMER "needsDatabaseQuery": true setzen!
"""
retry_analysis_prompt = retry_analysis_prompt + json_format_instruction
logger.info("Using custom analysis prompt for retry from instance config with JSON format requirement")
# AI call for retry analysis
retry_analysis_result = await method_ai.process({
@ -1603,6 +2238,9 @@ async def _processChatbotMessage(
if retry_analysis_content:
retry_analysis = _extractJsonFromResponse(retry_analysis_content)
if retry_analysis is None:
logger.warning("Failed to extract JSON from retry analysis response")
retry_analysis = {}
if retry_analysis and retry_analysis.get("needsDatabaseQuery", False):
retry_sql_queries = retry_analysis.get("sqlQueries", [])
# Limit to maximum 5 queries for performance
@ -1621,7 +2259,7 @@ async def _processChatbotMessage(
# Execute retry queries
try:
retry_results = await _execute_queries_parallel(retry_sql_queries)
retry_results = await _execute_queries_parallel(retry_sql_queries, chatbot_config)
# Merge retry results into main results (renumber to continue sequence)
base_query_num = len(sql_queries)
@ -1737,8 +2375,9 @@ async def _processChatbotMessage(
logger.info("Generating final answer with AI...")
await _emit_log_and_event(interfaceDbChat, workflowId, event_manager, "Formuliere finale Antwort...")
# Build prompt for final answer
system_prompt = get_final_answer_system_prompt()
# Build prompt for final answer - use custom prompt from configuration (already validated at start of chatProcess)
system_prompt = chatbot_config.custom_final_answer_prompt
logger.info("Using custom final answer prompt from instance config")
# Build answer context with query results using efficient list-based building
answer_context_parts = [f"User question: {userInput.prompt}{context}\n"]
@ -1861,7 +2500,13 @@ async def _processChatbotMessage(
# Add warning messages if needed (using efficient list building)
warning_parts = []
if not has_query_results and needsDatabaseQuery:
warning_parts.append("\n\nWICHTIG: Es wurden KEINE Datenbank-Ergebnisse gefunden. Die Datenbankabfrage wurde nicht ausgeführt oder hat keine Ergebnisse zurückgegeben.")
warning_parts.append("\n\n⚠️⚠️⚠️ WICHTIG - DATENBANKABFRAGE AUSGEFÜHRT ⚠️⚠️⚠️\n")
warning_parts.append("Die Datenbankabfrage wurde AUSGEFÜHRT, hat aber KEINE Ergebnisse zurückgegeben.\n")
warning_parts.append("DU HAST ZUGRIFF AUF DIE DATENBANK - die Abfrage wurde durchgeführt!\n")
warning_parts.append("Antworte dem Nutzer: 'Es wurden keine Artikel gefunden' oder 'Keine passenden Artikel in der Datenbank gefunden'\n")
warning_parts.append("VERBOTEN: Sage NIEMALS 'Ich habe keinen Zugriff' oder 'Ich kann nicht auf die Datenbank zugreifen'!\n")
warning_parts.append("VERBOTEN: Sage NIEMALS 'Es tut mir leid, aber ich habe keinen Zugriff auf die Datenbank'!\n")
warning_parts.append("Die Datenbank wurde durchsucht, es wurden nur keine passenden Artikel gefunden.\n")
if has_only_errors:
warning_parts.extend([
@ -1877,8 +2522,34 @@ async def _processChatbotMessage(
has_db_results = bool(db_results_part and db_results_part.strip())
has_web_results = bool(web_results_part and web_results_part.strip())
# Use the function from constants file to build the prompt
answer_prompt = get_final_answer_prompt_with_results(
# Add explicit instruction that AI has database access (if connectors are configured)
access_instruction = ""
if chatbot_config.connector_types and len(chatbot_config.connector_types) > 0:
access_instruction = "\n\n⚠️⚠️⚠️ ABSOLUT KRITISCH - DATENBANK-ZUGRIFF VERFÜGBAR ⚠️⚠️⚠️\n"
access_instruction += "DU HAST ZUGRIFF AUF DIE DATENBANK UND KANNST SQL-ABFRAGEN AUSFÜHREN!\n"
access_instruction += "Die Datenbankabfrage wurde bereits AUSGEFÜHRT (siehe DATENBANK-ERGEBNISSE oben)!\n"
access_instruction += "\nVERBOTEN - NIEMALS SAGEN:\n"
access_instruction += "- 'Ich habe keinen Zugriff auf die Datenbank'\n"
access_instruction += "- 'Ich kann nicht auf die Datenbank zugreifen'\n"
access_instruction += "- 'Es tut mir leid, aber ich habe keinen Zugriff'\n"
access_instruction += "- 'Ich habe keinen Zugriff auf Echtzeit-Datenbanken'\n"
access_instruction += "- Jede andere Formulierung, die suggeriert, dass du keinen Zugriff hast!\n"
access_instruction += "\nRICHTIG - WENN KEINE ERGEBNISSE:\n"
access_instruction += "- 'Es wurden keine Artikel gefunden'\n"
access_instruction += "- 'Keine passenden Artikel in der Datenbank gefunden'\n"
access_instruction += "- 'Die Datenbanksuche ergab keine Treffer'\n"
access_instruction += "- 'Ich habe in der Datenbank gesucht, aber keine passenden Artikel gefunden'\n"
access_instruction += "\nWICHTIG: Die Datenbank wurde durchsucht - es wurden nur keine passenden Artikel gefunden!\n"
if chatbot_config.enable_web_research:
access_instruction += "\n⚠️⚠️⚠️ ABSOLUT KRITISCH - INTERNET-ZUGRIFF VERFÜGBAR ⚠️⚠️⚠️\n"
access_instruction += "DU HAST ZUGRIFF AUF DAS INTERNET (Tavily)!\n"
access_instruction += "VERBOTEN: Sage NIEMALS, dass du keinen Zugriff auf das Internet hast!\n"
access_instruction += "VERBOTEN: Sage NIEMALS 'Ich habe keinen Zugriff auf das Internet'!\n"
# Build the final answer prompt using custom system prompt from config
answer_prompt = _build_final_answer_prompt_with_results(
system_prompt + access_instruction,
userInput.prompt,
context,
db_results_part,

View file

@ -157,7 +157,8 @@ class FeatureInterface:
mandateId: str,
label: str,
enabled: bool = True,
copyTemplateRoles: bool = True
copyTemplateRoles: bool = True,
config: Optional[Dict[str, Any]] = None
) -> FeatureInstance:
"""
Create a new feature instance for a mandate.
@ -184,7 +185,8 @@ class FeatureInterface:
featureCode=featureCode,
mandateId=mandateId,
label=label,
enabled=enabled
enabled=enabled,
config=config
)
createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump())

View file

@ -42,6 +42,7 @@ class FeatureInstanceCreate(BaseModel):
label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')")
enabled: bool = Field(True, description="Whether this feature instance is enabled")
copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation")
config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.")
class FeatureInstanceResponse(BaseModel):
@ -543,7 +544,8 @@ async def create_feature_instance(
mandateId=str(context.mandateId),
label=data.label,
enabled=data.enabled,
copyTemplateRoles=data.copyTemplateRoles
copyTemplateRoles=data.copyTemplateRoles,
config=data.config
)
logger.info(
@ -625,6 +627,7 @@ class FeatureInstanceUpdate(BaseModel):
"""Request model for updating a feature instance."""
label: Optional[str] = Field(None, description="New label for the instance")
enabled: Optional[bool] = Field(None, description="Enable/disable the instance")
config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.")
@router.put("/instances/{instanceId}", response_model=Dict[str, Any])
@ -677,6 +680,8 @@ async def updateFeatureInstance(
updateData["label"] = data.label
if data.enabled is not None:
updateData["enabled"] = data.enabled
if data.config is not None:
updateData["config"] = data.config
if not updateData:
return instance.model_dump()

View file

@ -6,7 +6,7 @@ import time
import json
from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelExtraction import ContentPart
logger = logging.getLogger(__name__)

View file

@ -109,3 +109,9 @@ pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326)
shapely>=2.0.0 # For geometric operations (intersections, area calculations)
geopandas>=0.14.0 # For reading and querying GeoPackage files
fiona>=1.9.0 # Required by geopandas for reading GeoPackage files
## LangChain & LangGraph for chatbot workflow
langchain>=0.1.0
langchain-core>=0.1.0
langgraph>=0.0.20
langchain-tavily>=0.0.1