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}
|
||||||
@@ -34,12 +34,14 @@ from .claims import router as claims_router # noqa: E402
|
|||||||
from .staff import router as staff_router # noqa: E402
|
from .staff import router as staff_router # noqa: E402
|
||||||
from .packet import router as packet_router # noqa: E402
|
from .packet import router as packet_router # noqa: E402
|
||||||
from .letters import router as letters_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(intake_router)
|
||||||
app.include_router(claims_router)
|
app.include_router(claims_router)
|
||||||
app.include_router(staff_router)
|
app.include_router(staff_router)
|
||||||
app.include_router(packet_router)
|
app.include_router(packet_router)
|
||||||
app.include_router(letters_router)
|
app.include_router(letters_router)
|
||||||
|
app.include_router(chatbot_router)
|
||||||
|
|
||||||
|
|
||||||
def _err(code: str, message: str, status_code: int):
|
def _err(code: str, message: str, status_code: int):
|
||||||
|
|||||||
Reference in New Issue
Block a user