1575 lines
69 KiB
Python
1575 lines
69 KiB
Python
"""Reply assistant service for task/opportunity communication.
|
|
|
|
v4928.1.4.9 keeps the source of truth in the opportunity and commercial
|
|
documents. Templates generate an editable draft; sending performs server-side
|
|
validation before posting a public Chatwoot message and recording the result in
|
|
ClientFlow.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import asdict
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.commercial_service import get_commercial_document, list_commercial_documents
|
|
from app.communication_service import create_timeline_event, record_outbound_communication
|
|
from app.config import settings
|
|
from app.db import engine
|
|
from app.message_templates import MessageTemplate, default_template_for_action, get_template, list_templates_for_action
|
|
from app.business_knowledge_service import KnowledgeMatch, retrieve_business_knowledge
|
|
from app.reply_intent_gate import ReplyIntent, classify_reply_guardrail, classify_reply_intent
|
|
from app.message_cleaner import extract_customer_reply_text
|
|
from app.reply_safety_validator import validate_generated_reply
|
|
from app.task_service import complete_task_with_note, get_task_detail
|
|
from app.reply_recipient_utils import apply_preferred_greeting, resolve_reply_recipient
|
|
|
|
|
|
class ReplyAssistantError(Exception):
|
|
"""Raised when a reply action cannot be completed safely."""
|
|
|
|
|
|
DOCUMENT_KIND_LABELS = {
|
|
"quotation": "Orçamento",
|
|
"proforma": "Pró-forma",
|
|
"invoice": "Fatura",
|
|
}
|
|
|
|
|
|
def _is_jasmin_orc_document(doc: Dict[str, Any]) -> bool:
|
|
"""True when a quotation document is the ClientFlow operational proforma.
|
|
|
|
Business rule: in BLIF/ClientFlow a proforma is represented in Jasmin as
|
|
an ORC.* quotation document.
|
|
"""
|
|
kind = str(doc.get("document_kind") or "").lower()
|
|
number = str(doc.get("document_number") or doc.get("external_id") or "").upper()
|
|
system = str(doc.get("system") or "jasmin").lower()
|
|
return kind == "quotation" and system == "jasmin" and (number.startswith("ORC.") or number.startswith("ORC"))
|
|
|
|
|
|
|
|
INTERNAL_REPLY_TYPES = {"no_customer_reply", "manual_internal"}
|
|
MANUAL_CUSTOMER_SEND_ACTIONS = {
|
|
"SEND_INFO",
|
|
"SEND_QUOTE",
|
|
"FOLLOW_UP_QUOTE",
|
|
"FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_PAYMENT",
|
|
"FOLLOW_UP_CUSTOMER_REVIEW",
|
|
"FOLLOW_UP_GENERIC",
|
|
"SUPPORT",
|
|
}
|
|
|
|
|
|
def _template_is_customer_sendable(template: MessageTemplate) -> bool:
|
|
return str(template.reply_type or "").strip().lower() not in INTERNAL_REPLY_TYPES and str(template.code or "").upper() not in {
|
|
"MANUAL_REVIEW_REQUIRED",
|
|
"INTERNAL_BOUNCE_EMAIL",
|
|
"INTERNAL_AUTO_REPLY",
|
|
}
|
|
|
|
|
|
def _looks_like_operator_customer_body(message_body: str) -> bool:
|
|
body = str(message_body or "").strip()
|
|
if not body:
|
|
return False
|
|
low = body.lower()
|
|
internal_markers = (
|
|
"triagem interna",
|
|
"não deve ser enviado ao cliente",
|
|
"nao deve ser enviado ao cliente",
|
|
"rever manualmente antes de responder",
|
|
"sem sugestão automática",
|
|
"sem sugestao automatica",
|
|
)
|
|
return not any(marker in low for marker in internal_markers)
|
|
|
|
|
|
def _safe_customer_template_for_manual_send(task: Dict[str, Any], selected_template: MessageTemplate, message_body: str) -> MessageTemplate:
|
|
"""Allow an operator-edited SEND_INFO/SEND_QUOTE reply to be sent even if
|
|
the persisted draft/template was the internal manual-review fallback.
|
|
|
|
The manual-review template is useful while the AI is unsure, but once the
|
|
operator has written a customer-facing message for a sendable action, the
|
|
Chatwoot send flow must validate it against a customer-facing template,
|
|
not against the internal blocker.
|
|
"""
|
|
if _template_is_customer_sendable(selected_template):
|
|
return selected_template
|
|
if not _looks_like_operator_customer_body(message_body):
|
|
return selected_template
|
|
action = str(task.get("action_code") or "").strip().upper()
|
|
if action not in MANUAL_CUSTOMER_SEND_ACTIONS:
|
|
return selected_template
|
|
|
|
customer_message = _task_customer_message(task)
|
|
guardrail = classify_reply_guardrail(task, customer_message)
|
|
if guardrail:
|
|
guarded_template = get_template(guardrail.template_code)
|
|
if guarded_template and not _template_is_customer_sendable(guarded_template):
|
|
return selected_template
|
|
|
|
intent = classify_reply_intent(task, customer_message or message_body)
|
|
intent_template = get_template(intent.template_code)
|
|
if intent_template and _template_is_customer_sendable(intent_template):
|
|
return intent_template
|
|
|
|
fallback_code = {
|
|
"SEND_INFO": "SEND_INFO_EQUIPMENT_LIST",
|
|
"SEND_QUOTE": "SEND_QUOTE",
|
|
"SUPPORT": "ACK_SUPPORT_RECEIVED",
|
|
"FOLLOW_UP_QUOTE": "FOLLOW_UP_QUOTE",
|
|
"FOLLOW_UP_PROFORMA": "FOLLOW_UP_PROFORMA",
|
|
"FOLLOW_UP_PAYMENT": "FOLLOW_UP_PAYMENT",
|
|
"FOLLOW_UP_CUSTOMER_REVIEW": "FOLLOW_UP_CUSTOMER_REVIEW",
|
|
"FOLLOW_UP_GENERIC": "FOLLOW_UP_GENERIC",
|
|
}.get(action, "BUSINESS_KNOWLEDGE_REPLY")
|
|
fallback = get_template(fallback_code) or get_template("BUSINESS_KNOWLEDGE_REPLY") or selected_template
|
|
return fallback if _template_is_customer_sendable(fallback) else selected_template
|
|
|
|
|
|
def _payload_lookup(payload: Any, *path: str) -> str:
|
|
cur = payload
|
|
for key in path:
|
|
if isinstance(cur, str):
|
|
try:
|
|
cur = json.loads(cur)
|
|
except Exception:
|
|
return ""
|
|
if not isinstance(cur, dict):
|
|
return ""
|
|
cur = cur.get(key)
|
|
return str(cur or "").strip()
|
|
|
|
|
|
def _effective_conversation_id(task: Dict[str, Any]) -> str:
|
|
direct = str(task.get("conversation_id") or task.get("opportunity_conversation_id") or "").strip()
|
|
if direct:
|
|
return direct
|
|
payload = task.get("raw_payload") or task.get("metadata") or {}
|
|
for path in (
|
|
("conversation_id",),
|
|
("conversation", "id"),
|
|
("conversation", "display_id"),
|
|
("conversation", "identifier"),
|
|
):
|
|
value = _payload_lookup(payload, *path)
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
STAGE_ON_TEMPLATE_SENT = {
|
|
"SEND_INFO_EQUIPMENT_LIST": "INFO_SENT",
|
|
"SEND_PRICE_LIST": "INFO_SENT",
|
|
"SEND_QUOTE": "QUOTE_SENT",
|
|
"SEND_PROFORMA": "PROFORMA_SENT",
|
|
"SEND_INVOICE": "INVOICE_SENT",
|
|
"REQUEST_FISCAL_DATA": "INFO_REQUESTED",
|
|
"REQUEST_PAYMENT_PROOF": "WAITING_PAYMENT",
|
|
"CONFIRM_PAYMENT_RECEIVED": "PAYMENT_CONFIRMED",
|
|
"FOLLOW_UP_QUOTE": "QUOTE_SENT",
|
|
}
|
|
|
|
PDF_SUPPORTED_KINDS = {"quotation", "invoice"}
|
|
|
|
|
|
def _is_send_quote_text_allowed(template: MessageTemplate, action_code: str = "") -> bool:
|
|
"""SEND_QUOTE may be a textual commercial proposal without an attachment.
|
|
|
|
Formal proforma/invoice flows still require selected documents. For a fresh
|
|
customer quote request, however, BLIF often replies with an indicative price
|
|
list/proposal text before a Jasmin ORC.* exists.
|
|
"""
|
|
return str(template.code or "").upper() == "SEND_QUOTE" or str(action_code or "").upper() == "SEND_QUOTE"
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
|
|
|
|
|
def _as_list(value: Any) -> List[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [str(item).strip() for item in value if str(item or "").strip()]
|
|
value = str(value or "").strip()
|
|
if not value:
|
|
return []
|
|
return [part.strip() for part in re.split(r"[,\s]+", value) if part.strip()]
|
|
|
|
|
|
|
|
def _is_uuid_text(value: str) -> bool:
|
|
return bool(re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", str(value or "").strip()))
|
|
|
|
|
|
def _compact_context_text(value: Any, max_chars: int = 900) -> str:
|
|
text_value = " ".join(str(value or "").split()).strip()
|
|
if len(text_value) <= max_chars:
|
|
return text_value
|
|
return text_value[: max_chars - 1] + "…"
|
|
|
|
|
|
def _uuid_case(value: str) -> str:
|
|
return f"CASE WHEN :{value} = '' THEN NULL ELSE CAST(:{value} AS UUID) END"
|
|
|
|
|
|
def _money(value: Any, currency: str = "EUR") -> str:
|
|
if value is None or value == "":
|
|
return ""
|
|
try:
|
|
amount = Decimal(str(value)).quantize(Decimal("0.01"))
|
|
return f"{amount:,.2f} {currency or 'EUR'}".replace(",", "X").replace(".", ",").replace("X", ".")
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def _first_name(name: str) -> str:
|
|
name = str(name or "").strip()
|
|
if not name:
|
|
return ""
|
|
if "," in name:
|
|
name = name.split(",", 1)[0]
|
|
parts = [part for part in re.split(r"\s+", name) if part]
|
|
if not parts:
|
|
return ""
|
|
first = parts[0]
|
|
# Most company names should receive a neutral greeting.
|
|
if first.upper() in {"ACZCO", "LDA", "SA", "UNIPESSOAL"} or any(token in name.upper() for token in [" LDA", " S.A", " SA", " UNIPESSOAL", "LIMITADA"]):
|
|
return ""
|
|
return first
|
|
|
|
|
|
def _customer_display(task: Dict[str, Any]) -> str:
|
|
return str(
|
|
task.get("linked_customer_name")
|
|
or task.get("customer_name")
|
|
or task.get("customer_email")
|
|
or "cliente"
|
|
).strip()
|
|
|
|
|
|
def _task_customer_message(task: Dict[str, Any]) -> str:
|
|
return extract_customer_reply_text(task)
|
|
|
|
|
|
def _knowledge_for_task(task: Dict[str, Any]) -> KnowledgeMatch:
|
|
return retrieve_business_knowledge(_task_customer_message(task))
|
|
|
|
|
|
def recent_conversation_context_for_task(task: Dict[str, Any], *, limit: int = 3) -> List[Dict[str, str]]:
|
|
"""Return a compact recent conversation context for the reply LLM.
|
|
|
|
Uses only the last few persisted messages from the same Chatwoot conversation
|
|
or contact. This avoids sending the full thread while giving the LLM enough
|
|
context to understand follow-ups and avoid repeating previous answers.
|
|
"""
|
|
conversation_id = str(task.get("conversation_id") or "").strip()
|
|
contact_id = str(task.get("contact_id") or "").strip()
|
|
rows: List[Dict[str, Any]] = []
|
|
try:
|
|
where: List[str] = []
|
|
params: Dict[str, Any] = {"limit": max(1, int(limit or 3))}
|
|
if conversation_id:
|
|
where.append("conversation_id = :conversation_id")
|
|
params["conversation_id"] = conversation_id
|
|
if contact_id:
|
|
where.append("contact_id = :contact_id")
|
|
params["contact_id"] = contact_id
|
|
if where:
|
|
sql = text(f"""
|
|
SELECT created_at, direction, clean_body, raw_body, previous_context
|
|
FROM messages
|
|
WHERE ({' OR '.join(where)})
|
|
ORDER BY created_at DESC
|
|
LIMIT :limit
|
|
""")
|
|
with engine.begin() as conn:
|
|
rows = [dict(row) for row in conn.execute(sql, params).mappings().all()]
|
|
except Exception:
|
|
rows = []
|
|
|
|
items: List[Dict[str, str]] = []
|
|
for row in reversed(rows):
|
|
direction = str(row.get("direction") or "inbound").lower()
|
|
role = "BLIF" if direction in {"outbound", "agent", "operator"} else "cliente"
|
|
body = _compact_context_text(row.get("clean_body") or row.get("raw_body"), 1000)
|
|
if body:
|
|
items.append({
|
|
"role": role,
|
|
"created_at": str(row.get("created_at") or ""),
|
|
"body": body,
|
|
})
|
|
if not items:
|
|
previous = _compact_context_text(task.get("previous_context"), 1600)
|
|
if previous:
|
|
items.append({"role": "contexto", "created_at": "", "body": previous})
|
|
return items[-max(1, int(limit or 3)):]
|
|
|
|
|
|
def _task_with_recent_context(task: Dict[str, Any], *, limit: int = 3) -> Dict[str, Any]:
|
|
enriched = dict(task or {})
|
|
enriched["recent_conversation_context"] = recent_conversation_context_for_task(enriched, limit=limit)
|
|
return enriched
|
|
|
|
|
|
def _llm_first_enabled() -> bool:
|
|
return bool(getattr(settings, "clientflow_reply_llm_enabled", False)) and bool(getattr(settings, "clientflow_reply_llm_first_enabled", True))
|
|
|
|
|
|
def _llm_pending_intent() -> ReplyIntent:
|
|
return ReplyIntent(
|
|
"LLM_FIRST",
|
|
"Interpretação por LLM",
|
|
"LLM_BUSINESS_REPLY",
|
|
"llm_assisted",
|
|
0.0,
|
|
True,
|
|
True,
|
|
False,
|
|
("LLM-first ativo: regras determinísticas usadas apenas como guardrails; intenção e resposta serão inferidas pelo LLM.",),
|
|
(),
|
|
)
|
|
|
|
|
|
def _choose_template_for_task(task: Dict[str, Any], template_code: Optional[str]) -> tuple[MessageTemplate, KnowledgeMatch, ReplyIntent]:
|
|
knowledge = _knowledge_for_task(task)
|
|
message = _task_customer_message(task)
|
|
explicit_code = str(template_code or "").strip().upper()
|
|
|
|
# Explicit operator choice always wins, but still carries guardrail/intent metadata.
|
|
if explicit_code:
|
|
intent = classify_reply_guardrail(task, message) or classify_reply_intent(task, message)
|
|
return (get_template(explicit_code) or default_template_for_action(task.get("action_code")), knowledge, intent)
|
|
|
|
# LLM-first mode: only objective guardrails preempt the LLM. Nuanced
|
|
# commercial/support questions are left to the LLM with structured context.
|
|
if _llm_first_enabled():
|
|
guardrail = classify_reply_guardrail(task, message)
|
|
if guardrail and guardrail.template_code:
|
|
tpl = get_template(guardrail.template_code)
|
|
if tpl:
|
|
return tpl, knowledge, guardrail
|
|
return get_template("LLM_BUSINESS_REPLY") or get_template("BUSINESS_KNOWLEDGE_REPLY") or default_template_for_action(task.get("action_code")), knowledge, _llm_pending_intent()
|
|
|
|
# Deterministic fallback mode for installations without LLM/OpenRouter.
|
|
intent = classify_reply_intent(task, message)
|
|
if intent.template_code:
|
|
tpl = get_template(intent.template_code)
|
|
if tpl:
|
|
return tpl, knowledge, intent
|
|
if knowledge.has_topics and intent.commercial_reply_allowed:
|
|
tpl = get_template(knowledge.default_template_code)
|
|
if tpl:
|
|
return tpl, knowledge, intent
|
|
# Safe default: avoid aggressive sales fallback when uncertain.
|
|
if intent.requires_manual_review:
|
|
tpl = get_template("MANUAL_REVIEW_REQUIRED")
|
|
if tpl:
|
|
return tpl, knowledge, intent
|
|
return default_template_for_action(task.get("action_code")), knowledge, intent
|
|
|
|
|
|
def _knowledge_state(knowledge: KnowledgeMatch) -> Dict[str, Any]:
|
|
return {
|
|
"version": knowledge.version,
|
|
"source": knowledge.source,
|
|
"reply_type": knowledge.reply_type,
|
|
"default_template_code": knowledge.default_template_code,
|
|
"topics": [
|
|
{
|
|
"id": topic.id,
|
|
"title": topic.title,
|
|
"summary": topic.summary,
|
|
"score": topic.score,
|
|
"facts": list(topic.facts[:4]),
|
|
"forbidden": list(topic.forbidden[:3]),
|
|
}
|
|
for topic in knowledge.topics
|
|
],
|
|
}
|
|
|
|
|
|
def _intent_gate_state(intent: ReplyIntent) -> Dict[str, Any]:
|
|
return intent.to_dict()
|
|
|
|
|
|
def _llm_status(enabled: bool, used: bool, error: str = "") -> Dict[str, Any]:
|
|
if not enabled:
|
|
return {"enabled": False, "used": False, "status": "disabled", "error": ""}
|
|
return {"enabled": True, "used": used, "status": "used" if used else "fallback", "error": error}
|
|
|
|
|
|
def _email_agent_default_state(error: str = "") -> Dict[str, Any]:
|
|
try:
|
|
from app.email_reply_agent_service import disabled_agent_state
|
|
|
|
return disabled_agent_state(error=error)
|
|
except Exception:
|
|
return {"enabled": False, "used": False, "status": "disabled", "provider": "openai_responses_file_search", "error": error}
|
|
|
|
|
|
def _template_allows_email_agent(template: MessageTemplate) -> bool:
|
|
# The OpenAI email agent is a draft writer. It must never override hard
|
|
# no-reply/internal outcomes such as bounces, auto-replies or manual-only triage.
|
|
return template.reply_type not in {"no_customer_reply", "manual_internal"} and template.code not in {
|
|
"INTERNAL_BOUNCE_EMAIL",
|
|
"INTERNAL_AUTO_REPLY",
|
|
"MANUAL_REVIEW_REQUIRED",
|
|
}
|
|
|
|
|
|
def _agent_status_from_result(agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
meta = dict(agent_result.get("metadata") or {})
|
|
if not meta:
|
|
meta = {"enabled": True, "used": True, "status": "used", "provider": "openai_responses_file_search"}
|
|
return meta
|
|
|
|
|
|
def _generate_with_email_agent(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
selected_docs: Sequence[Dict[str, Any]],
|
|
knowledge: KnowledgeMatch,
|
|
fallback_body: str,
|
|
communication_objective: Optional[Dict[str, Any]] = None,
|
|
operator_instruction: str = "",
|
|
) -> tuple[str, Dict[str, Any], List[str], List[str], bool]:
|
|
"""Generate a draft using the Phase 1 OpenAI/file_search email agent.
|
|
|
|
Returns (body, agent_meta, blockers, warnings, used). A failure intentionally
|
|
falls back to the existing reply assistant flow; this keeps production safe.
|
|
"""
|
|
try:
|
|
from app.email_reply_agent_service import email_reply_agent_enabled, generate_email_reply_agent
|
|
|
|
if not email_reply_agent_enabled() or not _template_allows_email_agent(template):
|
|
return fallback_body, _email_agent_default_state(), [], [], False
|
|
|
|
augmented_task = dict(task)
|
|
augmented_task["communication_objective"] = communication_objective or {}
|
|
augmented_task["operator_instruction"] = str(operator_instruction or "").strip()
|
|
agent_result = generate_email_reply_agent(task=augmented_task, knowledge=knowledge, selected_documents=selected_docs)
|
|
body = str(agent_result.get("resposta_sugerida") or "").strip() or fallback_body
|
|
agent_meta = _agent_status_from_result(agent_result)
|
|
|
|
warnings: List[str] = []
|
|
blockers: List[str] = []
|
|
if agent_result.get("precisa_revisao_humana"):
|
|
reason = str(agent_result.get("motivo_revisao") or "").strip()
|
|
warnings.append(f"Agente marcou revisão humana{(': ' + reason) if reason else '.'}")
|
|
if str(agent_result.get("nivel_confianca") or "").lower() == "baixo":
|
|
blockers.append("Confiança baixa do agente; rever manualmente antes de responder.")
|
|
|
|
safety = validate_generated_reply(body, knowledge=knowledge, task=task, selected_documents=list(selected_docs))
|
|
blockers.extend(safety.get("blockers") or [])
|
|
warnings.extend(safety.get("warnings") or [])
|
|
return body, agent_meta, list(dict.fromkeys(blockers)), list(dict.fromkeys(warnings)), True
|
|
except Exception as exc:
|
|
return fallback_body, _email_agent_default_state(error=str(exc)), [], [f"Agente OpenAI indisponível; usado fallback ClientFlow. Detalhe: {exc}"], False
|
|
|
|
|
|
|
|
def _generate_follow_up_with_email_agent(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
selected_docs: Sequence[Dict[str, Any]],
|
|
knowledge: KnowledgeMatch,
|
|
fallback_body: str,
|
|
) -> tuple[str, Dict[str, Any], List[str], List[str], bool]:
|
|
"""Generate follow-up drafts with a dedicated OpenAI prompt.
|
|
|
|
Follow-ups are outbound commercial nudges, not replies to a fresh customer
|
|
request. This path keeps the safe deterministic template as baseline and
|
|
lets OpenAI personalize only within strict follow-up guardrails.
|
|
"""
|
|
if template.reply_type != "follow_up":
|
|
return fallback_body, _email_agent_default_state(), [], [], False
|
|
recipient = resolve_reply_recipient(task, cleaned_customer_message=extract_customer_reply_text(task))
|
|
safe_fallback_body = apply_preferred_greeting(fallback_body, recipient.get("preferred_greeting") or "")
|
|
try:
|
|
from app.email_reply_agent_service import email_reply_agent_enabled, generate_follow_up_draft_agent
|
|
|
|
if not email_reply_agent_enabled():
|
|
return safe_fallback_body, _email_agent_default_state(), [], ["Agente OpenAI de follow-up desativado; usado modelo base."], False
|
|
|
|
agent_result = generate_follow_up_draft_agent(
|
|
task=task,
|
|
knowledge=knowledge,
|
|
selected_documents=selected_docs,
|
|
baseline_message=safe_fallback_body,
|
|
)
|
|
body = str(agent_result.get("resposta_sugerida") or "").strip() or safe_fallback_body
|
|
agent_meta = _agent_status_from_result(agent_result)
|
|
|
|
warnings: List[str] = []
|
|
blockers: List[str] = []
|
|
if agent_result.get("precisa_revisao_humana"):
|
|
reason = str(agent_result.get("motivo_revisao") or "").strip()
|
|
warnings.append(f"Agente marcou revisão humana{(': ' + reason) if reason else '.'}")
|
|
if str(agent_result.get("nivel_confianca") or "").lower() == "baixo":
|
|
warnings.append("Confiança baixa do agente de follow-up; rever cuidadosamente antes de enviar.")
|
|
|
|
lower_body = body.lower()
|
|
action_code = str(task.get("action_code") or "").upper()
|
|
if action_code == "FOLLOW_UP_PAYMENT":
|
|
forbidden_fragments = [
|
|
"confirmar internamente se o pagamento",
|
|
"confirmar internamente o pagamento",
|
|
"necessitamos confirmar internamente",
|
|
"assim que tivermos essa informação",
|
|
"pagamento foi recebido",
|
|
]
|
|
if any(fragment in lower_body for fragment in forbidden_fragments):
|
|
blockers.append("Rascunho de follow-up de pagamento alterou o objetivo para confirmação interna de pagamento.")
|
|
|
|
safety = validate_generated_reply(body, knowledge=knowledge, task=task, selected_documents=list(selected_docs))
|
|
blockers.extend(safety.get("blockers") or [])
|
|
warnings.extend(safety.get("warnings") or [])
|
|
return body, agent_meta, list(dict.fromkeys(blockers)), list(dict.fromkeys(warnings)), True
|
|
except Exception as exc:
|
|
return safe_fallback_body, _email_agent_default_state(error=str(exc)), [], [f"Agente OpenAI de follow-up indisponível; usado modelo base. Detalhe: {exc}"], False
|
|
|
|
|
|
def _generate_business_aware_body(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
selected_docs: Sequence[Dict[str, Any]],
|
|
knowledge: KnowledgeMatch,
|
|
baseline_body: str,
|
|
) -> tuple[str, Dict[str, Any], List[str], List[str]]:
|
|
"""Optionally ask OpenRouter to adapt wording with BLIF knowledge.
|
|
|
|
The deterministic template remains the safe fallback. The LLM never chooses
|
|
documents; it only rewrites the editable message body.
|
|
"""
|
|
llm_enabled = bool(getattr(settings, "clientflow_reply_llm_enabled", False))
|
|
warnings: List[str] = []
|
|
blockers: List[str] = []
|
|
llm_meta = _llm_status(llm_enabled, False)
|
|
body = baseline_body
|
|
|
|
should_call_llm = llm_enabled and template.code not in {"INTERNAL_BOUNCE_EMAIL", "INTERNAL_AUTO_REPLY", "MANUAL_REVIEW_REQUIRED"}
|
|
if should_call_llm:
|
|
try:
|
|
from app.llm_reply_generator import build_llm_reply_context, generate_reply_with_openrouter, normalize_llm_reply
|
|
|
|
context = build_llm_reply_context(
|
|
task=task,
|
|
template=asdict(template),
|
|
baseline_message=baseline_body,
|
|
knowledge=knowledge,
|
|
selected_documents=list(selected_docs),
|
|
)
|
|
raw = generate_reply_with_openrouter(context)
|
|
normalized = normalize_llm_reply(
|
|
raw,
|
|
fallback_message=baseline_body,
|
|
knowledge=knowledge,
|
|
preferred_greeting=str(context.get("preferred_greeting") or ""),
|
|
)
|
|
body = str(normalized.get("message_body") or baseline_body).strip() or baseline_body
|
|
warnings.extend([str(item) for item in normalized.get("warnings") or []])
|
|
llm_meta = {
|
|
**_llm_status(True, True),
|
|
"intent": normalized.get("intent"),
|
|
"reply_type": normalized.get("reply_type"),
|
|
"requires_attachment": normalized.get("requires_attachment"),
|
|
"confidence": normalized.get("confidence"),
|
|
"knowledge_used": normalized.get("knowledge_used"),
|
|
"customer_need": normalized.get("customer_need"),
|
|
"recommended_next_action": normalized.get("recommended_next_action"),
|
|
}
|
|
if normalized.get("requires_attachment") and not selected_docs:
|
|
blockers.append("O LLM indicou que a resposta precisa de anexo/documento, mas não há documento selecionado da oportunidade.")
|
|
if float(normalized.get("confidence") or 0) < 0.62:
|
|
blockers.append("Confiança baixa do LLM; rever manualmente antes de responder.")
|
|
except Exception as exc:
|
|
llm_meta = _llm_status(True, False, str(exc))
|
|
if template.code == "LLM_BUSINESS_REPLY":
|
|
blockers.append("LLM indisponível; esta tarefa deve ser revista manualmente antes de responder.")
|
|
body = "Rever manualmente antes de responder. O LLM não conseguiu interpretar a mensagem com segurança."
|
|
else:
|
|
warnings.append(f"LLM indisponível; foi usado o modelo determinístico. Detalhe: {exc}")
|
|
|
|
safety = validate_generated_reply(body, knowledge=knowledge, task=task, selected_documents=list(selected_docs))
|
|
blockers.extend(safety.get("blockers") or [])
|
|
warnings.extend(safety.get("warnings") or [])
|
|
return body, llm_meta, list(dict.fromkeys(blockers)), list(dict.fromkeys(warnings))
|
|
|
|
|
|
def _document_number(doc: Dict[str, Any]) -> str:
|
|
return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento").strip()
|
|
|
|
|
|
def _document_label(doc: Dict[str, Any]) -> str:
|
|
kind = str(doc.get("document_kind") or "").strip().lower()
|
|
label = DOCUMENT_KIND_LABELS.get(kind, kind or "Documento")
|
|
number = _document_number(doc)
|
|
value = _money(doc.get("total_amount") or doc.get("amount"), str(doc.get("currency") or "EUR"))
|
|
suffix = f" · {value}" if value else ""
|
|
return f"{label} {number}{suffix}"
|
|
|
|
|
|
def _is_ready_fiscal_customer(task: Dict[str, Any]) -> bool:
|
|
return bool(
|
|
str(task.get("linked_customer_name") or "").strip()
|
|
and str(task.get("linked_customer_tax_id") or "").strip()
|
|
and str(task.get("linked_customer_city_name") or task.get("linked_customer_street_name") or "").strip()
|
|
)
|
|
|
|
|
|
def ensure_reply_assistant_schema() -> None:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
|
|
conn.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS message_drafts (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
task_id UUID,
|
|
opportunity_id UUID,
|
|
conversation_id TEXT,
|
|
template_code TEXT NOT NULL,
|
|
message_body TEXT NOT NULL,
|
|
selected_document_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
operator_instruction TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL DEFAULT 'draft',
|
|
generated_by TEXT NOT NULL DEFAULT 'template',
|
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
sent_at TIMESTAMPTZ
|
|
)
|
|
"""))
|
|
for stmt in [
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS task_id UUID",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS opportunity_id UUID",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS conversation_id TEXT",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS template_code TEXT NOT NULL DEFAULT 'SEND_INFO_EQUIPMENT_LIST'",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS message_body TEXT NOT NULL DEFAULT ''",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS selected_document_ids JSONB NOT NULL DEFAULT '[]'::jsonb",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS operator_instruction TEXT NOT NULL DEFAULT ''",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'draft'",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS generated_by TEXT NOT NULL DEFAULT 'template'",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
|
"ALTER TABLE message_drafts ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ",
|
|
]:
|
|
conn.execute(text(stmt))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_message_drafts_task ON message_drafts(task_id, created_at DESC)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_message_drafts_opportunity ON message_drafts(opportunity_id, created_at DESC)"))
|
|
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_message_drafts_status ON message_drafts(status)"))
|
|
|
|
|
|
def _doc_payload(doc: Dict[str, Any]) -> Dict[str, Any]:
|
|
payload = doc.get("payload") or {}
|
|
if isinstance(payload, dict):
|
|
return payload
|
|
try:
|
|
return json.loads(str(payload or "{}"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _document_pdf_format_blocker(doc: Dict[str, Any]) -> str:
|
|
payload = _doc_payload(doc)
|
|
warning = str(payload.get("clientflow_pdf_format_warning") or "").strip()
|
|
blocked = bool(payload.get("clientflow_pdf_send_blocked"))
|
|
if not blocked and warning not in {"not_pdf_header", "empty_response", "non_a4_or_unexpected_mediabox"}:
|
|
return ""
|
|
number = _document_number(doc)
|
|
first_box = str(payload.get("clientflow_pdf_first_box") or "").strip()
|
|
if warning == "non_a4_or_unexpected_mediabox":
|
|
return f"PDF de fatura bloqueado: formato Jasmin não-A4 ({first_box or 'MediaBox inesperado'}). Corrigir layout/template no Jasmin antes de enviar."
|
|
if warning == "not_pdf_header":
|
|
return "PDF de fatura bloqueado: o Jasmin não devolveu um PDF válido."
|
|
if warning == "empty_response":
|
|
return "PDF de fatura bloqueado: resposta vazia do Jasmin."
|
|
return f"PDF de fatura bloqueado para envio automático: {warning or number}."
|
|
|
|
|
|
def _document_pdf_supported(doc: Dict[str, Any]) -> bool:
|
|
"""Return whether ClientFlow can fetch/send a PDF for this document.
|
|
|
|
Jasmin invoices with a known non-A4 print layout are deliberately marked as
|
|
unavailable for automatic customer sending. The operator can still inspect
|
|
the document manually and fix the Jasmin print layout/template.
|
|
"""
|
|
kind = str(doc.get("document_kind") or "").lower()
|
|
system = str(doc.get("system") or "jasmin").lower()
|
|
if kind == "invoice" and _document_pdf_format_blocker(doc):
|
|
return False
|
|
return kind in PDF_SUPPORTED_KINDS and system == "jasmin" and bool(str(doc.get("external_id") or "").strip())
|
|
|
|
|
|
def _document_pdf_status(doc: Dict[str, Any]) -> str:
|
|
format_blocker = _document_pdf_format_blocker(doc)
|
|
if format_blocker:
|
|
return format_blocker
|
|
if _document_pdf_supported(doc):
|
|
return "PDF/anexo disponível para envio automático"
|
|
kind = str(doc.get("document_kind") or "").lower()
|
|
if kind in PDF_SUPPORTED_KINDS:
|
|
return "PDF/anexo não disponível; anexar manualmente ou sincronizar documento"
|
|
return "Documento sem PDF automático"
|
|
|
|
|
|
def available_documents_for_task(task: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
opportunity_id = str(task.get("opportunity_id") or "").strip()
|
|
if not opportunity_id:
|
|
return []
|
|
docs = list_commercial_documents(opportunity_id=opportunity_id, limit=80)
|
|
result = []
|
|
for doc in docs:
|
|
doc = dict(doc)
|
|
doc["label"] = _document_label(doc)
|
|
doc["pdf_supported"] = _document_pdf_supported(doc)
|
|
doc["pdf_available"] = bool(doc["pdf_supported"])
|
|
doc["attachment_status"] = _document_pdf_status(doc)
|
|
doc["attachment_required"] = str(doc.get("document_kind") or "").lower() in PDF_SUPPORTED_KINDS
|
|
result.append(doc)
|
|
return result
|
|
|
|
|
|
def _select_default_documents(template: MessageTemplate, docs: Sequence[Dict[str, Any]]) -> List[str]:
|
|
if not template.expected_document_kinds:
|
|
return []
|
|
expected = {kind.lower() for kind in template.expected_document_kinds}
|
|
matching = [doc for doc in docs if str(doc.get("document_kind") or "").lower() in expected]
|
|
current = [doc for doc in matching if str(doc.get("role") or "current") in {"current", "accepted"} and bool(doc.get("is_primary", True))]
|
|
chosen = current or matching
|
|
if not chosen:
|
|
return []
|
|
# Use the most relevant single document by default. Operator can select more.
|
|
return [str(chosen[0].get("id"))]
|
|
|
|
|
|
def _selected_documents(document_ids: Sequence[str], docs: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
by_id = {str(doc.get("id")): doc for doc in docs}
|
|
return [by_id[doc_id] for doc_id in document_ids if doc_id in by_id]
|
|
|
|
|
|
def _document_number_list(selected_docs: Sequence[Dict[str, Any]]) -> List[str]:
|
|
return [str(_document_number(doc) or "").strip() for doc in selected_docs if str(_document_number(doc) or "").strip()]
|
|
|
|
|
|
def build_communication_objective(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
selected_docs: Sequence[Dict[str, Any]],
|
|
operator_instruction: str = "",
|
|
) -> Dict[str, Any]:
|
|
"""Return explicit instructions that constrain LLM draft generation.
|
|
|
|
The LLM may improve wording, but it must not reinterpret the operational
|
|
objective. This object is persisted in draft metadata and passed into the
|
|
OpenAI/file_search prompt.
|
|
"""
|
|
action_code = str(task.get("action_code") or "").upper()
|
|
template_code = str(template.code or "").upper()
|
|
objective = template_code or action_code or "REPLY"
|
|
doc_kinds = [str(doc.get("document_kind") or "").lower() for doc in selected_docs]
|
|
doc_numbers = _document_number_list(selected_docs)
|
|
rules: List[str] = [
|
|
f"Objetivo operacional: {objective}.",
|
|
"Não alterar o objetivo operacional escolhido pelo operador.",
|
|
"Não inventar documentos, preços, URLs, prazos ou anexos.",
|
|
"Usar apenas documentos selecionados como anexos/contexto documental.",
|
|
]
|
|
forbidden: List[str] = []
|
|
if template_code == "SEND_INVOICE" or action_code == "SEND_INVOICE":
|
|
rules.extend([
|
|
"Responder como envio de fatura já emitida.",
|
|
"Mencionar que a fatura segue em anexo quando existir fatura selecionada.",
|
|
"Mencionar o número da fatura selecionada, se existir.",
|
|
"Não pedir comprovativo de pagamento.",
|
|
"Não dizer que a fatura será emitida mais tarde.",
|
|
])
|
|
forbidden.extend([
|
|
"A fatura será emitida após confirmação do pagamento.",
|
|
"Envie o comprovativo para emitirmos a fatura.",
|
|
"Por favor envie-nos o comprovativo do pagamento para emissão da fatura.",
|
|
])
|
|
elif template_code == "SEND_PROFORMA" or action_code == "SEND_PROFORMA":
|
|
rules.extend([
|
|
"Responder como envio de pró-forma operacional.",
|
|
"No ClientFlow/BLIF, pró-forma = orçamento Jasmin ORC.* selecionado.",
|
|
"Mencionar que segue em anexo o orçamento/proforma selecionado.",
|
|
"Mencionar o número ORC selecionado, se existir.",
|
|
"Explicar que a encomenda avança após pagamento/comprovativo quando aplicável.",
|
|
"Não tratar a pró-forma como fatura final.",
|
|
"Não dizer que ainda vai enviar proposta formal quando já existe ORC selecionado.",
|
|
])
|
|
forbidden.extend([
|
|
"Fico ao dispor para enviar proposta formal",
|
|
"Posso enviar proposta formal",
|
|
"Enviaremos a proposta formal",
|
|
])
|
|
elif template_code == "SEND_QUOTE" or action_code == "SEND_QUOTE":
|
|
rules.extend([
|
|
"Responder como envio de orçamento/proposta.",
|
|
"Não pedir pagamento nem tratar o documento como fatura.",
|
|
])
|
|
if doc_numbers:
|
|
rules.extend([
|
|
"Existe orçamento/proposta selecionado; mencionar que segue em anexo e o número do documento.",
|
|
])
|
|
else:
|
|
rules.extend([
|
|
"Não há documento/anexo selecionado: responder como proposta textual ou preços indicativos, sem escrever 'segue em anexo'.",
|
|
"Quando aplicável, indicar que o orçamento formal/Jasmin pode ser preparado após confirmação do modelo, quantidade e dados necessários.",
|
|
])
|
|
forbidden.extend([
|
|
"Segue em anexo",
|
|
"Em anexo envio",
|
|
"Segue anexo",
|
|
])
|
|
elif template_code == "SEND_INFO" or action_code == "SEND_INFO":
|
|
rules.extend([
|
|
"Responder apenas com informação comercial/técnica pedida.",
|
|
"Se listar equipamentos e preços, usar só valores disponíveis no contexto/base/documentos.",
|
|
])
|
|
elif template_code.startswith("FOLLOW_UP") or action_code.startswith("FOLLOW_UP"):
|
|
rules.extend([
|
|
"Responder como follow-up curto e cordial.",
|
|
"Não inventar estado de pagamento, emissão, envio ou stock.",
|
|
])
|
|
return {
|
|
"objective": objective,
|
|
"task_action_code": action_code,
|
|
"template_code": template.code,
|
|
"template_name": template.name,
|
|
"operator_instruction": str(operator_instruction or "").strip(),
|
|
"selected_document_numbers": doc_numbers,
|
|
"selected_document_kinds": doc_kinds,
|
|
"rules": rules,
|
|
"forbidden_claims": forbidden,
|
|
}
|
|
|
|
|
|
def validate_reply_readiness(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
selected_document_ids: Sequence[str],
|
|
docs: Sequence[Dict[str, Any]],
|
|
require_conversation: bool = False,
|
|
require_pdf: bool = False,
|
|
) -> Dict[str, List[str]]:
|
|
blockers: List[str] = []
|
|
warnings: List[str] = []
|
|
opportunity_id = str(task.get("opportunity_id") or "").strip()
|
|
conversation_id = str(task.get("conversation_id") or "").strip()
|
|
selected_ids = list(dict.fromkeys(_as_list(selected_document_ids)))
|
|
available_ids = {str(doc.get("id")): doc for doc in docs}
|
|
|
|
if template.reply_type in {"no_customer_reply", "manual_internal"}:
|
|
blockers.append("Este resultado é de triagem interna/revisão manual; não deve ser enviado ao cliente a partir do assistente.")
|
|
|
|
if template.requires_opportunity and not opportunity_id:
|
|
blockers.append("A tarefa não está ligada a uma oportunidade.")
|
|
|
|
if (template.requires_conversation or require_conversation) and not conversation_id:
|
|
blockers.append("A tarefa não tem conversa Chatwoot associada.")
|
|
|
|
if template.requires_fiscal_customer and not _is_ready_fiscal_customer(task):
|
|
blockers.append("Cliente fiscal incompleto para envio de documento fiscal.")
|
|
|
|
if template.expects_documents() and not selected_ids and template.reply_type != "follow_up":
|
|
if _is_send_quote_text_allowed(template, task.get("action_code") or ""):
|
|
warnings.append("Sem documento/anexo selecionado: a resposta será tratada como proposta textual/preços indicativos, não como ORC.* formal.")
|
|
else:
|
|
blockers.append("O modelo selecionado requer pelo menos um documento/anexo da oportunidade.")
|
|
|
|
expected = {kind.lower() for kind in template.expected_document_kinds}
|
|
selected_docs_for_validation: List[Dict[str, Any]] = []
|
|
for document_id in selected_ids:
|
|
doc = available_ids.get(document_id)
|
|
if not doc:
|
|
blockers.append(f"Documento {document_id} não pertence à oportunidade desta tarefa.")
|
|
continue
|
|
selected_docs_for_validation.append(doc)
|
|
kind = str(doc.get("document_kind") or "").lower()
|
|
if expected and kind not in expected:
|
|
blockers.append(f"Documento {_document_number(doc)} não é compatível com o modelo {template.name}.")
|
|
if require_pdf:
|
|
if kind not in PDF_SUPPORTED_KINDS:
|
|
blockers.append(f"Documento {_document_number(doc)} não tem PDF suportado para envio automático.")
|
|
if not str(doc.get("external_id") or "").strip():
|
|
blockers.append(f"Documento {_document_number(doc)} não tem external_id para obter PDF.")
|
|
if str(doc.get("system") or "jasmin").lower() != "jasmin":
|
|
blockers.append(f"Documento {_document_number(doc)} não pertence ao Jasmin; anexação automática ainda não suportada.")
|
|
|
|
if str(template.code or "").upper() == "SEND_PROFORMA":
|
|
if not selected_docs_for_validation:
|
|
blockers.append("SEND_PROFORMA requer um orçamento Jasmin ORC.* selecionado como pró-forma.")
|
|
elif not any(_is_jasmin_orc_document(doc) for doc in selected_docs_for_validation):
|
|
blockers.append("SEND_PROFORMA deve usar um orçamento Jasmin ORC.* como documento de pró-forma.")
|
|
|
|
if not str(task.get("linked_customer_email") or task.get("customer_email") or "").strip():
|
|
warnings.append("Contacto sem email visível no ClientFlow; envio Chatwoot pode continuar pelo canal da conversa.")
|
|
|
|
if opportunity_id and not docs and template.requires_opportunity:
|
|
warnings.append("A oportunidade ainda não tem documentos comerciais associados.")
|
|
|
|
return {"blockers": blockers, "warnings": warnings}
|
|
|
|
|
|
def render_template_body(task: Dict[str, Any], template: MessageTemplate, selected_docs: Sequence[Dict[str, Any]]) -> str:
|
|
customer_name = _customer_display(task)
|
|
first = _first_name(customer_name)
|
|
customer_first_name = first or ""
|
|
document_numbers = ", ".join(_document_number(doc) for doc in selected_docs) or ""
|
|
document_labels = ", ".join(_document_label(doc) for doc in selected_docs) or ""
|
|
total_amount = ""
|
|
if len(selected_docs) == 1:
|
|
doc = selected_docs[0]
|
|
total_amount = _money(doc.get("total_amount") or doc.get("amount"), str(doc.get("currency") or "EUR"))
|
|
if str(template.code or "").upper() == "SEND_QUOTE" and selected_docs:
|
|
# Same template supports either textual quote or formal ORC attachment.
|
|
body = (
|
|
"Olá {customer_first_name},\n\n"
|
|
"Segue em anexo a proposta {document_numbers}.\n\n"
|
|
"Qualquer questão ou ajuste necessário, estamos ao dispor.\n\n"
|
|
"Cumprimentos,\nEquipa BLIF"
|
|
).format(customer_first_name=customer_first_name, document_numbers=document_numbers)
|
|
return body.replace("Olá ,", "Olá,").replace("Olá ,", "Olá,").replace(" {document_numbers}", "")
|
|
body = template.body.format(
|
|
customer_name=customer_name,
|
|
customer_first_name=customer_first_name,
|
|
document_numbers=document_numbers,
|
|
document_labels=document_labels,
|
|
total_amount=total_amount,
|
|
)
|
|
return (
|
|
body.replace("Olá ,", "Olá,")
|
|
.replace("Olá ,", "Olá,")
|
|
.replace(" e ", " e ")
|
|
.replace(" {document_numbers}", "")
|
|
)
|
|
|
|
|
|
def create_message_draft(
|
|
*,
|
|
task: Dict[str, Any],
|
|
template: MessageTemplate,
|
|
message_body: str,
|
|
selected_document_ids: Sequence[str],
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
generated_by: str = "template",
|
|
operator_instruction: str = "",
|
|
) -> str:
|
|
ensure_reply_assistant_schema()
|
|
with engine.begin() as conn:
|
|
row = conn.execute(text("""
|
|
INSERT INTO message_drafts (
|
|
task_id, opportunity_id, conversation_id, template_code, message_body,
|
|
selected_document_ids, operator_instruction, status, generated_by, metadata
|
|
) VALUES (
|
|
CASE WHEN :task_id = '' THEN NULL ELSE CAST(:task_id AS UUID) END,
|
|
CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
|
:conversation_id, :template_code, :message_body,
|
|
CAST(:selected_document_ids AS JSONB), :operator_instruction, 'draft', :generated_by,
|
|
CAST(:metadata AS JSONB)
|
|
)
|
|
RETURNING id::text
|
|
"""), {
|
|
"task_id": task.get("id") or "",
|
|
"opportunity_id": task.get("opportunity_id") or "",
|
|
"conversation_id": task.get("conversation_id") or "",
|
|
"template_code": template.code,
|
|
"message_body": message_body,
|
|
"selected_document_ids": _json(list(selected_document_ids)),
|
|
"operator_instruction": str(operator_instruction or "").strip(),
|
|
"generated_by": generated_by,
|
|
"metadata": _json(metadata or {}),
|
|
}).first()
|
|
return str(row[0]) if row else ""
|
|
|
|
|
|
def update_message_draft(
|
|
*,
|
|
task_id: str,
|
|
draft_id: str,
|
|
template_code: Optional[str] = None,
|
|
message_body: str,
|
|
selected_document_ids: Optional[Sequence[str]] = None,
|
|
generated_by: Optional[str] = None,
|
|
metadata_patch: Optional[Dict[str, Any]] = None,
|
|
operator_instruction: Optional[str] = None,
|
|
) -> bool:
|
|
if not _is_uuid_text(draft_id):
|
|
return False
|
|
ensure_reply_assistant_schema()
|
|
selected_ids = _as_list(selected_document_ids)
|
|
assignments = ["message_body = :message_body", "updated_at = now()"]
|
|
params: Dict[str, Any] = {
|
|
"draft_id": draft_id,
|
|
"task_id": task_id,
|
|
"message_body": str(message_body or ""),
|
|
}
|
|
if template_code is not None:
|
|
assignments.append("template_code = :template_code")
|
|
params["template_code"] = str(template_code or "") or "SEND_INFO_EQUIPMENT_LIST"
|
|
if selected_document_ids is not None:
|
|
assignments.append("selected_document_ids = CAST(:selected_document_ids AS JSONB)")
|
|
params["selected_document_ids"] = _json(selected_ids)
|
|
if generated_by:
|
|
assignments.append("generated_by = :generated_by")
|
|
params["generated_by"] = str(generated_by)
|
|
if operator_instruction is not None:
|
|
assignments.append("operator_instruction = :operator_instruction")
|
|
params["operator_instruction"] = str(operator_instruction or "").strip()
|
|
if metadata_patch:
|
|
assignments.append("metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)")
|
|
params["metadata_patch"] = _json(metadata_patch)
|
|
with engine.begin() as conn:
|
|
result = conn.execute(text(f"""
|
|
UPDATE message_drafts
|
|
SET {', '.join(assignments)}
|
|
WHERE id = CAST(:draft_id AS UUID)
|
|
AND task_id = CAST(:task_id AS UUID)
|
|
AND status = 'draft'
|
|
"""), params)
|
|
return bool(getattr(result, "rowcount", 0))
|
|
|
|
|
|
def save_reply_draft(
|
|
task_id: str,
|
|
*,
|
|
draft_id: str = "",
|
|
template_code: Optional[str] = None,
|
|
message_body: str,
|
|
selected_document_ids: Optional[Sequence[str]] = None,
|
|
generated_by: str = "operator_edit",
|
|
metadata_patch: Optional[Dict[str, Any]] = None,
|
|
operator_instruction: str = "",
|
|
) -> Dict[str, Any]:
|
|
task = get_task_detail(task_id)
|
|
if not task:
|
|
raise ReplyAssistantError("Tarefa não encontrada.")
|
|
task = _task_with_recent_context(task)
|
|
effective_conversation_id = _effective_conversation_id(task)
|
|
if effective_conversation_id:
|
|
task["conversation_id"] = effective_conversation_id
|
|
template, knowledge, intent = _choose_template_for_task(task, template_code)
|
|
selected_ids = _as_list(selected_document_ids)
|
|
operator_instruction = str(operator_instruction or "").strip()
|
|
message_body = str(message_body or "").strip()
|
|
if not message_body:
|
|
raise ReplyAssistantError("Rascunho vazio.")
|
|
metadata_patch = metadata_patch or {}
|
|
metadata_patch.setdefault("manual_edit", True)
|
|
updated = False
|
|
if draft_id:
|
|
updated = update_message_draft(
|
|
task_id=task_id,
|
|
draft_id=draft_id,
|
|
template_code=template.code,
|
|
message_body=message_body,
|
|
selected_document_ids=selected_ids,
|
|
generated_by=generated_by,
|
|
metadata_patch=metadata_patch,
|
|
operator_instruction=operator_instruction,
|
|
)
|
|
if not updated:
|
|
draft_id = create_message_draft(
|
|
task=task,
|
|
template=template,
|
|
message_body=message_body,
|
|
selected_document_ids=selected_ids,
|
|
metadata={"validation": {"blockers": [], "warnings": []}, **metadata_patch},
|
|
generated_by=generated_by,
|
|
operator_instruction=operator_instruction,
|
|
)
|
|
return {
|
|
"draft_id": draft_id,
|
|
"task": task,
|
|
"template": asdict(template),
|
|
"message_body": message_body,
|
|
"documents": available_documents_for_task(task),
|
|
"selected_document_ids": selected_ids,
|
|
"blockers": [],
|
|
"warnings": [],
|
|
"business_knowledge": _knowledge_state(knowledge),
|
|
"intent_gate": _intent_gate_state(intent),
|
|
"email_agent": _email_agent_default_state(),
|
|
"llm": _llm_status(bool(getattr(settings, "clientflow_reply_llm_enabled", False)), False),
|
|
}
|
|
|
|
|
|
def revise_reply_draft(
|
|
task_id: str,
|
|
*,
|
|
draft_id: str = "",
|
|
template_code: Optional[str] = None,
|
|
message_body: str,
|
|
revision_instruction: str,
|
|
selected_document_ids: Optional[Sequence[str]] = None,
|
|
) -> Dict[str, Any]:
|
|
task = get_task_detail(task_id)
|
|
if not task:
|
|
raise ReplyAssistantError("Tarefa não encontrada.")
|
|
task = _task_with_recent_context(task)
|
|
template, knowledge, intent = _choose_template_for_task(task, template_code)
|
|
docs = available_documents_for_task(task)
|
|
selected_ids = _as_list(selected_document_ids)
|
|
selected_docs = _selected_documents(selected_ids, docs)
|
|
current_body = str(message_body or "").strip()
|
|
if not current_body:
|
|
raise ReplyAssistantError("Rascunho atual vazio.")
|
|
revision_instruction = str(revision_instruction or "").strip()
|
|
if not revision_instruction:
|
|
raise ReplyAssistantError("Instrução de correção vazia.")
|
|
|
|
try:
|
|
from app.email_reply_agent_service import revise_email_reply_agent
|
|
result = revise_email_reply_agent(
|
|
task=task,
|
|
knowledge=knowledge,
|
|
selected_documents=selected_docs,
|
|
current_body=current_body,
|
|
instruction=revision_instruction,
|
|
)
|
|
except Exception as exc:
|
|
raise ReplyAssistantError(f"Não foi possível corrigir com OpenAI: {exc}") from exc
|
|
|
|
revised_body = str(result.get("resposta_revisada") or "").strip()
|
|
if not revised_body:
|
|
raise ReplyAssistantError("A IA não devolveu um rascunho revisto.")
|
|
safety = validate_generated_reply(revised_body, knowledge=knowledge, task=task, selected_documents=list(selected_docs))
|
|
blockers = list(safety.get("blockers") or [])
|
|
warnings = list(safety.get("warnings") or [])
|
|
if result.get("precisa_revisao_humana") and result.get("motivo_revisao"):
|
|
warnings.append(str(result.get("motivo_revisao")))
|
|
if result.get("informacao_em_falta"):
|
|
warnings.append(f"Informação em falta: {result.get('informacao_em_falta')}")
|
|
if blockers:
|
|
raise ReplyAssistantError("\n".join(blockers))
|
|
|
|
state = save_reply_draft(
|
|
task_id,
|
|
draft_id=draft_id,
|
|
template_code=template.code,
|
|
message_body=revised_body,
|
|
selected_document_ids=selected_ids,
|
|
generated_by="openai_revision",
|
|
metadata_patch={
|
|
"revision": {
|
|
"instruction": revision_instruction,
|
|
"alteracoes_aplicadas": result.get("alteracoes_aplicadas") or "",
|
|
"avisos": result.get("avisos") or "",
|
|
"metadata": result.get("metadata") or {},
|
|
},
|
|
"recent_conversation_context": task.get("recent_conversation_context") or [],
|
|
},
|
|
)
|
|
state["warnings"] = list(dict.fromkeys(warnings))
|
|
state["email_agent"] = result.get("metadata") or {"enabled": True, "used": True, "status": "used", "mode": "draft_revision"}
|
|
return state
|
|
|
|
|
|
def _sync_invoice_delivery_task_context(task: Dict[str, Any], selected_docs: Sequence[Dict[str, Any]]) -> None:
|
|
"""Keep legacy SEND_INVOICE tasks aligned after the invoice exists.
|
|
|
|
Reconciliation can create a task with an old note such as "fatura por emitir".
|
|
Once a Jasmin invoice is linked and selected for delivery, the human task
|
|
should say "send this invoice", not "issue invoice" or "ask for payment".
|
|
"""
|
|
task_id = str(task.get("id") or "").strip()
|
|
if not _is_uuid_text(task_id):
|
|
return
|
|
if str(task.get("action_code") or "").upper() != "SEND_INVOICE":
|
|
return
|
|
invoice = next((doc for doc in selected_docs if str(doc.get("document_kind") or "").lower() == "invoice"), None)
|
|
if not invoice:
|
|
return
|
|
number = _document_number(invoice) or "fatura"
|
|
amount = _money(invoice.get("total_amount") or invoice.get("amount"), str(invoice.get("currency") or "EUR"))
|
|
pdf_ok = _document_pdf_supported(invoice)
|
|
action = f"Enviar fatura {number} ao cliente"
|
|
note = f"Enviar fatura {number} ao cliente." + (f" Valor: {amount}." if amount else "")
|
|
if pdf_ok:
|
|
note += " PDF/anexo disponível para envio automático."
|
|
else:
|
|
note += " PDF/anexo não disponível; anexar manualmente ou sincronizar documento antes de enviar."
|
|
patch = {
|
|
"invoice_delivery_context": {
|
|
"document_id": invoice.get("id"),
|
|
"document_number": number,
|
|
"document_kind": invoice.get("document_kind"),
|
|
"amount": str(invoice.get("total_amount") or invoice.get("amount") or ""),
|
|
"currency": str(invoice.get("currency") or "EUR"),
|
|
"pdf_available": bool(pdf_ok),
|
|
"synced_by": "invoice_delivery_guard",
|
|
}
|
|
}
|
|
try:
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE tasks
|
|
SET action = CAST(:action AS TEXT),
|
|
note = CAST(:note AS TEXT),
|
|
updated_at = now(),
|
|
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
|
WHERE id = CAST(:task_id AS UUID)
|
|
AND action_code = 'SEND_INVOICE'
|
|
AND status = 'pending'
|
|
"""), {"task_id": task_id, "action": action, "note": note, "metadata_patch": _json(patch)})
|
|
except Exception as exc:
|
|
print(f"ClientFlow invoice delivery context sync failed task_id={task_id}: {exc}", flush=True)
|
|
|
|
|
|
def generate_reply_draft(
|
|
task_id: str,
|
|
*,
|
|
template_code: Optional[str] = None,
|
|
selected_document_ids: Optional[Sequence[str]] = None,
|
|
operator_instruction: str = "",
|
|
persist: bool = True,
|
|
) -> Dict[str, Any]:
|
|
task = get_task_detail(task_id)
|
|
if not task:
|
|
raise ReplyAssistantError("Tarefa não encontrada.")
|
|
task = _task_with_recent_context(task)
|
|
|
|
template, knowledge, intent = _choose_template_for_task(task, template_code)
|
|
docs = available_documents_for_task(task)
|
|
selected_ids = _as_list(selected_document_ids)
|
|
if not selected_ids:
|
|
selected_ids = _select_default_documents(template, docs)
|
|
selected_docs = _selected_documents(selected_ids, docs)
|
|
_sync_invoice_delivery_task_context(task, selected_docs)
|
|
operator_instruction = str(operator_instruction or "").strip()
|
|
communication_objective = build_communication_objective(
|
|
task=task,
|
|
template=template,
|
|
selected_docs=selected_docs,
|
|
operator_instruction=operator_instruction,
|
|
)
|
|
|
|
validation = validate_reply_readiness(
|
|
task=task,
|
|
template=template,
|
|
selected_document_ids=selected_ids,
|
|
docs=docs,
|
|
require_conversation=False,
|
|
require_pdf=(str(template.reply_type or "") == "send_document" or (str(template.reply_type or "") == "send_document_or_text_quote" and bool(selected_ids))),
|
|
)
|
|
baseline_body = render_template_body(task, template, selected_docs)
|
|
|
|
# Phase 1 safe integration: prefer the OpenAI/file_search email agent when
|
|
# explicitly enabled. It only generates an editable draft and metadata.
|
|
# Follow-ups use a dedicated prompt because they are proactive commercial
|
|
# nudges, not generic replies to a customer request.
|
|
email_agent_meta = _email_agent_default_state()
|
|
llm_meta = _llm_status(bool(getattr(settings, "clientflow_reply_llm_enabled", False)), False)
|
|
|
|
if template.reply_type == "follow_up":
|
|
body, email_agent_meta, safety_blockers, safety_warnings, agent_used = _generate_follow_up_with_email_agent(
|
|
task=task,
|
|
template=template,
|
|
selected_docs=selected_docs,
|
|
knowledge=knowledge,
|
|
fallback_body=baseline_body,
|
|
)
|
|
# Do not fall through to the generic reply agent/OpenRouter for follow-ups;
|
|
# if the dedicated OpenAI follow-up agent is unavailable, keep the safe
|
|
# deterministic template rather than risking an operational reply.
|
|
else:
|
|
body, email_agent_meta, agent_blockers, agent_warnings, agent_used = _generate_with_email_agent(
|
|
task=task,
|
|
template=template,
|
|
selected_docs=selected_docs,
|
|
knowledge=knowledge,
|
|
fallback_body=baseline_body,
|
|
communication_objective=communication_objective,
|
|
operator_instruction=operator_instruction,
|
|
)
|
|
if agent_used:
|
|
safety_blockers = agent_blockers
|
|
safety_warnings = agent_warnings
|
|
else:
|
|
body, llm_meta, safety_blockers, safety_warnings = _generate_business_aware_body(
|
|
task=task,
|
|
template=template,
|
|
selected_docs=selected_docs,
|
|
knowledge=knowledge,
|
|
baseline_body=baseline_body,
|
|
)
|
|
# Preserve agent fallback status/errors alongside the existing LLM state.
|
|
|
|
validation_blockers = list(validation["blockers"]) + safety_blockers
|
|
validation_warnings = list(validation["warnings"]) + safety_warnings
|
|
draft_id = ""
|
|
metadata = {
|
|
"validation": {"blockers": validation_blockers, "warnings": validation_warnings},
|
|
"selected_documents": [_document_label(doc) for doc in selected_docs],
|
|
"communication_objective": communication_objective,
|
|
"operator_instruction": operator_instruction,
|
|
"business_knowledge": _knowledge_state(knowledge),
|
|
"intent_gate": _intent_gate_state(intent),
|
|
"email_agent": email_agent_meta,
|
|
"llm": llm_meta,
|
|
"baseline_message_body": baseline_body,
|
|
"recent_conversation_context": task.get("recent_conversation_context") or [],
|
|
}
|
|
if persist:
|
|
generated_by = "openai_email_agent" if email_agent_meta.get("used") else "llm" if llm_meta.get("used") else "business_knowledge" if knowledge.has_topics else "template"
|
|
draft_id = create_message_draft(
|
|
task=task,
|
|
template=template,
|
|
message_body=body,
|
|
selected_document_ids=selected_ids,
|
|
metadata=metadata,
|
|
generated_by=generated_by,
|
|
operator_instruction=operator_instruction,
|
|
)
|
|
|
|
return {
|
|
"draft_id": draft_id,
|
|
"task": task,
|
|
"template": asdict(template),
|
|
"message_body": body,
|
|
"documents": docs,
|
|
"selected_document_ids": selected_ids,
|
|
"selected_documents": selected_docs,
|
|
"communication_objective": communication_objective,
|
|
"operator_instruction": operator_instruction,
|
|
"blockers": list(dict.fromkeys(validation_blockers)),
|
|
"warnings": list(dict.fromkeys(validation_warnings)),
|
|
"business_knowledge": _knowledge_state(knowledge),
|
|
"intent_gate": _intent_gate_state(intent),
|
|
"email_agent": email_agent_meta,
|
|
"llm": llm_meta,
|
|
}
|
|
|
|
|
|
async def _load_pdf_attachments(selected_docs: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
from app.jasmin_service import get_commercial_document_pdf
|
|
|
|
attachments: List[Dict[str, Any]] = []
|
|
for doc in selected_docs:
|
|
doc_id = str(doc.get("id") or "").strip()
|
|
if not doc_id:
|
|
continue
|
|
refreshed_doc, data, content_type = await get_commercial_document_pdf(doc_id, validate_customer_send=True)
|
|
number = str(refreshed_doc.get("document_number") or refreshed_doc.get("external_id") or doc_id)
|
|
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in number)[:80] or "documento"
|
|
attachments.append({
|
|
"filename": f"{safe}.pdf",
|
|
"content": data,
|
|
"content_type": content_type or "application/pdf",
|
|
"document_id": doc_id,
|
|
"document_number": number,
|
|
})
|
|
return attachments
|
|
|
|
|
|
def _mark_draft_sent(draft_id: str, *, chatwoot_result: Dict[str, Any]) -> None:
|
|
if not draft_id:
|
|
return
|
|
ensure_reply_assistant_schema()
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE message_drafts
|
|
SET status = 'sent', sent_at = now(), updated_at = now(),
|
|
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
|
WHERE id = CAST(:draft_id AS UUID)
|
|
"""), {"draft_id": draft_id, "metadata_patch": _json({"chatwoot_result": chatwoot_result})})
|
|
|
|
|
|
def _mark_draft_failed(draft_id: str, *, error: str) -> None:
|
|
if not draft_id:
|
|
return
|
|
ensure_reply_assistant_schema()
|
|
with engine.begin() as conn:
|
|
conn.execute(text("""
|
|
UPDATE message_drafts
|
|
SET status = 'failed', updated_at = now(),
|
|
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
|
WHERE id = CAST(:draft_id AS UUID)
|
|
"""), {"draft_id": draft_id, "metadata_patch": _json({"last_error": error})})
|
|
|
|
|
|
def _advance_opportunity(template_code: str, opportunity_id: str, *, note: str) -> None:
|
|
stage = STAGE_ON_TEMPLATE_SENT.get(template_code)
|
|
if not stage or not opportunity_id:
|
|
return
|
|
try:
|
|
from app.opportunity_service import set_opportunity_stage
|
|
|
|
set_opportunity_stage(opportunity_id, stage, note=note, created_by="reply_assistant")
|
|
except Exception as exc:
|
|
print(f"ClientFlow reply assistant stage update failed opportunity_id={opportunity_id}: {exc}", flush=True)
|
|
|
|
|
|
async def send_reply(
|
|
task_id: str,
|
|
*,
|
|
template_code: Optional[str],
|
|
message_body: str,
|
|
selected_document_ids: Optional[Sequence[str]] = None,
|
|
send_and_complete: bool = False,
|
|
draft_id: str = "",
|
|
) -> Dict[str, Any]:
|
|
task = get_task_detail(task_id)
|
|
if not task:
|
|
raise ReplyAssistantError("Tarefa não encontrada.")
|
|
task = _task_with_recent_context(task)
|
|
template, knowledge, intent = _choose_template_for_task(task, template_code)
|
|
docs = available_documents_for_task(task)
|
|
selected_ids = _as_list(selected_document_ids)
|
|
selected_docs = _selected_documents(selected_ids, docs)
|
|
message_body = str(message_body or "").strip()
|
|
if not message_body:
|
|
raise ReplyAssistantError("Mensagem vazia.")
|
|
template = _safe_customer_template_for_manual_send(task, template, message_body)
|
|
if draft_id:
|
|
update_message_draft(
|
|
task_id=task_id,
|
|
draft_id=draft_id,
|
|
template_code=template.code,
|
|
message_body=message_body,
|
|
selected_document_ids=selected_ids,
|
|
generated_by="operator_final",
|
|
metadata_patch={"final_body_saved_before_send": True},
|
|
)
|
|
|
|
validation = validate_reply_readiness(
|
|
task=task,
|
|
template=template,
|
|
selected_document_ids=selected_ids,
|
|
docs=docs,
|
|
require_conversation=True,
|
|
require_pdf=True,
|
|
)
|
|
if validation["blockers"]:
|
|
raise ReplyAssistantError("\n".join(validation["blockers"]))
|
|
|
|
safety = validate_generated_reply(message_body, knowledge=knowledge, task=task, selected_documents=list(selected_docs))
|
|
if safety.get("blockers"):
|
|
raise ReplyAssistantError("\n".join(safety["blockers"]))
|
|
validation["warnings"] = list(dict.fromkeys(list(validation.get("warnings") or []) + list(safety.get("warnings") or [])))
|
|
|
|
try:
|
|
attachments = await _load_pdf_attachments(selected_docs)
|
|
from app.chatwoot_client import send_public_message_with_attachments
|
|
|
|
chatwoot_result = await send_public_message_with_attachments(
|
|
str(task.get("conversation_id") or ""),
|
|
message_body,
|
|
attachments=attachments,
|
|
)
|
|
if chatwoot_result.get("status") != "sent":
|
|
raise ReplyAssistantError(f"Envio Chatwoot não concluído: {chatwoot_result.get('reason') or chatwoot_result.get('body') or chatwoot_result.get('status')}")
|
|
except Exception as exc:
|
|
_mark_draft_failed(draft_id, error=str(exc))
|
|
if isinstance(exc, ReplyAssistantError):
|
|
raise
|
|
raise ReplyAssistantError(str(exc)) from exc
|
|
|
|
# From this point on the customer message was accepted by Chatwoot.
|
|
# Do not raise normal post-send persistence/timeline/task-completion errors,
|
|
# otherwise the operator may click again and duplicate the public reply.
|
|
post_send_warnings: List[str] = []
|
|
_mark_draft_sent(draft_id, chatwoot_result=chatwoot_result)
|
|
|
|
response_payload = chatwoot_result.get("response") if isinstance(chatwoot_result, dict) else {}
|
|
chatwoot_message_id = None
|
|
if isinstance(response_payload, dict):
|
|
chatwoot_message_id = response_payload.get("id") or response_payload.get("message_id")
|
|
|
|
communication_id = None
|
|
try:
|
|
communication_id = record_outbound_communication(
|
|
source_system="chatwoot",
|
|
source_message_id=str(chatwoot_message_id or "") or None,
|
|
conversation_id=str(task.get("conversation_id") or ""),
|
|
contact_id=str(task.get("contact_id") or ""),
|
|
body=message_body,
|
|
customer_id=str(task.get("linked_customer_id") or "") or None,
|
|
opportunity_id=str(task.get("opportunity_id") or "") or None,
|
|
task_id=task_id,
|
|
metadata={
|
|
"template_code": template.code,
|
|
"attachments": [
|
|
{"document_id": doc.get("id"), "document_number": _document_number(doc), "kind": doc.get("document_kind")}
|
|
for doc in selected_docs
|
|
],
|
|
"chatwoot_result": chatwoot_result,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
post_send_warnings.append(f"Mensagem enviada no Chatwoot, mas falhou o registo local da comunicação: {exc}")
|
|
print(f"ClientFlow reply assistant post-send communication record failed task_id={task_id}: {exc}", flush=True)
|
|
|
|
try:
|
|
create_timeline_event(
|
|
opportunity_id=str(task.get("opportunity_id") or "") or None,
|
|
customer_id=str(task.get("linked_customer_id") or "") or None,
|
|
event_type="reply_sent",
|
|
title=f"Mensagem enviada ao cliente: {template.name}",
|
|
description=message_body[:500],
|
|
source="reply_assistant",
|
|
related_type="communication" if communication_id else None,
|
|
related_id=communication_id,
|
|
payload={
|
|
"task_id": task_id,
|
|
"template_code": template.code,
|
|
"document_ids": selected_ids,
|
|
"attachments": [_document_label(doc) for doc in selected_docs],
|
|
"chatwoot_message_id": chatwoot_message_id,
|
|
"post_send_warnings": post_send_warnings,
|
|
},
|
|
created_by="operator",
|
|
)
|
|
except Exception as exc:
|
|
post_send_warnings.append(f"Mensagem enviada no Chatwoot, mas falhou o registo na timeline: {exc}")
|
|
print(f"ClientFlow reply assistant post-send timeline failed task_id={task_id}: {exc}", flush=True)
|
|
|
|
if str(task.get("opportunity_id") or ""):
|
|
try:
|
|
_advance_opportunity(
|
|
template.code,
|
|
str(task.get("opportunity_id") or ""),
|
|
note=f"Mensagem '{template.name}' enviada ao cliente via ClientFlow.",
|
|
)
|
|
except Exception as exc:
|
|
# _advance_opportunity already logs internally in current versions, but
|
|
# keep the warning explicit for the endpoint notice.
|
|
post_send_warnings.append(f"Mensagem enviada no Chatwoot, mas falhou o avanço da oportunidade: {exc}")
|
|
print(f"ClientFlow reply assistant post-send opportunity advance failed task_id={task_id}: {exc}", flush=True)
|
|
|
|
completed = False
|
|
if send_and_complete:
|
|
try:
|
|
complete_task_with_note(task_id, done_by="operator", done_note=template.complete_note)
|
|
completed = True
|
|
except Exception as exc:
|
|
post_send_warnings.append(f"Mensagem enviada no Chatwoot, mas a tarefa não foi concluída automaticamente: {exc}")
|
|
print(f"ClientFlow reply assistant post-send task complete failed task_id={task_id}: {exc}", flush=True)
|
|
|
|
return {
|
|
"ok": True,
|
|
"status": "sent",
|
|
"communication_id": communication_id,
|
|
"chatwoot_result": chatwoot_result,
|
|
"completed": completed,
|
|
"template_code": template.code,
|
|
"selected_document_ids": selected_ids,
|
|
"warnings": list(dict.fromkeys(list(validation["warnings"]) + post_send_warnings)),
|
|
"post_send_warnings": post_send_warnings,
|
|
}
|
|
|
|
def get_reply_panel_state(task_id: str, *, template_code: Optional[str] = None, selected_document_ids: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
return generate_reply_draft(task_id, template_code=template_code, selected_document_ids=selected_document_ids, persist=False)
|