"""Chat Agent background processing — GenAI call, RAG, MCP tool use.""" import os, json, uuid, time, re, asyncio, concurrent.futures from datetime import datetime, timedelta from typing import Optional, List, Dict, Any from fastapi import HTTPException from config import ( DATA, OCI_DIR, REPORTS, log, _chat_executor, COMPACT_TOKEN_THRESHOLD, COMPACT_KEEP_RECENT, ) from database import db from auth.crypto import _make_token, _safe_dec from auth.jwt_auth import _audit, _chat_log, _verify_config_access from services.genai import ( _call_genai, _embed_text, _build_rag_context, _get_active_adb_configs, _get_tables_for_config, _resolve_embed_config, _DIM_TO_MODEL, _get_table_embedding_dim, _vector_search_multi, _relevant_tables, _estimate_tokens, _should_compact, _compact_history, RAG_CONTEXT_TEMPLATE, RAG_DEFAULT_SYSTEM_PROMPT, ) def _chat_start(msg: "ChatMsg", u, attachments: list = None, agent_type: str = "chat"): """Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response. If genai_cfg is None, returns immediate fallback response in mid field as dict.""" is_new = not msg.session_id sid = msg.session_id or str(uuid.uuid4()) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), sid, u["id"], "user", msg.message, None, "done")) if is_new: title = (msg.message or "Nova conversa")[:80].strip() c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)", (sid, u["id"], agent_type, title)) else: c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,)) genai_cfg = None if msg.genai_config_id: _verify_config_access("genai", msg.genai_config_id, u) with db() as c: row = c.execute("SELECT * FROM genai_configs WHERE id=?", (msg.genai_config_id,)).fetchone() if row: genai_cfg = dict(row) if msg.temperature is not None: genai_cfg["temperature"] = msg.temperature if msg.max_tokens is not None: genai_cfg["max_tokens"] = msg.max_tokens if msg.top_p is not None: genai_cfg["top_p"] = msg.top_p if msg.top_k is not None: genai_cfg["top_k"] = msg.top_k if msg.frequency_penalty is not None: genai_cfg["frequency_penalty"] = msg.frequency_penalty if msg.presence_penalty is not None: genai_cfg["presence_penalty"] = msg.presence_penalty if msg.reasoning_effort is not None: genai_cfg["reasoning_effort"] = msg.reasoning_effort elif msg.model_id and msg.oci_config_id: _verify_config_access("oci", msg.oci_config_id, u) with db() as c: oci_row = c.execute("SELECT * FROM oci_configs WHERE id=?", (msg.oci_config_id,)).fetchone() if not oci_row: raise HTTPException(400, "OCI config not found") region = msg.genai_region or oci_row["region"] compartment = _safe_dec(oci_row["compartment_id"]) if oci_row["compartment_id"] else "" if not compartment: raise HTTPException(400, "compartment_id required") genai_cfg = { "oci_config_id": msg.oci_config_id, "model_id": msg.model_id, "model_ocid": None, "compartment_id": compartment, "genai_region": region, "endpoint": f"https://inference.generativeai.{region}.oci.oraclecloud.com", "serving_type": "ON_DEMAND", "dedicated_endpoint_id": None, "temperature": msg.temperature if msg.temperature is not None else 1.0, "max_tokens": msg.max_tokens if msg.max_tokens is not None else 6000, "top_p": msg.top_p if msg.top_p is not None else 0.95, "top_k": msg.top_k if msg.top_k is not None else 1, "frequency_penalty": msg.frequency_penalty if msg.frequency_penalty is not None else 0.0, "presence_penalty": msg.presence_penalty if msg.presence_penalty is not None else 0.0, "reasoning_effort": msg.reasoning_effort, } if not genai_cfg: # No GenAI config — return immediate fallback resp = _agent_respond(msg.message, u) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), sid, u["id"], "assistant", resp, None, "done")) return sid, {"session_id": sid, "response": resp, "model_id": None, "status": "done"}, None # Create placeholder assistant message for background processing mid = str(uuid.uuid4()) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (mid, sid, u["id"], "assistant", "", genai_cfg.get("model_id"), "processing")) return sid, mid, genai_cfg async def _chat_background(mid: str, sid: str, msg: "ChatMsg", user: dict, genai_cfg: dict, attachments: list = None, agent_type: str = "chat"): """Background worker — processes GenAI chat, updates DB when done.""" log.info(f"Chat background started: mid={mid}, sid={sid}") try: history = [] with db() as c: prev = c.execute("SELECT role,content FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') AND status='done' ORDER BY created_at ASC", (sid,)).fetchall() history = [{"role":r["role"],"content":r["content"]} for r in prev] # ── RAG: augment with vector context from ALL active ADB configs ── # Resolve active tenancy for filtered vector search rag_tenancy = None active_oci_for_rag = genai_cfg.get("oci_config_id") or (msg.oci_config_id if hasattr(msg, 'oci_config_id') and msg.oci_config_id else None) if active_oci_for_rag: with db() as c: oci_for_rag = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (active_oci_for_rag,)).fetchone() if oci_for_rag: rag_tenancy = oci_for_rag["tenancy_name"] log.info(f"RAG: filtering by tenancy '{rag_tenancy}'") rag_context = "" # For short follow-up questions, enrich with previous context for better RAG search import re as _re_chat rag_query = msg.message if len(msg.message.split()) <= 8 and history: # Short question — get previous user messages (excluding current which is already in history) prev_user_msgs = [h["content"] for h in history if h["role"] == "user" and h["content"] != msg.message] if prev_user_msgs: rag_query = prev_user_msgs[-1] + " " + msg.message log.info(f"RAG: enriched short query → '{rag_query[:100]}'") elif len(history) >= 2: # Fallback: use last assistant response for context last_assistant = [h["content"][:200] for h in history if h["role"] == "assistant"] if last_assistant: rag_query = last_assistant[-1] + " " + msg.message log.info(f"RAG: enriched from assistant context") # Detect CIS recommendation number for exact text filtering cis_chat_match = _re_chat.search(r'(?:cis|recommendation)\s*(\d+\.\d+)', rag_query, _re_chat.IGNORECASE) cis_chat_filter = f"CIS Recommendation: {cis_chat_match.group(1)}" if cis_chat_match else None if cis_chat_filter: log.info(f"RAG: detected CIS filter '{cis_chat_filter}'") adb_cfgs = _get_active_adb_configs(user["id"]) if agent_type != "terraform" else [] rag_errors = [] if adb_cfgs: all_documents = [] for adb_cfg in adb_cfgs: try: genai_linked = None if adb_cfg.get("genai_config_id"): with db() as c: row = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone() if row: genai_linked = dict(row) emb_genai = _resolve_embed_config(oci_config_id=active_oci_for_rag, genai_cfg=genai_linked, user_id=user["id"]) if not emb_genai: continue default_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0") tables = _get_tables_for_config(adb_cfg["id"], active_only=True) if not tables: tables = [{"table_name": adb_cfg.get("table_name", "")}] all_table_names = [t["table_name"] for t in tables if t["table_name"]] # Smart skip: only search relevant tables relevant = _relevant_tables(rag_query, all_table_names) skipped = set(all_table_names) - set(relevant) if skipped: log.info(f"RAG: skipped {', '.join(skipped)} (not relevant)") # Auto-detect embedding model (use first table's dim as representative) emb_model = default_model for tbl_name in relevant: try: dim = _get_table_embedding_dim(adb_cfg, tbl_name) if dim and dim in _DIM_TO_MODEL: emb_model = _DIM_TO_MODEL[dim] break except Exception: pass query_embedding = _embed_text(rag_query, emb_genai, emb_model) # Single connection, multi-table search tbl_top_k = 10 if cis_chat_filter else 3 docs = _vector_search_multi(adb_cfg, query_embedding, relevant, top_k_per_table=tbl_top_k, tenancy=rag_tenancy, text_filter=cis_chat_filter, user_id=user["id"]) if docs: all_documents.extend(docs) sources = {} for d in docs: sources[d["source"]] = sources.get(d["source"], 0) + 1 log.info(f"RAG: {len(docs)} docs — {', '.join(f'{k}:{v}' for k,v in sources.items())}") except Exception as e: err_short = str(e)[:150] log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')}: {err_short}") if "DPY-6001" in str(e) or "DPY-6005" in str(e) or "timeout" in str(e).lower() or "connect" in str(e).lower(): rag_errors.append(f"Não foi possível conectar ao ADB ({adb_cfg.get('config_name','?')})") if all_documents: all_documents.sort(key=lambda d: d["distance"]) top_limit = 15 if cis_chat_filter else 8 rag_context = _build_rag_context(all_documents[:top_limit], max_total_chars=16000 if cis_chat_filter else 12000) # Append connection errors to context so LLM can inform the user if rag_errors and not rag_context: rag_context = "⚠️ AVISO: " + "; ".join(set(rag_errors)) + ". A base de conhecimento não está disponível no momento. Responda com base no seu conhecimento geral, informando que os dados do ADB não puderam ser consultados." elif rag_errors: rag_context += "\n\n⚠️ AVISO: Algumas bases não puderam ser consultadas: " + "; ".join(set(rag_errors)) cfg_dict = dict(genai_cfg) with db() as c: sp_row = c.execute("SELECT content FROM system_prompts WHERE agent=? AND is_active=1 LIMIT 1", (agent_type,)).fetchone() global_prompt = sp_row["content"] if sp_row and sp_row["content"] else "" if rag_context: augmented_message = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=msg.message) cfg_dict["system_prompt"] = global_prompt or RAG_DEFAULT_SYSTEM_PROMPT else: augmented_message = msg.message if global_prompt: cfg_dict["system_prompt"] = global_prompt # ── Inject all config context into system prompt so model auto-resolves IDs ── ctx_parts = [] active_oci_id = cfg_dict.get("oci_config_id") or (msg.oci_config_id if msg.oci_config_id else None) with db() as c: oci_cfgs = c.execute("SELECT id,tenancy_name,region,compartment_id FROM oci_configs WHERE user_id=?", (user["id"],)).fetchall() genai_cfgs = c.execute("SELECT id,name,model_id,genai_region,compartment_id,oci_config_id FROM genai_configs WHERE user_id=?", (user["id"],)).fetchall() adb_cfgs = c.execute("SELECT id,config_name,dsn,table_name,is_active,genai_config_id,embedding_model_id FROM adb_vector_configs WHERE user_id=? AND is_active=1", (user["id"],)).fetchall() mcp_srvs = c.execute("SELECT id,name,description,is_active FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))", (user["id"], user["id"])).fetchall() # ── Active OCI config (selected by user in this session) ── if active_oci_id: for oc in oci_cfgs: if oc["id"] == active_oci_id: comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A" ctx_parts.append( f"⚡ CONFIG OCI ATIVA (usar como config_id em TODAS as tools que precisarem): " f"config_id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\"" ) break if oci_cfgs: ctx_parts.append("\nTodas as configurações OCI disponíveis (use 'id' como config_id):") for oc in oci_cfgs: comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A" active_tag = " ← ATIVA" if oc["id"] == active_oci_id else "" ctx_parts.append(f" - id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\"{active_tag}") if genai_cfgs: ctx_parts.append("\nConfigurações GenAI disponíveis (use 'id' como genai_config_id):") for gc in genai_cfgs: comp = _safe_dec(gc["compartment_id"]) if gc["compartment_id"] else "N/A" ctx_parts.append(f" - id=\"{gc['id']}\" name=\"{gc['name']}\" model=\"{gc['model_id']}\" region=\"{gc['genai_region']}\" compartment_id=\"{comp}\"") if adb_cfgs: ctx_parts.append("\nConfigurações ADB Vector Store disponíveis (use 'id' como adb_config_id):") for ac in adb_cfgs: emb = ac["embedding_model_id"] if ac["embedding_model_id"] else "N/A" ctx_parts.append(f" - id=\"{ac['id']}\" name=\"{ac['config_name']}\" table=\"{ac['table_name']}\" embedding_model=\"{emb}\"") if mcp_srvs: ctx_parts.append("\nMCP Servers ativos (tools disponíveis para uso):") for ms in mcp_srvs: desc = ms["description"] or "" ctx_parts.append(f" - id=\"{ms['id']}\" name=\"{ms['name']}\" desc=\"{desc}\"") if ctx_parts: ctx_parts.append("\nIMPORTANTE: Use automaticamente a config OCI ATIVA como config_id em todas as tools. NUNCA peça config_id, tenancy ou IDs ao usuário — já estão definidos acima.") config_hint = "\n".join(ctx_parts) base_prompt = cfg_dict.get("system_prompt", "") cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint # ── Terraform agent: boost max_tokens (capped at 65K to avoid context overflow) + force reasoning_effort=high ── if agent_type == "terraform": tf_model_info = GENAI_MODELS.get(cfg_dict.get("model_id", ""), {}) tf_model_max = tf_model_info.get("max_tokens", 32768) cfg_dict["max_tokens"] = min(tf_model_max, 65000) if tf_model_info.get("reasoning") and not cfg_dict.get("reasoning_effort"): cfg_dict["reasoning_effort"] = "HIGH" # ── Inject existing OCI resources for terraform agent ── if agent_type == "terraform" and active_oci_id: try: # Determine compartment: from msg or from OCI config tf_compartment = getattr(msg, 'compartment_id', None) or None if not tf_compartment: for oc in oci_cfgs: if oc["id"] == active_oci_id: tf_compartment = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else None break if tf_compartment: tf_region = None for oc in oci_cfgs: if oc["id"] == active_oci_id: tf_region = oc["region"] break loop = asyncio.get_event_loop() resources = await loop.run_in_executor( _chat_executor, partial(_fetch_compartment_resources, active_oci_id, tf_compartment, tf_region)) resource_ctx = _build_resource_context(resources) cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + resource_ctx log.info(f"Terraform: injected resource context for compartment {tf_compartment[:20]}...") except Exception as e: log.warning(f"Failed to inject terraform resource context: {e}") # ── Inject OCI Terraform resource reference for correct resource names ── if agent_type == "terraform": tf_ref = _load_tf_resource_reference() if tf_ref: # Extract only the categorized sections (not the full 900+ resource list) # to keep prompt size manageable sections = [] for line in tf_ref.split('\n'): if line.startswith('## All Resource Types'): break sections.append(line) ref_compact = '\n'.join(sections).strip() if ref_compact: cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + \ "\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \ "Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \ ref_compact log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)") # ── Inject official Terraform resource docs (Example Usage + Arguments) ── if agent_type == "terraform": try: # Detect resource types from user message + recent conversation history detect_text = msg.message if hasattr(msg, 'message') else "" if history: # Include last 3 messages for context for h in history[-3:]: detect_text += "\n" + (h.get("content", "") or "") resource_types = _detect_tf_resource_types(detect_text) if resource_types: loop = asyncio.get_event_loop() docs_ctx = await loop.run_in_executor( _chat_executor, partial(_get_tf_docs_for_resources, resource_types)) if docs_ctx: cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + docs_ctx log.info(f"Terraform: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)") except Exception as e: log.warning(f"Failed to inject terraform resource docs: {e}") # Log total prompt sizes for debugging if agent_type == "terraform": sp_len = len(cfg_dict.get("system_prompt", "")) msg_len = len(msg.message) if hasattr(msg, 'message') else 0 log.info(f"Terraform prompt sizes: system_prompt={sp_len} chars (~{sp_len//4} tokens), user_msg={msg_len} chars (~{msg_len//4} tokens), max_tokens={cfg_dict.get('max_tokens')}") mcp_tools = [] tool_defs = None if msg.use_tools: mcp_tools = _get_active_mcp_tools(user["id"]) if mcp_tools: tool_defs = [t["tool"] for t in mcp_tools] log.info(f"Chat with {len(tool_defs)} MCP tools available") if history and _should_compact(history): log.info(f"Compaction triggered: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens") history = _compact_history(sid, user["id"], cfg_dict, history) log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens") hist = history[:-1] if len(history) > 1 else None loop = asyncio.get_event_loop() try: resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for( loop.run_in_executor( _chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, tool_defs, None, None, attachments)), timeout=300) except asyncio.TimeoutError: log.error(f"GenAI call timed out after 300s for session {sid}") raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.") all_tool_results = [] accumulated_msgs = [] iterations = 0 api_format = GENAI_MODELS.get(genai_cfg.get("model_id", ""), {}).get("api_format", "GENERIC") while tool_calls and iterations < 5: iterations += 1 log.info(f"Tool use iteration {iterations}: {len(tool_calls)} tool call(s)") iteration_results = [] for tc in tool_calls: mcp_match = next((m for m in mcp_tools if m["tool"]["name"] == tc["name"]), None) if mcp_match: try: result = await _execute_mcp_tool(mcp_match["server"], tc["name"], tc["arguments"]) log.info(f"Tool {tc['name']} executed successfully ({len(result)} chars)") _chat_log(sid, mid, user["id"], "info", "tool", "tool_success", f"{tc['name']} ({len(result)} chars)") except Exception as te: result = f"Erro ao executar tool {tc['name']}: {str(te)[:300]}" log.warning(f"Tool {tc['name']} failed: {te}") _chat_log(sid, mid, user["id"], "error", "tool", "tool_error", f"{tc['name']}: {te}") else: result = f"Tool {tc['name']} não encontrada nos MCP servers ativos" _chat_log(sid, mid, user["id"], "error", "tool", "tool_not_found", tc["name"]) iteration_results.append({"tool_call_id": tc["id"], "name": tc["name"], "content": result}) all_tool_results.extend(iteration_results) if api_format == "COHERE": import oci cohere_results = [] for tr in iteration_results: tc_obj = oci.generative_ai_inference.models.CohereToolCall() tc_obj.name = tr["name"] tc_obj.parameters = {} tr_obj = oci.generative_ai_inference.models.CohereToolResult() tr_obj.call = tc_obj tr_obj.outputs = [{"result": tr["content"]}] cohere_results.append(tr_obj) try: resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for( loop.run_in_executor( _chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, tool_defs, cohere_results)), timeout=300) except asyncio.TimeoutError: log.error(f"GenAI tool-use call timed out after 300s (Cohere, iteration {iterations})") raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.") else: import oci assistant_msg = oci.generative_ai_inference.models.AssistantMessage() assistant_msg.tool_calls = tool_calls_raw accumulated_msgs.append(assistant_msg) for tr in iteration_results: tool_msg = oci.generative_ai_inference.models.ToolMessage() tool_msg.tool_call_id = tr["tool_call_id"] tool_content = oci.generative_ai_inference.models.TextContent() tool_content.text = tr["content"] tool_msg.content = [tool_content] accumulated_msgs.append(tool_msg) try: resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for( loop.run_in_executor( _chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist, tool_defs, None, accumulated_msgs)), timeout=300) except asyncio.TimeoutError: log.error(f"GenAI tool-use call timed out after 300s (Generic, iteration {iterations})") raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.") resp = resp_text if all_tool_results: tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results) resp += tools_info if not resp or not resp.strip(): model_id = genai_cfg.get("model_id", "unknown") resp = f"⚠️ O modelo **{model_id}** retornou uma resposta vazia. Isso pode ocorrer com alguns modelos. Tente novamente ou selecione outro modelo (ex: Gemini, Llama)." log.warning(f"Chat {mid}: empty response from model {model_id}, returning fallback message") # Validate terraform resource types against DB if agent_type == "terraform" and resp and '```' in resp: try: tf_errors = _validate_tf_resource_types(resp) if tf_errors: warning_lines = ["\n\n⚠️ **Validação automática — Resource types inválidos detectados:**"] for err in tf_errors: line = f"- `{err['type']}` — NÃO EXISTE no provider oracle/oci." if err['suggestions']: line += f" Sugestões: {', '.join('`' + s + '`' for s in err['suggestions'])}" warning_lines.append(line) warning_lines.append("\n**Clique em 'Re-plan' após o modelo corrigir, ou peça correção no chat.**") resp += '\n'.join(warning_lines) log.warning(f"Chat {mid}: {len(tf_errors)} invalid TF resource types detected: {[e['type'] for e in tf_errors]}") except Exception as e: log.warning(f"TF resource type validation failed: {e}") with db() as c: c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid)) log.info(f"Chat {mid} completed successfully") except Exception as e: log.error(f"Chat {mid} failed: {e}") _chat_log(sid, mid, user["id"], "error", "genai", "genai_failed", str(e)) with db() as c: c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?", (f"❌ Erro GenAI: {str(e)[:400]}", mid)) MAX_UPLOAD_FILES = 5 MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"} DOC_MIMES = {"application/pdf"}