samsonleegh commited on
Commit
9be22f0
·
verified ·
1 Parent(s): b3f7dfc

Update app_webhook.py

Browse files
Files changed (1) hide show
  1. app_webhook.py +222 -0
app_webhook.py CHANGED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import hashlib
4
+ import numpy as np
5
+ import pandas as pd
6
+ from typing import List, Tuple
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.responses import JSONResponse, PlainTextResponse
9
+ from dotenv import load_dotenv
10
+ from openai import OpenAI
11
+ from docx import Document as DocxDocument
12
+ from pypdf import PdfReader
13
+ import faiss
14
+ from telegram import Update
15
+ from telegram.ext import Application, CommandHandler, MessageHandler, ContextTypes, AIORateLimiter, filters
16
+
17
+ # ---------- Load environment ----------
18
+ load_dotenv()
19
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
20
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
21
+ PUBLIC_URL = os.getenv("PUBLIC_URL", "") # e.g., https://username-space.hf.space
22
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
23
+ EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
24
+ STRICT_DOC_MODE = os.getenv("STRICT_DOC_MODE", "true").lower() == "true"
25
+ DOCS_DIR = os.getenv("DOCS_DIR", "wedding_docs")
26
+ INDEX_PATH = os.getenv("INDEX_PATH", "wedding.index")
27
+ META_CSV = os.getenv("META_CSV", "wedding_chunks.csv")
28
+
29
+ client = OpenAI(api_key=OPENAI_API_KEY)
30
+
31
+ # ---------- Doc loaders ----------
32
+ def read_txt_md(path: str) -> str:
33
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
34
+ return f.read()
35
+
36
+ def read_docx(path: str) -> str:
37
+ doc = DocxDocument(path)
38
+ return "\n".join(p.text for p in doc.paragraphs)
39
+
40
+ def read_pdf(path: str) -> str:
41
+ reader = PdfReader(path)
42
+ return "\n".join((p.extract_text() or "") for p in reader.pages)
43
+
44
+ def load_all_docs(folder: str) -> List[Tuple[str, str]]:
45
+ paths = []
46
+ for ext in ("*.md", "*.txt", "*.docx", "*.pdf"):
47
+ paths.extend(glob.glob(os.path.join(folder, ext)))
48
+ docs = []
49
+ for p in paths:
50
+ if p.endswith((".md", ".txt")):
51
+ text = read_txt_md(p)
52
+ elif p.endswith(".docx"):
53
+ text = read_docx(p)
54
+ elif p.endswith(".pdf"):
55
+ text = read_pdf(p)
56
+ else:
57
+ continue
58
+ if text.strip():
59
+ docs.append((p, text))
60
+ return docs
61
+
62
+ # ---------- Index ----------
63
+ def chunk_text(text: str, source: str, chunk_size: int = 900, overlap: int = 150):
64
+ words = text.split()
65
+ i = 0
66
+ while i < len(words):
67
+ chunk = " ".join(words[i:i + chunk_size])
68
+ yield {"source": source, "chunk": chunk, "hash": hashlib.md5((source + str(i)).encode()).hexdigest()}
69
+ i += (chunk_size - overlap)
70
+
71
+ def embed_texts(texts: list[str]) -> np.ndarray:
72
+ resp = client.embeddings.create(model=EMBEDDING_MODEL, input=texts)
73
+ vecs = [d.embedding for d in resp.data]
74
+ return np.array(vecs).astype("float32")
75
+
76
+ class RAGIndex:
77
+ def __init__(self):
78
+ self.index = None
79
+ self.df = None
80
+ self.dim = None
81
+
82
+ def load_or_build(self, force=False):
83
+ docs = load_all_docs(DOCS_DIR)
84
+ if not docs:
85
+ raise RuntimeError(f"No docs in {DOCS_DIR}/")
86
+ index_exists = os.path.exists(INDEX_PATH) and os.path.exists(META_CSV)
87
+ need = force or not index_exists
88
+
89
+ if index_exists and not need:
90
+ df = pd.read_csv(META_CSV)
91
+ vecs = np.load(INDEX_PATH)
92
+ faiss.normalize_L2(vecs)
93
+ idx = faiss.IndexFlatIP(vecs.shape[1])
94
+ idx.add(vecs)
95
+ self.index, self.df, self.dim = idx, df, vecs.shape[1]
96
+ return
97
+
98
+ # build new index
99
+ chunks = []
100
+ for p, t in docs:
101
+ for c in chunk_text(t, p):
102
+ chunks.append(c)
103
+ df = pd.DataFrame(chunks)
104
+ vecs = embed_texts(df["chunk"].tolist())
105
+ faiss.normalize_L2(vecs)
106
+ np.save(INDEX_PATH, vecs)
107
+ df.to_csv(META_CSV, index=False)
108
+ idx = faiss.IndexFlatIP(vecs.shape[1])
109
+ idx.add(vecs)
110
+ self.index, self.df, self.dim = idx, df, vecs.shape[1]
111
+
112
+ def retrieve(self, q: str, k=6):
113
+ qv = embed_texts([q])
114
+ faiss.normalize_L2(qv)
115
+ D, I = self.index.search(qv, k)
116
+ out = []
117
+ for s, i in zip(D[0], I[0]):
118
+ if int(i) >= 0:
119
+ row = self.df.iloc[int(i)]
120
+ out.append({"score": float(s), "source": row["source"], "chunk": row["chunk"]})
121
+ return out
122
+
123
+ RAG = RAGIndex()
124
+
125
+ SYSTEM_PROMPT = (
126
+ "You are a concise wedding assistant for Samson’s brother’s wedding. "
127
+ "Use ONLY the provided context. If missing, say so and suggest contacting Overall IC. "
128
+ "Keep answers under 150 words."
129
+ )
130
+
131
+ async def answer_with_rag(q: str) -> str:
132
+ ctx = RAG.retrieve(q, k=6)
133
+ blocks = []
134
+ for r in ctx:
135
+ t = r["chunk"]
136
+ if len(t) > 800:
137
+ t = t[:800] + "…"
138
+ blocks.append(f"[Source: {os.path.basename(r['source'])}]\n{t}")
139
+
140
+ context_text = "\n\n".join(blocks)
141
+ completion = client.chat.completions.create(
142
+ model=OPENAI_MODEL,
143
+ messages=[
144
+ {"role": "system", "content": SYSTEM_PROMPT},
145
+ {"role": "user", "content": f"Context from docs:\n\n{context_text}\n\nQuestion: {q}"}
146
+ ],
147
+ temperature=0.2,
148
+ )
149
+ a = completion.choices[0].message.content.strip()
150
+ if STRICT_DOC_MODE and not blocks:
151
+ return (
152
+ "I couldn’t find this in the docs. Please check the playbook or ask the Overall IC. "
153
+ "Admins can trigger /refresh after updating docs."
154
+ )
155
+ return a
156
+
157
+ # ---------- Telegram App (Webhook) ----------
158
+ app = FastAPI()
159
+ telegram_app: Application | None = None
160
+
161
+ async def start_telegram():
162
+ global telegram_app
163
+ if telegram_app is not None:
164
+ return telegram_app
165
+
166
+ RAG.load_or_build(force=False)
167
+
168
+ application = (
169
+ Application.builder()
170
+ .token(TELEGRAM_BOT_TOKEN)
171
+ .rate_limiter(AIORateLimiter())
172
+ .build()
173
+ )
174
+
175
+ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
176
+ await update.message.reply_text(
177
+ "Hello! Ask me anything about roles, timings, addresses, and logistics.\n"
178
+ "Admins can use /refresh after updating docs."
179
+ )
180
+
181
+ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
182
+ await update.message.reply_text("Use /refresh or just ask your question in plain text.")
183
+
184
+ async def refresh_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
185
+ RAG.load_or_build(force=True)
186
+ await update.message.reply_text("✅ Refreshed from latest documents.")
187
+
188
+ async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
189
+ q = update.message.text or ""
190
+ a = await answer_with_rag(q)
191
+ await update.message.reply_text(a)
192
+
193
+ application.add_handler(CommandHandler("start", start))
194
+ application.add_handler(CommandHandler("help", help_cmd))
195
+ application.add_handler(CommandHandler("refresh", refresh_cmd))
196
+ application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
197
+
198
+ # Configure webhook
199
+ if not PUBLIC_URL:
200
+ raise RuntimeError("PUBLIC_URL is not set. Add it in Space secrets.")
201
+ webhook_path = f"/webhook/{TELEGRAM_BOT_TOKEN}"
202
+ await application.bot.set_webhook(url=PUBLIC_URL.rstrip("/") + webhook_path)
203
+
204
+ telegram_app = application
205
+ return telegram_app
206
+
207
+ @app.on_event("startup")
208
+ async def _startup():
209
+ await start_telegram()
210
+
211
+ @app.get("/")
212
+ async def root():
213
+ return {"status": "ok", "msg": "Wedding bot running", "docs": os.listdir(DOCS_DIR)}
214
+
215
+ @app.post("/webhook/{token}")
216
+ async def telegram_webhook(token: str, request: Request):
217
+ if token != TELEGRAM_BOT_TOKEN:
218
+ return PlainTextResponse("forbidden", status_code=403)
219
+ data = await request.json()
220
+ update = Update.de_json(data, (await start_telegram()).bot)
221
+ await (await start_telegram()).process_update(update)
222
+ return JSONResponse({"ok": True})