"""Task list, detail and task action routes.
Moved from app.admin_dashboard in v4.7.2.
Reply assistant lineage: v4928.1.5.0 compatibility marker; v4928.1.5.13 compatibility marker; current badge v4928.1.5.14; draft refresh persistence v4928.1.5.15; editable revision workflow v4928.1.5.16; semi-automatic follow-ups v4928.1.5.17; follow-up draft generator v4928.1.5.18; dedicated OpenAI follow-up LLM prompt v4928.1.5.23; contact-person greeting v4928.1.5.24. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response, PlainTextResponse
from urllib.parse import urlsplit, quote, urlencode
import json
import re
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.guidance import (
fiscal_contact_panel_html,
fiscal_customer_missing_fields,
readiness_checklist_html,
)
from app.admin_ui.labels import primary_action_label
from app.task_service import reschedule_task_due_at
from app.reply_recipient_utils import resolve_reply_recipient
from app.company_opportunity_linking import associate_task_to_opportunity
router = APIRouter()
def _safe_search_query(value: Optional[str]) -> Optional[str]:
if value is None:
return None
cleaned = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", str(value))
cleaned = cleaned.strip()
if len(cleaned) > 120:
cleaned = cleaned[:120]
return cleaned or None
def _invalid_task_response():
return PlainTextResponse("Identificador de tarefa inválido.", status_code=422)
def _safe_return_to(value: str, default: str = "/tasks?status=pending") -> str:
"""Preserve safe internal navigation context after a task action."""
value = str(value or "").strip()
if not value:
return default
if not value.startswith("/") or value.startswith("//"):
return default
parsed = urlsplit(value)
if parsed.scheme or parsed.netloc:
return default
allowed_prefixes = ("/operations", "/operacoes", "/tasks", "/opportunities", "/finance", "/financeiro", "/orders", "/encomendas")
if not parsed.path.startswith(allowed_prefixes):
return default
return value
def _return_to_hidden(return_to: str) -> str:
return f'' if return_to else ""
def _return_to_link(return_to: str) -> str:
href = return_to or "/tasks?status=pending"
return f'← Voltar'
def _task_href(task_id: str, return_to: str = "") -> str:
"""Build task detail URL preserving the caller page as navigation context."""
href = f"/tasks/{task_id}"
safe_return_to = _safe_return_to(return_to, default="") if return_to else ""
if safe_return_to:
href += f"?return_to={quote(safe_return_to, safe='')}"
return href
def _tasks_list_return_to(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200) -> str:
params = {}
if status:
params["status"] = status
if route:
params["route"] = route
if view:
params["view"] = view
if q:
params["q"] = q
if limit and int(limit) != 200:
params["limit"] = str(limit)
query = urlencode(params)
return "/tasks" + (f"?{query}" if query else "")
def _htmx_redirect_response(return_to: str):
response = Response(status_code=204)
response.headers["HX-Redirect"] = return_to
return response
def _safe_task_display_html(value: str) -> str:
text = str(value or "")
replacements = {
"Traceback (most recent call last)": "Relatório técnico remoto",
"Internal Server Error": "Erro interno reportado na mensagem",
"Application startup failed": "Falha de arranque reportada na mensagem",
"sqlalchemy.exc.": "sqlalchemy exc.",
"psycopg.errors.": "psycopg errors.",
"SyntaxError:": "SyntaxError reportado:",
"Exception:": "Exceção reportada:",
"fatura por emitir": "fatura criada/associada; enviar PDF ao cliente",
"Fatura por emitir": "Fatura criada/associada; enviar PDF ao cliente",
"Preparar e enviar orçamento para pagamento para pagamento.": "Preparar e enviar orçamento para pagamento.",
"Enviar orçamento para pagamento": "Enviar orçamento para pagamento",
"orçamento para pagamento": "orçamento para pagamento",
"Orçamento para pagamento": "Orçamento para pagamento",
}
for old, new in replacements.items():
text = text.replace(old, new)
text = text.replace(old.lower(), new)
text = text.replace(old.upper(), new)
return text
def _task_opportunity_linking_panel_html(task: dict, return_to: str = "") -> str:
metadata = task.get("metadata") or {}
if isinstance(metadata, str):
try:
metadata = json.loads(metadata)
except Exception:
metadata = {}
if str(metadata.get("opportunity_linking_status") or "").lower() != "ambiguous":
return ""
candidates = metadata.get("opportunity_linking_candidates") or []
reason = str(metadata.get("opportunity_linking_reason") or "associação ambígua")
if not candidates:
return f'''
Associar oportunidade existente
O sistema encontrou risco de associação errada: {esc(reason)}. Usa a oportunidade correta antes de executar documentos ou pagamentos.
'''
rows = []
task_id = str(task.get("id") or "")
hidden_return_to = _return_to_hidden(return_to)
for c in candidates[:8]:
opp_id = str(c.get("opportunity_id") or "")
if not is_uuid_text(opp_id):
continue
title = str(c.get("title") or "Oportunidade")
customer = str(c.get("customer_name") or c.get("customer_email") or "")
doc = str(c.get("document_number") or "")
amount = str(c.get("total_amount") or "")
why = str(c.get("reason") or "candidato")
rows.append(f'''
{esc(doc or 'sem documento visível')} {esc(('· ' + amount) if amount else '')}
Evidência: {esc(why)}
''')
body = "".join(rows) or "
Sem candidatos válidos.
"
return f'''
Associar a oportunidade existente
A mensagem pode vir de outro departamento da mesma empresa. Escolhe a compra/processo correto antes de avançar.
{body}
'''
DOCUMENT_TASK_ACTIONS = {"SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE"}
# Legacy static anchor: Segue em anexo a fatura pró-forma solicitada
# Legacy static anchor: Segue em anexo a fatura orçamento para pagamento solicitada
FOLLOW_UP_ACTIONS = {"FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC"}
def _is_follow_up_task(task_or_code) -> bool:
if isinstance(task_or_code, dict):
code = task_or_code.get("action_code")
else:
code = task_or_code
return str(code or "").strip().upper() in FOLLOW_UP_ACTIONS
def _follow_up_template_for_action(action_code: str) -> str:
code = str(action_code or "").strip().upper()
return {
"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(code, "FOLLOW_UP_GENERIC")
def _fallback_follow_up_message(action_code: str) -> str:
code = str(action_code or "").strip().upper()
if code == "FOLLOW_UP_PAYMENT":
return "Olá,\n\nGostaria apenas de confirmar se recebeu o orçamento/dados de pagamento e se precisa de alguma informação adicional para avançar.\n\nObrigado."
if code == "FOLLOW_UP_PROFORMA":
return "Olá,\n\nGostaria apenas de confirmar se recebeu o orçamento para pagamento e se precisa de alguma informação adicional.\n\nObrigado."
if code == "FOLLOW_UP_QUOTE":
return "Olá,\n\nGostaria apenas de confirmar se recebeu a proposta/orçamento e se ficou com alguma dúvida.\n\nObrigado."
if code == "FOLLOW_UP_CUSTOMER_REVIEW":
return "Olá,\n\nGostaria apenas de confirmar se mantém interesse e se precisa de alguma informação adicional.\n\nObrigado."
return "Olá,\n\nGostaria apenas de dar seguimento ao processo e confirmar se precisa de alguma informação adicional.\n\nObrigado."
def _metadata_dict(value) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
return json.loads(value)
except Exception:
return {}
return {}
_IDENTITY_STOPWORDS = {
"da", "de", "do", "dos", "das", "e", "lda", "ltda", "unipessoal",
"sa", "s.a", "s.a.", "email", "mail", "geral", "info", "office",
"frontoffice", "comercial", "vendas", "admin", "contacto", "contact",
}
PUBLIC_CONTACT_EMAIL_DOMAINS = {
"gmail.com", "googlemail.com", "hotmail.com", "hotmail.pt", "outlook.com",
"outlook.pt", "live.com", "msn.com", "icloud.com", "me.com", "mac.com",
"yahoo.com", "yahoo.pt", "sapo.pt", "mail.telepac.pt", "mail.com",
"proton.me", "protonmail.com", "aol.com", "gmx.com", "gmx.net", "uol.com.br",
}
def _email_domain_for_identity(value) -> str:
text = str(value or "").strip().casefold()
if "@" not in text:
return ""
domain = text.rsplit("@", 1)[-1].strip(" .")
if domain.startswith("www."):
domain = domain[4:]
return domain
def _email_local_for_identity(value) -> str:
text = str(value or "").strip().casefold()
if "@" not in text:
return ""
return text.split("@", 1)[0].strip(" ._-+")
def _is_public_contact_email_domain(domain) -> bool:
return _email_domain_for_identity(f"x@{domain}") in PUBLIC_CONTACT_EMAIL_DOMAINS if "@" not in str(domain or "") else _email_domain_for_identity(domain) in PUBLIC_CONTACT_EMAIL_DOMAINS
def _norm_identity(value) -> str:
return " ".join(re.sub(r"[^0-9a-zA-ZÀ-ÿ]+", " ", str(value or "").casefold()).split())
def _identity_tokens(value) -> set[str]:
return {
token
for token in _norm_identity(value).split()
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
}
def _email_tokens(value) -> set[str]:
email = str(value or "").strip().casefold()
local = email.split("@", 1)[0]
return {
token
for token in re.split(r"[^0-9a-zA-ZÀ-ÿ]+", local)
if len(token) >= 3 and token not in _IDENTITY_STOPWORDS
}
def _identity_overlaps(left, right) -> bool:
left_norm = _norm_identity(left)
right_norm = _norm_identity(right)
# Company names frequently appear once as a short campaign hint
# ("CARPINTARIA AVELEIRAS") and once as a full fiscal name
# ("CARPINTARIA AVELEIRAS, UNIPESSOAL, LDA"). Treat substring
# matches as safe before falling back to token overlap.
if left_norm and right_norm and (left_norm in right_norm or right_norm in left_norm):
return True
left_tokens = _identity_tokens(left)
right_tokens = _identity_tokens(right)
if not left_tokens or not right_tokens:
return False
if left_tokens & right_tokens:
return True
return any(a in b or b in a for a in left_tokens for b in right_tokens)
def _task_has_compatible_opportunity_identity(task: dict, hint: str | None = None) -> bool:
"""Return true when opportunity/document identity matches the process hint.
Legacy tasks may have no direct local_customer_id but still carry a valid
fiscal identity through the opportunity documents. In that case we must not
show the contradictory "cliente fiscal por confirmar" guard.
"""
hint = str(hint or _task_process_customer_hint(task) or "").strip()
names = [
task.get("linked_customer_name"),
task.get("opportunity_customer_name"),
task.get("customer_name"),
]
if not hint:
return any(str(v or "").strip() for v in names)
return any(_identity_overlaps(value, hint) for value in names if str(value or "").strip())
def _name_matches_email(name, email) -> bool:
name_tokens = _identity_tokens(name)
email_tokens = _email_tokens(email)
if not name_tokens or not email_tokens:
return False
if name_tokens & email_tokens:
return True
return any(nt in et or et in nt for nt in name_tokens for et in email_tokens)
def _raw_payload_dict(task: dict) -> dict:
payload = task.get("raw_payload")
if isinstance(payload, dict):
return payload
if isinstance(payload, str) and payload.strip():
try:
data = json.loads(payload)
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def _payload_get(payload: dict, *path) -> str:
cur = payload or {}
for part in path:
if not isinstance(cur, dict):
return ""
cur = cur.get(part)
return str(cur or "").strip()
def _task_sender_name(task: dict) -> str:
payload = _raw_payload_dict(task)
return (
_payload_get(payload, "sender", "name")
or _payload_get(payload, "conversation", "meta", "sender", "name")
or str(task.get("sender_name") or "").strip()
)
def _task_sender_email(task: dict) -> str:
payload = _raw_payload_dict(task)
return (
_payload_get(payload, "sender", "email")
or _payload_get(payload, "conversation", "meta", "sender", "email")
or str(task.get("sender_email") or "").strip()
or str(task.get("customer_email") or "").strip()
)
def _task_sender_phone(task: dict) -> str:
payload = _raw_payload_dict(task)
return (
_payload_get(payload, "sender", "phone_number")
or _payload_get(payload, "conversation", "meta", "sender", "phone_number")
or str(task.get("customer_phone") or "").strip()
)
def _task_process_customer_hint(task: dict) -> str:
value = str(task.get("opportunity_title") or task.get("message_subject") or "").strip()
if not value:
return ""
if "·" in value:
tail = value.rsplit("·", 1)[-1].strip()
if tail and not tail.upper().startswith(("ORC.", "S0")):
return tail
match = re.search(r"\bpara\s+(?:a|o|as|os|à|ao)?\s*(.+)$", value, re.IGNORECASE)
if match:
candidate = re.sub(r"\s+", " ", match.group(1)).strip(" .:-–—")
candidate = re.split(r"\s+(?:de:|from:|enviada:|sent:)", candidate, maxsplit=1, flags=re.IGNORECASE)[0].strip()
if 2 <= len(candidate) <= 120:
return candidate
return ""
def _task_identity_context(task: dict) -> dict:
hint = _task_process_customer_hint(task)
linked_name = str(task.get("linked_customer_name") or "").strip()
linked_email = str(task.get("linked_customer_email") or "").strip()
opportunity_customer_name = str(task.get("opportunity_customer_name") or "").strip()
opportunity_customer_email = str(task.get("opportunity_customer_email") or "").strip()
sender_name = _task_sender_name(task)
sender_email = _task_sender_email(task)
fiscal_unsafe = False
process_identity_unsafe = False
reason = ""
compatible_identity = _task_has_compatible_opportunity_identity(task, hint)
if hint and linked_name and not compatible_identity and not _identity_overlaps(linked_name, hint):
# A common failure mode is a person/contact being auto-associated as the
# fiscal customer while the campaign/process title clearly points to a
# different company. This is a UI/task-safety guard: it does not modify
# customers, opportunities or tasks.
fiscal_unsafe = True
reason = "linked_fiscal_customer_mismatch_process_hint"
if hint and opportunity_customer_name and not compatible_identity and not _identity_overlaps(opportunity_customer_name, hint):
# If a repair script has already detached the fiscal customer, the legacy
# opportunity.customer_name can still carry the polluted contact name.
# Prefer the process/title hint until the fiscal customer is confirmed.
process_identity_unsafe = True
reason = reason or "opportunity_customer_mismatch_process_hint"
if fiscal_unsafe or process_identity_unsafe:
display_name = hint
else:
display_name = ""
for value in (
linked_name,
opportunity_customer_name,
sender_name,
sender_email,
linked_email,
task.get("opportunity_title"),
task.get("contact_id"),
task.get("customer_id"),
):
text = str(value or "").strip()
if text and text.lower() not in {"cliente", "contacto sem identificação"}:
display_name = text
break
if not display_name:
display_name = hint or "Cliente"
contact_name = sender_name
if not contact_name and _name_matches_email(linked_name, sender_email):
contact_name = linked_name
if not contact_name and _name_matches_email(opportunity_customer_name, sender_email):
contact_name = opportunity_customer_name
if not contact_name:
contact_name = sender_email or opportunity_customer_email or "Contacto por confirmar"
return {
"display_name": display_name,
"process_customer_hint": hint,
"fiscal_identity_unsafe": fiscal_unsafe,
"process_identity_unsafe": process_identity_unsafe,
"identity_unsafe": fiscal_unsafe or process_identity_unsafe,
"fiscal_identity_reason": reason,
"unsafe_fiscal_name": linked_name,
"unsafe_fiscal_email": linked_email,
"unsafe_opportunity_customer_name": opportunity_customer_name,
"unsafe_opportunity_customer_email": opportunity_customer_email,
"contact_name": contact_name,
"contact_email": sender_email or opportunity_customer_email or linked_email,
"contact_phone": _task_sender_phone(task),
}
def _safe_fiscal_customer_for_task(task: dict, safe_customer_id: str) -> dict | None:
ctx = _task_identity_context(task)
if ctx.get("identity_unsafe") and not _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")):
return None
name = task.get("linked_customer_name") or task.get("opportunity_customer_name") or task.get("customer_name")
email = task.get("linked_customer_email") or task.get("opportunity_customer_email") or task.get("customer_email")
if safe_customer_id or name:
return {
"id": safe_customer_id,
"name": name,
"email": email,
"tax_id": task.get("linked_customer_tax_id"),
"street_name": task.get("linked_customer_street_name"),
"postal_zone": task.get("linked_customer_postal_zone"),
"city_name": task.get("linked_customer_city_name"),
"phone": task.get("linked_customer_phone"),
}
return None
def _task_identity_warning_html(task: dict) -> str:
ctx = _task_identity_context(task)
if not ctx.get("identity_unsafe") or _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")):
return ""
hint = ctx.get("process_customer_hint") or "processo atual"
linked = ctx.get("unsafe_fiscal_name") or ctx.get("unsafe_opportunity_customer_name") or "cliente/contacto associado"
return f'''
Cliente fiscal por confirmar.
O processo parece ser {esc(hint)}, mas a task está ligada a {esc(linked)}. Não emitir orçamento ou fatura com esta associação sem validar/corrigir o cliente fiscal.
'''
def _task_public_domain_identity_context(task: dict) -> dict:
"""Detect misleading fiscal/contact email matches on public domains.
Public provider domains are communication channels, not company identity.
`epotencia.geral@sapo.pt` and `casa-figueiredo@sapo.pt` must therefore
never be treated as matching evidence just because both end in sapo.pt.
"""
fiscal_email = str(
task.get("linked_customer_email")
or task.get("opportunity_customer_email")
or ""
).strip()
contact_email = _task_sender_email(task)
fiscal_domain = _email_domain_for_identity(fiscal_email)
contact_domain = _email_domain_for_identity(contact_email)
if not fiscal_email or not contact_email or not fiscal_domain or not contact_domain:
return {"warning": False}
if fiscal_email.casefold() == contact_email.casefold():
return {"warning": False}
fiscal_public = fiscal_domain in PUBLIC_CONTACT_EMAIL_DOMAINS
contact_public = contact_domain in PUBLIC_CONTACT_EMAIL_DOMAINS
same_public_domain = fiscal_public and contact_public and fiscal_domain == contact_domain
if not same_public_domain:
return {"warning": False}
return {
"warning": True,
"fiscal_email": fiscal_email,
"contact_email": contact_email,
"domain": fiscal_domain,
"same_local_part": _email_local_for_identity(fiscal_email) == _email_local_for_identity(contact_email),
}
def _task_public_domain_identity_warning_html(task: dict) -> str:
ctx = _task_public_domain_identity_context(task)
if not ctx.get("warning"):
return ""
return f'''
Domínio público não confirma identidade.
O email fiscal {esc(ctx.get('fiscal_email'))} e o contacto Chatwoot {esc(ctx.get('contact_email'))} usam {esc(ctx.get('domain'))}. Este domínio é público/ISP, por isso deve servir apenas como canal de contacto e não como prova de que pertencem à mesma empresa.
'''
def _apply_identity_context_to_preparation(prep_vm: dict, task: dict) -> dict:
ctx = _task_identity_context(task)
if not ctx.get("identity_unsafe") or _task_has_compatible_opportunity_identity(task, ctx.get("process_customer_hint")):
return prep_vm
fixed = dict(prep_vm or {})
confirmed = []
for item in fixed.get("confirmed_fields") or []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "")
if label == "Empresa":
value = ctx.get("process_customer_hint") or ctx.get("display_name") or item.get("value")
confirmed.append({"label": label, "value": f"{value} (por confirmar)"})
elif label == "Email":
confirmed.append({"label": label, "value": ctx.get("contact_email") or item.get("value")})
elif label == "NIF":
# NIF belongs to the unsafe fiscal customer, so do not display it as
# confirmed for this process.
continue
else:
confirmed.append(item)
fixed["confirmed_fields"] = confirmed
return fixed
COMMUNICATION_ACTIONS = {"SEND_INFO", "SEND_QUOTE", "SEND_PROFORMA", "SEND_INVOICE", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC"}
def _task_display_name(task: dict) -> str:
return str(_task_identity_context(task).get("display_name") or "Cliente")
def _task_email(task: dict) -> str:
ctx = _task_identity_context(task)
for value in (
ctx.get("contact_email"),
task.get("opportunity_customer_email"),
task.get("customer_email"),
task.get("linked_customer_email"),
):
text = str(value or "").strip()
if "@" in text:
return text
return ""
def _task_opportunity_navigation_html(task: dict, opportunity_id: str, *, small: bool = False) -> str:
"""Return a useful opportunity navigation control for task detail pages.
Some support/commercial tasks are created from Chatwoot/email before an
opportunity is explicitly linked. In that case, do not leave the operator
without navigation: provide a read-only search link prefilled with the best
email/name/subject hint so the opportunity can be found or linked manually.
"""
btn_class = "btn btn-sm btn-outline-primary" if small else "btn btn-outline-primary"
opportunity_id = str(opportunity_id or "").strip()
if opportunity_id and is_uuid_text(opportunity_id):
return f'Ver oportunidade'
query = (
_safe_search_query(_task_email(task))
or _safe_search_query(_task_display_name(task))
or _safe_search_query(task.get("message_subject"))
or ""
)
href = "/opportunities" + (f"?q={quote(query)}" if query else "")
return (
f'Procurar oportunidade'
'Sem oportunidade ligada diretamente a esta tarefa.'
)
def _task_contact_person(task: dict) -> dict:
try:
return resolve_reply_recipient(task) or {}
except Exception:
return {}
def _task_contact_person_notice_html(task: dict) -> str:
recipient = _task_contact_person(task)
name = str(recipient.get("person_name") or "").strip()
if not name:
return ""
first = str(recipient.get("person_first_name") or "").strip()
source = str(recipient.get("greeting_source") or "").strip()
confidence = str(recipient.get("person_confidence") or "").strip()
greeting = str(recipient.get("preferred_greeting") or "").strip()
bits = [f"Pessoa de contacto: {esc(name)}"]
if first and first != name:
bits.append(f"primeiro nome: {esc(first)}")
if greeting:
bits.append(f"saudação IA: {esc(greeting)}")
if source:
bits.append(f"origem: {esc(source)}{(' · confiança ' + esc(confidence)) if confidence else ''}")
return '
' + ' · '.join(bits) + '
'
def _task_has_channel(task: dict) -> bool:
return bool(_task_email(task) or _task_effective_conversation_id(task))
def _task_effective_conversation_id(task: dict) -> 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 {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
if isinstance(payload, dict):
for path in (("conversation_id",), ("conversation", "id"), ("conversation", "display_id")):
cur = payload
for key in path:
cur = cur.get(key) if isinstance(cur, dict) else None
if cur:
return str(cur).strip()
return ""
def _customer_send_template_code_for_task(task: dict, template_code: str, message_body: str = "") -> str:
code = str(template_code or "").strip().upper()
action = str(task.get("action_code") or "").strip().upper()
body = str(message_body or "")
body_low = body.lower()
if code in {"MANUAL_REVIEW_REQUIRED", "INTERNAL_BOUNCE_EMAIL", "INTERNAL_AUTO_REPLY"} and action in {"SEND_INFO", "SEND_QUOTE", "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "SUPPORT"}:
if body.strip() and not any(marker in body_low for marker in ("triagem interna", "não deve ser enviado ao cliente", "nao deve ser enviado ao cliente", "rever manualmente")):
if action == "SEND_INFO":
return "SEND_INFO_EQUIPMENT_LIST"
if action == "SUPPORT":
return "ACK_SUPPORT_RECEIVED"
return action
return code
def _communication_channel_notice_html(task: dict) -> str:
action_code = str(task.get("action_code") or "").upper()
if action_code not in COMMUNICATION_ACTIONS:
return ""
email = _task_email(task)
conversation_id = _task_effective_conversation_id(task)
if email or conversation_id:
bits = []
if email:
bits.append(f"Email disponível: {esc(email)}")
if conversation_id:
bits.append(f"Chatwoot: conversa #{esc(conversation_id)}")
return '
Canal de comunicação: ' + ' · '.join(bits) + '
'
return """
Sem canal de comunicação visível.
Esta tarefa pede contacto com o cliente, mas não existe email nem conversa Chatwoot associada. Se o pedido veio por WhatsApp, telefone ou outro canal externo, regista o envio manualmente no bloco Concluir.
"""
def _task_has_available_document_for_action(task: dict, action_code: str) -> bool:
"""Return True when a document already exists and can be referenced as sent.
Missing fiscal/contact fields must block issuing or automatic sending, but
they must not prevent the operator from closing a task after sending an
already-created document through WhatsApp, phone or another external channel.
"""
code = str(action_code or "").upper()
docs = _available_docs_for_task_safe(task)
if not docs:
return False
if code == "SEND_INVOICE":
return any(str(doc.get("document_kind") or "").lower() == "invoice" for doc in docs)
if code in {"SEND_QUOTE", "SEND_PROFORMA"}:
return any(str(doc.get("document_kind") or "").lower() in {"quotation", "proforma", "invoice"} for doc in docs)
return bool(docs)
def _external_channel_completion_note(action_code: str, channel: str) -> str:
code = str(action_code or "").upper()
channel_label = str(channel or "canal externo").strip() or "canal externo"
if code == "SEND_PROFORMA":
return f"Orçamento para pagamento enviado ao cliente por {channel_label}."
if code == "SEND_QUOTE":
return f"Orçamento/proposta enviado ao cliente por {channel_label}."
if code == "SEND_INVOICE":
return f"Fatura enviada ao cliente por {channel_label}."
if code.startswith("FOLLOW_UP_"):
return f"Follow-up/contacto feito por {channel_label}."
return f"Contacto tratado pelo operador por {channel_label}."
def _external_channel_completion_form_html(task_id: str, task: dict, action_code: str, status: str, return_to_hidden: str) -> str:
"""Render explicit external-channel completion for WhatsApp/manual flows."""
code = str(action_code or "").upper()
if str(status or "").lower() != "pending" or code not in COMMUNICATION_ACTIONS:
return ""
if _task_has_channel(task):
return ""
has_doc = _task_has_available_document_for_action(task, code)
doc_hint = "Documento/anexo associado encontrado; será registado como enviado externamente." if has_doc else "Sem documento associado visível; usa apenas se o contacto já foi tratado fora do ClientFlow."
default_note = _external_channel_completion_note(code, "WhatsApp")
return f'''
Canal externo
Sem email/Chatwoot nesta tarefa. Usa isto quando já enviaste por WhatsApp, telefone, presencialmente ou outro canal externo. {esc(doc_hint)}
'''
def _context_task_html(task: dict, request_text: str, *, is_follow_up: bool) -> str:
text = str(request_text or "").strip()
if not is_follow_up:
return f'
{esc(text)}
'
marker = "Criado por backfill"
internal = ""
visible = text
if marker in text:
before, after = text.split(marker, 1)
visible = before.strip()
internal = marker + after.strip()
if not visible:
visible = task_next_action_text(task) or str(task.get("action") or "")
internal_html = ""
if internal:
internal_html = f"""
Ver contexto interno
{esc(internal)}
"""
return f"""
Objetivo operacional
{esc(visible)}
{internal_html}
"""
def _document_label_from_doc(doc: dict) -> str:
label = str(doc.get("label") or "").strip()
if label:
return label
number = str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento")
kind = str(doc.get("document_kind") or "documento")
amount = doc.get("total_amount") or doc.get("amount") or ""
currency = str(doc.get("currency") or "EUR")
suffix = f" · {amount} {currency}" if amount else ""
return f"{kind} {number}{suffix}"
def _is_orc_quotation_doc(doc: dict) -> bool:
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"))
def _document_label_for_task(doc: dict, action_code: str) -> str:
if str(action_code or "").upper() == "SEND_PROFORMA" and _is_orc_quotation_doc(doc):
number = str(doc.get("document_number") or doc.get("external_id") or "ORC")
amount = doc.get("total_amount") or doc.get("amount") or ""
currency = str(doc.get("currency") or "EUR")
suffix = f" · {amount} {currency}" if amount else ""
return f"Orçamento para pagamento {number}{suffix}"
return _document_label_from_doc(doc)
def _document_kind_label_for_task(doc: dict, action_code: str) -> str:
kind = str(doc.get("document_kind") or "").lower()
if str(action_code or "").upper() == "SEND_PROFORMA" and _is_orc_quotation_doc(doc):
return "orçamento para pagamento · ORC Jasmin"
return kind or "documento"
def _available_docs_for_task_safe(task: dict) -> list[dict]:
try:
from app.reply_assistant_service import available_documents_for_task
return list(available_documents_for_task(task) or [])
except Exception:
return []
def _default_doc_ids_for_followup(action_code: str, docs: list[dict]) -> list[str]:
if not docs:
return []
expected = {
"FOLLOW_UP_QUOTE": {"quotation"},
"FOLLOW_UP_PROFORMA": {"proforma", "invoice", "quotation"},
"FOLLOW_UP_PAYMENT": {"proforma", "invoice", "quotation"},
}.get(str(action_code or "").upper(), {"quotation"} if str(action_code or "").upper() == "SEND_PROFORMA" else set())
matching = [doc for doc in docs if str(doc.get("document_kind") or "").lower() in expected]
chosen = matching or docs
return [str(chosen[0].get("id"))] if chosen and chosen[0].get("id") else []
def _effective_selected_doc_ids_for_task(task: dict, selected_ids: list[str] | None = None) -> list[str]:
"""Return persisted/posted document ids, falling back to the UI default.
v1.5.39: older drafts saved before attachment persistence can render a
selected ORC/invoice in compact mode via default selection while posting no
hidden selected_document_ids. That made revision/send validation think no
document was selected. Centralise the fallback so UI, revision and send use
the same effective selection.
"""
ids = [str(x).strip() for x in (selected_ids or []) if str(x or "").strip()]
if ids:
return ids
docs = _available_docs_for_task_safe(task)
return _default_doc_ids_for_followup(str(task.get("action_code") or ""), docs)
def _followup_documents_picker_html(task: dict, selected_ids: list[str] | None = None, *, compact: bool = False) -> str:
docs = _available_docs_for_task_safe(task)
if not docs:
return ""
selected = set(str(x) for x in (selected_ids or []))
if not selected:
selected = set(_default_doc_ids_for_followup(str(task.get("action_code") or ""), docs))
warning = ""
if len(docs) > 1:
warning = '
Vários documentos ligados. Confirma qual deve ser usado no rascunho antes de gerar/enviar.
'
rows = []
for doc in docs:
doc_id = str(doc.get("id") or "")
checked = "checked" if doc_id in selected else ""
kind = str(doc.get("document_kind") or "").lower()
label = _document_label_for_task(doc, str(task.get("action_code") or ""))
kind_label = _document_kind_label_for_task(doc, str(task.get("action_code") or ""))
pdf_ok = bool(doc.get("pdf_available") or doc.get("pdf_supported"))
requires_attachment = kind in {"invoice", "quotation", "proforma"}
status_text = str(doc.get("attachment_status") or ("PDF/anexo disponível" if pdf_ok else "PDF/anexo não disponível" if requires_attachment else kind or "documento"))
status_badge = (
'✓ PDF/anexo disponível'
if pdf_ok else
'⚠ PDF/anexo não disponível'
if requires_attachment else
f'{esc(kind or "documento")}'
)
if compact:
if selected and doc_id not in selected:
continue
rows.append(
'
'
f'{esc(label)} '
f'{esc(kind_label)}
{status_badge}✓ selecionado para envio
'
'
'
)
else:
rows.append(f"""
""")
if str(task.get("action_code") or "").upper() == "SEND_PROFORMA":
title = "Documento de orçamento/anexo" if compact else "Documentos de orçamento/anexos da oportunidade"
else:
title = "Documento/anexo usado no rascunho" if compact else "Documentos/anexos da oportunidade"
return f"""
{esc(title)}
{warning}
{''.join(rows) or '
Sem documento selecionado.
'}
"""
def _task_due_chip(task: dict) -> str:
if not task.get("due_at"):
return ""
label = "Follow-up" if _is_follow_up_task(task) else "Vence"
return f'{esc(label)}: {esc(fmt_dt(task.get("due_at")))}'
def _follow_up_controls_html(task_id: str, task: dict, return_to_hidden: str = "") -> str:
if not _is_follow_up_task(task):
return ""
metadata = _metadata_dict(task.get("metadata"))
action_code = str(task.get("action_code") or "")
suggested = str(
metadata.get("suggested_customer_message")
or metadata.get("suggested_message")
or metadata.get("followup_draft")
or _fallback_follow_up_message(action_code)
).strip()
reason = str(metadata.get("follow_up_reason") or "").strip() or action_code
due_line = f'
O sistema agenda e sugere; o operador valida e envia. Nada é enviado automaticamente.
manual/semi-auto
{due_line}
'''
def _task_has_ready_fiscal_customer(action_code: str, fiscal_customer: dict | None, fiscal_missing_labels: list[str]) -> bool:
"""True when the linked fiscal customer should override stale preparation gaps."""
return str(action_code or "").upper() in DOCUMENT_TASK_ACTIONS and bool(fiscal_customer) and not fiscal_missing_labels
def _is_fiscal_preparation_missing_item(item: dict) -> bool:
"""Detect preparation missing fields that are satisfied by a linked fiscal customer.
Preparations are snapshots from the original message extraction. After the
operator enriches/links a fiscal customer, fields such as customer.company,
billing.tax_id or billing.billing_address must no longer drive the visible
task state. Product/shipment gaps are intentionally not matched here.
"""
raw = str((item or {}).get("raw") or "").strip().lower()
label = str((item or {}).get("label") or "").strip().lower()
text = f"{raw} {label}"
fiscal_tokens = (
"customer.company", "customer · company", "company",
"customer.email", "customer · email",
"customer.phone", "customer · phone",
"billing.billing_name", "billing · billing name", "billing name",
"billing.tax_id", "nif", "tax id",
"billing.billing_address", "morada fiscal", "billing address",
"billing.billing_email", "email faturação", "email de faturação",
)
return any(token in text for token in fiscal_tokens)
def _effective_missing_items_for_task(
*,
action_code: str,
prep_vm: dict,
fiscal_customer: dict | None,
fiscal_missing_labels: list[str],
) -> list[dict]:
"""Merge preparation gaps with current fiscal readiness without contradictions."""
missing_items = [item for item in list(prep_vm.get("missing_fields") or []) if isinstance(item, dict)]
if _task_has_ready_fiscal_customer(action_code, fiscal_customer, fiscal_missing_labels):
missing_items = [item for item in missing_items if not _is_fiscal_preparation_missing_item(item)]
else:
if fiscal_customer:
# Remove stale, generic preparation gaps that contradict the current
# task/opportunity context. Keep concrete missing fields below
# (morada, CP, localidade, email de faturação, etc.).
stale_generic = (
"cliente fiscal por confirmar",
"cliente fiscal associado",
"cliente fiscal: cliente fiscal por confirmar",
"cliente fiscal: cliente fiscal associado",
)
missing_items = [
item for item in missing_items
if str(item.get("label") or "").strip().lower() not in stale_generic
]
existing_missing_labels = {str(item.get("label") or "") for item in missing_items}
for label in fiscal_missing_labels:
fiscal_label = f"Cliente fiscal: {label}"
if fiscal_label not in existing_missing_labels:
missing_items.append({"label": fiscal_label})
return missing_items
def _effective_primary_action_for_task(action_code: str, prep_vm: dict, missing_items: list[dict]) -> str:
primary = str(prep_vm.get("primary_action") or "").strip()
if not missing_items and primary.lower().startswith("pedir dados em falta"):
return primary_action_label(action_code, fallback=action_label(action_code))
return primary or primary_action_label(action_code, fallback=action_label(action_code))
def _ready_document_reply_for_task(task: dict, action_code: str) -> str:
customer_name = str(task.get("customer_name") or task.get("linked_customer_name") or "").strip()
first_name = customer_name.split()[0] if customer_name else ""
greeting = f"Olá {first_name}," if first_name and first_name.lower() not in ["cliente", "desconhecido"] else "Olá,"
closing = "Obrigado,\nEquipa BLIF"
if str(action_code or "").upper() == "SEND_PROFORMA":
return f"""{greeting}
Segue em anexo o orçamento para pagamento solicitado.
Após pagamento, envie por favor o comprovativo para confirmação e seguimento da encomenda.
{closing}"""
if str(action_code or "").upper() == "SEND_INVOICE":
return f"""{greeting}
Segue em anexo a fatura solicitada.
Qualquer questão, estamos ao dispor.
{closing}"""
if str(action_code or "").upper() == "SEND_QUOTE":
return f"""{greeting}
Segue em anexo a proposta solicitada.
Qualquer questão ou ajuste necessário, estamos ao dispor.
{closing}"""
return suggested_reply_for_task(task)
def _effective_suggested_reply_for_task(task: dict, action_code: str, prep_vm: dict, missing_items: list[dict]) -> str:
suggested = str(prep_vm.get("suggested_reply") or "").strip()
if not missing_items and str(action_code or "").upper() in DOCUMENT_TASK_ACTIONS:
# Avoid showing stale preparation text asking for fiscal data that is now complete.
stale_markers = ("dados de fatur", "dados para fatur", "nif", "morada fiscal", "email de fatur")
if not suggested or any(marker in suggested.lower() for marker in stale_markers):
return _ready_document_reply_for_task(task, action_code)
return suggested or suggested_reply_for_task(task)
def _tasks_for_filters(status: Optional[str] = "pending", route: Optional[str] = None, view: Optional[str] = None, q: Optional[str] = None, limit: int = 200):
effective_status = status or "pending"
status_filter = None if effective_status == "all" else effective_status
q = _safe_search_query(q)
tasks = list_tasks(status=status_filter, route=route, q=q, limit=limit)
if view == "overdue":
tasks = [task for task in tasks if is_task_overdue(task)]
elif view == "today":
tasks = [task for task in tasks if is_task_today(task)]
elif view == "followups":
tasks = [task for task in tasks if _is_follow_up_task(task)]
elif view == "followups_due":
tasks = [task for task in tasks if _is_follow_up_task(task) and is_task_overdue(task)]
return tasks
def render_tasks_list_partial(tasks: list[dict], return_to: str = "/tasks?status=pending") -> str:
rows = ""
for task in tasks:
task_id = str(task.get("id") or "")
task_url = _task_href(task_id, return_to)
action_code = str(task.get("action_code") or "")
customer = _task_display_name(task)
subject = compact_text(task.get("message_subject") or "", 80)
detail = compact_text(task_next_action_text(task), 150)
opp_id = opportunity_id_from_task(task)
source_system = str(task.get("source_system") or "").strip()
conversation_id = str(task.get("conversation_id") or "").strip()
source_line = ""
if source_system or conversation_id:
source_bits = []
if source_system:
source_bits.append(source_system.capitalize())
if conversation_id:
source_bits.append(f"conversa #{conversation_id}")
source_line = '
Mesma leitura da Fila operacional: prioridade, cliente/oportunidade, fila, estado e próxima ação. Esta página mostra apenas tasks humanas.
{len(tasks)} resultado(s)
{render_tasks_list_partial(tasks, return_to=return_to)}
'''
return layout("Tarefas", "Fila operacional com foco na próxima ação", body, "tasks")
def _reply_assistant_panel_html(task_id: str, state: dict | None = None, notice: str = "", error: str = "") -> str:
"""Render the editable reply assistant panel for a task."""
try:
from app.message_templates import default_template_for_action, list_templates_for_action
from app.reply_assistant_service import get_reply_panel_state
task = get_task_detail(task_id)
if not task:
return '
Tarefa não encontrada.
'
if state is None:
state = get_reply_panel_state(task_id)
action_code = str(task.get("action_code") or "")
current_template_code = str((state.get("template") or {}).get("code") or default_template_for_action(action_code).code)
templates = list_templates_for_action(action_code)
template_options = "".join(
f''
for tpl in templates
)
selected_ids = {str(item) for item in (state.get("selected_document_ids") or [])}
documents = state.get("documents") or []
doc_checks = ""
for doc in documents:
doc_id = str(doc.get("id") or "")
checked = "checked" if doc_id in selected_ids else ""
disabled_hint = "" if doc.get("pdf_supported") else 'PDF automático indisponível'
label = doc.get('label') or doc.get('document_number') or doc_id
system = str(doc.get('system') or '')
kind = str(doc.get('document_kind') or '')
doc_checks += (
f''
)
if not doc_checks:
doc_checks = '
Sem documentos/anexos associados à oportunidade.
'
warnings = state.get("warnings") or []
blockers = state.get("blockers") or []
warning_html = "".join(f'
{esc(item)}
' for item in warnings)
blocker_html = "".join(f'
{esc(item)}
' for item in blockers)
notice_html = f'
{esc(notice)}
' if notice else ""
error_html = f'
{esc(error)}
' if error else ""
draft_id = str(state.get("draft_id") or "")
message_body = str(state.get("message_body") or "")
selected_hidden = "".join(f'' for doc_id in selected_ids)
# The legacy eager reply panel also renders the send form. Keep the
# operator instruction hidden field defined even when the current state
# does not carry one, otherwise support/reply tasks can show
# "Assistente indisponível: name 'instruction_hidden' is not defined".
operator_instruction = str(state.get("operator_instruction") or state.get("instruction") or "")
instruction_hidden = f''
knowledge = state.get("business_knowledge") or {}
topics = knowledge.get("topics") or []
knowledge_html = ""
if topics:
topic_items = ""
for topic in topics[:3]:
facts = topic.get("facts") or []
facts_html = "".join(f'
{esc(str(fact))}
' for fact in facts[:3])
topic_items += f'''
{esc(topic.get('title') or topic.get('id') or 'Conhecimento BLIF')}
{esc(topic.get('summary') or '')}
{facts_html}
'''
reply_type = str(knowledge.get("reply_type") or "")
knowledge_html = f'''
Conhecimento BLIF usado · {esc(reply_type or 'resposta')}
{topic_items}
'''
intent_gate = state.get("intent_gate") or {}
intent_html = ""
if intent_gate:
reasons = "; ".join(str(item) for item in (intent_gate.get("reasons") or []))
label = str(intent_gate.get("label") or intent_gate.get("category") or "Triagem")
category = str(intent_gate.get("category") or "")
badge = "text-bg-warning" if intent_gate.get("requires_manual_review") else "text-bg-info"
intent_html = f"""
Diagnóstico da IA · triagem {esc(category)}
{esc(label)}{(" · " + esc(reasons)) if reasons else ""}
"""
email_agent = state.get("email_agent") or {}
email_agent_html = ""
if email_agent:
if email_agent.get("enabled"):
status = "usado" if email_agent.get("used") else "fallback"
intent = str(email_agent.get("intencao") or "")
confidence = str(email_agent.get("nivel_confianca") or "")
review = bool(email_agent.get("precisa_revisao_humana"))
review_badge = 'revisão humana' if review else 'rascunho simples'
details = " · ".join(part for part in [intent, confidence] if part)
email_agent_html = f'''
Agente de email OpenAI/file_search {esc(status)}{review_badge}
{esc(details or str(email_agent.get('error') or ''))}
'''
elif email_agent.get("error"):
email_agent_html = f'
Agente de email OpenAI: fallback · {esc(str(email_agent.get("error") or ""))}
'
llm = state.get("llm") or {}
llm_html = ""
if llm.get("enabled"):
status = "usado" if llm.get("used") else "fallback"
detail = str(llm.get("error") or llm.get("intent") or "")
llm_html = f'
'
def _latest_message_draft_for_task(task_id: str) -> dict:
"""Load the latest persisted editable draft without invoking the LLM.
This keeps the task detail page fast while making generated drafts survive a
browser refresh. The expensive OpenAI/file_search generation still happens
only in /tasks/{task_id}/reply-draft.
"""
if not is_uuid_text(str(task_id or "")):
return {}
try:
from sqlalchemy import text as sa_text
from app.db import engine
with engine.begin() as conn:
row = conn.execute(sa_text("""
SELECT
id::text AS id,
task_id::text AS task_id,
template_code,
message_body,
selected_document_ids::text AS selected_document_ids,
operator_instruction,
generated_by,
status,
updated_at
FROM message_drafts
WHERE task_id = CAST(:task_id AS UUID)
AND status = 'draft'
ORDER BY updated_at DESC NULLS LAST, created_at DESC
LIMIT 1
"""), {"task_id": task_id}).mappings().first()
if not row:
return {}
selected_raw = row.get("selected_document_ids") or "[]"
try:
selected_ids = json.loads(selected_raw) if isinstance(selected_raw, str) else list(selected_raw or [])
except Exception:
selected_ids = []
return {
"draft_id": str(row.get("id") or ""),
"template_code": str(row.get("template_code") or ""),
"message_body": str(row.get("message_body") or ""),
"selected_document_ids": [str(item) for item in selected_ids],
"operator_instruction": str(row.get("operator_instruction") or ""),
"generated_by": str(row.get("generated_by") or ""),
"updated_at": str(row.get("updated_at") or ""),
}
except Exception:
return {}
NON_COMMUNICATION_TASK_ACTIONS = {
"CONFIRM_PAYMENT",
"PREPARE_ORDER",
"VALIDATE_PHYSICAL_ORDER",
"CREATE_SHIPMENT",
"REVIEW_RECONSTRUCTED_PROCESS",
}
def _task_reply_assistant_html(task_id: str, action_code: str) -> str:
if str(action_code or "").upper() in NON_COMMUNICATION_TASK_ACTIONS:
return ""
return _reply_assistant_lazy_panel_html(task_id)
def _reply_assistant_lazy_panel_html(task_id: str, notice: str = "") -> str:
"""Render a lightweight reply panel without calling LLM/vector-store.
The full assistant state can be slow because it may call the email reply
agent, OpenRouter/OpenAI and knowledge retrieval. Task detail pages should
open fast; the expensive generation is triggered only by the operator via
/tasks/{task_id}/reply-draft. v4928.1.5.15 also reloads the latest
persisted message_draft after refresh without invoking the LLM.
"""
notice_html = f'
{esc(notice)}
' if notice else ""
try:
task = get_task_detail(task_id) or {}
except Exception:
task = {}
action_code = str(task.get("action_code") or "")
is_follow_up = _is_follow_up_task(action_code)
default_template_code = _follow_up_template_for_action(action_code) if is_follow_up else ""
panel_title = "Rascunho de follow-up" if is_follow_up else "Resposta ao cliente"
generate_label = "Gerar rascunho personalizado com IA" if is_follow_up else "Gerar rascunho"
regenerate_label = "Regenerar rascunho personalizado com IA" if is_follow_up else "Regenerar rascunho"
mode_badge = "lazy · follow-up · OpenAI · v4928.1.5.24" if is_follow_up else "lazy · v4928.1.5.16"
help_text = (
"A tarefa abre sem chamar IA. Este botão chama OpenAI com um prompt específico de follow-up e personaliza a mensagem com dados do cliente, oportunidade, documentos e histórico recente. Nada é enviado automaticamente."
if is_follow_up else
"A tarefa abre sem chamar IA. Gera o rascunho apenas quando precisares de responder; a geração usa OpenAI e o histórico recente da conversa."
)
draft = _latest_message_draft_for_task(task_id)
if draft.get("message_body"):
draft_id = str(draft.get("draft_id") or "")
template_code = str(draft.get("template_code") or "")
message_body = str(draft.get("message_body") or "")
template_code = _customer_send_template_code_for_task(task, template_code, message_body)
send_conversation_id = _task_effective_conversation_id(task)
selected_ids = [str(item) for item in (draft.get("selected_document_ids") or [])]
selected_ids = _effective_selected_doc_ids_for_task(task, selected_ids)
operator_instruction = str(draft.get("operator_instruction") or "")
selected_hidden = "".join(f'' for doc_id in selected_ids)
instruction_hidden = f''
documents_html = _followup_documents_picker_html(task, selected_ids, compact=True) if is_follow_up else _followup_documents_picker_html(task, selected_ids, compact=True)
email_to = _task_email(task)
subject_for_email = str(task.get("message_subject") or task.get("opportunity_title") or "Seguimento do processo")
email_hidden = f''
updated_at = str(draft.get("updated_at") or "")
generated_by = str(draft.get("generated_by") or "")
meta_bits = " · ".join(part for part in ["rascunho guardado", generated_by, updated_at[:19]] if part)
return f"""
{esc(panel_title)}
{esc(meta_bits)}. Podes editar, guardar ou pedir uma correção à IA antes de enviar.
v4928.1.5.121 · persistente
{notice_html}
{f'
Objetivo/instrução do operador
{esc(operator_instruction) if operator_instruction else "Sem instrução adicional."}
'}
{documents_html}
"""
try:
from app.message_templates import default_template_for_action, list_templates_for_action
templates = list_templates_for_action(action_code)
default_template = default_template_code or default_template_for_action(action_code).code
template_options = "".join(
f''
for tpl in templates
)
except Exception:
template_options = f''
documents_picker = _followup_documents_picker_html(task) if is_follow_up else _followup_documents_picker_html(task)
return f"""
{esc(panel_title)}
{esc(help_text)} O operador escolhe o objetivo e os anexos antes de chamar IA.
'''
return _safe_task_display_html(html)
@router.get("/tasks/{task_id}/partials/detail", response_class=HTMLResponse)
async def task_detail_partial(task_id: str, return_to: str = ""):
if not is_uuid_text(task_id):
return _invalid_task_response()
return HTMLResponse(render_task_detail_partial(task_id, return_to=return_to))
@router.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_bootstrap_page(task_id: str, return_to: str = ""):
if not is_uuid_text(task_id):
return _invalid_task_response()
task = get_task_detail(task_id)
if not task:
return HTMLResponse("
Tarefa não encontrada
", status_code=404)
return_to = _safe_return_to(return_to, default="/tasks?status=pending")
return_to_hidden = _return_to_hidden(return_to)
return_link_html = _return_to_link(return_to)
action_code = str(task.get("action_code") or "")
route_name = str(task.get("route") or "")
status = str(task.get("status") or "")
conversation_id = str(task.get("conversation_id") or "")
contact_id = str(task.get("contact_id") or "")
local_customer_id = str(task.get("linked_customer_id") or "")
task_customer_id = str(task.get("customer_id") or "")
safe_customer_id = local_customer_id or (task_customer_id if is_uuid_text(task_customer_id) else "")
customer = _task_display_name(task)
customer_email = _task_email(task)
customer_phone = str(task.get("customer_phone") or "")
subject = str(task.get("message_subject") or "—")
opportunity_id = opportunity_id_from_task(task)
request_text = (
task.get("request_text")
or task.get("clean_body")
or task.get("raw_body")
or task.get("note")
or ""
)
if len(str(request_text)) > 1600:
request_text = str(request_text)[:1600] + "…"
request_text = _safe_task_display_html(str(request_text))
preparation = get_latest_task_preparation(task_id)
prep_vm = _apply_identity_context_to_preparation(build_preparation_view_model(task, preparation), task)
done_note_options_html = done_note_options_html_for(action_code) or ""
public_url = (
getattr(settings, "chatwoot_public_url", "")
or getattr(settings, "chatwoot_base_url", "")
or ""
).rstrip("/")
account_id = getattr(settings, "chatwoot_account_id", "")
chatwoot_link = ""
if public_url and account_id and conversation_id:
href = f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}"
chatwoot_link = f'Abrir Chatwoot ↗'
identity_ctx = _task_identity_context(task)
fiscal_customer = _safe_fiscal_customer_for_task(task, safe_customer_id)
fiscal_contact_html = fiscal_contact_panel_html(
fiscal_customer=fiscal_customer,
contact_name=identity_ctx.get("contact_name") or customer,
contact_email=identity_ctx.get("contact_email") or customer_email,
contact_phone=identity_ctx.get("contact_phone") or customer_phone,
conversation_id=conversation_id,
contact_id=contact_id,
customer_href=f"/customers/{esc(safe_customer_id)}" if safe_customer_id and fiscal_customer else "",
)
fiscal_missing_labels = fiscal_customer_missing_fields(fiscal_customer) if action_code in DOCUMENT_TASK_ACTIONS else []
if identity_ctx.get("identity_unsafe") and action_code in DOCUMENT_TASK_ACTIONS:
if not _task_has_compatible_opportunity_identity(task, identity_ctx.get("process_customer_hint")):
fiscal_missing_labels = ["Cliente fiscal por confirmar", *fiscal_missing_labels]
fiscal_readiness_html = readiness_checklist_html(
title="Prontidão fiscal da tarefa",
missing=fiscal_missing_labels,
ok_text="Sem bloqueios fiscais mínimos para esta tarefa.",
blocked_text="Corrigir estes dados antes de emitir documento.",
)
missing_items = _effective_missing_items_for_task(
action_code=action_code,
prep_vm=prep_vm,
fiscal_customer=fiscal_customer,
fiscal_missing_labels=fiscal_missing_labels,
)
suggested_reply = _safe_task_display_html(_effective_suggested_reply_for_task(task, action_code, prep_vm, missing_items))
primary_action_text = _effective_primary_action_for_task(action_code, prep_vm, missing_items)
if missing_items:
missing_html = "".join(
f'⚠ {esc(item.get("label"))}'
for item in missing_items
)
else:
missing_html = 'Sem dados críticos em falta'
confirmed = prep_vm.get("confirmed_fields") or []
confirmed_html = "".join(
f"
{esc(item.get('label'))}{esc(item.get('value'))}
"
for item in confirmed[:8]
) or '
Ainda não existem dados confirmados pela preparação.
'
prep_type = str(prep_vm.get("prep_type") or "generic")
assistant_buttons = ""
if action_code == "SEND_PROFORMA":
assistant_buttons += f''
if action_code in {"CONFIRM_PAYMENT", "SUPPORT"}:
assistant_buttons += f''
assistant_buttons += f''
if not assistant_buttons:
assistant_buttons = '
Sem assistente específico para esta ação.
'
external_completion_html = _external_channel_completion_form_html(task_id, task, action_code, status, return_to_hidden)
completion_html = ""
fiscal_completion_blockers = [
str(item.get("label") or "")
for item in missing_items
if "cliente fiscal" in str(item.get("label") or "").lower()
and any(marker in str(item.get("label") or "").lower() for marker in ("por confirmar", "por associar", "nif divergente", "cliente errado"))
]
if status == "pending" and action_code in {"SEND_PROFORMA", "SEND_INVOICE"} and fiscal_completion_blockers:
completion_html = '
Valida a identidade fiscal antes de concluir esta tarefa fiscal.
'
elif status == "pending":
completion_html = f"""
{external_completion_html}
"""
else:
completion_html = f'
Usa apenas quando a ação sugerida não corresponde ao pedido do cliente.
"""
technical = prep_vm.get("technical") or {}
def _has_useful_technical_data(data: object) -> bool:
if data is None:
return False
if isinstance(data, dict):
return any(_has_useful_technical_data(value) for value in data.values())
if isinstance(data, (list, tuple, set)):
return any(_has_useful_technical_data(value) for value in data)
return str(data).strip() not in {"", "—", "None", "null"}
technical_entries = [
("Cliente", technical.get("customer")),
("Faturação", technical.get("billing")),
("Venda", technical.get("sale")),
("Logística", technical.get("shipment")),
]
technical_blocks = "".join(
f"