sofzcc commited on
Commit
4d20c23
·
verified ·
1 Parent(s): 1cb7516

Rename app.py to app/main.py

Browse files
Files changed (2) hide show
  1. app.py +0 -432
  2. app/main.py +33 -0
app.py DELETED
@@ -1,432 +0,0 @@
1
- import os
2
- import re
3
- import glob
4
- import shutil
5
- import threading
6
- import logging
7
- from typing import List, Tuple, Optional
8
-
9
- import faiss
10
- import numpy as np
11
- import yaml
12
- from sentence_transformers import SentenceTransformer
13
- from fastapi import FastAPI, UploadFile, File, HTTPException
14
- from fastapi.staticfiles import StaticFiles
15
- from fastapi.responses import FileResponse, JSONResponse
16
- from pydantic import BaseModel
17
-
18
- # ─── Logging ──────────────────────────────────────────────────────────────────
19
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
20
- logger = logging.getLogger(__name__)
21
-
22
- # ─── Config ───────────────────────────────────────────────────────────────────
23
- _CONFIG_PATH = "./config.yaml"
24
-
25
- def load_config(path: str = _CONFIG_PATH) -> dict:
26
- defaults = {
27
- "kb": {"directory": "./kb", "index_directory": "./.cache/faiss"},
28
- "models": {
29
- "embedding": "sentence-transformers/all-MiniLM-L6-v2",
30
- "qa": "google/flan-t5-small",
31
- },
32
- "chunking": {"chunk_size": 500, "overlap": 100},
33
- "retrieval": {"top_k": 5, "min_similarity": 0.25, "max_sentences": 6},
34
- "client": {"name": "Self-Service KB Assistant"},
35
- "messages": {
36
- "welcome": "Ask me anything about the documents in the knowledge base!",
37
- },
38
- "quick_actions": [],
39
- }
40
- if os.path.exists(path):
41
- try:
42
- with open(path, "r", encoding="utf-8") as f:
43
- user_cfg = yaml.safe_load(f) or {}
44
- for key, val in user_cfg.items():
45
- if isinstance(val, dict) and key in defaults:
46
- defaults[key].update(val)
47
- else:
48
- defaults[key] = val
49
- logger.info(f"Config loaded from {path}")
50
- except Exception as e:
51
- logger.warning(f"Could not load {path}: {e}. Using defaults.")
52
- return defaults
53
-
54
- CONFIG = load_config()
55
- KB_DIR = CONFIG["kb"]["directory"]
56
- INDEX_DIR = CONFIG["kb"]["index_directory"]
57
- EMBEDDING_MODEL_NAME = CONFIG["models"]["embedding"]
58
- QA_MODEL_NAME = CONFIG["models"]["qa"]
59
- CHUNK_SIZE = CONFIG["chunking"]["chunk_size"]
60
- CHUNK_OVERLAP = CONFIG["chunking"]["overlap"]
61
- TOP_K = CONFIG["retrieval"]["top_k"]
62
- MIN_SIMILARITY = CONFIG["retrieval"]["min_similarity"]
63
- MAX_SENTENCES = CONFIG["retrieval"]["max_sentences"]
64
- MAX_QUERY_CHARS = 800
65
- ALLOWED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".doc"}
66
- MAX_FILE_SIZE_MB = 10
67
- _IDX_PATH = os.path.join(INDEX_DIR, "kb.index")
68
- _META_PATH = os.path.join(INDEX_DIR, "kb_meta.npy")
69
-
70
- # ─── File loading ──────────────────────────────────────────────────────────────
71
- def load_file_text(path: str) -> str:
72
- ext = os.path.splitext(path)[1].lower()
73
- if ext == ".pdf":
74
- from PyPDF2 import PdfReader
75
- reader = PdfReader(path)
76
- return "\n".join(p.extract_text() or "" for p in reader.pages)
77
- elif ext in (".docx", ".doc"):
78
- import docx as python_docx
79
- doc = python_docx.Document(path)
80
- return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
81
- else:
82
- with open(path, "r", encoding="utf-8", errors="replace") as f:
83
- return f.read()
84
-
85
- def load_kb_documents(kb_dir: str) -> List[Tuple[str, str]]:
86
- docs = []
87
- os.makedirs(kb_dir, exist_ok=True)
88
- for pat in ["*.txt", "*.md", "*.pdf", "*.docx", "*.doc"]:
89
- for path in sorted(glob.glob(os.path.join(kb_dir, pat))):
90
- try:
91
- text = load_file_text(path)
92
- if text.strip():
93
- docs.append((os.path.basename(path), text))
94
- except Exception as e:
95
- logger.warning(f"Skipped {path}: {e}")
96
- return docs
97
-
98
- # ─── Chunking ──────────────────────────────────────────────────────────────────
99
- def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
100
- if not text:
101
- return []
102
- overlap = min(overlap, chunk_size - 1)
103
- sentences = re.compile(r'(?<=[.!?])\s+').split(text.strip())
104
- chunks, current, current_len = [], [], 0
105
- for sent in sentences:
106
- sent = sent.strip()
107
- if not sent:
108
- continue
109
- slen = len(sent)
110
- if slen > chunk_size:
111
- if current:
112
- chunks.append(" ".join(current))
113
- current, current_len = [], 0
114
- start = 0
115
- while start < slen:
116
- chunks.append(sent[start:min(start + chunk_size, slen)])
117
- start += chunk_size - overlap
118
- continue
119
- if current_len + slen + 1 > chunk_size:
120
- if current:
121
- chunks.append(" ".join(current))
122
- carry, carry_len = [], 0
123
- for s in reversed(current):
124
- if carry_len + len(s) + 1 > overlap:
125
- break
126
- carry.insert(0, s)
127
- carry_len += len(s) + 1
128
- current, current_len = carry, carry_len
129
- current.append(sent)
130
- current_len += slen + 1
131
- if current:
132
- chunks.append(" ".join(current))
133
- return [c for c in chunks if len(c.strip()) > 20]
134
-
135
- def clean_context(text: str) -> str:
136
- lines, cleaned, seen = text.splitlines(), [], set()
137
- for line in lines:
138
- l = re.sub(r'^#+\s*', '', line.strip())
139
- l = re.sub(r'^\d+[\.\)]\s*', '', l)
140
- l = re.sub(r'^[-*]\s*', '', l)
141
- if len(l) < 5 or l in seen:
142
- continue
143
- words = l.split()
144
- if words and len(words) <= 10 and sum(1 for w in words if w[:1].isupper()) >= len(words) - 1:
145
- continue
146
- seen.add(l)
147
- cleaned.append(l)
148
- return "\n".join(cleaned)
149
-
150
- # ─── KB Index (FAISS) ──────────────────────────────────────────────────────────
151
- class KBIndex:
152
- def __init__(self):
153
- self.embedder: Optional[SentenceTransformer] = None
154
- self.chunks: List[str] = []
155
- self.sources: List[str] = []
156
- self.index: Optional[faiss.Index] = None
157
- self._lock = threading.Lock()
158
- logger.info("Loading embedding model…")
159
- self.embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)
160
- logger.info("Embedding model loaded.")
161
- os.makedirs(INDEX_DIR, exist_ok=True)
162
- self._load_or_build()
163
-
164
- def _load_or_build(self):
165
- if os.path.exists(_IDX_PATH) and os.path.exists(_META_PATH):
166
- try:
167
- self.index = faiss.read_index(_IDX_PATH)
168
- meta = np.load(_META_PATH, allow_pickle=True).item()
169
- self.chunks = list(meta["chunks"])
170
- self.sources = list(meta["sources"])
171
- logger.info(f"✅ FAISS index loaded — {len(self.chunks)} chunks.")
172
- return
173
- except Exception as e:
174
- logger.warning(f"Could not load index: {e}. Rebuilding…")
175
- self._build()
176
-
177
- def _build(self):
178
- docs = load_kb_documents(KB_DIR)
179
- if not docs:
180
- demo = ("demo_content.txt",
181
- "Welcome to the Self-Service KB Assistant. "
182
- "Upload your own documents via the Knowledge Base panel. "
183
- "The assistant will index them and answer questions from their content.")
184
- docs = [demo]
185
- all_chunks, all_sources = [], []
186
- for source, text in docs:
187
- for chunk in chunk_text(text):
188
- all_chunks.append(chunk)
189
- all_sources.append(source)
190
- if not all_chunks:
191
- self.index = None; self.chunks = []; self.sources = []
192
- return
193
- logger.info(f"Encoding {len(all_chunks)} chunks…")
194
- embeddings = self.embedder.encode(
195
- all_chunks, show_progress_bar=False,
196
- convert_to_numpy=True, batch_size=64
197
- ).astype("float32")
198
- faiss.normalize_L2(embeddings)
199
- index = faiss.IndexFlatIP(embeddings.shape[1])
200
- index.add(embeddings)
201
- self.index = index; self.chunks = all_chunks; self.sources = all_sources
202
- try:
203
- faiss.write_index(index, _IDX_PATH)
204
- np.save(_META_PATH, {"chunks": np.array(all_chunks, dtype=object),
205
- "sources": np.array(all_sources, dtype=object)})
206
- logger.info("FAISS index saved.")
207
- except Exception as e:
208
- logger.warning(f"Could not save index: {e}")
209
-
210
- def rebuild(self) -> str:
211
- with self._lock:
212
- for p in (_IDX_PATH, _META_PATH):
213
- if os.path.exists(p): os.remove(p)
214
- self._build()
215
- if not self.chunks:
216
- return "Index rebuilt (empty — no documents found)."
217
- return f"Index rebuilt — {len(self.chunks)} chunks from {len(set(self.sources))} files."
218
-
219
- def retrieve(self, query: str) -> List[Tuple[str, str, float]]:
220
- if not query.strip() or self.index is None or not self.chunks:
221
- return []
222
- q = self.embedder.encode([query], convert_to_numpy=True).astype("float32")
223
- faiss.normalize_L2(q)
224
- scores, idxs = self.index.search(q, min(TOP_K, len(self.chunks)))
225
- return [
226
- (self.chunks[i], self.sources[i], float(s))
227
- for s, i in zip(scores[0], idxs[0])
228
- if 0 <= i < len(self.chunks) and float(s) >= MIN_SIMILARITY
229
- ]
230
-
231
- def best_sentences(self, query: str, results: List[Tuple[str, str, float]]) -> str:
232
- sents = []
233
- for chunk, _, _ in results:
234
- for s in re.split(r'(?<=[.!?])\s+|\n+', clean_context(chunk)):
235
- if len(s.strip()) >= 25:
236
- sents.append(s.strip())
237
- if not sents:
238
- return ""
239
- q_emb = self.embedder.encode([query], convert_to_numpy=True).astype("float32")
240
- s_emb = self.embedder.encode(sents, convert_to_numpy=True).astype("float32")
241
- faiss.normalize_L2(q_emb); faiss.normalize_L2(s_emb)
242
- sims = np.dot(s_emb, q_emb.T).reshape(-1)
243
- q_words = {w.lower() for w in re.findall(r"\w+", query) if len(w) > 3}
244
- lex = np.array([len(q_words & {w.lower() for w in re.findall(r"\w+", s) if len(w) > 3})
245
- for s in sents], dtype=float)
246
- top = np.argsort(-(1.5 * sims + 0.5 * lex))
247
- chosen, seen = [], set()
248
- for i in top:
249
- s = sents[i].strip()
250
- if s and s not in seen:
251
- chosen.append(s); seen.add(s)
252
- if len(chosen) >= MAX_SENTENCES:
253
- break
254
- return "\n".join(chosen)
255
-
256
- # ─── QA Model ─────────────────────────────────────────────────────────────────
257
- qa_tokenizer = None
258
- qa_model = None
259
- qa_available = False
260
- _qa_lock = threading.Lock()
261
-
262
- try:
263
- from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
264
- logger.info(f"Loading QA model: {QA_MODEL_NAME}…")
265
- qa_tokenizer = AutoTokenizer.from_pretrained(QA_MODEL_NAME)
266
- qa_model = AutoModelForSeq2SeqLM.from_pretrained(QA_MODEL_NAME)
267
- qa_model.eval()
268
- qa_available = True
269
- logger.info(f"✅ QA model loaded ({QA_MODEL_NAME})")
270
- except Exception as e:
271
- logger.warning(f"⚠️ Could not load QA model: {e}. Using extractive fallback.")
272
-
273
- def generate_answer(question: str, context: str) -> Optional[str]:
274
- if not qa_available:
275
- return None
276
- prompt = (
277
- "You are a warm, helpful assistant. Answer using ONLY the context below. "
278
- "Open with a friendly sentence. Introduce any steps with a sentence first. "
279
- "Keep tone conversational. If context is insufficient, say so honestly.\n\n"
280
- f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"
281
- )
282
- try:
283
- with _qa_lock:
284
- inputs = qa_tokenizer(prompt, return_tensors="pt", truncation=True, max_length=768)
285
- outputs = qa_model.generate(**inputs, max_new_tokens=220, temperature=1.0, do_sample=False)
286
- answer = qa_tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
287
- return answer or None
288
- except Exception as e:
289
- logger.error(f"QA error: {e}")
290
- return None
291
-
292
- # ─── Init index ───────────────────────────────────────────────────────────────
293
- logger.info("Initialising KB index…")
294
- kb_index = KBIndex()
295
-
296
- # ─── RAG pipeline ─────────────────────────────────────────────────────────────
297
- def build_answer(query: str, history: list) -> dict:
298
- """Returns {"answer": str, "sources": [str]}"""
299
- results = kb_index.retrieve(query)
300
- if not results:
301
- return {"answer": "I couldn't find anything relevant for your question. Try rephrasing or using different keywords.", "sources": []}
302
-
303
- context = kb_index.best_sentences(query, results)
304
- if not context:
305
- return {"answer": "I found some content but couldn't extract relevant sentences. Please try rephrasing.", "sources": []}
306
-
307
- # Collect unique sources above threshold
308
- seen_sources: List[str] = []
309
- for _, src, score in results:
310
- if src not in seen_sources and score >= MIN_SIMILARITY:
311
- seen_sources.append(src)
312
- if len(seen_sources) == 3:
313
- break
314
-
315
- answer = generate_answer(query, context)
316
- if not answer:
317
- answer = "Here's the most relevant information I found:\n\n" + "\n".join(
318
- f"• {s}" for s in context.split("\n") if s.strip()
319
- )
320
- return {"answer": answer, "sources": seen_sources}
321
-
322
- # ─── FastAPI app ───────────────────────────────────────────────────────────────
323
- app = FastAPI(title="Self-Service KB Assistant")
324
- app.mount("/site", StaticFiles(directory="site"), name="site")
325
-
326
- # ── Pydantic models ────────────────────────────────────────────────────────────
327
- class ChatRequest(BaseModel):
328
- message: str
329
- history: list = []
330
-
331
- class DeleteRequest(BaseModel):
332
- filename: str
333
-
334
- class RenameRequest(BaseModel):
335
- old_name: str
336
- new_name: str
337
-
338
- # ── Routes ─────────────────────────────────────────────────────────────────────
339
- @app.get("/")
340
- async def root():
341
- return FileResponse("static/index.html")
342
-
343
- @app.get("/api/config")
344
- async def get_config():
345
- quick = [q["query"] for q in CONFIG.get("quick_actions", []) if "query" in q] or [
346
- "What makes a good knowledge base article?",
347
- "How could a KB assistant help support agents?",
348
- "Why is self-service important for customer support?",
349
- ]
350
- return {
351
- "name": CONFIG["client"]["name"],
352
- "welcome": CONFIG["messages"].get("welcome", "Ask me anything!"),
353
- "quick_actions": quick,
354
- }
355
-
356
- @app.post("/api/chat")
357
- async def chat(req: ChatRequest):
358
- if not req.message.strip():
359
- return {"answer": "Please ask a question.", "sources": []}
360
- try:
361
- return build_answer(req.message.strip()[:MAX_QUERY_CHARS], req.history)
362
- except Exception as e:
363
- logger.error(f"Chat error: {e}")
364
- raise HTTPException(status_code=500, detail="Something went wrong. Please try again.")
365
-
366
- @app.get("/api/files")
367
- async def list_files():
368
- files = []
369
- for pat in ["*.txt", "*.md", "*.pdf", "*.docx", "*.doc"]:
370
- for p in sorted(glob.glob(os.path.join(KB_DIR, pat))):
371
- name = os.path.basename(p)
372
- size = os.path.getsize(p)
373
- files.append({"name": name, "size": size})
374
- return {"files": files, "count": len(files)}
375
-
376
- @app.post("/api/files/upload")
377
- async def upload_files(files: List[UploadFile] = File(...)):
378
- os.makedirs(KB_DIR, exist_ok=True)
379
- saved, skipped = [], []
380
- for file in files:
381
- name = os.path.basename(file.filename or "")
382
- ext = os.path.splitext(name)[1].lower()
383
- if ext not in ALLOWED_EXTENSIONS:
384
- skipped.append(f"{name} (unsupported type)")
385
- continue
386
- content = await file.read()
387
- if len(content) / (1024 * 1024) > MAX_FILE_SIZE_MB:
388
- skipped.append(f"{name} (exceeds {MAX_FILE_SIZE_MB} MB)")
389
- continue
390
- try:
391
- with open(os.path.join(KB_DIR, name), "wb") as f:
392
- f.write(content)
393
- saved.append(name)
394
- except Exception as e:
395
- skipped.append(f"{name} (error: {e})")
396
- msg = ""
397
- if saved:
398
- msg = kb_index.rebuild()
399
- return {"saved": saved, "skipped": skipped, "message": msg}
400
-
401
- @app.delete("/api/files/{filename}")
402
- async def delete_file(filename: str):
403
- filename = os.path.basename(filename)
404
- if os.path.splitext(filename)[1].lower() not in ALLOWED_EXTENSIONS:
405
- raise HTTPException(status_code=400, detail="Invalid file type")
406
- target = os.path.join(KB_DIR, filename)
407
- if not os.path.exists(target):
408
- raise HTTPException(status_code=404, detail="File not found")
409
- os.remove(target)
410
- msg = kb_index.rebuild()
411
- return {"deleted": filename, "message": msg}
412
-
413
- @app.put("/api/files/rename")
414
- async def rename_file(req: RenameRequest):
415
- old = os.path.basename(req.old_name.strip())
416
- new = os.path.basename(req.new_name.strip())
417
- if not old or not new:
418
- raise HTTPException(status_code=400, detail="Both names required")
419
- if os.path.splitext(new)[1].lower() not in ALLOWED_EXTENSIONS:
420
- raise HTTPException(status_code=400, detail="New name must have a supported extension")
421
- src = os.path.join(KB_DIR, old)
422
- if not os.path.exists(src):
423
- raise HTTPException(status_code=404, detail=f"File not found: {old}")
424
- os.rename(src, os.path.join(KB_DIR, new))
425
- msg = kb_index.rebuild()
426
- return {"renamed_to": new, "message": msg}
427
-
428
- # ─── Entry point ──────────────────────────────────────────────────────────────
429
- if __name__ == "__main__":
430
- import uvicorn
431
- port = int(os.environ.get("PORT", 7860))
432
- uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from fastapi import FastAPI
3
+ from fastapi.staticfiles import StaticFiles
4
+
5
+ from app.core.lifecycle import lifespan
6
+ from app.routers.chat import router as chat_router
7
+ from app.routers.health import router as health_router
8
+ from app.routers.evaluation import router as evaluation_router
9
+
10
+ app = FastAPI(
11
+ title="Self-Service KB Assistant",
12
+ version="1.0.0",
13
+ lifespan=lifespan,
14
+ )
15
+
16
+ # Routers
17
+ app.include_router(chat_router, prefix="/api/chat", tags=["chat"])
18
+ app.include_router(health_router, tags=["health"])
19
+ app.include_router(evaluation_router, prefix="/api/evaluation", tags=["evaluation"])
20
+
21
+ # Safe static mounting
22
+ SITE_DIR = Path("site")
23
+
24
+ if SITE_DIR.exists():
25
+ app.mount("/site", StaticFiles(directory=SITE_DIR), name="site")
26
+
27
+
28
+ @app.get("/")
29
+ async def root():
30
+ return {
31
+ "status": "running",
32
+ "service": "Self-Service KB Assistant"
33
+ }