362 lines
11 KiB
Python
362 lines
11 KiB
Python
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
|