"""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}