gloomy_pooplar commited on
Commit
b4a1c1a
·
1 Parent(s): 421cf0f

feat: search_debug(), RAG details in web logs

Browse files
Files changed (4) hide show
  1. bot.py +22 -6
  2. knowledge.py +26 -14
  3. log_store.py +11 -7
  4. 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, search
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, [], "greeting")
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, [], "operator")
158
  continue
159
 
160
- results = search(text, top_k=2, items=kb_items, tfidf=kb_tfidf)
161
- sources = [r.title for r in results]
 
 
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
- log_add(text, response, sources, mode)
 
 
 
 
 
 
 
 
 
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
- score += 0.4 * (1 / (bm25_rank + 1))
155
- # Embed rank
156
  if i in embed_ranks:
157
- score += 0.6 * (1 / (embed_ranks[i] + 1))
158
- rrf[i] = score
159
  top_indices = sorted(rrf.keys(), key=lambda i: -rrf[i])[:top_k]
160
- return [_sections[i] for i in top_indices]
 
 
 
 
 
 
 
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
- return [_sections[i] for i in top_indices]
 
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
- return [_sections[i] for i in top_indices]
 
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
- return tfidf_search(query, top_k=top_k, items=_items_backup, tfidf=_tfidf_backup)
 
 
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(question: str, response: str, sources: list[str], mode: str):
11
  entry = {
12
- "time": __import__("time").strftime("%H:%M:%S", __import__("time").gmtime()),
13
- "question": question,
14
- "response": response[:300],
15
- "sources": sources,
16
- "mode": mode,
 
 
 
 
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:8px;border-bottom:1px solid #333;vertical-align:top}
41
- th{background:#222;color:#0f0}
42
  tr:hover{background:#1a1a1a}
43
- .mode{font-weight:bold;padding:2px 6px;border-radius:3px;font-size:12px}
44
  .groq{background:#1a3a1a;color:#4f4}
45
- .local{background:#3a1a1a;color:#f44}
46
  .fallback{background:#3a3a1a;color:#ff4}
47
  .greeting{background:#1a1a3a;color:#44f}
48
  .operator{background:#3a1a3a;color:#f4f}
49
- .src{color:#888;font-size:11px}
50
- .q{color:#ffa;max-width:300px;word-break:break-word}
51
- .r{color:#afa;max-width:400px;word-break:break-word}
 
 
 
 
 
52
  </style></head><body>
53
  <h2>ORTOS Bot &mdash; last 50 interactions</h2>
54
- <table><tr><th>Time</th><th>Mode</th><th>Question</th><th>Response</th><th>Sources</th></tr>"""
55
  for e in entries:
56
  css = e["mode"]
57
- src = ", ".join(e["sources"]) if e["sources"] else "—"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 class='src'>{src}</td></tr>"
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 &mdash; 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)