Download support_rag.py from Enayut/expt8: direct link, hf CLI and curl.
- Browser
- Download file 8.84 kB
-
https://huggingface.co/spaces/Enayut/expt8/resolve/main/support_rag.py
- Command line
-
hf download hf://spaces/Enayut/expt8/support_rag.py
-
curl -L -o support_rag.py https://huggingface.co/spaces/Enayut/expt8/resolve/main/support_rag.py
8.84 kB
| """Customer Support RAG pipeline (from Experiment 6) with a pluggable LLM backend. | |
| Retrieval (FAISS + MiniLM embeddings) is kept separate from generation. The backend is | |
| chosen with LLM_BACKEND: "groq" (cloud API, default) or "mock" (offline, for testing). | |
| """ | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from typing import List, TypedDict | |
| import requests | |
| from dotenv import load_dotenv | |
| from langchain_community.vectorstores import FAISS | |
| from langchain_core.documents import Document | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langchain_text_splitters import RecursiveCharacterTextSplitter | |
| from langgraph.graph import END, START, StateGraph | |
| load_dotenv() | |
| log = logging.getLogger("support_rag") | |
| BASE_DIR = Path(__file__).parent | |
| KB_PATH = BASE_DIR / "support_faq.txt" | |
| PAGE_MARKER = "=====PAGE=====" | |
| GROQ_URL = os.getenv("GROQ_API_URL", "https://api.groq.com/openai/v1/chat/completions") | |
| GROQ_MODEL = os.getenv("GROQ_MODEL", "openai/gpt-oss-20b") | |
| LLM_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "30")) | |
| FALLBACK_TEXT = ( | |
| "I couldn't find a reliable answer in the available FAQ. " | |
| "Please contact the support team through the Help section. " | |
| "Do not share your password, one-time code, or full payment-card details." | |
| ) | |
| class BackendError(Exception): | |
| """The LLM backend failed. The message is safe to log; never contains secrets.""" | |
| # ---------------------------------------------------------------- LLM backends | |
| def groq_generate(prompt: str) -> str: | |
| api_key = os.getenv("GROQ_API_KEY") | |
| if not api_key: | |
| raise BackendError("GROQ_API_KEY is not configured") | |
| try: | |
| r = requests.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {api_key}"}, | |
| json={ | |
| "model": GROQ_MODEL, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "temperature": 0, | |
| "max_tokens": 1000, | |
| }, | |
| timeout=LLM_TIMEOUT, | |
| ) | |
| except requests.RequestException as e: | |
| raise BackendError(f"network error: {type(e).__name__}") from None | |
| if r.status_code != 200: | |
| # status code only: the response body could echo request details | |
| raise BackendError(f"provider returned HTTP {r.status_code}") | |
| try: | |
| return r.json()["choices"][0]["message"]["content"].strip() | |
| except (KeyError, IndexError, ValueError): | |
| raise BackendError("unexpected provider response format") from None | |
| def mock_generate(prompt: str) -> str: | |
| """Offline stand-in: answers with the first context passage, or INSUFFICIENT_CONTEXT.""" | |
| context = prompt.split("FAQ CONTEXT:")[1].split("CUSTOMER QUESTION:")[0].strip() | |
| question = prompt.split("CUSTOMER QUESTION:")[1].split("ANSWER:")[0].lower() | |
| if "insurance" in question or not context: | |
| return "INSUFFICIENT_CONTEXT" | |
| return "[mock backend] " + context[:200] | |
| BACKENDS = {"groq": groq_generate, "mock": mock_generate} | |
| def generate(prompt: str) -> str: | |
| return BACKENDS[os.getenv("LLM_BACKEND", "groq")](prompt) | |
| # ----------------------------------------------------------- knowledge base | |
| def load_documents() -> List[Document]: | |
| raw = KB_PATH.read_text(encoding="utf-8") | |
| docs = [] | |
| for block in raw.split(PAGE_MARKER): | |
| block = block.strip() | |
| if not block: | |
| continue | |
| topic, source, *rest = block.split("\n") | |
| docs.append(Document( | |
| page_content=f"{topic}\n" + "\n".join(rest), | |
| metadata={"topic": topic, "source": source.removeprefix("Source: ").strip()}, | |
| )) | |
| return docs | |
| class SupportState(TypedDict, total=False): | |
| question: str | |
| intent: str | |
| search_query: str | |
| context: str | |
| sources: List[str] | |
| draft: str | |
| retry_count: int | |
| final_answer: str | |
| class SupportRAG: | |
| """Builds the embedding model, FAISS index and LangGraph once; reuse for every request.""" | |
| def __init__(self, k: int = 3): | |
| chunks = RecursiveCharacterTextSplitter(chunk_size=450, chunk_overlap=60).split_documents( | |
| load_documents()) | |
| embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2") | |
| self.vector_store = FAISS.from_documents(chunks, embeddings) | |
| self.retriever = self.vector_store.as_retriever(search_type="similarity", search_kwargs={"k": k}) | |
| self.num_chunks = len(chunks) | |
| self.graph = self._build_graph() | |
| log.info("RAG ready: %d chunks indexed", self.num_chunks) | |
| # --- nodes | |
| def classify_intent(state: SupportState): | |
| q = state["question"].lower() | |
| if any(w in q for w in ["refund", "return", "exchange"]): | |
| intent = "returns_refunds" | |
| elif any(w in q for w in ["track", "tracking", "shipment", "delivery", "order status"]): | |
| intent = "order_tracking" | |
| elif any(w in q for w in ["password", "sign in", "login", "account", "verification"]): | |
| intent = "account_access" | |
| elif any(w in q for w in ["charged", "payment", "billing", "invoice", "card"]): | |
| intent = "billing_payments" | |
| elif any(w in q for w in ["website", "error", "loading", "browser", "technical"]): | |
| intent = "technical_support" | |
| else: | |
| intent = "general_support" | |
| return {"intent": intent, "search_query": state["question"], | |
| "retry_count": state.get("retry_count", 0)} | |
| def retrieve_context(self, state: SupportState): | |
| docs = self.retriever.invoke(state["search_query"]) | |
| return {"context": "\n\n".join(d.page_content for d in docs), | |
| "sources": [d.metadata.get("source", "unknown") for d in docs]} | |
| def draft_answer(state: SupportState): | |
| prompt = f"""You are a polite customer-support assistant. | |
| Use only the FAQ CONTEXT to answer the CUSTOMER QUESTION. | |
| Do not invent order status, refund approvals, timelines, or company policies. | |
| If the context does not contain the answer, output exactly: | |
| INSUFFICIENT_CONTEXT | |
| Do not ask for passwords, one-time codes, or full payment-card details. | |
| Keep the answer clear and concise. | |
| FAQ CONTEXT: | |
| {state.get("context", "")} | |
| CUSTOMER QUESTION: | |
| {state["question"]} | |
| ANSWER:""" | |
| return {"draft": generate(prompt)} | |
| def review_answer(state: SupportState): | |
| draft = state.get("draft", "").strip() | |
| return {"final_answer": "" if "INSUFFICIENT_CONTEXT" in draft else draft} | |
| def route_after_review(state: SupportState): | |
| if state.get("final_answer"): | |
| return "finish" | |
| return "retry" if state.get("retry_count", 0) < 1 else "fallback" | |
| def rewrite_query(state: SupportState): | |
| terms = { | |
| "returns_refunds": "return eligibility refund process", | |
| "order_tracking": "order dispatch tracking shipment status", | |
| "account_access": "account sign in password reset", | |
| "billing_payments": "billing payment transaction", | |
| "technical_support": "website technical troubleshooting", | |
| "general_support": "customer support help contact", | |
| } | |
| return {"search_query": state["question"] + " " + terms.get(state.get("intent"), "customer support"), | |
| "retry_count": state.get("retry_count", 0) + 1} | |
| def fallback_answer(state: SupportState): | |
| return {"final_answer": FALLBACK_TEXT} | |
| def _build_graph(self): | |
| g = StateGraph(SupportState) | |
| g.add_node("classify_intent", self.classify_intent) | |
| g.add_node("retrieve", self.retrieve_context) | |
| g.add_node("draft_answer", self.draft_answer) | |
| g.add_node("review_answer", self.review_answer) | |
| g.add_node("rewrite_query", self.rewrite_query) | |
| g.add_node("fallback", self.fallback_answer) | |
| g.add_edge(START, "classify_intent") | |
| g.add_edge("classify_intent", "retrieve") | |
| g.add_edge("retrieve", "draft_answer") | |
| g.add_edge("draft_answer", "review_answer") | |
| g.add_conditional_edges("review_answer", self.route_after_review, | |
| {"finish": END, "retry": "rewrite_query", "fallback": "fallback"}) | |
| g.add_edge("rewrite_query", "retrieve") | |
| g.add_edge("fallback", END) | |
| return g.compile() | |
| def ask(self, question: str) -> dict: | |
| """Run the workflow. Raises BackendError if the LLM backend fails.""" | |
| result = self.graph.invoke({"question": question, "retry_count": 0}) | |
| return { | |
| "answer": result["final_answer"], | |
| "intent": result.get("intent"), | |
| "sources": sorted(set(result.get("sources", []))), | |
| "retries": result.get("retry_count", 0), | |
| "fallback": result["final_answer"] == FALLBACK_TEXT, | |
| } | |