Files
clientflow_backend/app/reply_assistant_service.py

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)