Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 09:53:32 -03:00
parent 0ecff719b7
commit 88e1f070d7
791 changed files with 27040 additions and 29038 deletions

View File

@@ -402,7 +402,44 @@ class TimApiClient:
def contestar(self, payload: dict[str, Any]) -> Any:
if self.mock:
return self.fixture("contestacao_tool")
# The mock must behave like the real provider for the current request.
# Returning the whole static fixture leaked unrelated invoice items into
# a one-item transaction and also exposed contradictory fixture fields.
fixture = self.fixture("contestacao_tool")
provider = fixture.get("body") if isinstance(fixture, dict) and isinstance(fixture.get("body"), dict) else fixture
provider = dict(provider or {})
requested = [x for x in (payload.get("items") or []) if isinstance(x, dict)]
fixture_rows = [x for x in (provider.get("itemsResponse") or provider.get("items_response") or []) if isinstance(x, dict)]
def norm(value: Any) -> str:
import unicodedata
text = unicodedata.normalize("NFKD", str(value or "").casefold())
text = "".join(ch for ch in text if not unicodedata.combining(ch))
return " ".join("".join(ch if ch.isalnum() else " " for ch in text).split())
selected: list[dict[str, Any]] = []
for req in requested:
name = str(req.get("itemName") or req.get("item_name") or req.get("name") or "").strip()
row = next((dict(x) for x in fixture_rows if norm(x.get("itemName") or x.get("item_name")) == norm(name)), None)
if row is None:
row = {
"correctAccountStatus": "NAO_CRIAR",
"itemName": name,
"message": "Item não existe na fatura com o valor informado",
"status": "NAO_INICIADA",
}
selected.append(row)
if requested:
provider["itemsResponse"] = selected
provider["sr"] = str(payload.get("sr") or provider.get("sr") or "")
# Keep useful provider response fields, but never return the fixture's
# stale top-level normalized/result/protocol fields as if they came
# from the remote API.
if isinstance(fixture, dict):
for key in ("barcode", "codigo_boleto", "contestation_id", "contestationId", "manualContaCertaIndicator"):
if key in fixture and key not in provider:
provider[key] = fixture[key]
return provider
data = dict(payload)
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", ))
data.setdefault("customerIdCurrent", data.get("customerId") or "")

View File

@@ -36,15 +36,54 @@ def _money(value: Decimal) -> Decimal:
def _parse_amount(value: str) -> Decimal | None:
if not value:
"""Parse monetary values without assuming that every dot is a thousands separator.
Accepted examples include Brazilian and API/JSON representations such as
``R$ 19,99``, ``19.99``, ``1.999,99`` and ``1,999.99``.
"""
if value is None:
return None
cleaned = (
str(value)
.replace("R$", "")
.replace(" ", "")
.replace(".", "")
.replace(",", ".")
)
cleaned = str(value).strip().replace("R$", "").replace(" ", "")
if not cleaned:
return None
# Keep only a numeric sign and decimal/grouping separators. This avoids
# accidentally feeding currency labels or other text to Decimal.
cleaned = re.sub(r"[^0-9, .+\-]", "", cleaned).replace(" ", "")
if not cleaned:
return None
comma = cleaned.rfind(",")
dot = cleaned.rfind(".")
if comma >= 0 and dot >= 0:
# The rightmost separator is the decimal separator; the other one is
# grouping. This supports both 1.999,99 and 1,999.99.
if comma > dot:
cleaned = cleaned.replace(".", "").replace(",", ".")
else:
cleaned = cleaned.replace(",", "")
elif comma >= 0:
# pt-BR decimal notation. Multiple commas are treated conservatively
# by preserving only the last one as decimal separator.
if cleaned.count(",") > 1:
head, tail = cleaned.rsplit(",", 1)
cleaned = head.replace(",", "") + "." + tail
else:
cleaned = cleaned.replace(",", ".")
elif dot >= 0:
# A single dot followed by 1-2 digits is decimal notation (the form
# normally returned by JSON/backends). For multiple dots, keep the
# last one as decimal only when it looks like cents; otherwise treat
# them as grouping separators.
if cleaned.count(".") > 1:
head, tail = cleaned.rsplit(".", 1)
if 1 <= len(tail) <= 2:
cleaned = head.replace(".", "") + "." + tail
else:
cleaned = cleaned.replace(".", "")
try:
return Decimal(cleaned)
except Exception:
@@ -427,7 +466,48 @@ def validate_contestation_items(
0 if _normalize_match_text(candidate.get("name", "")) == _normalize_match_text(item_name) else 1,
)
)
matched_candidate = matching_candidates[0] if matching_candidates else None
# When the same subject appears more than once on the invoice, the
# requested amount is useful evidence for selecting the correct
# occurrence. Prefer an exact amount match. For a partial adjustment
# choose the smallest invoice occurrence that can cover the requested
# amount. If none can, select the largest occurrence so the generic
# ``validated > item_amount`` rule below rejects the request.
matched_candidate = None
if matching_candidates:
requested_amount = validated if validated > 0 else claimed
if requested_amount > 0 and len(matching_candidates) > 1:
monetary_candidates = [
candidate
for candidate in matching_candidates
if isinstance(candidate.get("amount"), Decimal)
and candidate.get("amount") > 0
]
exact_matches = [
candidate
for candidate in monetary_candidates
if _money(candidate["amount"]) == _money(requested_amount)
]
if exact_matches:
matched_candidate = exact_matches[0]
else:
sufficient = sorted(
(
candidate
for candidate in monetary_candidates
if candidate["amount"] >= requested_amount
),
key=lambda candidate: candidate["amount"],
)
if sufficient:
matched_candidate = sufficient[0]
elif monetary_candidates:
matched_candidate = max(
monetary_candidates,
key=lambda candidate: candidate["amount"],
)
if matched_candidate is None:
matched_candidate = matching_candidates[0]
if matched_candidate is None:
_record_failure(
item_log,

View File

@@ -0,0 +1,23 @@
{
"accounts": [
{
"authenticated_msisdn": "11999999999",
"customer_key": "11999999999",
"contract_key": "3000131180",
"authorized_lines": [
{
"msisdn": "11999999999",
"relationship": "titular",
"status": "ACTIVE",
"authorized": true
},
{
"msisdn": "11988884321",
"relationship": "dependente",
"status": "ACTIVE",
"authorized": true
}
]
}
]
}

View File

@@ -0,0 +1,52 @@
{
"accounts": [
{
"msisdn": "11999999999",
"customer_key": "11999999999",
"contract_key": "3000131180",
"discounts": [
{
"discount_id": "DESC-FIDEL-80-BLACK",
"discount_name": "Desc Fidel 80 TIM Black A compartilhado 8.0",
"discount_type": "FIDELITY",
"plan_name": "TIM Black A 8.0",
"previous_value": 80.0,
"current_value": 0.0,
"start_date": "2024-11-20",
"end_date": "2025-11-20",
"discount_status": "EXPIRED",
"termination_reason": "FIM_PERIODO_FIDELIDADE",
"termination_reason_description": "O período de fidelidade contratado foi encerrado.",
"current_value_reference": "contract_as_of_date",
"as_of_date": "2025-11-20",
"last_billed_discount_value": 80.0,
"last_billed_period": "14/10 a 13/11",
"last_invoice_issue_date": "2025-11-20",
"last_billed_status": "APPLIED"
},
{
"discount_id": "DESC-FIDEL-33-CTRL",
"discount_name": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0",
"discount_type": "FIDELITY",
"plan_name": "TIM CTRL Redes Sociais 8.0",
"previous_value": 33.0,
"current_value": 33.0,
"start_date": "2025-04-20",
"end_date": "2026-04-20",
"discount_status": "ACTIVE",
"termination_reason": null,
"termination_reason_description": null,
"current_value_reference": "contract_as_of_date",
"as_of_date": "2025-11-20",
"last_billed_discount_value": 33.0,
"last_billed_period": "14/10 a 13/11",
"last_invoice_issue_date": "2025-11-20",
"last_billed_status": "APPLIED"
}
],
"as_of_date": "2025-11-20",
"last_invoice_issue_date": "2025-11-20",
"last_billed_period": "14/10 a 13/11"
}
]
}

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import re
import unicodedata
from typing import Any
_DIGIT_WORDS = {
"zero": "0", "um": "1", "uma": "1", "dois": "2", "duas": "2",
"tres": "3", "quatro": "4", "cinco": "5", "seis": "6", "sete": "7",
"oito": "8", "nove": "9",
}
def _norm(text: Any) -> str:
value = unicodedata.normalize("NFKD", str(text or "").casefold())
value = "".join(ch for ch in value if not unicodedata.combining(ch))
return re.sub(r"\s+", " ", value).strip()
def extract_requested_line_reference(text: Any) -> dict[str, str] | None:
"""Extrai somente uma referência explícita de linha citada pelo usuário.
Não transforma a referência em identidade autorizada. Essa decisão pertence à
política de linha ativa no domínio Contas.
"""
raw = str(text or "").strip()
if not raw:
return None
norm = _norm(raw)
# Número completo explicitamente presente no texto (10 a 13 dígitos, com
# separadores opcionais). Evita capturar valores monetários ou protocolos curtos.
for match in re.finditer(r"(?<!\d)(?:\+?\d[\s().-]*){10,13}(?!\d)", raw):
digits = "".join(ch for ch in match.group(0) if ch.isdigit())
if 10 <= len(digits) <= 13:
return {"kind": "full", "value": digits, "raw": match.group(0).strip()}
# Referência por final da linha: "final 4321", "final quatro três dois um",
# "termina em 4321". Só a referência é extraída; nunca é assumida como MSISDN.
marker = re.search(r"\b(?:final|termina(?:ndo)?\s+em|terminado\s+em)\b(.{0,45})", norm)
if marker:
tail = marker.group(1)
numeric = re.search(r"\b(\d{4})\b", tail)
if numeric:
return {"kind": "suffix", "value": numeric.group(1), "raw": numeric.group(1)}
tokens = re.findall(r"[a-z]+", tail)
digits: list[str] = []
for token in tokens:
if token in _DIGIT_WORDS:
digits.append(_DIGIT_WORDS[token])
if len(digits) == 4:
return {"kind": "suffix", "value": "".join(digits), "raw": " ".join(tokens[:4])}
elif digits:
break
return None

View File

@@ -114,9 +114,26 @@ class ContasDomainService:
return {
"complete_invoices": complete,
"billing_analysis": billing,
"invoice_detail": args.get("invoice_detail") if isinstance(args.get("invoice_detail"), dict) else {},
"invoice_amount": args.get("invoice_amount"),
"invoice_period": args.get("invoice_period"),
"invoice_emissao": args.get("invoice_emissao"),
"instruction": "Use estes dados como evidência para explicar composição/variação da fatura. Não invente cobranças ausentes.",
}
def buscar_informacao(self, *, queries: list[str] | None = None, **_: Any) -> dict[str, Any]:
"""Preserva a capability legada sem reimplementar RAG no domínio.
O agente/framework é o dono da recuperação. Esta tool apenas converte o
pedido legado em um contrato explícito de RAG, mantendo paridade de API.
"""
normalized = [str(q).strip() for q in (queries or []) if str(q).strip()]
return {
"requires_rag": True,
"source": "agent_framework.rag",
"rag_queries": normalized,
}
def consultar_vas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.consultar_vas(msisdn)

View File

@@ -267,6 +267,31 @@ def _successful_contestation_item(item: dict[str, Any]) -> bool:
return any(str(x or "").strip().upper() in {"ENVIADA", "CRIAR", "INICIADA"} for x in statuses)
def _money_decimal(value: Any) -> Decimal:
"""Parse TIM monetary values without turning 14.99 into 1499.
Accepts canonical decimal-dot values (14.99), pt-BR decimal-comma values
(14,99), and values with thousands separators (1.234,56 / 1,234.56).
"""
text = str(value if value is not None else "0").strip()
if not text:
return Decimal("0")
text = re.sub(r"[^0-9,.-]", "", text)
if "," in text and "." in text:
if text.rfind(",") > text.rfind("."):
text = text.replace(".", "").replace(",", ".")
else:
text = text.replace(",", "")
elif "," in text:
text = text.replace(".", "").replace(",", ".")
# Dot-only input is already the canonical decimal representation used by
# the migrated agent/MCP contract. Do not strip it as a thousands marker.
try:
return Decimal(text)
except InvalidOperation:
return Decimal("0")
def _classify_contestation_items(requested: list[dict[str, Any]], response_items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str], str, str]:
contested = [x for x in response_items if _successful_contestation_item(x) and not _already_contested_item(x)]
already = [str(x.get("itemName") or x.get("item_name") or "").strip() for x in response_items if _already_contested_item(x)]
@@ -293,13 +318,10 @@ def _classify_contestation_items(requested: list[dict[str, Any]], response_items
name = str(req.get("item_name") or req.get("itemName") or req.get("name") or "")
if not any(_same_contestation_name(name, x) for x in contested_names):
continue
try:
c = str(req.get("claimed_amount", req.get("claimedAmount", "0"))).replace(".", "").replace(",", ".")
v = str(req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))).replace(".", "").replace(",", ".")
claimed += Decimal(c)
validated += Decimal(v)
except InvalidOperation:
pass
claimed += _money_decimal(req.get("claimed_amount", req.get("claimedAmount", "0")))
validated += _money_decimal(
req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))
)
return contested, not_contested, already, f"{claimed:.2f}", f"{validated:.2f}"
@@ -645,7 +667,15 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
message = base
if trailer and trailer.lower() not in message.lower():
message = f"{message.rstrip()} {trailer}".strip()
return {"mensagem": message}
return {
"mensagem": message,
"await_user_input": True,
"requires_llm_composition": True,
"response_instruction": (
"Componha a resposta somente com a evidência fornecida; não invente cobranças, "
"causas, políticas ou valores ausentes. Preserve a pergunta final prevista pelo workflow."
),
}
@reg.action("checar_tentativa_cvn")
def checar_tentativa_cvn(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -660,6 +690,26 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
event_params = {**params, "customerMessage": str(_first(params, state, "resposta_usuario", "customer_message") or path)}
return {"success": True, "accepted": accepted, "business_events": _events_ctx(MPITag.EXPLICACAO_SIM if accepted else MPITag.EXPLICACAO_NAO, event_params, state)}
@reg.action("preparar_handoff_invoice_explanation")
def preparar_handoff_invoice_explanation(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
"""Materializa a decisão de handoff declarada no workflow do domínio.
A action não decide quando transferir: ela apenas transforma a configuração
do nó em um resultado estrutural consumível pelo agente/framework.
"""
message = str(params.get("mensagem") or "Para continuar com a sua solicitação, aguarde um instante.").strip()
reason = str(params.get("reason") or "invoice_explanation_not_resolved").strip()
return {
"success": True,
"mensagem": message,
"session_control": "HUMAN_HANDOFF",
"human_handoff_requested": True,
"handoff": True,
"session_ended": True,
"terminal_status": "human_handoff",
"handoff_reason": reason,
}
@reg.action("registrar_protocolo_inicio")
@reg.action("registrar_protocolo")
def registrar_protocolo(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -686,7 +736,18 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
or (result or {}).get("protocolo")
or ""
) if isinstance(result, dict) else ""
return {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
response = {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
# A protocol-opening action is reused by several workflows. Only nodes
# that explicitly request a final workflow response opt into this
# presentation contract; session terminality remains independent.
if bool(params.get("workflow_response_final")):
response["workflow_response_final"] = True
configured = str(params.get("mensagem_final") or "").strip()
if configured:
response["mensagem"] = configured.replace("{protocol}", protocol)
elif protocol:
response["mensagem"] = f"Seu número de protocolo é {protocol}."
return response
@reg.action("checar_vas_variado")
def checar_vas_variado(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
@@ -1500,15 +1561,185 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
"business_events": _events_ctx(VAATag.STATUS_SR_OK, params, state, agentProtocolId=protocol_id, adjustedProtocol=protocol_id, **status_meta),
}
def _discount_evidence_context(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
source = state.get("input") if isinstance(state.get("input"), dict) else {}
return {**source, **params}
def _first_explicit_discount_reason(value: Any) -> tuple[str, str]:
"""Return (reason, source_key) only from explicit backend evidence.
This deliberately does not infer expiration from installment counters,
absence of a discount, plan names or the customer's wording. Causal
claims must be supplied by a system of record/mock contract.
"""
reason_keys = {
"discount_reason", "discountReason", "motivo_desconto", "motivoDesconto",
"termination_reason", "terminationReason", "motivo_termino", "motivoTermino",
"promotion_end_reason", "promotionEndReason",
}
status_keys = {"discount_status", "discountStatus", "promotion_status", "promotionStatus"}
end_date_keys = {"promotion_end_date", "promotionEndDate", "discount_end_date", "discountEndDate"}
def walk(obj: Any) -> tuple[str, str]:
if isinstance(obj, dict):
for key, item in obj.items():
if key in reason_keys and isinstance(item, (str, int, float)) and str(item).strip():
return str(item).strip(), key
for key, item in obj.items():
if key in status_keys and str(item or "").strip().upper() in {
"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"
}:
return f"status explícito: {str(item).strip()}", key
for key, item in obj.items():
if key in end_date_keys and isinstance(item, (str, int, float)) and str(item).strip():
return f"data de término registrada: {str(item).strip()}", key
for item in obj.values():
found = walk(item)
if found[0]:
return found
elif isinstance(obj, list):
for item in obj:
found = walk(item)
if found[0]:
return found
return "", ""
return walk(value)
def _discount_record_from_evidence(value: Any) -> dict[str, Any]:
"""Select the most relevant discount record from authoritative evidence."""
if not isinstance(value, dict):
return {}
rows = value.get("discounts") if isinstance(value.get("discounts"), list) else []
candidates = [row for row in rows if isinstance(row, dict)]
if not candidates:
return {}
terminal_statuses = {"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"}
for row in candidates:
if str(row.get("discount_status") or row.get("status") or "").strip().upper() in terminal_statuses:
return row
return candidates[0]
@staticmethod
def _format_brl(value: Any) -> str:
try:
number = float(value)
except (TypeError, ValueError):
return ""
return f"R$ {number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
def _format_iso_date_br(value: Any) -> str:
text = str(value or "").strip()
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
year, month, day = text.split("-")
return f"{day}/{month}/{year}"
return text
def _plan_names_from_invoice_detail(invoice_detail: Any) -> list[str]:
names: list[str] = []
if not isinstance(invoice_detail, dict):
return names
for bucket in invoice_detail.values():
if not isinstance(bucket, dict):
continue
planos = bucket.get("Planos")
if isinstance(planos, dict):
for name in planos:
text = str(name or "").strip()
if text and text not in names:
names.append(text)
return names
@reg.action("formatar_capability_resposta")
def formatar_capability_resposta(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
typ = str(params.get("tipo") or "")
if typ == "termino_desconto":
plan = str(params.get("nome_plano") or "seu plano")
msg = f"Identifiquei que a variação está relacionada ao término de um desconto do {plan}."
ev = [MPITag.TERMINO_DESCONTO]
context = _discount_evidence_context(params, state)
evidence = {
"discount_evidence": context.get("discount_evidence"),
"invoice_detail": context.get("invoice_detail"),
"billing_analysis": context.get("billing_analysis"),
"plan_data": context.get("plan_data"),
}
reason, reason_source = _first_explicit_discount_reason(evidence)
record = _discount_record_from_evidence(context.get("discount_evidence"))
requested_plan = str(context.get("nome_plano") or "").strip()
discovered_plans = _plan_names_from_invoice_detail(context.get("invoice_detail"))
plan = str(record.get("plan_name") or requested_plan or (discovered_plans[0] if len(discovered_plans) == 1 else "")).strip()
plan_text = f" do plano {plan}" if plan else ""
# Prefer the human-readable causal description from the system of
# record. The code remains audit metadata, not customer-facing prose.
reason_description = str(
record.get("termination_reason_description")
or record.get("discount_reason_description")
or ""
).strip()
if reason_description:
reason = reason_description
reason_source = "termination_reason_description"
if reason:
discount_name = str(record.get("discount_name") or "").strip()
previous_value = _format_brl(record.get("previous_value"))
end_date = _format_iso_date_br(record.get("end_date") or record.get("discount_end_date"))
subject = f"do desconto {discount_name}" if discount_name else "do desconto"
details: list[str] = []
if previous_value:
details.append(f"no valor de {previous_value}")
if end_date:
details.append(f"em {end_date}")
detail_text = (", " + ", ".join(details)) if details else ""
clean_reason = reason.rstrip(" .")
msg = f"Identifiquei o término {subject}{plan_text}{detail_text}. Motivo informado pelo sistema: {clean_reason}."
# O histórico de desconto descreve a situação contratual em uma
# data de referência, enquanto a última fatura pode cobrir um
# período anterior. Quando o backend fornece ambas as referências
# temporais, explicite a diferença sem inferir causalidade.
last_billed_value = _format_brl(record.get("last_billed_discount_value"))
last_billed_period = str(record.get("last_billed_period") or "").strip()
last_invoice_issue_date = _format_iso_date_br(record.get("last_invoice_issue_date"))
as_of_date = _format_iso_date_br(record.get("as_of_date"))
current_value = record.get("current_value")
current_value_reference = str(record.get("current_value_reference") or "").strip()
if (
current_value_reference == "contract_as_of_date"
and current_value in (0, 0.0, "0", "0.0", "0.00")
and last_billed_value
and last_billed_period
):
billed_parts = [
f"O último período faturado com esse desconto foi {last_billed_period}",
f"com {last_billed_value} de desconto",
]
if last_invoice_issue_date:
billed_parts.append(f"na fatura emitida em {last_invoice_issue_date}")
contract_ref = f"; a situação contratual em {as_of_date} já consta como encerrada" if as_of_date else "; a situação contratual atual já consta como encerrada"
msg += " " + ", ".join(billed_parts) + contract_ref + "."
grounded = True
else:
msg = (
f"Identifiquei dados de desconto{plan_text}, mas os dados disponíveis não informam "
"o motivo da retirada ou do término do desconto."
)
grounded = False
return {
"mensagem": msg,
"business_events": _events(MPITag.TERMINO_DESCONTO),
"discount_reason_grounded": grounded,
"discount_reason": reason or None,
"discount_reason_source": reason_source or None,
"discount_record": record or None,
"evidence_policy": "explicit_reason_only",
"epistemic_status": "grounded_fact" if grounded else "insufficient_evidence",
}
elif typ == "valor_divergente":
msg = "Identifiquei divergência de valor na fatura. Vou considerar os dados da fatura e do billing analysis para orientar a tratativa."
msisdn = str(params.get("msisdn") or "").strip()
suffix = msisdn[-2:] if len(msisdn) >= 2 else msisdn
line = f" na linha final {suffix}" if suffix else ""
msg = f"Identifiquei uma alteração no valor do plano{line}."
ev = [MPITag.VALOR_DIVERGENTE]
else:
msg = str(params.get("mensagem") or "")