flare / chat_handler.py
ciyidogan's picture
Update chat_handler.py
21a66b7 verified
raw
history blame
19.4 kB
"""
Flare – Chat Handler (v1.7 Β· parameter parsing dΓΌzeltmesi)
==========================================
"""
import re, json, sys, httpx
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel
import requests
from prompt_builder import build_intent_prompt, build_parameter_prompt
from utils import log
from api_executor import call_api as execute_api
from config_provider import ConfigProvider
from validation_engine import validate
from session import session_store, Session
# ───────────────────────── HELPERS ───────────────────────── #
def _trim_response(raw: str) -> str:
"""
Remove everything after the first logical assistant block or intent tag.
Also strips trailing 'assistant' artifacts and prompt injections.
"""
# Stop at our own rules if model leaked them
for stop in ["#DETECTED_INTENT", "⚠️", "\nassistant", "assistant\n", "assistant"]:
idx = raw.find(stop)
if idx != -1:
raw = raw[:idx]
# Normalise selamlama
raw = re.sub(r"Hoş[\s-]?geldin(iz)?", "Hoş geldiniz", raw, flags=re.IGNORECASE)
return raw.strip()
def _safe_intent_parse(raw: str) -> tuple[str, str]:
"""Extract intent name and extra tail."""
m = re.search(r"#DETECTED_INTENT:\s*([A-Za-z0-9_-]+)", raw)
if not m:
return "", raw
name = m.group(1)
# Remove 'assistant' suffix if exists
if name.endswith("assistant"):
name = name[:-9] # Remove last 9 chars ("assistant")
log(f"πŸ”§ Removed 'assistant' suffix from intent name")
tail = raw[m.end():]
log(f"🎯 Parsed intent: {name}")
return name, tail
# ───────────────────────── CONFIG ───────────────────────── #
cfg = ConfigProvider.get()
SPARK_URL = str(cfg.global_config.spark_endpoint).rstrip("/")
ALLOWED_INTENTS = {"flight-booking", "flight-info", "booking-cancel"}
# ───────────────────────── SPARK ───────────────────────── #
async def spark_generate(s: Session, prompt: str, user_msg: str) -> str:
"""Call Spark with proper error handling"""
try:
project = next((p for p in cfg.projects if p.name == s.project_name), None)
if not project:
raise ValueError(f"Project not found: {s.project_name}")
version = next((v for v in project.versions if v.published), None)
if not version:
raise ValueError("No published version found")
payload = {
"project_name": s.project_name,
"user_input": user_msg,
"context": s.chat_history[-10:],
"system_prompt": prompt
}
log(f"πŸš€ Calling Spark for session {s.session_id[:8]}...")
log(f"πŸ“‹ Prompt preview (first 200 chars): {prompt[:200]}...")
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(SPARK_URL + "/generate", json=payload)
response.raise_for_status()
data = response.json()
raw = (data.get("assistant") or data.get("model_answer") or data.get("text", "")).strip()
log(f"πŸͺ„ Spark raw: {raw[:120]!r}")
return raw
except httpx.TimeoutException:
log(f"⏱️ Spark timeout for session {s.session_id[:8]}")
raise
except Exception as e:
log(f"❌ Spark error: {e}")
raise
# ───────────────────────── FASTAPI ───────────────────────── #
router = APIRouter()
@router.get("/")
def health():
return {"status": "ok", "sessions": len(session_store._sessions)}
class StartRequest(BaseModel):
project_name: str
class ChatRequest(BaseModel):
user_input: str
class ChatResponse(BaseModel):
session_id: str
answer: str
@router.post("/start_session", response_model=ChatResponse)
async def start_session(req: StartRequest):
"""Create new session"""
try:
# Validate project exists
project = next((p for p in cfg.projects if p.name == req.project_name and p.enabled), None)
if not project:
raise HTTPException(404, f"Project '{req.project_name}' not found or disabled")
# Create session
session = session_store.create_session(req.project_name)
greeting = "Hoş geldiniz! Size nasıl yardımcı olabilirim?"
session.add_turn("assistant", greeting)
return ChatResponse(session_id=session.session_id, answer=greeting)
except Exception as e:
log(f"❌ Error creating session: {e}")
raise HTTPException(500, str(e))
@router.post("/chat", response_model=ChatResponse)
async def chat(body: ChatRequest, x_session_id: str = Header(...)):
"""Process chat message"""
try:
# Get session
session = session_store.get_session(x_session_id)
if not session:
raise HTTPException(404, "Session not found")
user_input = body.user_input.strip()
if not user_input:
raise HTTPException(400, "Empty message")
log(f"πŸ’¬ User input: {user_input}")
log(f"πŸ“Š Session state: {session.state}, last_intent: {session.last_intent}")
session.add_turn("user", user_input)
# Get project config
project = next((p for p in cfg.projects if p.name == session.project_name), None)
if not project:
raise HTTPException(500, "Project configuration lost")
version = next((v for v in project.versions if v.published), None)
if not version:
raise HTTPException(500, "No published version")
# Handle based on state
if session.state == "await_param":
log(f"πŸ”„ Handling parameter followup for missing: {session.awaiting_parameters}")
answer = await _handle_parameter_followup(session, user_input, version)
else:
log("πŸ†• Handling new message")
answer = await _handle_new_message(session, user_input, version)
session.add_turn("assistant", answer)
return ChatResponse(session_id=session.session_id, answer=answer)
except HTTPException:
raise
except Exception as e:
log(f"❌ Chat error: {e}")
session.reset_flow()
error_msg = "Bir hata oluştu. Lütfen tekrar deneyin."
session.add_turn("assistant", error_msg)
return ChatResponse(session_id=x_session_id, answer=error_msg)
# ───────────────────────── MESSAGE HANDLERS ───────────────────────── #
async def _handle_new_message(session: Session, user_input: str, version) -> str:
"""Handle new message (not parameter followup)"""
# Build intent detection prompt
prompt = build_intent_prompt(
version.general_prompt,
session.chat_history,
user_input,
version.intents
)
# Get Spark response
raw = await spark_generate(session, prompt, user_input)
# Empty response fallback
if not raw:
log("⚠️ Empty response from Spark")
return "Üzgünüm, mesajınızı anlayamadım. Lütfen tekrar dener misiniz?"
# Check for intent
if not raw.startswith("#DETECTED_INTENT"):
# Small talk response
log("πŸ’¬ No intent detected, returning small talk")
return _trim_response(raw)
# Parse intent
intent_name, tail = _safe_intent_parse(raw)
# Validate intent
if intent_name not in ALLOWED_INTENTS:
log(f"⚠️ Invalid intent: {intent_name}")
return _trim_response(tail) if tail else "Size nasΔ±l yardΔ±mcΔ± olabilirim?"
# Short message guard (less than 3 words usually means incomplete request)
if len(user_input.split()) < 3 and intent_name != "flight-info":
log(f"⚠️ Message too short ({len(user_input.split())} words) for intent {intent_name}")
return _trim_response(tail) if tail else "LΓΌtfen talebinizi biraz daha detaylandΔ±rΔ±r mΔ±sΔ±nΔ±z?"
# Find intent config
intent_config = next((i for i in version.intents if i.name == intent_name), None)
if not intent_config:
log(f"❌ Intent config not found for: {intent_name}")
return "Üzgünüm, bu işlemi gerçekleştiremiyorum."
# Set intent in session
session.last_intent = intent_name
log(f"βœ… Intent set: {intent_name}")
# Log intent parameters
log(f"πŸ“‹ Intent parameters: {[p.name for p in intent_config.parameters]}")
# Extract parameters
return await _extract_parameters(session, intent_config, user_input)
async def _handle_parameter_followup(session: Session, user_input: str, version) -> str:
"""Handle parameter collection followup"""
if not session.last_intent:
log("⚠️ No last intent in session")
session.reset_flow()
return "Üzgünüm, hangi işlem için bilgi istediğimi unuttum. Baştan başlayalım."
# Get intent config
intent_config = next((i for i in version.intents if i.name == session.last_intent), None)
if not intent_config:
log(f"❌ Intent config not found for: {session.last_intent}")
session.reset_flow()
return "Bir hata oluştu. Lütfen tekrar deneyin."
# Try to extract missing parameters
missing = session.awaiting_parameters
log(f"πŸ” Trying to extract missing params: {missing}")
prompt = build_parameter_prompt(intent_config, missing, user_input, session.chat_history)
raw = await spark_generate(session, prompt, user_input)
# Try parsing with or without #PARAMETERS: prefix
success = _process_parameters(session, intent_config, raw)
if not success:
# Increment miss count
session.missing_ask_count += 1
log(f"⚠️ No parameters extracted, miss count: {session.missing_ask_count}")
if session.missing_ask_count >= 3:
session.reset_flow()
return "Üzgünüm, istediğiniz bilgileri anlayamadım. Başka bir konuda yardımcı olabilir miyim?"
return "Üzgünüm, anlayamadım. Lütfen tekrar sâyler misiniz?"
# Check if we have all required parameters
missing = _get_missing_parameters(session, intent_config)
log(f"πŸ“Š Still missing params: {missing}")
if missing:
session.awaiting_parameters = missing
param = next(p for p in intent_config.parameters if p.name == missing[0])
return f"{param.caption} bilgisini alabilir miyim?"
# All parameters collected, call API
log("βœ… All parameters collected, calling API")
session.state = "call_api"
return await _execute_api_call(session, intent_config)
# ───────────────────────── PARAMETER HANDLING ───────────────────────── #
async def _extract_parameters(session: Session, intent_config, user_input: str) -> str:
"""Extract parameters from user input"""
missing = _get_missing_parameters(session, intent_config)
log(f"πŸ” Missing parameters: {missing}")
if not missing:
# All parameters already available
log("βœ… All parameters already available")
return await _execute_api_call(session, intent_config)
# Build parameter extraction prompt
prompt = build_parameter_prompt(intent_config, missing, user_input, session.chat_history)
raw = await spark_generate(session, prompt, user_input)
# Try processing with flexible parsing
success = _process_parameters(session, intent_config, raw)
if success:
missing = _get_missing_parameters(session, intent_config)
log(f"πŸ“Š After extraction, missing: {missing}")
else:
log("⚠️ Failed to extract parameters from response")
if missing:
# Still missing parameters
session.state = "await_param"
session.awaiting_parameters = missing
session.missing_ask_count = 0
param = next(p for p in intent_config.parameters if p.name == missing[0])
log(f"❓ Asking for parameter: {param.name} ({param.caption})")
return f"{param.caption} bilgisini alabilir miyim?"
# All parameters collected
log("βœ… All parameters collected after extraction")
return await _execute_api_call(session, intent_config)
def _get_missing_parameters(session: Session, intent_config) -> List[str]:
"""Get list of missing required parameters"""
missing = [
p.name for p in intent_config.parameters
if p.required and p.variable_name not in session.variables
]
log(f"πŸ“Š Session variables: {list(session.variables.keys())}")
return missing
def _process_parameters(session: Session, intent_config, raw: str) -> bool:
"""Process parameter extraction response with flexible parsing"""
try:
# Try to parse JSON, handling both with and without #PARAMETERS: prefix
json_str = raw
if raw.startswith("#PARAMETERS:"):
json_str = raw[len("#PARAMETERS:"):]
log(f"πŸ” Found #PARAMETERS: prefix, removing it")
# Clean up any trailing content after JSON
# Find the closing brace for the JSON object
brace_count = 0
json_end = -1
in_string = False
escape_next = False
for i, char in enumerate(json_str):
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '"' and not escape_next:
in_string = not in_string
continue
if not in_string:
if char == '{':
brace_count += 1
elif char == '}':
brace_count -= 1
if brace_count == 0:
json_end = i + 1
break
if json_end > 0:
json_str = json_str[:json_end]
log(f"πŸ” Cleaned JSON string: {json_str[:200]}")
data = json.loads(json_str)
extracted = data.get("extracted", [])
log(f"πŸ“¦ Extracted data: {extracted}")
any_valid = False
for param_data in extracted:
param_name = param_data.get("name")
param_value = param_data.get("value")
if not param_name or not param_value:
log(f"⚠️ Invalid param data: {param_data}")
continue
# Find parameter config
param_config = next(
(p for p in intent_config.parameters if p.name == param_name),
None
)
if not param_config:
log(f"⚠️ Parameter config not found for: {param_name}")
continue
# Validate parameter
if validate(str(param_value), param_config):
session.variables[param_config.variable_name] = str(param_value)
any_valid = True
log(f"βœ… Extracted {param_name}={param_value} β†’ {param_config.variable_name}")
else:
log(f"❌ Invalid {param_name}={param_value}")
return any_valid
except json.JSONDecodeError as e:
log(f"❌ JSON parsing error: {e}")
log(f"❌ Failed to parse: {raw[:200]}")
# Fallback: Try to extract simple values from user input
# This is especially useful for single parameter responses
if session.state == "await_param" and len(session.awaiting_parameters) > 0:
# Get the first missing parameter
first_missing = session.awaiting_parameters[0]
param_config = next(
(p for p in intent_config.parameters if p.name == first_missing),
None
)
if param_config and session.chat_history:
# Get the last user input
last_user_input = session.chat_history[-1].get("content", "").strip()
# For simple inputs like city names, try direct assignment
if param_config.type in ["str", "string"] and len(last_user_input.split()) <= 3:
if validate(last_user_input, param_config):
session.variables[param_config.variable_name] = last_user_input
log(f"βœ… Fallback extraction: {first_missing}={last_user_input}")
return True
return False
except Exception as e:
log(f"❌ Parameter processing error: {e}")
return False
# ───────────────────────── API EXECUTION ───────────────────────── #
async def _execute_api_call(session: Session, intent_config) -> str:
"""Execute API call and return humanized response"""
try:
session.state = "call_api"
api_name = intent_config.action
api_config = cfg.get_api(api_name)
if not api_config:
log(f"❌ API config not found: {api_name}")
session.reset_flow()
return intent_config.fallback_error_prompt or "İşlem başarısız oldu."
log(f"πŸ“‘ Calling API: {api_name}")
log(f"πŸ“¦ API variables: {session.variables}")
# Execute API call with session
response = execute_api(api_config, session)
api_json = response.json()
log(f"βœ… API response: {api_json}")
# Humanize response
session.state = "humanize"
if api_config.response_prompt:
prompt = api_config.response_prompt.replace(
"{{api_response}}",
json.dumps(api_json, ensure_ascii=False)
)
human_response = await spark_generate(session, prompt, json.dumps(api_json))
# Trim response to remove any trailing "assistant" artifacts
trimmed_response = _trim_response(human_response)
session.reset_flow()
return trimmed_response if trimmed_response else f"İşlem sonucu: {api_json}"
else:
session.reset_flow()
return f"İşlem tamamlandı: {api_json}"
except requests.exceptions.Timeout:
log(f"⏱️ API timeout: {api_name}")
session.reset_flow()
return intent_config.fallback_timeout_prompt or "İşlem zaman aşımına uğradı."
except Exception as e:
log(f"❌ API call error: {e}")
session.reset_flow()
return intent_config.fallback_error_prompt or "İşlem sırasında bir hata oluştu."