gloomy_pooplar commited on
Commit ·
b4a1c1a
1
Parent(s): 421cf0f
feat: search_debug(), RAG details in web logs
Browse files- bot.py +22 -6
- knowledge.py +26 -14
- log_store.py +11 -7
- server.py +41 -11
bot.py
CHANGED
|
@@ -7,6 +7,7 @@ from openai import OpenAI
|
|
| 7 |
import httpx
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
from log_store import add as log_add
|
|
|
|
| 10 |
|
| 11 |
load_dotenv()
|
| 12 |
|
|
@@ -15,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|
| 15 |
GROQ_API_KEY = os.getenv('GROQ_API_KEY')
|
| 16 |
LLAMA_MODEL_PATH = os.getenv('LLAMA_MODEL_PATH')
|
| 17 |
|
| 18 |
-
from knowledge import reload_knowledge
|
| 19 |
kb_items, kb_tfidf = reload_knowledge(['knowledge_base.json', 'knowledge_base_insoles.json'])
|
| 20 |
|
| 21 |
_llm = None
|
|
@@ -145,7 +146,7 @@ def start_bot(bot_token: str):
|
|
| 145 |
"chat_id": chat_id,
|
| 146 |
"text": msg,
|
| 147 |
})
|
| 148 |
-
log_add(text, msg,
|
| 149 |
continue
|
| 150 |
|
| 151 |
if need_operator(text):
|
|
@@ -154,26 +155,41 @@ def start_bot(bot_token: str):
|
|
| 154 |
"chat_id": chat_id,
|
| 155 |
"text": msg,
|
| 156 |
})
|
| 157 |
-
log_add(text, msg,
|
| 158 |
continue
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
|
| 163 |
response = get_grok_response(text, results)
|
| 164 |
mode = "groq"
|
|
|
|
| 165 |
if response is None:
|
| 166 |
response = get_local_response(text, results)
|
| 167 |
mode = "local"
|
|
|
|
| 168 |
if response is None:
|
| 169 |
response = "Извините, не удалось получить ответ. Попробуйте позже или позвоните +375 (29) 145-03-03."
|
| 170 |
mode = "fallback"
|
|
|
|
|
|
|
| 171 |
|
| 172 |
client.post(f"{api_url}/sendMessage", json={
|
| 173 |
"chat_id": chat_id,
|
| 174 |
"text": response,
|
| 175 |
})
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
except Exception as e:
|
| 179 |
logger.error(f"Polling error: {e}")
|
|
|
|
| 7 |
import httpx
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
from log_store import add as log_add
|
| 10 |
+
from knowledge import search_debug
|
| 11 |
|
| 12 |
load_dotenv()
|
| 13 |
|
|
|
|
| 16 |
GROQ_API_KEY = os.getenv('GROQ_API_KEY')
|
| 17 |
LLAMA_MODEL_PATH = os.getenv('LLAMA_MODEL_PATH')
|
| 18 |
|
| 19 |
+
from knowledge import reload_knowledge
|
| 20 |
kb_items, kb_tfidf = reload_knowledge(['knowledge_base.json', 'knowledge_base_insoles.json'])
|
| 21 |
|
| 22 |
_llm = None
|
|
|
|
| 146 |
"chat_id": chat_id,
|
| 147 |
"text": msg,
|
| 148 |
})
|
| 149 |
+
log_add(question=text, response=msg, mode="greeting", search_method="—", timing_ms=0)
|
| 150 |
continue
|
| 151 |
|
| 152 |
if need_operator(text):
|
|
|
|
| 155 |
"chat_id": chat_id,
|
| 156 |
"text": msg,
|
| 157 |
})
|
| 158 |
+
log_add(question=text, response=msg, mode="operator", search_method="—", timing_ms=0)
|
| 159 |
continue
|
| 160 |
|
| 161 |
+
t0 = time.time()
|
| 162 |
+
debug = search_debug(text, top_k=2)
|
| 163 |
+
results = debug["items"]
|
| 164 |
+
t1 = time.time()
|
| 165 |
|
| 166 |
response = get_grok_response(text, results)
|
| 167 |
mode = "groq"
|
| 168 |
+
llm_model = "llama-3.3-70b-versatile (Groq)"
|
| 169 |
if response is None:
|
| 170 |
response = get_local_response(text, results)
|
| 171 |
mode = "local"
|
| 172 |
+
llm_model = "Llama 3.2 3B (local)"
|
| 173 |
if response is None:
|
| 174 |
response = "Извините, не удалось получить ответ. Попробуйте позже или позвоните +375 (29) 145-03-03."
|
| 175 |
mode = "fallback"
|
| 176 |
+
llm_model = ""
|
| 177 |
+
t2 = time.time()
|
| 178 |
|
| 179 |
client.post(f"{api_url}/sendMessage", json={
|
| 180 |
"chat_id": chat_id,
|
| 181 |
"text": response,
|
| 182 |
})
|
| 183 |
+
|
| 184 |
+
log_add(
|
| 185 |
+
question=text,
|
| 186 |
+
response=response,
|
| 187 |
+
mode=mode,
|
| 188 |
+
search_method=debug["method"],
|
| 189 |
+
search_details=debug["details"],
|
| 190 |
+
llm_model=llm_model,
|
| 191 |
+
timing_ms=round((t1 - t0) * 1000 + (t2 - t1) * 1000),
|
| 192 |
+
)
|
| 193 |
|
| 194 |
except Exception as e:
|
| 195 |
logger.error(f"Polling error: {e}")
|
knowledge.py
CHANGED
|
@@ -128,16 +128,19 @@ def load_knowledge_base(paths: list[str] | str) -> list[KnowledgeItem]:
|
|
| 128 |
|
| 129 |
def search(query: str, top_k: int = 2, items: list[KnowledgeItem] = None,
|
| 130 |
tfidf: dict = None) -> list[KnowledgeItem]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
n = len(_sections)
|
| 132 |
if n == 0:
|
| 133 |
-
return []
|
| 134 |
|
| 135 |
-
# --- BM25 scores ---
|
| 136 |
bm25_scores = None
|
| 137 |
if _bm25 is not None:
|
| 138 |
bm25_scores = _bm25.get_scores(_tokenize(query))
|
| 139 |
|
| 140 |
-
# --- Dense scores (embedding) ---
|
| 141 |
embed_ranks = None
|
| 142 |
qvec = _embed_query(query)
|
| 143 |
if qvec is not None and _index is not None and _index.ntotal > 0:
|
|
@@ -148,33 +151,42 @@ def search(query: str, top_k: int = 2, items: list[KnowledgeItem] = None,
|
|
| 148 |
if bm25_scores is not None and embed_ranks is not None:
|
| 149 |
rrf = {}
|
| 150 |
for i in range(n):
|
| 151 |
-
score = 0.0
|
| 152 |
-
# BM25 rank
|
| 153 |
bm25_rank = sorted(range(n), key=lambda j: -bm25_scores[j]).index(i)
|
| 154 |
-
|
| 155 |
-
|
| 156 |
if i in embed_ranks:
|
| 157 |
-
|
| 158 |
-
rrf[i] =
|
| 159 |
top_indices = sorted(rrf.keys(), key=lambda i: -rrf[i])[:top_k]
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
# --- Embedding only ---
|
| 163 |
if embed_ranks is not None:
|
| 164 |
top_indices = sorted(embed_ranks.keys(), key=lambda i: embed_ranks[i])[:top_k]
|
| 165 |
-
|
|
|
|
| 166 |
|
| 167 |
# --- BM25 only ---
|
| 168 |
if bm25_scores is not None:
|
| 169 |
top_indices = sorted(range(n), key=lambda i: -bm25_scores[i])[:top_k]
|
| 170 |
-
|
|
|
|
| 171 |
|
| 172 |
# --- TF-IDF fallback ---
|
| 173 |
if _tfidf_backup is not None and _items_backup:
|
| 174 |
from knowledge_tfidf_backup import search as tfidf_search
|
| 175 |
-
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
return []
|
| 178 |
|
| 179 |
|
| 180 |
def reload_knowledge(paths: list[str] | str) -> tuple[list[KnowledgeItem], dict]:
|
|
|
|
| 128 |
|
| 129 |
def search(query: str, top_k: int = 2, items: list[KnowledgeItem] = None,
|
| 130 |
tfidf: dict = None) -> list[KnowledgeItem]:
|
| 131 |
+
return search_debug(query, top_k)["items"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def search_debug(query: str, top_k: int = 2) -> dict:
|
| 135 |
+
"""Returns {items, method, details: [{title, bm25_score, embed_rank, rrf_score}]}"""
|
| 136 |
n = len(_sections)
|
| 137 |
if n == 0:
|
| 138 |
+
return {"items": [], "method": "none", "details": []}
|
| 139 |
|
|
|
|
| 140 |
bm25_scores = None
|
| 141 |
if _bm25 is not None:
|
| 142 |
bm25_scores = _bm25.get_scores(_tokenize(query))
|
| 143 |
|
|
|
|
| 144 |
embed_ranks = None
|
| 145 |
qvec = _embed_query(query)
|
| 146 |
if qvec is not None and _index is not None and _index.ntotal > 0:
|
|
|
|
| 151 |
if bm25_scores is not None and embed_ranks is not None:
|
| 152 |
rrf = {}
|
| 153 |
for i in range(n):
|
|
|
|
|
|
|
| 154 |
bm25_rank = sorted(range(n), key=lambda j: -bm25_scores[j]).index(i)
|
| 155 |
+
rrf_score = 0.0
|
| 156 |
+
rrf_score += 0.4 * (1 / (bm25_rank + 1))
|
| 157 |
if i in embed_ranks:
|
| 158 |
+
rrf_score += 0.6 * (1 / (embed_ranks[i] + 1))
|
| 159 |
+
rrf[i] = rrf_score
|
| 160 |
top_indices = sorted(rrf.keys(), key=lambda i: -rrf[i])[:top_k]
|
| 161 |
+
details = [{
|
| 162 |
+
"id": _sections[i].id,
|
| 163 |
+
"title": _sections[i].title,
|
| 164 |
+
"bm25_rank": sorted(range(n), key=lambda j: -bm25_scores[j]).index(i),
|
| 165 |
+
"embed_rank": embed_ranks.get(i, None),
|
| 166 |
+
"rrf_score": round(rrf[i], 4),
|
| 167 |
+
} for i in top_indices]
|
| 168 |
+
return {"items": [_sections[i] for i in top_indices], "method": "hybrid (bge-m3+BM25)", "details": details}
|
| 169 |
|
| 170 |
# --- Embedding only ---
|
| 171 |
if embed_ranks is not None:
|
| 172 |
top_indices = sorted(embed_ranks.keys(), key=lambda i: embed_ranks[i])[:top_k]
|
| 173 |
+
details = [{"id": _sections[i].id, "title": _sections[i].title, "embed_rank": embed_ranks[i], "rrf_score": None} for i in top_indices]
|
| 174 |
+
return {"items": [_sections[i] for i in top_indices], "method": "bge-m3 only", "details": details}
|
| 175 |
|
| 176 |
# --- BM25 only ---
|
| 177 |
if bm25_scores is not None:
|
| 178 |
top_indices = sorted(range(n), key=lambda i: -bm25_scores[i])[:top_k]
|
| 179 |
+
details = [{"id": _sections[i].id, "title": _sections[i].title, "bm25_rank": i, "rrf_score": None} for i in top_indices]
|
| 180 |
+
return {"items": [_sections[i] for i in top_indices], "method": "BM25 only", "details": details}
|
| 181 |
|
| 182 |
# --- TF-IDF fallback ---
|
| 183 |
if _tfidf_backup is not None and _items_backup:
|
| 184 |
from knowledge_tfidf_backup import search as tfidf_search
|
| 185 |
+
items = tfidf_search(query, top_k=top_k, items=_items_backup, tfidf=_tfidf_backup)
|
| 186 |
+
details = [{"id": it.id, "title": it.title, "rrf_score": None} for it in items]
|
| 187 |
+
return {"items": items, "method": "TF-IDF fallback", "details": details}
|
| 188 |
|
| 189 |
+
return {"items": [], "method": "none", "details": []}
|
| 190 |
|
| 191 |
|
| 192 |
def reload_knowledge(paths: list[str] | str) -> tuple[list[KnowledgeItem], dict]:
|
log_store.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
| 1 |
"""Thread-safe in-memory log of bot interactions."""
|
| 2 |
|
| 3 |
-
import threading
|
| 4 |
from collections import deque
|
| 5 |
|
| 6 |
_log: deque[dict] = deque(maxlen=50)
|
| 7 |
_lock = threading.Lock()
|
| 8 |
|
| 9 |
|
| 10 |
-
def add(
|
| 11 |
entry = {
|
| 12 |
-
"time":
|
| 13 |
-
"question": question,
|
| 14 |
-
"response": response[:
|
| 15 |
-
"
|
| 16 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
with _lock:
|
| 19 |
_log.appendleft(entry)
|
|
|
|
| 1 |
"""Thread-safe in-memory log of bot interactions."""
|
| 2 |
|
| 3 |
+
import threading, time
|
| 4 |
from collections import deque
|
| 5 |
|
| 6 |
_log: deque[dict] = deque(maxlen=50)
|
| 7 |
_lock = threading.Lock()
|
| 8 |
|
| 9 |
|
| 10 |
+
def add(**kwargs):
|
| 11 |
entry = {
|
| 12 |
+
"time": time.strftime("%H:%M:%S", time.gmtime()),
|
| 13 |
+
"question": kwargs.get("question", ""),
|
| 14 |
+
"response": str(kwargs.get("response", ""))[:500],
|
| 15 |
+
"mode": kwargs.get("mode", ""),
|
| 16 |
+
"search_method": kwargs.get("search_method", ""),
|
| 17 |
+
"search_details": kwargs.get("search_details", []),
|
| 18 |
+
"llm_model": kwargs.get("llm_model", ""),
|
| 19 |
+
"timing_ms": kwargs.get("timing_ms", 0),
|
| 20 |
+
"error": kwargs.get("error", ""),
|
| 21 |
}
|
| 22 |
with _lock:
|
| 23 |
_log.appendleft(entry)
|
server.py
CHANGED
|
@@ -36,29 +36,59 @@ class HealthHandler(BaseHTTPRequestHandler):
|
|
| 36 |
<meta charset="utf-8"><title>ORTOS Bot Logs</title>
|
| 37 |
<style>
|
| 38 |
body{font-family:sans-serif;margin:20px;background:#111;color:#eee}
|
| 39 |
-
table{border-collapse:collapse;width:100%}
|
| 40 |
-
th,td{text-align:left;padding:
|
| 41 |
-
th{background:#222;color:#0f0}
|
| 42 |
tr:hover{background:#1a1a1a}
|
| 43 |
-
.mode{font-weight:bold;padding:2px 6px;border-radius:3px;font-size:
|
| 44 |
.groq{background:#1a3a1a;color:#4f4}
|
| 45 |
-
.local{background:#3a1a1a;color:#
|
| 46 |
.fallback{background:#3a3a1a;color:#ff4}
|
| 47 |
.greeting{background:#1a1a3a;color:#44f}
|
| 48 |
.operator{background:#3a1a3a;color:#f4f}
|
| 49 |
-
.
|
| 50 |
-
.
|
| 51 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</style></head><body>
|
| 53 |
<h2>ORTOS Bot — last 50 interactions</h2>
|
| 54 |
-
<table><tr><th>Time</th><th>Mode</th><th>
|
| 55 |
for e in entries:
|
| 56 |
css = e["mode"]
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
page += f"<tr><td>{e['time']}</td><td><span class='mode {css}'>{e['mode']}</span></td>"
|
| 59 |
page += f"<td class='q'>{html.escape(e['question'])}</td>"
|
| 60 |
page += f"<td class='r'>{html.escape(e['response'])}</td>"
|
| 61 |
-
page += f"<td
|
| 62 |
page += "</table></body></html>"
|
| 63 |
|
| 64 |
self.send_response(200)
|
|
|
|
| 36 |
<meta charset="utf-8"><title>ORTOS Bot Logs</title>
|
| 37 |
<style>
|
| 38 |
body{font-family:sans-serif;margin:20px;background:#111;color:#eee}
|
| 39 |
+
table{border-collapse:collapse;width:100%;font-size:13px}
|
| 40 |
+
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #333;vertical-align:top}
|
| 41 |
+
th{background:#222;color:#0f0;position:sticky;top:0}
|
| 42 |
tr:hover{background:#1a1a1a}
|
| 43 |
+
.mode{font-weight:bold;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap}
|
| 44 |
.groq{background:#1a3a1a;color:#4f4}
|
| 45 |
+
.local{background:#3a1a1a;color:#f88}
|
| 46 |
.fallback{background:#3a3a1a;color:#ff4}
|
| 47 |
.greeting{background:#1a1a3a;color:#44f}
|
| 48 |
.operator{background:#3a1a3a;color:#f4f}
|
| 49 |
+
.q{color:#ffa;max-width:250px;word-break:break-word}
|
| 50 |
+
.r{color:#afa;max-width:350px;word-break:break-word}
|
| 51 |
+
.detail{color:#888;font-size:11px;margin-top:4px;border-top:1px solid #333;padding-top:4px}
|
| 52 |
+
.lbl{color:#666}
|
| 53 |
+
.val{color:#eee}
|
| 54 |
+
.src{color:#8af}
|
| 55 |
+
.err{color:#f44}
|
| 56 |
+
summary{cursor:pointer;color:#8af;font-size:12px}
|
| 57 |
</style></head><body>
|
| 58 |
<h2>ORTOS Bot — last 50 interactions</h2>
|
| 59 |
+
<table><thead><tr><th>Time</th><th>Mode</th><th>Q</th><th>Response</th><th>RAG</th></tr></thead>"""
|
| 60 |
for e in entries:
|
| 61 |
css = e["mode"]
|
| 62 |
+
md = e["search_method"]
|
| 63 |
+
llm = html.escape(e["llm_model"])
|
| 64 |
+
ms = e["timing_ms"]
|
| 65 |
+
|
| 66 |
+
# Search details
|
| 67 |
+
rag_html = f"<span class='src'>{html.escape(md)}</span>"
|
| 68 |
+
rag_html += f"<br><span class='lbl'>LLM:</span> <span class='val'>{llm}</span>"
|
| 69 |
+
rag_html += f"<br><span class='lbl'>⏱</span> <span class='val'>{ms}ms</span>"
|
| 70 |
+
|
| 71 |
+
if e.get("search_details"):
|
| 72 |
+
rag_html += "<details><summary>search results</summary>"
|
| 73 |
+
for d in e["search_details"]:
|
| 74 |
+
rag_html += f"<div class='detail'>"
|
| 75 |
+
rag_html += f"<b class='src'>{html.escape(d.get('title',''))}</b>"
|
| 76 |
+
br = d.get("bm25_rank")
|
| 77 |
+
er = d.get("embed_rank")
|
| 78 |
+
rs = d.get("rrf_score")
|
| 79 |
+
if br is not None:
|
| 80 |
+
rag_html += f"<br><span class='lbl'>BM25 rank:</span> <span class='val'>{br}</span>"
|
| 81 |
+
if er is not None:
|
| 82 |
+
rag_html += f"<br><span class='lbl'>bge-m3 rank:</span> <span class='val'>{er}</span>"
|
| 83 |
+
if rs is not None:
|
| 84 |
+
rag_html += f"<br><span class='lbl'>RRF score:</span> <span class='val'>{rs}</span>"
|
| 85 |
+
rag_html += "</div>"
|
| 86 |
+
rag_html += "</details>"
|
| 87 |
+
|
| 88 |
page += f"<tr><td>{e['time']}</td><td><span class='mode {css}'>{e['mode']}</span></td>"
|
| 89 |
page += f"<td class='q'>{html.escape(e['question'])}</td>"
|
| 90 |
page += f"<td class='r'>{html.escape(e['response'])}</td>"
|
| 91 |
+
page += f"<td>{rag_html}</td></tr>"
|
| 92 |
page += "</table></body></html>"
|
| 93 |
|
| 94 |
self.send_response(200)
|