Files
clientflow_backend/scripts/prepare_task.py
2026-06-09 22:55:58 +01:00

513 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
import os
import re
import urllib.request
import urllib.error
from typing import Any, Dict, List, Optional, Tuple
import psycopg
def env(name: str, default: str = "") -> str:
return os.getenv(name, default).strip()
PSQL_DATABASE_URL = env("PSQL_DATABASE_URL")
OPENROUTER_API_KEY = env("OPENROUTER_API_KEY")
OPENROUTER_URL = env("OPENROUTER_URL", "https://openrouter.ai/api/v1/chat/completions")
OPENROUTER_MODEL = env("EXTRACTION_MODEL", env("OPENROUTER_MODEL", "qwen/qwen3-30b-a3b"))
def extract_first_json_object(raw: str) -> str:
s = str(raw or "").strip()
if s.startswith("```"):
lines = s.splitlines()
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
s = "\n".join(lines).strip()
start = s.find("{")
if start == -1:
raise ValueError(f"no JSON object found: {s[:300]}")
in_string = False
escaped = False
depth = 0
for i in range(start, len(s)):
ch = s[i]
if escaped:
escaped = False
continue
if ch == "\\":
escaped = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return s[start:i + 1]
raise ValueError(f"incomplete JSON object: {s[:500]}")
def parse_json(raw: str) -> Dict[str, Any]:
try:
return json.loads(raw)
except Exception:
return json.loads(extract_first_json_object(raw))
def strip_html(text: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", text or "", flags=re.I)
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", " ", text)
text = text.replace("&nbsp;", " ")
text = text.replace("&amp;", "&")
text = text.replace("&quot;", '"')
text = text.replace("&#39;", "'")
return re.sub(r"[ \t]+", " ", text).strip()
def remove_quoted_text(text: str) -> str:
if not text:
return ""
markers = [
"\nÀs ",
"\nEm ",
"\nOn ",
"\n-----Original Message-----",
"\nDe:",
"\nFrom:",
]
cut = len(text)
for marker in markers:
idx = text.find(marker)
if idx != -1:
cut = min(cut, idx)
lines = []
for line in text[:cut].splitlines():
if line.strip().startswith(">"):
continue
lines.append(line)
return "\n".join(lines).strip()
def extract_message_content(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
msg = payload.get("message") or payload.get("messages") or {}
if isinstance(msg, list):
msg = msg[0] if msg else {}
if not isinstance(msg, dict):
return None
content = msg.get("content") or payload.get("content") or ""
message_type = str(msg.get("message_type") or payload.get("message_type") or "").lower()
private = bool(msg.get("private") or payload.get("private"))
if not content:
return None
return {
"content": remove_quoted_text(strip_html(str(content))),
"message_type": message_type,
"private": private,
"id": str(msg.get("id") or payload.get("id") or ""),
}
def fetch_task(conn, task_id: Optional[str], conversation_id: Optional[str]) -> Dict[str, Any]:
if task_id:
sql = """
select id::text, conversation_id, contact_id, action_code, route, status, action, note
from tasks
where id = %s
limit 1
"""
params = (task_id,)
else:
sql = """
select id::text, conversation_id, contact_id, action_code, route, status, action, note
from tasks
where conversation_id = %s
order by created_at desc
limit 1
"""
params = (conversation_id,)
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql, params)
row = cur.fetchone()
if not row:
raise SystemExit("ERRO: task não encontrada.")
return dict(row)
def fetch_conversation_messages(conn, conversation_id: str, limit: int = 20) -> List[Dict[str, Any]]:
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(
"""
select created_at, payload
from raw_events
where source_system = 'chatwoot'
and conversation_id = %s
order by created_at asc
limit %s
""",
(conversation_id, limit),
)
rows = cur.fetchall()
messages = []
for row in rows:
payload = row.get("payload") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
msg = extract_message_content(payload)
if not msg:
continue
# Para extração, manter incoming e outgoing públicos, ignorar notas privadas.
if msg["private"]:
continue
messages.append({
"created_at": str(row["created_at"]),
**msg,
})
return messages
def build_prompt(task: Dict[str, Any], messages: List[Dict[str, Any]], prep_type: str) -> List[Dict[str, str]]:
conversation_text = "\n\n".join(
f"[{m['created_at']}] {m.get('message_type') or 'message'}:\n{m['content']}"
for m in messages
if m.get("content")
)
if prep_type == "proforma":
objective = """
Objetivo: preparar dados para emitir fatura pró-forma.
Extrai apenas dados relevantes para faturação/proforma:
cliente, empresa, email, telefone, NIF, morada fiscal, produto, quantidade, preço, condições comerciais e dados em falta para emitir a pró-forma.
Para prep_type=proforma:
- NÃO peças morada de entrega, destinatário ou telefone para transportadora, exceto se a conversa indicar que são necessários para a pró-forma.
- Morada de entrega/recolha pertence à fase de envio/recolha, não à fase de pró-forma.
- Se já houver NIF, morada fiscal, nome/empresa de faturação, email, produto e preço, missing_fields deve ser [].
"""
elif prep_type == "shipment":
objective = """
Objetivo: preparar envio.
Extrai apenas dados relevantes para logística de entrega:
morada de entrega, contacto no local, telefone, produto/equipamento, quantidade, instruções, estado do pagamento e dados em falta.
Para prep_type=shipment:
- Usa shipment.delivery_address para a morada de entrega.
- Usa shipment.recipient_name e shipment.recipient_phone para contacto da entrega.
- Os campos obrigatórios são morada de entrega, contacto, telefone, produto/equipamento, quantidade e estado do pagamento.
- NÃO coloques customer.tax_id, billing.tax_id, billing_address ou customer.email em missing_fields, exceto se forem explicitamente necessários para a transportadora.
- Se já existir morada de entrega, contacto e telefone, não peças esses dados novamente.
- Se faltar produto, quantidade ou comprovativo/estado de pagamento, pede apenas esses dados.
- A suggested_reply deve falar em envio/entrega, nunca em recolha.
"""
elif prep_type == "pickup":
objective = """
Objetivo: preparar recolha.
Extrai apenas dados relevantes para recolha ou assistência logística:
morada de recolha, contacto no local, telefone, produto/equipamento a recolher, motivo/instruções, data preferida e dados em falta.
Para prep_type=pickup:
- Usa shipment.pickup_address para a morada de recolha.
- Usa shipment.recipient_name e shipment.recipient_phone para a pessoa de contacto da recolha.
- Se a conversa só tiver uma morada e o objetivo é recolha, coloca essa morada em shipment.pickup_address, não em shipment.delivery_address.
- NÃO coloques sale.total_estimate, customer.tax_id, billing.tax_id, billing_address ou customer.email em missing_fields.
- Os campos importantes são pickup_address, recipient_name, recipient_phone, produto/equipamento, motivo/instruções da recolha.
- payment.status só é obrigatório se a tarefa for claramente sobre pagamento ou envio após pagamento.
- A suggested_reply deve falar em recolha, nunca em envio.
"""
else:
objective = """
Objetivo: preparar execução operacional da tarefa.
Extrai dados úteis para a ação, dados em falta e resposta sugerida.
"""
system = f"""
És o ClientFlow Sales Assistant.
A tua função é extrair dados operacionais de conversas B2B para ajudar a preparar pró-forma, fatura, envio ou recolha.
Regras:
- Devolve apenas JSON puro.
- Não inventes dados.
- Se um dado não existir, usa null.
- Ignora texto citado antigo, dados da BLIF, IBANs e assinaturas da BLIF.
- Não associes IBAN ao cliente.
- Distingue morada fiscal de morada de entrega/recolha.
- Usa evidências curtas.
- Se faltar dado necessário, coloca em missing_fields.
- suggested_reply deve ser uma resposta curta em português para pedir dados em falta ou indicar o próximo passo.
- Não digas que a pró-forma/fatura/envio já foi emitida, enviada, agendada ou concluída.
- O assistente apenas prepara dados para revisão; usa linguagem como "vamos preparar", "podemos avançar", "dados suficientes para preparar".
"""
user = f"""
{objective}
Tarefa:
action_code: {task.get('action_code')}
route: {task.get('route')}
action: {task.get('action')}
note: {task.get('note')}
Conversa:
{conversation_text}
Schema obrigatório:
{{
"customer": {{
"name": null,
"company": null,
"email": null,
"phone": null,
"tax_id": null
}},
"billing": {{
"billing_name": null,
"tax_id": null,
"billing_address": null,
"billing_email": null
}},
"sale": {{
"products": [
{{
"name": null,
"description": null,
"quantity": null,
"unit_price": null,
"currency": "EUR"
}}
],
"total_estimate": null,
"commercial_terms": null
}},
"payment": {{
"status": null,
"proof_mentioned": false
}},
"shipment": {{
"delivery_address": null,
"pickup_address": null,
"recipient_name": null,
"recipient_phone": null,
"instructions": null
}},
"missing_fields": [],
"suggested_reply": "",
"confidence": 0.0,
"evidence": []
}}
"""
return [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
def call_openrouter(messages: List[Dict[str, str]]) -> Tuple[Dict[str, Any], Dict[str, Any], str]:
body = {
"model": OPENROUTER_MODEL,
"messages": messages,
"temperature": 0,
}
req = urllib.request.Request(
OPENROUTER_URL,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://clientflow.blif.pt",
"X-Title": "ClientFlow",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8", errors="replace")
parsed = json.loads(raw)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenRouter HTTP {e.code}: {raw[:1000]}")
content = parsed["choices"][0]["message"].get("content") or "{}"
extracted = parse_json(content)
return extracted, parsed, content
def save_preparation(conn, task: Dict[str, Any], prep_type: str, extracted: Dict[str, Any], raw_response: Dict[str, Any]) -> str:
usage = raw_response.get("usage") or {}
model = raw_response.get("model") or OPENROUTER_MODEL
provider = raw_response.get("provider") or raw_response.get("provider_name")
missing_fields = extracted.get("missing_fields") or []
suggested_reply = extracted.get("suggested_reply") or ""
confidence = extracted.get("confidence")
with conn.cursor() as cur:
cur.execute(
"""
insert into task_preparations (
task_id,
conversation_id,
contact_id,
prep_type,
status,
extracted_data,
missing_fields,
suggested_reply,
confidence,
model,
provider,
total_tokens,
cost,
raw_response
)
values (
%s, %s, %s, %s, 'draft',
%s::jsonb,
%s::jsonb,
%s,
%s,
%s,
%s,
%s,
%s,
%s::jsonb
)
returning id::text
""",
(
task["id"],
task["conversation_id"],
task.get("contact_id"),
prep_type,
json.dumps(extracted, ensure_ascii=False),
json.dumps(missing_fields, ensure_ascii=False),
suggested_reply,
confidence,
model,
provider,
int(usage.get("total_tokens") or 0),
usage.get("cost") or 0,
json.dumps(raw_response, ensure_ascii=False),
),
)
prep_id = cur.fetchone()[0]
conn.commit()
return prep_id
def run_preparation(
*,
task_id: Optional[str] = None,
conversation_id: Optional[str] = None,
prep_type: str,
database_url: Optional[str] = None,
) -> Dict[str, Any]:
db_url = database_url or PSQL_DATABASE_URL
if not task_id and not conversation_id:
raise ValueError("Usa task_id ou conversation_id.")
if not db_url:
raise RuntimeError("PSQL_DATABASE_URL/DATABASE_URL não definida.")
if not OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY não definida.")
with psycopg.connect(db_url) as conn:
task = fetch_task(conn, task_id, conversation_id)
messages = fetch_conversation_messages(conn, task["conversation_id"])
if not messages:
raise RuntimeError("Não encontrei mensagens públicas da conversa.")
prompt = build_prompt(task, messages, prep_type)
extracted, raw_response, raw_content = call_openrouter(prompt)
prep_id = save_preparation(conn, task, prep_type, extracted, raw_response)
return {
"preparation_id": prep_id,
"task_id": task["id"],
"conversation_id": task["conversation_id"],
"prep_type": prep_type,
"extracted": extracted,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--task-id")
parser.add_argument("--conversation-id")
parser.add_argument("--type", choices=["proforma", "shipment", "pickup", "generic"], required=True)
args = parser.parse_args()
if not args.task_id and not args.conversation_id:
raise SystemExit("Usa --task-id ou --conversation-id.")
if not PSQL_DATABASE_URL:
raise SystemExit("PSQL_DATABASE_URL não definida.")
if not OPENROUTER_API_KEY:
raise SystemExit("OPENROUTER_API_KEY não definida.")
with psycopg.connect(PSQL_DATABASE_URL) as conn:
task = fetch_task(conn, args.task_id, args.conversation_id)
messages = fetch_conversation_messages(conn, task["conversation_id"])
if not messages:
raise SystemExit("ERRO: não encontrei mensagens públicas da conversa.")
prompt = build_prompt(task, messages, args.type)
extracted, raw_response, raw_content = call_openrouter(prompt)
prep_id = save_preparation(conn, task, args.type, extracted, raw_response)
print(f"OK: preparation_id={prep_id}")
print(f"task_id={task['id']}")
print(f"conversation_id={task['conversation_id']}")
print(f"prep_type={args.type}")
print("--- extracted")
print(json.dumps(extracted, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())