from typing import Any, Dict, Optional, Tuple import json from sqlalchemy import text from app.config import settings from app.db import engine from app.schemas import ( ActionDecision, ActionResult, AnalyzeRequest, CurrentState, UsageInfo, ) def _json(value) -> str: if hasattr(value, "model_dump"): value = value.model_dump() return json.dumps(value or {}, ensure_ascii=False) def _uuid_or_none(value): value = str(value or "").strip() return value or None def save_action_run( *, request: AnalyzeRequest, action_decision: ActionDecision, action_result: ActionResult, usage: UsageInfo, needs_review: bool, model: str, decision_source: str, raw_body: Optional[str] = None, clean_body: Optional[str] = None, raw_event_id: Optional[str] = None, ) -> Tuple[str, str]: if not settings.clientflow_persist: return "", "" conversation_id = request.conversation_id or "manual" with engine.begin() as conn: message_row = conn.execute(text(""" INSERT INTO messages ( raw_event_id, source_system, source_event_id, conversation_id, contact_id, direction, raw_body, clean_body, previous_context, metadata ) VALUES ( CAST(:raw_event_id AS UUID), :source_system, NULL, :conversation_id, :contact_id, 'inbound', :raw_body, :clean_body, :previous_context, CAST(:metadata AS JSONB) ) RETURNING id::text """), { "raw_event_id": raw_event_id, "source_system": request.source or "manual", "conversation_id": conversation_id, "contact_id": request.contact_id, "raw_body": raw_body or request.last_customer_message, "clean_body": clean_body or request.last_customer_message, "previous_context": request.previous_context, "metadata": _json({ "current_state": request.current_state.model_dump(), }), }).fetchone() message_id = message_row[0] run_row = conn.execute(text(""" INSERT INTO action_runs ( message_id, raw_event_id, conversation_id, contact_id, source_system, model, provider, openrouter_generation_id, decision_source, action_decision, action_result, prompt_tokens, completion_tokens, total_tokens, cost, usage, needs_review ) VALUES ( CAST(:message_id AS UUID), CAST(:raw_event_id AS UUID), :conversation_id, :contact_id, :source_system, :model, :provider, :openrouter_generation_id, :decision_source, CAST(:action_decision AS JSONB), CAST(:action_result AS JSONB), :prompt_tokens, :completion_tokens, :total_tokens, :cost, CAST(:usage AS JSONB), :needs_review ) RETURNING id::text """), { "message_id": message_id, "raw_event_id": raw_event_id, "conversation_id": conversation_id, "contact_id": request.contact_id, "source_system": request.source or "manual", "model": model, "provider": usage.provider, "openrouter_generation_id": usage.id, "decision_source": decision_source, "action_decision": _json(action_decision), "action_result": _json(action_result), "prompt_tokens": usage.prompt_tokens, "completion_tokens": usage.completion_tokens, "total_tokens": usage.total_tokens, "cost": usage.cost, "usage": _json(usage), "needs_review": needs_review, }).fetchone() action_run_id = run_row[0] if raw_event_id: conn.execute(text(""" UPDATE raw_events SET message_id = CAST(:message_id AS UUID), action_run_id = CAST(:action_run_id AS UUID) WHERE id = CAST(:raw_event_id AS UUID) """), { "message_id": message_id, "action_run_id": action_run_id, "raw_event_id": raw_event_id, }) return action_run_id, message_id def save_raw_event( source_system: str, event_type: str | None, source_event_id: str | None, conversation_id: str | None, contact_id: str | None, payload: dict, ) -> Dict[str, Any]: with engine.begin() as conn: row = conn.execute(text(""" INSERT INTO raw_events ( source_system, event_type, source_event_id, conversation_id, contact_id, payload ) VALUES ( :source_system, :event_type, :source_event_id, :conversation_id, :contact_id, CAST(:payload AS JSONB) ) ON CONFLICT (source_system, source_event_id) WHERE source_event_id IS NOT NULL DO UPDATE SET payload = EXCLUDED.payload, event_type = EXCLUDED.event_type, conversation_id = COALESCE(EXCLUDED.conversation_id, raw_events.conversation_id), contact_id = COALESCE(EXCLUDED.contact_id, raw_events.contact_id) RETURNING id::text, processed, ignored, message_id::text, action_run_id::text, (xmax = 0) AS inserted """), { "source_system": source_system, "event_type": event_type, "source_event_id": source_event_id, "conversation_id": conversation_id, "contact_id": contact_id, "payload": _json(payload), }).mappings().first() return dict(row or {}) def mark_raw_event_processed( raw_event_id: str, action_run_id: str | None = None, message_id: str | None = None, ignored: bool = False, error: str | None = None, ) -> None: with engine.begin() as conn: conn.execute(text(""" UPDATE raw_events SET processed = TRUE, ignored = :ignored, processing_error = :error, action_run_id = COALESCE(CAST(:action_run_id AS UUID), action_run_id), message_id = COALESCE(CAST(:message_id AS UUID), message_id), processed_at = now() WHERE id = CAST(:raw_event_id AS UUID) """), { "raw_event_id": raw_event_id, "ignored": ignored, "error": error, "action_run_id": _uuid_or_none(action_run_id), "message_id": _uuid_or_none(message_id), }) def mark_raw_event_error(raw_event_id: str, error: str) -> None: """Regista erro de processamento sem marcar o evento como processado. Isto evita filas silenciosas: o evento deixa de ficar em processed=false/ignored=false/processing_error=null, mas continua elegível para recovery explícito com scripts administrativos. """ with engine.begin() as conn: conn.execute(text(""" UPDATE raw_events SET processed = FALSE, ignored = FALSE, processing_error = :error, processed_at = now() WHERE id = CAST(:raw_event_id AS UUID) """), { "raw_event_id": raw_event_id, "error": str(error or "processing_exception")[:1000], }) def get_state_for_conversation(conversation_id: str | None) -> CurrentState: if not conversation_id: return CurrentState() with engine.begin() as conn: row = conn.execute(text(""" SELECT t.action_code, t.route, t.status, t.action, t.note, t.created_at FROM tasks t WHERE t.conversation_id = :conversation_id ORDER BY t.created_at DESC LIMIT 1 """), { "conversation_id": conversation_id, }).mappings().first() if not row: return CurrentState() return CurrentState( last_action_code=row.get("action_code") or "desconhecido", last_route=row.get("route") or "desconhecido", last_task_status=row.get("status") or "desconhecido", metadata={ "last_action": row.get("action"), "last_note": row.get("note"), "last_created_at": str(row.get("created_at")), }, ) def get_recent_chatwoot_public_context( conversation_id: str | None, *, current_source_event_id: str | None = None, max_messages: int = 2, ) -> list[str]: """Devolve as últimas mensagens públicas Chatwoot para contexto LLM. Usado quando o webhook não traz `conversation.messages`. Lê raw_events já guardados e exclui a mensagem atual para evitar que o LLM confunda histórico com o email recebido agora. """ if not conversation_id: return [] with engine.begin() as conn: rows = conn.execute(text(""" SELECT source_event_id, payload #>> '{message_type}' AS message_type, payload #>> '{content}' AS content, created_at FROM raw_events WHERE source_system = 'chatwoot' AND event_type = 'message_created' AND conversation_id = :conversation_id AND COALESCE(payload #>> '{content}', '') <> '' AND (CAST(:current_source_event_id AS TEXT) IS NULL OR source_event_id <> CAST(:current_source_event_id AS TEXT)) AND COALESCE(payload #>> '{private}', 'false') <> 'true' ORDER BY created_at DESC LIMIT :limit """), { "conversation_id": str(conversation_id), "current_source_event_id": str(current_source_event_id or "") or None, "limit": max(1, int(max_messages or 2)), }).mappings().all() lines: list[str] = [] for row in reversed(rows): role = "BLIF" if str(row.get("message_type") or "").lower() == "outgoing" else "Cliente" content = str(row.get("content") or "") content = " ".join(content.split()) if len(content) > 360: content = content[:359].rstrip() + "…" if content: lines.append(f"- {role}: {content}") return lines