dsh-biocore 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,6 +120,27 @@ except Exception: # pragma: no cover
120
120
  from biocore_runtime import relationship_state as _relationship_state
121
121
  except Exception:
122
122
  _relationship_state = None
123
+ try: # task-62: normalized channels / chunks / shards / prediction
124
+ from . import retrieval_plus as _retrieval_plus
125
+ except Exception: # pragma: no cover
126
+ try:
127
+ from biocore_runtime import retrieval_plus as _retrieval_plus
128
+ except Exception:
129
+ _retrieval_plus = None
130
+ try: # contract migration 1: cache stats in the core
131
+ from . import cache_stats as _cache_stats
132
+ except Exception: # pragma: no cover
133
+ try:
134
+ from biocore_runtime import cache_stats as _cache_stats
135
+ except Exception:
136
+ _cache_stats = None
137
+ try: # task-36 + task-78: tier hysteresis / reconsolidation / repair
138
+ from . import lifecycle as _lifecycle
139
+ except Exception: # pragma: no cover
140
+ try:
141
+ from biocore_runtime import lifecycle as _lifecycle
142
+ except Exception:
143
+ _lifecycle = None
123
144
  try: # task-225: P0 memory + expression extensions
124
145
  from . import extensions as _extensions
125
146
  except Exception: # pragma: no cover
@@ -682,6 +703,42 @@ def _query_entries(store, session_id, query, top_k, rerank_config):
682
703
  _num(entry.get("importance"), 0.5)), reverse=True)
683
704
  except Exception:
684
705
  pass
706
+ if _retrieval_plus is not None and query:
707
+ try:
708
+ text_query = str(query)
709
+ switches = _effective_switches(store)
710
+ enabled = bool(switches.get("retrieval_plus", True))
711
+ _retrieval_plus.record_query(store, text_query, now=time.time())
712
+ rerank_on = isinstance(rerank_config, dict) and bool(
713
+ rerank_config.get("rerank_enabled")
714
+ or rerank_config.get("enabled"))
715
+ if enabled and not rerank_on and len(picked) > 1:
716
+ ranked = _retrieval_plus.rank(
717
+ store, session_id, text_query, picked,
718
+ now=time.time(), limit=len(picked))
719
+ if ranked:
720
+ _retrieval_plus.record_debug(store, text_query, ranked)
721
+ engine_scores = {str(row.get("entry", {}).get("id") or ""):
722
+ _num(row.get("score"), 0.0) for row in rows}
723
+ top = max(engine_scores.values() or [1.0]) or 1.0
724
+ combined = []
725
+ for item in ranked:
726
+ entry_id = str(item["entry"].get("id") or "")
727
+ engine_norm = engine_scores.get(entry_id, 0.0) / top
728
+ combined.append((0.7 * engine_norm + 0.3 * item["score"],
729
+ item))
730
+ combined.sort(key=lambda pair: pair[0], reverse=True)
731
+ picked = [item["entry"] for _score, item in combined]
732
+ except Exception:
733
+ pass
734
+ if _lifecycle is not None:
735
+ try:
736
+ _lifecycle.record_recall(store, session_id,
737
+ [str(entry.get("id") or "")
738
+ for entry in picked], now=time.time())
739
+ _lifecycle.tier_tick(store, picked, now=time.time())
740
+ except Exception:
741
+ pass
685
742
  return picked[:top_k]
686
743
 
687
744
 
@@ -867,6 +924,13 @@ def prompt_block(payload):
867
924
  state_text = _state_block(state, budget)
868
925
  constraints = _constraints_block(state, switches, data)
869
926
  text = (state_text + "\n" + constraints).strip()
927
+ if _lifecycle is not None:
928
+ try:
929
+ repair = _lifecycle.repair_lines(data.get("store"))
930
+ if repair:
931
+ text = text + "\n" + "\n".join("- " + line for line in repair)
932
+ except Exception:
933
+ pass
870
934
  expression = None
871
935
  if _extensions is not None:
872
936
  try:
@@ -879,8 +943,14 @@ def prompt_block(payload):
879
943
  expression.get("template"),
880
944
  expression.get("topic"),
881
945
  expression.get("voice")) if value]
882
- if extra:
883
- text = text + "\n" + "\n".join("- " + line for line in extra[:4])
946
+ lines = []
947
+ for value in extra:
948
+ for line in str(value).splitlines():
949
+ line = line.strip()
950
+ if line:
951
+ lines.append(line if line.startswith("- ") else "- " + line)
952
+ if lines:
953
+ text = text + "\n" + "\n".join(lines[:6])
884
954
  except Exception:
885
955
  expression = None
886
956
  thinking_line = thinking_style_line(data)
@@ -2056,7 +2126,7 @@ CORE_DEFAULTS = {
2056
2126
  "evict": True, "evict_delete": False,
2057
2127
  "relationship_governance": True,
2058
2128
  "self_layer": True, "motivation": True,
2059
- "autonomy": True, "standby": True,
2129
+ "autonomy": True, "standby": False, "retrieval_plus": True,
2060
2130
  "safety_enabled": False, "boundary_enabled": False,
2061
2131
  "crisis_safety_enabled": False,
2062
2132
  "proactive_send": True,
@@ -2227,6 +2297,17 @@ def _ui_state_body(store, session_id, data):
2227
2297
  "limits": _autonomy.limits(store)}
2228
2298
  except Exception:
2229
2299
  autonomy_view = None
2300
+ standby_view = None
2301
+ try:
2302
+ from . import standby as _standby_view
2303
+ except Exception:
2304
+ _standby_view = None
2305
+ if _standby_view is not None:
2306
+ try:
2307
+ standby_view = _standby_view.summary(
2308
+ store, enabled=bool(_effective_switches(store).get("standby")))
2309
+ except Exception:
2310
+ standby_view = None
2230
2311
  signals_summary = None
2231
2312
  if _signals is not None:
2232
2313
  try:
@@ -2246,6 +2327,15 @@ def _ui_state_body(store, session_id, data):
2246
2327
  "identity": identity_summary,
2247
2328
  "signals": signals_summary,
2248
2329
  "extensions": extensions_summary,
2330
+ "standby": standby_view,
2331
+ "recall_debug": (_retrieval_plus.summary(store)
2332
+ if _retrieval_plus is not None else None),
2333
+ "tiers": (_lifecycle.tier_summary(store)
2334
+ if _lifecycle is not None else None),
2335
+ "reconsolidation": (_lifecycle.reconsolidation_summary(store)
2336
+ if _lifecycle is not None else None),
2337
+ "repair": (_lifecycle.repair_state(store)
2338
+ if _lifecycle is not None else None),
2249
2339
  "available": True,
2250
2340
  "session_id": sanitize_session_id(session_id),
2251
2341
  "memory_count": len(entries),
@@ -2549,6 +2639,7 @@ def dispatch(action, payload=None):
2549
2639
  else:
2550
2640
  session_state = apply_intero_to_state(session_state,
2551
2641
  data.get("state"))
2642
+ session_state["last_human_ts"] = time.time()
2552
2643
  if state_store is not None:
2553
2644
  _save_session_state(state_store, session_id, session_state)
2554
2645
  _signals_eval(store, session_id, user_text=user_text,
@@ -2729,6 +2820,14 @@ def dispatch(action, payload=None):
2729
2820
  _extensions.record_extension_snapshot(store, snapshot)
2730
2821
  except Exception:
2731
2822
  pass
2823
+ if _lifecycle is not None:
2824
+ try:
2825
+ _lifecycle.reconsolidate(store, now=time.time())
2826
+ if gate.get("blocked"):
2827
+ _lifecycle.arm_repair(store, gate.get("rule") or "",
2828
+ now=time.time())
2829
+ except Exception:
2830
+ pass
2732
2831
  identity_info = None
2733
2832
  if relationship_info:
2734
2833
  identity_info = _identity_tick(
@@ -2757,6 +2856,14 @@ def dispatch(action, payload=None):
2757
2856
  "relationship": relationship_info,
2758
2857
  "identity": identity_info,
2759
2858
  }
2859
+ if action == "usage_event":
2860
+ if _cache_stats is None:
2861
+ return {"ok": False, "reason": "cache_stats_unavailable"}
2862
+ payload = data.get("event") if isinstance(data.get("event"), dict) else data
2863
+ row = _cache_stats.record_event(store, payload,
2864
+ now=data.get("now"))
2865
+ return {"ok": row is not None, "event": row,
2866
+ "cache": _cache_stats.summary(store, now=data.get("now"))}
2760
2867
  if action == "stats":
2761
2868
  switches = _effective_switches(store, data.get("switches"))
2762
2869
  if not switches.get("core_enabled", True):
@@ -2765,6 +2872,12 @@ def dispatch(action, payload=None):
2765
2872
  "remote", "local", "fail")},
2766
2873
  "switches": {}, "store": str(store)}
2767
2874
  embed = _embed_stats_snapshot()
2875
+ cache_info = None
2876
+ if _cache_stats is not None:
2877
+ try:
2878
+ cache_info = _cache_stats.summary(store)
2879
+ except Exception:
2880
+ cache_info = None
2768
2881
  names = ("mood_recall", "affect_encoding", "memory_feedback",
2769
2882
  "preference_drift")
2770
2883
  out = {name: {"enabled": bool(switches.get(name)), "source": "core"}
@@ -2811,7 +2924,25 @@ def dispatch(action, payload=None):
2811
2924
  "identity": identity_info,
2812
2925
  "autonomy": autonomy_info,
2813
2926
  "signals": signals_info,
2927
+ "cache": cache_info,
2814
2928
  "store": str(store)}
2929
+ if action == "standby_tick":
2930
+ try:
2931
+ from . import standby as _standby
2932
+ except Exception:
2933
+ from biocore_runtime import standby as _standby
2934
+ payload = data if isinstance(data, dict) else {}
2935
+ cfg = payload.get("standby")
2936
+ if not isinstance(cfg, dict):
2937
+ cfg = {}
2938
+ runtime = _load_runtime_config(store) if store is not None else {}
2939
+ section = runtime.get("standby")
2940
+ merged = dict(section) if isinstance(section, dict) else {}
2941
+ merged.update(cfg)
2942
+ merged["standby_store"] = str(store or "")
2943
+ merged["standby_session_id"] = str(
2944
+ payload.get("session_id") or "standby")
2945
+ return _standby.tick(now=payload.get("now"), config=merged)
2815
2946
  if action == "autonomy_tick":
2816
2947
  return _autonomy_tick(store, data) or {
2817
2948
  "ok": False, "reason": "autonomy_unavailable"}
@@ -0,0 +1,352 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-62: retrieval enhancement -- normalized channels, chunks, shards,
3
+ predictive exploration.
4
+
5
+ Pipeline-level retriever used after the memory engine returns candidates:
6
+ bm25 lexical term overlap with idf-like weighting
7
+ vector hashed bag-of-tokens cosine
8
+ graph query-entity co-occurrence neighbours
9
+ time recency decay
10
+ Each channel is min-max normalized independently, then fused. Sentences are
11
+ chunked (indexed per entry) and the best chunk provides the returned snippet.
12
+ Entries are assigned to physical shards (session or month) with a per-shard
13
+ cap and rolling suffix. Recent queries form a topic "expectation": entries far
14
+ from it get a small exploration boost, highly expected ones a redundancy
15
+ penalty.
16
+
17
+ Default on; the caller passes enabled=False to keep the legacy order.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import math
24
+ import re
25
+ import time
26
+ from pathlib import Path
27
+
28
+ CHANNELS = ("bm25", "vector", "graph", "time")
29
+ SHARD_LIMIT = 200
30
+ VECTOR_DIM = 64
31
+ EXPECTATION_TURNS = 5
32
+ EXPLORE_ALPHA = 0.6
33
+ DEBUG_FILE = "recall_debug.json"
34
+ TOPIC_FILE = "topic_history.json"
35
+ SHARD_FILE = "retrieval/shards.json"
36
+ CHUNK_FILE = "retrieval/chunks.json"
37
+ SCHEMA = 1
38
+
39
+ _WORD = re.compile(r"[a-z0-9_]{2,}")
40
+ _CJK = re.compile(r"[\u4e00-\u9fff]")
41
+ _SENT = re.compile(r"[^。!?!?;;\n]+[。!?!?;;]?")
42
+
43
+
44
+ def _root(store):
45
+ if isinstance(store, dict):
46
+ raw = store.get("root") or store.get("store") or store.get("path")
47
+ else:
48
+ raw = store
49
+ text = str(raw or "").strip()
50
+ return Path(text) if text else None
51
+
52
+
53
+ def _data_dir(store):
54
+ root = _root(store)
55
+ return (root.parent if root.name == "memory" else root) if root else None
56
+
57
+
58
+ def _read(path):
59
+ try:
60
+ if path is None or not path.exists():
61
+ return {}
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ return data if isinstance(data, dict) else {}
64
+ except Exception:
65
+ return {}
66
+
67
+
68
+ def _write(path, doc):
69
+ try:
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ tmp = path.with_name(path.name + ".tmp")
72
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2),
73
+ encoding="utf-8")
74
+ tmp.replace(path)
75
+ return True
76
+ except Exception:
77
+ return False
78
+
79
+
80
+ def _num(value, default=0.0):
81
+ try:
82
+ number = float(value)
83
+ except Exception:
84
+ return default
85
+ return number if number == number else default
86
+
87
+
88
+ def tokens(text):
89
+ lowered = str(text or "").lower()
90
+ out = _WORD.findall(lowered)
91
+ for run in re.findall(r"[\u4e00-\u9fff]+", lowered):
92
+ out.extend(run[i:i + 2] for i in range(len(run) - 1))
93
+ if len(run) <= 2:
94
+ out.append(run)
95
+ return out
96
+
97
+
98
+ def vector(text, dim=VECTOR_DIM):
99
+ counts = {}
100
+ for token in tokens(text):
101
+ bucket = int(hashlib.sha1(token.encode("utf-8")).hexdigest()[:8], 16) % dim
102
+ counts[bucket] = counts.get(bucket, 0) + 1
103
+ norm = math.sqrt(sum(value * value for value in counts.values())) or 1.0
104
+ return {bucket: value / norm for bucket, value in counts.items()}
105
+
106
+
107
+ def cosine(left, right):
108
+ if not left or not right:
109
+ return 0.0
110
+ if len(left) > len(right):
111
+ left, right = right, left
112
+ return sum(value * right.get(bucket, 0.0) for bucket, value in left.items())
113
+
114
+
115
+ def normalize(scores):
116
+ """Min-max each channel independently; flat all-equal -> 1.0 or 0.0."""
117
+ values = [float(value) for value in scores]
118
+ if not values:
119
+ return []
120
+ low, high = min(values), max(values)
121
+ if high - low <= 1e-12:
122
+ return [1.0 if high > 0 else 0.0 for _ in values]
123
+ return [(value - low) / (high - low) for value in values]
124
+
125
+
126
+ def split_chunks(text, max_chars=120):
127
+ pieces = [piece.strip() for piece in _SENT.findall(str(text or ""))
128
+ if piece.strip()]
129
+ chunks = []
130
+ for piece in pieces:
131
+ if len(piece) <= max_chars:
132
+ chunks.append(piece)
133
+ continue
134
+ for start in range(0, len(piece), max_chars):
135
+ part = piece[start:start + max_chars].strip()
136
+ if part:
137
+ chunks.append(part)
138
+ return chunks or ([str(text or "").strip()] if str(text or "").strip() else [])
139
+
140
+
141
+ def chunk_index(store):
142
+ return _read((_data_dir(store) or Path(".")) / CHUNK_FILE)
143
+
144
+
145
+ def build_chunks(store, entries):
146
+ """Persist a sentence index per entry; returns id -> chunks."""
147
+ doc = chunk_index(store)
148
+ mapping = doc.get("entries") if isinstance(doc.get("entries"), dict) else {}
149
+ for entry in entries or []:
150
+ entry_id = str((entry or {}).get("id") or "")
151
+ text = str((entry or {}).get("text") or "")
152
+ if not entry_id or not text:
153
+ continue
154
+ digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:12]
155
+ current = mapping.get(entry_id)
156
+ if isinstance(current, dict) and current.get("hash") == digest:
157
+ continue
158
+ mapping[entry_id] = {"hash": digest, "chunks": split_chunks(text)}
159
+ doc.update({"schema": SCHEMA, "entries": mapping,
160
+ "updated_at": round(time.time(), 3)})
161
+ _write((_data_dir(store) or Path(".")) / CHUNK_FILE, doc)
162
+ return {key: value.get("chunks") or [] for key, value in mapping.items()}
163
+
164
+
165
+ def _best_chunk(chunks, query_tokens):
166
+ best = ""
167
+ best_hits = -1
168
+ for chunk in chunks:
169
+ lowered = set(tokens(chunk))
170
+ hits = len(lowered & set(query_tokens))
171
+ if hits > best_hits:
172
+ best, best_hits = chunk, hits
173
+ return best, max(0, best_hits)
174
+
175
+
176
+ def _shard_base(entry, session_id):
177
+ session = str(session_id or entry.get("session_id") or "")
178
+ if session:
179
+ return "s-" + re.sub(r"[^A-Za-z0-9_-]", "", session)[:24]
180
+ ts = _num(entry.get("ts"), 0.0)
181
+ return time.strftime("%Y-%m", time.localtime(ts or time.time()))
182
+
183
+
184
+ def assign_shards(store, entries, session_id="", limit=SHARD_LIMIT):
185
+ """Session/month shard with rolling suffix when a shard exceeds the cap."""
186
+ doc = _read((_data_dir(store) or Path(".")) / SHARD_FILE)
187
+ by_id = doc.get("by_id") if isinstance(doc.get("by_id"), dict) else {}
188
+ for entry in entries or []:
189
+ entry_id = str((entry or {}).get("id") or "")
190
+ if not entry_id:
191
+ continue
192
+ base = _shard_base(entry, session_id)
193
+ index = sum(1 for value in by_id.values()
194
+ if str((value or {}).get("base")) == base)
195
+ shard = base if index < limit else base + "-r%d" % (index // limit)
196
+ by_id[entry_id] = {"base": base, "shard": shard,
197
+ "ts": round(time.time(), 3)}
198
+ doc.update({"schema": SCHEMA, "by_id": by_id,
199
+ "updated_at": round(time.time(), 3)})
200
+ data_dir = _data_dir(store) or Path(".")
201
+ _write(data_dir / SHARD_FILE, doc)
202
+ # physical shard files: one JSON per shard with its entry ids
203
+ grouped = {}
204
+ for entry_id, value in by_id.items():
205
+ grouped.setdefault(str(value.get("shard") or ""), []).append(entry_id)
206
+ for name, ids in grouped.items():
207
+ _write(data_dir / "retrieval" / "shards" / (name + ".json"),
208
+ {"schema": SCHEMA, "shard": name, "ids": ids[-200:],
209
+ "updated_at": round(time.time(), 3)})
210
+ return {key: value.get("shard") for key, value in by_id.items()}
211
+
212
+
213
+ def record_query(store, query, now=None):
214
+ path = (_data_dir(store) or Path(".")) / TOPIC_FILE
215
+ doc = _read(path)
216
+ rows = [row for row in (doc.get("turns") or []) if isinstance(row, dict)]
217
+ rows.append({"ts": round(float(now or time.time()), 3),
218
+ "vector": vector(query)})
219
+ doc.update({"schema": SCHEMA, "turns": rows[-20:]})
220
+ _write(path, doc)
221
+ return len(rows[-20:])
222
+
223
+
224
+ def expectation(store):
225
+ doc = _read((_data_dir(store) or Path(".")) / TOPIC_FILE)
226
+ rows = [row for row in (doc.get("turns") or [])
227
+ if isinstance(row.get("vector"), dict)][-EXPECTATION_TURNS:]
228
+ if not rows:
229
+ return {}
230
+ total = {}
231
+ for row in rows:
232
+ for bucket, value in row["vector"].items():
233
+ try:
234
+ bucket = int(bucket)
235
+ except Exception:
236
+ continue
237
+ total[bucket] = total.get(bucket, 0.0) + _num(value)
238
+ norm = math.sqrt(sum(value * value for value in total.values())) or 1.0
239
+ return {bucket: value / norm for bucket, value in total.items()}
240
+
241
+
242
+ def _bm25_score(query_tokens, text, corpus_size, df):
243
+ body = tokens(text)
244
+ if not body:
245
+ return 0.0
246
+ score = 0.0
247
+ for token in set(query_tokens):
248
+ tf = body.count(token)
249
+ if not tf:
250
+ continue
251
+ idf = math.log(1.0 + (corpus_size + 1) / (df.get(token, 1) + 0.5))
252
+ score += idf * tf / (tf + 1.2)
253
+ return score
254
+
255
+
256
+ def _graph_score(query_tokens, entry, neighbours):
257
+ body = set(tokens(entry.get("text")))
258
+ score = 0.0
259
+ for token in query_tokens:
260
+ for other in neighbours.get(token, ()):
261
+ if other != entry.get("id") and other in neighbours.get(
262
+ "__entry_text__", {}):
263
+ pass
264
+ # co-occurrence: count query-token neighbours that also contain entry tokens
265
+ for token in query_tokens:
266
+ for other_id in neighbours.get(token, ()):
267
+ if other_id == entry.get("id"):
268
+ continue
269
+ other_text = neighbours.get("__text__", {}).get(other_id, "")
270
+ if set(tokens(other_text)) & body:
271
+ score += 1.0
272
+ return score
273
+
274
+
275
+ def rank(store, session_id, query, entries, now=None, limit=24,
276
+ enabled=True, alpha=EXPLORE_ALPHA):
277
+ """Return ranked rows with channel scores, chunk snippet, shard, reason."""
278
+ rows = [dict(entry) for entry in (entries or []) if isinstance(entry, dict)]
279
+ stamp = float(now or time.time())
280
+ query_tokens = tokens(query)
281
+ if not enabled or not rows:
282
+ return [{"entry": row, "score": float(row.get("score") or 0.0),
283
+ "reason": "fallback", "channels": {}, "chunk": "",
284
+ "shard": "", "chunk_hits": 0} for row in rows][:limit]
285
+ chunks_map = build_chunks(store, rows)
286
+ shard_map = assign_shards(store, rows, session_id=session_id)
287
+ corpus = len(rows)
288
+ df = {}
289
+ for row in rows:
290
+ for token in set(tokens(row.get("text"))):
291
+ df[token] = df.get(token, 0) + 1
292
+ neighbours = {"__text__": {str(row.get("id")): str(row.get("text")) for row in rows}}
293
+ for row in rows:
294
+ for token in set(tokens(row.get("text"))):
295
+ neighbours.setdefault(token, []).append(str(row.get("id")))
296
+ raw = {channel: [] for channel in CHANNELS}
297
+ for row in rows:
298
+ text = str(row.get("text") or "")
299
+ raw["bm25"].append(_bm25_score(query_tokens, text, corpus, df))
300
+ raw["vector"].append(cosine(vector(query), vector(text)))
301
+ raw["graph"].append(_graph_score(query_tokens, row, neighbours))
302
+ age_hours = max(0.0, (stamp - _num(row.get("ts"), stamp)) / 3600.0)
303
+ raw["time"].append(math.exp(-age_hours / (24.0 * 30.0)))
304
+ norm = {channel: normalize(values) for channel, values in raw.items()}
305
+ weights = {"bm25": 0.4, "vector": 0.3, "graph": 0.2, "time": 0.1}
306
+ expected = expectation(store)
307
+ scored = []
308
+ for index, row in enumerate(rows):
309
+ channels = {channel: round(norm[channel][index], 4)
310
+ for channel in CHANNELS}
311
+ base = sum(weights[channel] * channels[channel] for channel in CHANNELS)
312
+ dev = 1.0 - cosine(vector(row.get("text")), expected) if expected else 0.5
313
+ expected_weight = max(0.4, min(1.6, 1.0 + alpha * (dev - 0.5)))
314
+ chunk, hits = _best_chunk(chunks_map.get(str(row.get("id")), []),
315
+ query_tokens)
316
+ scored.append({
317
+ "entry": row,
318
+ "score": round(base * expected_weight, 4),
319
+ "base": round(base, 4),
320
+ "reason": "fused",
321
+ "channels": channels,
322
+ "expectation_weight": round(expected_weight, 4),
323
+ "chunk": chunk,
324
+ "chunk_hits": hits,
325
+ "shard": shard_map.get(str(row.get("id")), ""),
326
+ })
327
+ scored.sort(key=lambda item: item["score"], reverse=True)
328
+ return scored[:limit]
329
+
330
+
331
+ def record_debug(store, query, rows, now=None):
332
+ path = (_data_dir(store) or Path(".")) / DEBUG_FILE
333
+ payload = {
334
+ "schema": SCHEMA,
335
+ "ts": round(float(now or time.time()), 3),
336
+ "query": str(query or "")[:80],
337
+ "rows": [{"id": str(item["entry"].get("id") or ""),
338
+ "score": item["score"],
339
+ "channels": item["channels"],
340
+ "reason": item["reason"],
341
+ "chunk": str(item["chunk"])[:120],
342
+ "chunk_hits": item["chunk_hits"],
343
+ "shard": item["shard"]} for item in rows[:8]],
344
+ "expectation_turns": EXPECTATION_TURNS,
345
+ }
346
+ _write(path, payload)
347
+ return payload
348
+
349
+
350
+ def summary(store):
351
+ doc = _read((_data_dir(store) or Path(".")) / DEBUG_FILE)
352
+ return doc if doc else {"query": "", "rows": [], "ts": 0.0}