sofzcc commited on
Commit
94e01f8
·
verified ·
1 Parent(s): 3da1132

Upload 9 files

Browse files
README.md CHANGED
@@ -1,12 +1,19 @@
1
- ---
2
- title: Self Service KB Assistant
3
- emoji: 🏆
4
- colorFrom: green
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.49.1
8
- app_file: app.py
9
- pinned: false
10
- ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Self-Service KB Assistant (Free, No API Key)
 
 
 
 
 
 
 
 
 
2
 
3
+ A lightweight RAG chatbot that answers **only from your Markdown Knowledge Base**, cites sources, asks clarifying questions when uncertain, and offers quick guided intents. Built with **Gradio**, **FAISS**, **sentence-transformers**, and an **extractive QA model**—no paid API needed.
4
+
5
+ ## Features
6
+ - Markdown-based KB (`/kb/*.md`)
7
+ - Embeddings: `sentence-transformers/all-MiniLM-L6-v2`
8
+ - Vector search: FAISS (cosine on normalized vectors)
9
+ - Reader: `deepset/roberta-base-squad2` (extractive QA)
10
+ - Citations (title + section)
11
+ - Low-confidence fallback (suggest related articles)
12
+ - Quick intents (buttons for top tasks)
13
+ - One-click “Rebuild Index” admin control
14
+
15
+ ## Run locally
16
+ ```bash
17
+ python -m venv .venv && source .venv/bin/activate # or .venv\Scripts\activate on Windows
18
+ pip install -r requirements.txt
19
+ python app.py
app.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List, Dict, Tuple
7
+
8
+ import numpy as np
9
+ import faiss
10
+
11
+ import gradio as gr
12
+ from transformers import pipeline, AutoTokenizer, AutoModelForQuestionAnswering
13
+ from sentence_transformers import SentenceTransformer
14
+
15
+ KB_DIR = Path("./kb")
16
+ INDEX_DIR = Path("./.index")
17
+ INDEX_DIR.mkdir(exist_ok=True, parents=True)
18
+
19
+ EMBEDDING_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
20
+ READER_MODEL_NAME = "deepset/roberta-base-squad2"
21
+
22
+ EMBEDDINGS_PATH = INDEX_DIR / "kb_embeddings.npy"
23
+ METADATA_PATH = INDEX_DIR / "kb_metadata.json"
24
+ FAISS_PATH = INDEX_DIR / "kb_faiss.index"
25
+
26
+
27
+ # ---------------------------
28
+ # Utilities: Markdown loading
29
+ # ---------------------------
30
+
31
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
32
+
33
+ def read_markdown_files(kb_dir: Path) -> List[Dict]:
34
+ docs = []
35
+ for md_path in sorted(kb_dir.glob("*.md")):
36
+ text = md_path.read_text(encoding="utf-8", errors="ignore")
37
+ title = md_path.stem.replace("_", " ").title()
38
+ # Try first H1 as title if present
39
+ m = re.search(r"^#\s+(.*)$", text, flags=re.MULTILINE)
40
+ if m:
41
+ title = m.group(1).strip()
42
+
43
+ docs.append({
44
+ "filepath": str(md_path),
45
+ "filename": md_path.name,
46
+ "title": title,
47
+ "text": text
48
+ })
49
+ return docs
50
+
51
+
52
+ def chunk_markdown(doc: Dict, chunk_chars: int = 1200, overlap: int = 150) -> List[Dict]:
53
+ """
54
+ Simple header-aware chunking: split by H2/H3 when possible and then by char length.
55
+ Stores anchor-ish metadata for basic citations.
56
+ """
57
+ text = doc["text"]
58
+ # Split by H2/H3 as sections (fallback to entire text)
59
+ sections = re.split(r"(?=^##\s+|\n##\s+|\n###\s+|^###\s+)", text, flags=re.MULTILINE)
60
+ if len(sections) == 1:
61
+ sections = [text]
62
+
63
+ chunks = []
64
+ for sec in sections:
65
+ sec = sec.strip()
66
+ if not sec:
67
+ continue
68
+
69
+ # Derive a section heading for citation
70
+ heading_match = HEADING_RE.search(sec)
71
+ section_heading = heading_match.group(2).strip() if heading_match else doc["title"]
72
+
73
+ # Hard wrap into chunks
74
+ start = 0
75
+ while start < len(sec):
76
+ end = min(start + chunk_chars, len(sec))
77
+ chunk_text = sec[start:end].strip()
78
+ if chunk_text:
79
+ chunks.append({
80
+ "doc_title": doc["title"],
81
+ "filename": doc["filename"],
82
+ "filepath": doc["filepath"],
83
+ "section": section_heading,
84
+ "content": chunk_text
85
+ })
86
+ start = end - overlap if end - overlap > 0 else end
87
+ if start < 0:
88
+ start = 0
89
+ if end == len(sec):
90
+ break
91
+
92
+ return chunks
93
+
94
+
95
+ # ---------------------------
96
+ # Build / Load Index
97
+ # ---------------------------
98
+
99
+ class KBIndex:
100
+ def __init__(self):
101
+ self.embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)
102
+ self.reader_tokenizer = AutoTokenizer.from_pretrained(READER_MODEL_NAME)
103
+ self.reader_model = AutoModelForQuestionAnswering.from_pretrained(READER_MODEL_NAME)
104
+ self.reader = pipeline("question-answering", model=self.reader_model, tokenizer=self.reader_tokenizer)
105
+
106
+ self.index = None # FAISS index
107
+ self.embeddings = None # numpy array
108
+ self.metadata = [] # list of dicts
109
+
110
+ def build(self, kb_dir: Path):
111
+ docs = read_markdown_files(kb_dir)
112
+ if not docs:
113
+ raise RuntimeError(f"No markdown files found in {kb_dir.resolve()}. Please add *.md files.")
114
+
115
+ # Produce chunks
116
+ all_chunks = []
117
+ for d in docs:
118
+ all_chunks.extend(chunk_markdown(d))
119
+
120
+ texts = [c["content"] for c in all_chunks]
121
+ if not texts:
122
+ raise RuntimeError("No content chunks generated from KB.")
123
+
124
+ embeddings = self.embedder.encode(texts, batch_size=32, convert_to_numpy=True, show_progress_bar=False)
125
+ # Normalize for cosine similarity
126
+ faiss.normalize_L2(embeddings)
127
+
128
+ # Build FAISS index (cosine via inner product on normalized vectors)
129
+ dim = embeddings.shape[1]
130
+ index = faiss.IndexFlatIP(dim)
131
+ index.add(embeddings)
132
+
133
+ self.index = index
134
+ self.embeddings = embeddings
135
+ self.metadata = all_chunks
136
+
137
+ # Persist to disk
138
+ np.save(EMBEDDINGS_PATH, embeddings)
139
+ with open(METADATA_PATH, "w", encoding="utf-8") as f:
140
+ json.dump(self.metadata, f, ensure_ascii=False, indent=2)
141
+ faiss.write_index(index, str(FAISS_PATH))
142
+
143
+ def load(self):
144
+ if not (EMBEDDINGS_PATH.exists() and METADATA_PATH.exists() and FAISS_PATH.exists()):
145
+ return False
146
+
147
+ self.embeddings = np.load(EMBEDDINGS_PATH)
148
+ with open(METADATA_PATH, "r", encoding="utf-8") as f:
149
+ self.metadata = json.load(f)
150
+ self.index = faiss.read_index(str(FAISS_PATH))
151
+ return True
152
+
153
+ def rebuild_if_kb_changed(self):
154
+ """
155
+ Very light heuristic: if index older than newest kb file, rebuild.
156
+ """
157
+ kb_mtime = max([p.stat().st_mtime for p in KB_DIR.glob("*.md")] or [0])
158
+ idx_mtime = min([
159
+ EMBEDDINGS_PATH.stat().st_mtime if EMBEDDINGS_PATH.exists() else 0,
160
+ METADATA_PATH.stat().st_mtime if METADATA_PATH.exists() else 0,
161
+ FAISS_PATH.stat().st_mtime if FAISS_PATH.exists() else 0,
162
+ ])
163
+ if kb_mtime > idx_mtime:
164
+ self.build(KB_DIR)
165
+
166
+ def retrieve(self, query: str, top_k: int = 4) -> List[Tuple[int, float]]:
167
+ q_emb = self.embedder.encode([query], convert_to_numpy=True)
168
+ faiss.normalize_L2(q_emb)
169
+ D, I = self.index.search(q_emb, top_k)
170
+ indices = I[0].tolist()
171
+ sims = D[0].tolist()
172
+ return list(zip(indices, sims))
173
+
174
+ def answer(self, question: str, retrieved: List[Tuple[int, float]]):
175
+ """
176
+ Use extractive QA across the top retrieved chunks; pick the best span by score.
177
+ Return (answer_text, best_score, citations)
178
+ """
179
+ best = {"text": None, "score": -1e9, "meta": None, "ctx": None, "sim": 0.0}
180
+ for idx, sim in retrieved:
181
+ meta = self.metadata[idx]
182
+ context = meta["content"]
183
+ try:
184
+ out = self.reader(question=question, context=context)
185
+ except Exception:
186
+ continue
187
+ score = float(out.get("score", 0.0))
188
+ if score > best["score"]:
189
+ best = {
190
+ "text": out.get("answer", "").strip(),
191
+ "score": score,
192
+ "meta": meta,
193
+ "ctx": context,
194
+ "sim": float(sim)
195
+ }
196
+
197
+ if not best["text"]:
198
+ return None, 0.0, []
199
+
200
+ # Build citations: top 2 sources from retrieved
201
+ citations = []
202
+ seen = set()
203
+ for idx, sim in retrieved[:2]:
204
+ meta = self.metadata[idx]
205
+ key = (meta["filename"], meta["section"])
206
+ if key in seen:
207
+ continue
208
+ seen.add(key)
209
+ citations.append({
210
+ "title": meta["doc_title"],
211
+ "filename": meta["filename"],
212
+ "section": meta["section"]
213
+ })
214
+ return best["text"], best["score"], citations
215
+
216
+
217
+ kb = KBIndex()
218
+
219
+ def ensure_index():
220
+ if not kb.load():
221
+ kb.build(KB_DIR)
222
+ else:
223
+ kb.rebuild_if_kb_changed()
224
+
225
+ ensure_index()
226
+
227
+
228
+ # ---------------------------
229
+ # Clarify / Guardrails logic
230
+ # ---------------------------
231
+
232
+ def format_citations(citations: List[Dict]) -> str:
233
+ if not citations:
234
+ return ""
235
+ lines = []
236
+ for c in citations:
237
+ lines.append(f"• **{c['title']}** — _{c['section']}_ (`{c['filename']}`)")
238
+ return "\n".join(lines)
239
+
240
+ LOW_CONF_THRESHOLD = 0.20 # reader score heuristic (0–1)
241
+ LOW_SIM_THRESHOLD = 0.30 # retriever sim heuristic (cosine/IP on normalized vectors)
242
+
243
+ HELPFUL_SUGGESTIONS = [
244
+ ("Connect WhatsApp", "How do I connect my WhatsApp number?"),
245
+ ("Reset Password", "I can't sign in / forgot my password"),
246
+ ("First Automation", "How do I create my first automation?"),
247
+ ("Billing & Invoices", "How do I download invoices for billing?"),
248
+ ("Fix Instagram Connect", "Why can't I connect Instagram?")
249
+ ]
250
+
251
+
252
+ def respond(user_msg, history):
253
+ user_msg = (user_msg or "").strip()
254
+ if not user_msg:
255
+ return "How can I help? Try: **Connect WhatsApp** or **Reset password**."
256
+
257
+ # Retrieve
258
+ retrieved = kb.retrieve(user_msg, top_k=4)
259
+ if not retrieved:
260
+ return "I couldn't find anything yet. Try rephrasing or pick a quick action below."
261
+
262
+ # Answer
263
+ span, score, citations = kb.answer(user_msg, retrieved)
264
+
265
+ # If no span, surface top articles as fallback
266
+ if not span:
267
+ suggestions = "\n".join([f"- {c['title']} — _{c['section']}_" for c in citations]) or "- Try a different query."
268
+ return f"I’m not fully sure. Here are the closest matches:\n\n{suggestions}"
269
+
270
+ # Confidence heuristics
271
+ best_sim = max(_
kb/billing_invoices.md ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Billing & Invoices
2
+ ## Quick Answer
3
+ Download invoices in **Settings > Billing > Invoices**.
4
+ ## Notes
5
+ - The assistant cannot access or change billing data.
6
+ - For refund or plan changes, contact support.
kb/build_first_automation.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Your First Automation
2
+ ## Overview
3
+ Automations run actions when a trigger condition is met.
4
+ ## Steps
5
+ 1. Go to **Automation > Flows**.
6
+ 2. Click **Create Flow**.
7
+ 3. Choose a template or start blank.
8
+ 4. Add a trigger (e.g., message received).
9
+ 5. Add an action (e.g., send reply).
10
+ 6. **Save** and **Enable**.
11
+ ## Tips
12
+ - Start simple; add conditions later.
13
+ - Test with a sample event first.
14
+ ## Related
15
+ - Connect WhatsApp
16
+ - Troubleshooting
kb/connect_whatsapp.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # How to Connect WhatsApp
2
+ ## Objectives
3
+ Connect a WhatsApp number to enable messaging flows.
4
+ ## Prerequisites
5
+ - Admin access
6
+ - WhatsApp Business number
7
+ ## Steps
8
+ 1. Go to **Settings > Channels > WhatsApp**.
9
+ 2. Click **Connect Number**.
10
+ 3. Follow the provider flow and grant all permissions.
11
+ 4. Confirm the number shows **Active**.
12
+ ## Common Pitfalls
13
+ - Business verification pending
14
+ - Missing permissions
15
+ - Number linked elsewhere
16
+ ## Related
17
+ - Troubleshoot Instagram Connect
18
+ - Build First Automation
kb/get_started.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Get Started
2
+ ## Objectives
3
+ Create your account, sign in, and explore the dashboard.
4
+ ## Prerequisites
5
+ An email address and internet connection.
6
+ ## Steps
7
+ 1. Open the **Dashboard**.
8
+ 2. Select **Create Account**.
9
+ 3. Verify your email.
10
+ 4. Sign in and review **Dashboard > Overview**.
11
+ ## Related
12
+ - Reset Password
13
+ - Build First Automation
14
+
kb/reset_password.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Reset Password / Sign-In Issues
2
+ ## Quick Answer
3
+ Use **Forgot Password** on the sign-in screen, then check your email (and spam).
4
+ ## Details
5
+ - Reset links expire after 15 minutes.
6
+ - Multiple requests: the newest link works.
7
+ ## Related
8
+ - Get Started
kb/troubleshoot_instagram_connect.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Troubleshoot Instagram Connect
2
+ ## Symptoms
3
+ - “Unable to connect account”
4
+ - “Permissions missing”
5
+ ## Possible Causes
6
+ - Business account not linked to the Page
7
+ - Permissions not granted during connect flow
8
+ ## Fix
9
+ 1. In **Facebook Business Settings**, link the Instagram account to the Page.
10
+ 2. Re-run the connect flow and grant all requested permissions.
11
+ 3. Check **Settings > Channels > Instagram** shows **Connected**.
12
+ ## Related
13
+ - Connect WhatsApp
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ transformers>=4.44.0
3
+ sentence-transformers>=3.0.0
4
+ faiss-cpu>=1.8.0
5
+ torch>=2.2.0
6
+ numpy>=1.26