Agent D.R.E chatbot backend: POST /api/chat via DeepSeek (deepseek-v4-flash, thinking disabled), public FAQ KB + guardrails
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""Agent D.R.E chatbot — public FAQ assistant.
|
||||
|
||||
Backed by DeepSeek (deepseek-v4-flash, thinking disabled) via native API.
|
||||
Stdlib-only (urllib + json) so no new venv dependencies.
|
||||
Public endpoint: POST /api/chat
|
||||
|
||||
Guardrails (defense in depth):
|
||||
1. Deterministic private-topic blocklist -> out-of-scope fallback (no LLM call).
|
||||
2. System prompt hard rules: identity, no promises, elevator-pitch-first,
|
||||
no em/en dashes, no internal methods.
|
||||
3. Per-IP rate limit to cap spend.
|
||||
|
||||
No DB access, no PII, no case data. Stateless public FAQ only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth as authmod
|
||||
|
||||
logger = logging.getLogger("dre.chatbot")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com").rstrip("/")
|
||||
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||
CHATBOT_MODEL = os.environ.get("DRE_CHATBOT_MODEL", "deepseek-v4-flash")
|
||||
CHATBOT_MAX_TOKENS = int(os.environ.get("DRE_CHATBOT_MAX_TOKENS", "300"))
|
||||
CHATBOT_TIMEOUT = float(os.environ.get("DRE_CHATBOT_TIMEOUT", "30"))
|
||||
|
||||
MAX_MESSAGE_CHARS = 1000
|
||||
MAX_HISTORY_TURNS = 6
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Public FAQ knowledge base (ONLY this content is ever shared)
|
||||
# ---------------------------------------------------------------
|
||||
FAQ = [
|
||||
("about", "Debt Recovery Experts (DRE) is a Texas-registered debt collection agency, FDCPA compliant. We recover unpaid invoices and debts for contractors, owner-operators, and businesses."),
|
||||
("how it works", "We handle recovery from start to finish: you submit your claim, we send a demand, and keep escalating until you get paid. Most claims resolve in 15 to 30 days."),
|
||||
("fees", "We only get paid when you do. Fees start around 20% for early resolution and scale up based on the effort required. Use the Fee Calculator for an exact breakdown."),
|
||||
("documents", "The basics: a copy of the contract, proof of delivery or completed work, and any invoices or correspondence showing the debt."),
|
||||
("timelines", "Most claims resolve within 30 days, depending on how the debtor responds. Some pay quickly, others need a certified letter."),
|
||||
("texas liens", "For construction claims in Texas, a pre-lien notice often resolves the debt before a lien is even filed. We handle the notices and deadlines."),
|
||||
("lpoa", "You sign a limited power of attorney (LPOA) authorizing us to collect on your behalf. It is notarized and spells out exactly what we are authorized to do."),
|
||||
("payments", "Debtors pay by ACH through our secure payment page. We disburse your share minus our agreed fee."),
|
||||
("submit", "Fill out the claim form at portal.debtrecoveryexperts.com with the basics, and we take it from there."),
|
||||
("contact", "Submit through the portal, or email hello@debtrecoveryexperts.com."),
|
||||
]
|
||||
|
||||
# Private topics: any hit short-circuits to the out-of-scope fallback.
|
||||
PRIVATE_TOPIC_MARKERS = [
|
||||
"skip trac", "skip-trac", "asset scan", "scoring", "score algorithm",
|
||||
"ai method", "weakness", "approval workflow", "partner firm", "law firm",
|
||||
"referral agreement", "fee negotiation", "compliance strategy",
|
||||
"internal notes", "debtor research", "how do you score", "how do you decide",
|
||||
]
|
||||
|
||||
FALLBACK_OUT_OF_SCOPE = (
|
||||
"That's a great question, but it's a bit outside what I can help with here. "
|
||||
"If you'd like, you can submit a claim and our team will review it."
|
||||
)
|
||||
FALLBACK_ERROR = (
|
||||
"I'm having a little trouble right now. Could you try again in a moment?"
|
||||
)
|
||||
|
||||
|
||||
def _system_prompt() -> str:
|
||||
kb = "\n".join(f"- {content}" for _, content in FAQ)
|
||||
return (
|
||||
"You are Agent D.R.E, the customer-service assistant for Debt Recovery Experts (DRE), "
|
||||
"a Texas-registered debt collection agency. DRE is FDCPA compliant.\n\n"
|
||||
"Answer using ONLY the FAQ knowledge below. Be conversational, empathetic, and concise.\n\n"
|
||||
"HARD RULES:\n"
|
||||
"- If asked who you are or what DRE is, say DRE is a Texas-registered debt collection agency, FDCPA compliant.\n"
|
||||
"- Elevator pitch first: one conversational sentence, then a short follow-up question. No numbered lists or step-by-step dumps.\n"
|
||||
"- Use contractions (we're, you'll, don't).\n"
|
||||
"- NO promises: never guarantee an outcome, timeline, or amount. Use \"typically\", \"most claims\", \"can be\". Never use \"definitely\", \"guaranteed\", or an unqualified \"will\".\n"
|
||||
"- On fees, give the elevator pitch and point to the Fee Calculator (https://portal.debtrecoveryexperts.com/fee-calculator.html). Do not dump a percentage breakdown on the first mention.\n"
|
||||
"- Never reveal internal methods: AI scoring, skip tracing, approval workflow, partner firm names, fee negotiation limits, compliance strategy, or any case data.\n"
|
||||
"- Never use em dashes, en dashes, or double hyphens. Use normal hyphens or commas instead.\n"
|
||||
"- If asked something outside the FAQ (legal advice, specific-case details, or anything not listed below), respond: \"That's a great question, but it's a bit outside what I can help with here. If you'd like, you can submit a claim and our team will review it.\"\n\n"
|
||||
f"FAQ KNOWLEDGE:\n{kb}\n"
|
||||
)
|
||||
|
||||
|
||||
def _is_private_topic(text: str) -> bool:
|
||||
low = text.lower()
|
||||
return any(m in low for m in PRIVATE_TOPIC_MARKERS)
|
||||
|
||||
|
||||
def _call_deepseek(system_prompt: str, history: list[dict], message: str) -> str | None:
|
||||
if not DEEPSEEK_API_KEY:
|
||||
logger.error("DEEPSEEK_API_KEY not set; chatbot cannot call LLM")
|
||||
return None
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for turn in history[-MAX_HISTORY_TURNS:]:
|
||||
role = turn.get("role")
|
||||
content = turn.get("content")
|
||||
if role in ("user", "assistant") and isinstance(content, str) and content:
|
||||
messages.append({"role": role, "content": content[:MAX_MESSAGE_CHARS]})
|
||||
messages.append({"role": "user", "content": message[:MAX_MESSAGE_CHARS]})
|
||||
|
||||
payload = {
|
||||
"model": CHATBOT_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": CHATBOT_MAX_TOKENS,
|
||||
"temperature": 0.3,
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
url = f"{DEEPSEEK_BASE_URL}/v1/chat/completions"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=CHATBOT_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
logger.error("deepseek call failed: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
content = data["choices"][0]["message"].get("content") or ""
|
||||
content = content.strip()
|
||||
except (KeyError, IndexError, TypeError):
|
||||
logger.error("unexpected deepseek response shape: %s", data)
|
||||
return None
|
||||
|
||||
# If thinking was not actually disabled, content can come back empty. Do not
|
||||
# surface the model's reasoning; treat it as a failed call instead.
|
||||
if not content:
|
||||
logger.error("deepseek returned empty content (thinking may not be disabled)")
|
||||
return None
|
||||
return content
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(..., max_length=MAX_MESSAGE_CHARS)
|
||||
history: list[dict] = Field(default_factory=list)
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
_RATE: dict[str, list[float]] = {}
|
||||
_RATE_LIMIT = 20
|
||||
_RATE_WINDOW = 60.0
|
||||
_RATE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _rate_ok(ip: str) -> bool:
|
||||
now = time.time()
|
||||
with _RATE_LOCK:
|
||||
ts = [t for t in _RATE.get(ip, []) if now - t < _RATE_WINDOW]
|
||||
if len(ts) >= _RATE_LIMIT:
|
||||
_RATE[ip] = ts
|
||||
return False
|
||||
ts.append(now)
|
||||
_RATE[ip] = ts
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/api/chat")
|
||||
async def chat(req: ChatRequest, request: Request):
|
||||
message = (req.message or "").strip()
|
||||
if not message:
|
||||
return {"reply": "What can I help you with? Ask about fees, the process, timelines, or documents."}
|
||||
|
||||
ip = authmod.get_client_ip(request)
|
||||
if not _rate_ok(ip):
|
||||
return {"reply": "You're asking a lot of questions quickly. Give me a moment, then try again."}
|
||||
|
||||
if req.name or req.email:
|
||||
# Lead capture: log only for now; CRM sync is a follow-up.
|
||||
logger.info("chat lead name=%s email=%s", (req.name or "")[:80], (req.email or "")[:120])
|
||||
|
||||
# 1. Deterministic private-topic gate (no LLM call, no leakage path)
|
||||
if _is_private_topic(message):
|
||||
return {"reply": FALLBACK_OUT_OF_SCOPE}
|
||||
|
||||
# 2. LLM answer from public FAQ only
|
||||
reply = _call_deepseek(_system_prompt(), req.history, message)
|
||||
if reply is None:
|
||||
return {"reply": FALLBACK_ERROR}
|
||||
|
||||
return {"reply": reply}
|
||||
Reference in New Issue
Block a user