Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -0,0 +1,525 @@
#!/usr/bin/env python3
"""Audit ClientFlow task requests and reply suggestions.
This script is intentionally read-only by default:
- it does not send Chatwoot messages;
- it does not complete tasks;
- it does not persist message_drafts unless --persist-drafts is used;
- it disables the optional OpenRouter reply LLM unless --use-llm is used.
Typical usage:
python scripts/audit_task_reply_suggestions.py --status pending --format markdown --out /tmp/task_reply_audit.md
python scripts/audit_task_reply_suggestions.py --status all --limit 500 --format csv --out /tmp/task_reply_audit.csv
python scripts/audit_task_reply_suggestions.py --status pending --use-llm --format json
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
STATUS_CHOICES = ["all", "pending", "done", "failed", "skipped"]
FORMAT_CHOICES = ["markdown", "json", "csv"]
def bootstrap_project_root() -> None:
"""Ensure `app` imports work when this file is executed as scripts/*.py.
When Python runs a script by path, sys.path[0] points to the
script directory (`scripts/`) rather than the project root. The
backend package lives in `<project_root>/app`, so add the parent
directory explicitly.
"""
project_root = Path(__file__).resolve().parents[1]
root_str = str(project_root)
if root_str not in sys.path:
sys.path.insert(0, root_str)
bootstrap_project_root()
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Audita tarefas ClientFlow e gera a resposta sugerida pelo Reply Assistant.",
)
parser.add_argument(
"--status",
default="all",
choices=STATUS_CHOICES,
help="Estado das tarefas a consultar. Por defeito consulta todos os estados.",
)
parser.add_argument("--route", default="", help="Filtrar por fila/route, ex.: comercial, financeiro, operacoes.")
parser.add_argument("--q", default="", help="Filtro textual simples sobre tarefa/mensagem/cliente.")
parser.add_argument("--limit", type=int, default=0, help="Número máximo de tarefas. 0 = sem limite.")
parser.add_argument("--format", default="markdown", choices=FORMAT_CHOICES, help="Formato de saída.")
parser.add_argument("--out", default="", help="Ficheiro de saída. Se omitido, imprime no terminal.")
parser.add_argument(
"--use-llm",
action="store_true",
help="Ativa CLIENTFLOW_REPLY_LLM_ENABLED para testar OpenRouter. Por defeito fica desligado para evitar custos.",
)
parser.add_argument(
"--persist-drafts",
action="store_true",
help="Guarda message_drafts na base de dados. Por defeito é read-only e não persiste rascunhos.",
)
parser.add_argument(
"--include-body",
action="store_true",
help="Inclui corpo completo do pedido e resposta. Sem esta opção, o CSV/JSON mantém tudo; Markdown truncará textos longos.",
)
parser.add_argument(
"--max-text-chars",
type=int,
default=900,
help="Limite de caracteres por texto no Markdown quando --include-body não é usado.",
)
return parser.parse_args(argv)
def configure_environment(args: argparse.Namespace) -> None:
# app.config loads `.env` via pydantic-settings. Do not inject a dummy
# OPENROUTER_API_KEY when --use-llm is active, because environment
# variables take precedence over `.env` and the dummy value would shadow
# the real OpenRouter key.
if args.use_llm:
if os.environ.get("OPENROUTER_API_KEY") == "not-used-by-task-reply-audit":
os.environ.pop("OPENROUTER_API_KEY", None)
os.environ["CLIENTFLOW_REPLY_LLM_ENABLED"] = "true"
else:
# OPENROUTER_API_KEY is required by app.config even when LLM is disabled.
# In read-only/no-LLM audit mode a harmless placeholder avoids forcing
# operators to export a real key.
os.environ.setdefault("OPENROUTER_API_KEY", "not-used-by-task-reply-audit")
os.environ["CLIENTFLOW_REPLY_LLM_ENABLED"] = "false"
if not os.environ.get("DATABASE_URL"):
env_path = Path(".env")
if not env_path.exists():
raise SystemExit(
"DATABASE_URL não está definido e não existe .env no diretório atual. "
"Execute dentro do backend ClientFlow ou exporte DATABASE_URL."
)
def normalize_space(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def compact_text(value: Any, *, max_chars: int) -> str:
text = str(value or "").strip()
if len(text) <= max_chars:
return text
return text[: max_chars - 1].rstrip() + ""
def task_customer_message(task: Dict[str, Any]) -> str:
metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
message_metadata = task.get("message_metadata") if isinstance(task.get("message_metadata"), dict) else {}
candidates = [
task.get("request_text"),
task.get("clean_body"),
task.get("raw_body"),
metadata.get("request_text"),
metadata.get("customer_message"),
message_metadata.get("clean_body"),
message_metadata.get("raw_body"),
task.get("message_subject"),
task.get("note"),
]
for candidate in candidates:
text = str(candidate or "").strip()
if text:
return text
return ""
def query_task_ids(*, status: str, route: str, q: str, limit: int) -> List[str]:
from sqlalchemy import text
from app.db import engine
where: List[str] = []
params: Dict[str, Any] = {}
if status and status != "all":
where.append("t.status = :status")
params["status"] = status
if route:
where.append("t.route = :route")
params["route"] = route
if q:
params["q"] = f"%{q.strip().lower()}%"
where.append(
"""
(
lower(coalesce(t.conversation_id, '')) LIKE :q
OR lower(coalesce(t.contact_id, '')) LIKE :q
OR lower(coalesce(t.customer_id, '')) LIKE :q
OR lower(coalesce(t.action_code, '')) LIKE :q
OR lower(coalesce(t.route, '')) LIKE :q
OR lower(coalesce(t.action, '')) LIKE :q
OR lower(coalesce(t.note, '')) LIKE :q
OR lower(coalesce(m.clean_body, '')) LIKE :q
OR lower(coalesce(m.raw_body, '')) LIKE :q
OR lower(coalesce(re.payload->'sender'->>'name', '')) LIKE :q
OR lower(coalesce(re.payload->'sender'->>'email', '')) LIKE :q
OR lower(coalesce(re.payload->>'content', '')) LIKE :q
)
"""
)
where_sql = "WHERE " + " AND ".join(where) if where else ""
limit_sql = "LIMIT :limit" if limit and limit > 0 else ""
if limit and limit > 0:
params["limit"] = limit
sql = text(
f"""
SELECT t.id::text AS id
FROM tasks t
LEFT JOIN messages m ON m.id = t.message_id
LEFT JOIN raw_events re ON re.id = t.raw_event_id
{where_sql}
ORDER BY
CASE t.status
WHEN 'pending' THEN 1
WHEN 'failed' THEN 2
WHEN 'skipped' THEN 3
WHEN 'done' THEN 4
ELSE 5
END,
CASE COALESCE(t.priority, 'normal')
WHEN 'alta' THEN 1
WHEN 'normal' THEN 2
WHEN 'baixa' THEN 3
ELSE 4
END,
t.due_at NULLS LAST,
t.created_at DESC
{limit_sql}
"""
)
with engine.begin() as conn:
rows = conn.execute(sql, params).mappings().all()
return [str(row["id"]) for row in rows]
def latest_preparation_for_task(task_id: str) -> Dict[str, Any]:
from sqlalchemy import text
from app.db import engine
try:
with engine.begin() as conn:
row = conn.execute(
text(
"""
SELECT prep_type, status, suggested_reply, confidence, created_at
FROM task_preparations
WHERE task_id = CAST(:task_id AS UUID)
ORDER BY created_at DESC
LIMIT 1
"""
),
{"task_id": task_id},
).mappings().first()
return dict(row) if row else {}
except Exception:
return {}
def audit_one_task(task_id: str, *, persist_drafts: bool) -> Dict[str, Any]:
from app.reply_assistant_service import ReplyAssistantError, generate_reply_draft
try:
draft = generate_reply_draft(task_id, persist=persist_drafts)
task = dict(draft.get("task") or {})
template = dict(draft.get("template") or {})
knowledge = dict(draft.get("business_knowledge") or {})
intent_gate = dict(draft.get("intent_gate") or {})
topics = knowledge.get("topics") or []
preparation = latest_preparation_for_task(task_id)
selected_docs = [str(doc.get("label") or doc.get("document_number") or doc.get("id")) for doc in draft.get("selected_documents") or []]
return {
"ok": True,
"task_id": task_id,
"status": task.get("status"),
"route": task.get("route"),
"action_code": task.get("action_code"),
"action": task.get("action"),
"priority": task.get("priority"),
"created_at": str(task.get("created_at") or ""),
"conversation_id": task.get("conversation_id"),
"customer_name": task.get("linked_customer_name") or task.get("customer_name"),
"customer_email": task.get("linked_customer_email") or task.get("customer_email"),
"subject": task.get("message_subject") or "",
"request_text": task_customer_message(task),
"previous_suggested_reply": preparation.get("suggested_reply") or "",
"intent_category": intent_gate.get("category"),
"intent_label": intent_gate.get("label"),
"intent_confidence": intent_gate.get("confidence"),
"intent_reasons": intent_gate.get("reasons") or [],
"intent_matched_terms": intent_gate.get("matched_terms") or [],
"commercial_reply_allowed": intent_gate.get("commercial_reply_allowed"),
"template_code": template.get("code"),
"template_name": template.get("name"),
"reply_type": template.get("reply_type") or knowledge.get("reply_type") or intent_gate.get("reply_type"),
"requires_attachment": bool(template.get("expected_document_kinds")),
"selected_documents": selected_docs,
"knowledge_topics": [topic.get("id") for topic in topics],
"knowledge_titles": [topic.get("title") for topic in topics],
"llm": draft.get("llm") or {},
"llm_intent": (draft.get("llm") or {}).get("intent"),
"llm_reply_type": (draft.get("llm") or {}).get("reply_type"),
"llm_confidence": (draft.get("llm") or {}).get("confidence"),
"llm_customer_need": (draft.get("llm") or {}).get("customer_need"),
"suggested_reply": draft.get("message_body") or "",
"blockers": draft.get("blockers") or [],
"warnings": draft.get("warnings") or [],
"draft_id": draft.get("draft_id") or "",
}
except ReplyAssistantError as exc:
return {"ok": False, "task_id": task_id, "error": str(exc)}
except Exception as exc:
return {"ok": False, "task_id": task_id, "error": f"{type(exc).__name__}: {exc}"}
def audit_tasks(args: argparse.Namespace) -> Dict[str, Any]:
task_ids = query_task_ids(status=args.status, route=args.route, q=args.q, limit=args.limit)
results = [audit_one_task(task_id, persist_drafts=args.persist_drafts) for task_id in task_ids]
ok_count = sum(1 for row in results if row.get("ok"))
blocked_count = sum(1 for row in results if row.get("blockers"))
warning_count = sum(1 for row in results if row.get("warnings"))
intent_counts: Dict[str, int] = {}
for row in results:
key = str(row.get("intent_category") or "ERROR")
intent_counts[key] = intent_counts.get(key, 0) + 1
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"filters": {"status": args.status, "route": args.route, "q": args.q, "limit": args.limit},
"options": {"use_llm": args.use_llm, "persist_drafts": args.persist_drafts},
"summary": {
"tasks_found": len(task_ids),
"tasks_ok": ok_count,
"tasks_failed": len(results) - ok_count,
"tasks_with_blockers": blocked_count,
"tasks_with_warnings": warning_count,
"intent_counts": intent_counts,
},
"results": results,
}
def render_markdown(report: Dict[str, Any], *, include_body: bool, max_text_chars: int) -> str:
summary = report["summary"]
filters = report["filters"]
lines = [
"# Auditoria de respostas sugeridas por tarefa",
"",
f"Gerado em: `{report['generated_at']}`",
"",
"## Filtros",
"",
f"- Estado: `{filters.get('status')}`",
f"- Fila/route: `{filters.get('route') or 'todas'}`",
f"- Pesquisa: `{filters.get('q') or ''}`",
f"- Limite: `{filters.get('limit') or 'sem limite'}`",
f"- LLM: `{'ativo' if report['options'].get('use_llm') else 'inativo'}`",
f"- Persistir rascunhos: `{'sim' if report['options'].get('persist_drafts') else 'não'}`",
"",
"## Resumo",
"",
f"- Tarefas encontradas: **{summary['tasks_found']}**",
f"- Tarefas auditadas com sucesso: **{summary['tasks_ok']}**",
f"- Falhas: **{summary['tasks_failed']}**",
f"- Com bloqueios: **{summary['tasks_with_blockers']}**",
f"- Com avisos: **{summary['tasks_with_warnings']}**",
"",
"## Triagem por intenção",
"",
*[f"- `{key}`: **{value}**" for key, value in sorted((summary.get("intent_counts") or {}).items())],
"",
"## Tarefas",
"",
]
for idx, row in enumerate(report["results"], 1):
lines.append(f"### {idx}. {row.get('action') or row.get('task_id')}")
lines.append("")
if not row.get("ok"):
lines.append(f"- Task ID: `{row.get('task_id')}`")
lines.append(f"- Erro: `{row.get('error')}`")
lines.append("")
continue
request_text = row.get("request_text") or ""
suggested = row.get("suggested_reply") or ""
if not include_body:
request_text = compact_text(request_text, max_chars=max_text_chars)
suggested = compact_text(suggested, max_chars=max_text_chars)
topics = ", ".join(row.get("knowledge_titles") or row.get("knowledge_topics") or []) or ""
docs = ", ".join(row.get("selected_documents") or []) or ""
blockers = row.get("blockers") or []
warnings = row.get("warnings") or []
lines.extend(
[
f"- Task ID: `{row.get('task_id')}`",
f"- Estado/fila: `{row.get('status')}` / `{row.get('route')}`",
f"- Cliente: **{row.get('customer_name') or ''}** `{row.get('customer_email') or ''}`",
f"- Conversa: `{row.get('conversation_id') or ''}`",
f"- Action code: `{row.get('action_code')}`",
f"- Intent gate: `{row.get('intent_category') or ''}` — {row.get('intent_label') or ''}",
f"- Template: `{row.get('template_code')}` — {row.get('template_name') or ''}",
f"- Tipo de resposta: `{row.get('reply_type') or ''}`",
f"- Conhecimento usado: {topics}",
f"- Anexos selecionados: {docs}",
f"- Motivo da triagem: {'; '.join(row.get('intent_reasons') or []) or ''}",
f"- LLM: `{(row.get('llm') or {}).get('status') or ''}` · intent `{row.get('llm_intent') or ''}` · confiança `{row.get('llm_confidence') if row.get('llm_confidence') is not None else ''}`",
f"- Necessidade detetada pelo LLM: {row.get('llm_customer_need') or ''}",
"",
"**Pedido do cliente**",
"",
"```text",
request_text or "",
"```",
"",
"**Resposta sugerida**",
"",
"```text",
suggested or "",
"```",
]
)
if row.get("previous_suggested_reply"):
previous = row.get("previous_suggested_reply")
if not include_body:
previous = compact_text(previous, max_chars=max_text_chars)
lines.extend(["", "**Resposta sugerida anterior/preparação**", "", "```text", previous, "```"])
if blockers:
lines.append("")
lines.append("**Bloqueios**")
lines.extend([f"- {item}" for item in blockers])
if warnings:
lines.append("")
lines.append("**Avisos**")
lines.extend([f"- {item}" for item in warnings])
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def render_json(report: Dict[str, Any]) -> str:
return json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n"
def render_csv(report: Dict[str, Any]) -> str:
import io
output = io.StringIO()
fieldnames = [
"task_id",
"status",
"route",
"customer_name",
"customer_email",
"conversation_id",
"action_code",
"action",
"intent_category",
"intent_label",
"intent_reasons",
"commercial_reply_allowed",
"template_code",
"template_name",
"reply_type",
"llm_intent",
"llm_reply_type",
"llm_confidence",
"llm_customer_need",
"knowledge_topics",
"selected_documents",
"request_text",
"suggested_reply",
"previous_suggested_reply",
"blockers",
"warnings",
"error",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in report["results"]:
writer.writerow(
{
"task_id": row.get("task_id"),
"status": row.get("status"),
"route": row.get("route"),
"customer_name": row.get("customer_name"),
"customer_email": row.get("customer_email"),
"conversation_id": row.get("conversation_id"),
"action_code": row.get("action_code"),
"action": row.get("action"),
"intent_category": row.get("intent_category"),
"intent_label": row.get("intent_label"),
"intent_reasons": "; ".join(row.get("intent_reasons") or []),
"commercial_reply_allowed": row.get("commercial_reply_allowed"),
"template_code": row.get("template_code"),
"template_name": row.get("template_name"),
"reply_type": row.get("reply_type"),
"llm_intent": row.get("llm_intent"),
"llm_reply_type": row.get("llm_reply_type"),
"llm_confidence": row.get("llm_confidence"),
"llm_customer_need": row.get("llm_customer_need"),
"knowledge_topics": "; ".join(row.get("knowledge_topics") or []),
"selected_documents": "; ".join(row.get("selected_documents") or []),
"request_text": row.get("request_text"),
"suggested_reply": row.get("suggested_reply"),
"previous_suggested_reply": row.get("previous_suggested_reply"),
"blockers": "; ".join(row.get("blockers") or []),
"warnings": "; ".join(row.get("warnings") or []),
"error": row.get("error"),
}
)
return output.getvalue()
def write_output(content: str, out_path: str) -> None:
if not out_path:
print(content, end="")
return
path = Path(out_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(f"Relatório escrito em: {path}")
def main(argv: Optional[List[str]] = None) -> int:
args = parse_args(argv)
configure_environment(args)
try:
report = audit_tasks(args)
except Exception as exc:
print(f"Erro ao auditar tarefas: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
if args.format == "json":
content = render_json(report)
elif args.format == "csv":
content = render_csv(report)
else:
content = render_markdown(report, include_body=args.include_body, max_text_chars=args.max_text_chars)
write_output(content, args.out)
return 0 if not report["summary"].get("tasks_failed") else 1
if __name__ == "__main__":
raise SystemExit(main())