diff --git a/backend/chatbot.py b/backend/chatbot.py new file mode 100644 index 0000000..4d30f87 --- /dev/null +++ b/backend/chatbot.py @@ -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} diff --git a/backend/main.py b/backend/main.py index 2a238f6..cf43494 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,12 +34,14 @@ from .claims import router as claims_router # noqa: E402 from .staff import router as staff_router # noqa: E402 from .packet import router as packet_router # noqa: E402 from .letters import router as letters_router # noqa: E402 +from .chatbot import router as chatbot_router # noqa: E402 app.include_router(intake_router) app.include_router(claims_router) app.include_router(staff_router) app.include_router(packet_router) app.include_router(letters_router) +app.include_router(chatbot_router) def _err(code: str, message: str, status_code: int):