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.
- package/lib/index.js +30 -1
- package/lib/modules/standby.js +61 -0
- package/package.json +1 -1
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/sanitize.py +6 -1
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/pipeline.py +134 -3
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +125 -18
- package/python/data/signals.json +3266 -206
|
@@ -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
|
-
|
|
883
|
-
|
|
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}
|