dsh-biocore 0.3.3 → 0.5.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/lib/modules/state.js +22 -3
- package/package.json +1 -1
- package/python/biocore_memory/core/config.py +7 -3
- package/python/biocore_memory/engine.py +91 -6
- package/python/biocore_memory/recall/embedding.py +0 -51
- package/python/biocore_memory/recall/feedback.py +34 -7
- package/python/biocore_memory/recall/fusion.py +5 -1
- package/python/biocore_memory/scoring.py +203 -0
- package/python/biocore_memory/space.py +355 -0
- package/python/biocore_memory/store/dedupe.py +1 -0
- package/python/biocore_memory/store/tiering.py +14 -3
- package/python/biocore_model/affective_labor.py +14 -1
- package/python/biocore_model/appraisal.py +172 -3
- package/python/biocore_model/bio_tick.py +43 -13
- package/python/biocore_model/emotion_geometry.py +9 -0
- package/python/biocore_model/envy.py +21 -0
- package/python/biocore_model/feedback_memory.py +53 -0
- package/python/biocore_model/needs_events.py +32 -11
- package/python/biocore_model/regulation_strategies.py +5 -0
- package/python/biocore_model/repair_after_boundary.py +1 -0
- package/python/biocore_model/self_esteem.py +14 -2
- package/python/biocore_model/self_intent.py +6 -4
- package/python/biocore_model/tests/test_autonomy_self.py +153 -153
- package/python/biocore_model/tests/test_cache_series.py +137 -0
- package/python/biocore_model/tests/test_core_state.py +240 -240
- package/python/biocore_model/tests/test_extensions.py +167 -167
- package/python/biocore_model/tests/test_field_arbiter.py +444 -0
- package/python/biocore_model/tests/test_governance.py +181 -181
- package/python/biocore_model/tests/test_lifecycle.py +153 -0
- package/python/biocore_model/tests/test_memory_inject.py +211 -211
- package/python/biocore_model/tests/test_retrieval_plus.py +143 -0
- package/python/biocore_model/tests/test_signals.py +106 -106
- package/python/biocore_model/tests/test_standby.py +199 -0
- package/python/biocore_runtime/__init__.py +3 -0
- package/python/biocore_runtime/arbiter.py +307 -0
- package/python/biocore_runtime/batch_b.py +724 -0
- package/python/biocore_runtime/batch_c.py +186 -0
- package/python/biocore_runtime/batch_d.py +659 -0
- package/python/biocore_runtime/batch_e.py +328 -0
- package/python/biocore_runtime/batch_registry.py +446 -0
- package/python/biocore_runtime/cache_stats.py +161 -0
- package/python/biocore_runtime/core_state.py +106 -3
- package/python/biocore_runtime/dimensions.py +419 -0
- package/python/biocore_runtime/expression.py +261 -0
- package/python/biocore_runtime/field.py +440 -0
- package/python/biocore_runtime/lifecycle.py +233 -0
- package/python/biocore_runtime/perception.py +1045 -0
- package/python/biocore_runtime/pipeline.py +4458 -2974
- package/python/biocore_runtime/priority.py +210 -0
- package/python/biocore_runtime/retrieval_plus.py +352 -0
- package/python/biocore_runtime/standby.py +581 -472
- package/python/biocore_runtime/timescales.py +263 -0
- package/python/data/signals.json +3266 -206
- package/ui/panel-layout.js +32 -29
- package/ui/panel.css +563 -974
- package/ui/panel.html +427 -401
- package/ui/panel.js +543 -450
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-230: indicative priority driven by surprise (predictive processing).
|
|
3
|
+
|
|
4
|
+
Friston 2010 (free energy): a signal that departs from the expectation carries
|
|
5
|
+
more information, so it should be encoded and consolidated before the rest.
|
|
6
|
+
|
|
7
|
+
Surprise
|
|
8
|
+
|field - setpoint| / scale(axis), per axis, clamped to 0..1. The setpoint
|
|
9
|
+
is the neutral line the field itself declares (field.DEFAULTS), the scale is
|
|
10
|
+
that axis' own span, so the six axes stay comparable without being summed
|
|
11
|
+
into one state number.
|
|
12
|
+
|
|
13
|
+
Priority (per candidate)
|
|
14
|
+
score = surprise x importance x relation x predictable
|
|
15
|
+
|
|
16
|
+
surprise how far this candidate's stored affect sits from the current
|
|
17
|
+
expectation (the live field)
|
|
18
|
+
importance the entry's own importance (memory layer)
|
|
19
|
+
relation the relationship context at read time (trust / closeness)
|
|
20
|
+
predictable repeated content with no change is damped (0.3 .. 1.0), so
|
|
21
|
+
the tenth identical message is not what gets rehearsed
|
|
22
|
+
|
|
23
|
+
The switch surprise_priority is ON by default; with it off the caller keeps the
|
|
24
|
+
old newest-first ordering and the recall scores are untouched.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from . import field as _field
|
|
32
|
+
except Exception: # pragma: no cover
|
|
33
|
+
try:
|
|
34
|
+
from biocore_runtime import field as _field
|
|
35
|
+
except Exception:
|
|
36
|
+
_field = None
|
|
37
|
+
|
|
38
|
+
SWITCH_KEY = "surprise_priority"
|
|
39
|
+
ENV_KEY = "BIOCORE_SURPRISE_PRIORITY"
|
|
40
|
+
SCHEMA = 1
|
|
41
|
+
AXES = ("v", "a", "stress", "social", "trust", "closeness")
|
|
42
|
+
SURPRISE_FLOOR = 0.35 # below this the field is "as expected"
|
|
43
|
+
PREDICTABLE_FLOOR = 0.3 # the lowest multiplier repeated content can reach
|
|
44
|
+
RANK_LIMIT = 10
|
|
45
|
+
|
|
46
|
+
RANGES = (dict(_field.RANGES) if _field is not None else
|
|
47
|
+
{"v": (-1.0, 1.0), "a": (0.0, 1.0), "stress": (0.0, 1.0),
|
|
48
|
+
"social": (0.0, 1.0), "trust": (0.0, 1.0), "closeness": (0.0, 1.0)})
|
|
49
|
+
SETPOINTS = (dict(_field.DEFAULTS) if _field is not None else
|
|
50
|
+
{"v": 0.0, "a": 0.5, "stress": 0.5, "social": 0.5,
|
|
51
|
+
"trust": 0.5, "closeness": 0.5})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _num(value, default=0.0):
|
|
55
|
+
try:
|
|
56
|
+
number = float(value)
|
|
57
|
+
except Exception:
|
|
58
|
+
return float(default)
|
|
59
|
+
if not math.isfinite(number):
|
|
60
|
+
return float(default)
|
|
61
|
+
return number
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _clamp(value, low=0.0, high=1.0):
|
|
65
|
+
return max(low, min(high, _num(value, low)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def enabled(flags=None, env=None):
|
|
69
|
+
"""surprise_priority: explicit flag > nested switches > env > default ON."""
|
|
70
|
+
data = flags if isinstance(flags, dict) else {}
|
|
71
|
+
if isinstance(data.get(SWITCH_KEY), bool):
|
|
72
|
+
return data[SWITCH_KEY]
|
|
73
|
+
switches = data.get("switches") if isinstance(data.get("switches"), dict) else {}
|
|
74
|
+
if isinstance(switches.get(SWITCH_KEY), bool):
|
|
75
|
+
return switches[SWITCH_KEY]
|
|
76
|
+
environ = env if hasattr(env, "get") else None
|
|
77
|
+
if environ is not None and ENV_KEY in environ:
|
|
78
|
+
raw = str(environ.get(ENV_KEY) or "").strip().lower()
|
|
79
|
+
if raw in ("0", "false", "off", "no"):
|
|
80
|
+
return False
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scale_of(axis):
|
|
85
|
+
low, high = RANGES.get(axis, (-1.0, 1.0))
|
|
86
|
+
return max(1e-6, high - low)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def axis_surprise(axis, value):
|
|
90
|
+
"""|value - setpoint| / scale, clamped to 0..1."""
|
|
91
|
+
setpoint = _num(SETPOINTS.get(axis, 0.0))
|
|
92
|
+
return round(_clamp(abs(_num(value, setpoint) - setpoint)
|
|
93
|
+
/ scale_of(axis)), 6)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def field_values(core=None, relationship=None):
|
|
97
|
+
"""The six field numbers from a state's core plus the relationship."""
|
|
98
|
+
core = core if isinstance(core, dict) else {}
|
|
99
|
+
rel = relationship if isinstance(relationship, dict) else {}
|
|
100
|
+
out = {}
|
|
101
|
+
for axis in AXES:
|
|
102
|
+
if axis in ("trust", "closeness"):
|
|
103
|
+
out[axis] = rel.get(axis, core.get(axis, SETPOINTS[axis]))
|
|
104
|
+
else:
|
|
105
|
+
out[axis] = core.get(axis, SETPOINTS[axis])
|
|
106
|
+
return {axis: _num(value, SETPOINTS[axis]) for axis, value in out.items()}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def field_surprise(values=None):
|
|
110
|
+
"""Per-axis surprise plus the mean; axes are never summed into a state."""
|
|
111
|
+
data = values if isinstance(values, dict) else {}
|
|
112
|
+
axes = {axis: axis_surprise(axis, data.get(axis, SETPOINTS[axis]))
|
|
113
|
+
for axis in AXES}
|
|
114
|
+
top = sorted(axes.items(), key=lambda row: -row[1])[:3]
|
|
115
|
+
return {"axes": axes,
|
|
116
|
+
"value": round(sum(axes.values()) / max(1, len(axes)), 4),
|
|
117
|
+
"top": [{"axis": axis, "surprise": value} for axis, value in top]}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def entry_surprise(entry, values=None):
|
|
121
|
+
"""How unexpected this entry's own affect is against the live field."""
|
|
122
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
123
|
+
field = values if isinstance(values, dict) else {}
|
|
124
|
+
affect = entry.get("affect") if isinstance(entry.get("affect"), dict) else {}
|
|
125
|
+
pairs = []
|
|
126
|
+
for axis in ("v", "a"):
|
|
127
|
+
raw = affect.get(axis, entry.get(axis))
|
|
128
|
+
if raw is None:
|
|
129
|
+
continue
|
|
130
|
+
expected = field.get(axis, SETPOINTS[axis])
|
|
131
|
+
pairs.append(_clamp(abs(_num(raw, expected) - _num(expected))
|
|
132
|
+
/ scale_of(axis)))
|
|
133
|
+
if not pairs:
|
|
134
|
+
return 0.0
|
|
135
|
+
return round(sum(pairs) / len(pairs), 6)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def predictable(entry):
|
|
139
|
+
"""Repeated, unchanged content is less worth rehearsing (1.0 = not damped)."""
|
|
140
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
141
|
+
hits = max(1, int(_num(entry.get("hits"), 1)))
|
|
142
|
+
recalls = max(0, int(_num(entry.get("retrieval_count"), 0)))
|
|
143
|
+
repeat = min(1.0, (hits - 1) * 0.25 + recalls * 0.10)
|
|
144
|
+
if repeat <= 0:
|
|
145
|
+
return 1.0
|
|
146
|
+
return round(max(PREDICTABLE_FLOOR, 1.0 - 0.7 * repeat), 4)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def relation_factor(relationship=None):
|
|
150
|
+
"""Relationship context in 0.25..1.0 (trust/closeness, not a sum of axes)."""
|
|
151
|
+
rel = relationship if isinstance(relationship, dict) else {}
|
|
152
|
+
trust = _clamp(rel.get("trust", 0.5))
|
|
153
|
+
closeness = _clamp(rel.get("closeness", 0.5))
|
|
154
|
+
return round(_clamp(0.25 + 0.5 * closeness + 0.25 * trust), 4)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def score_entry(entry, values=None, relationship=None):
|
|
158
|
+
"""One candidate line: every factor kept, plus the product."""
|
|
159
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
160
|
+
surprise = entry_surprise(entry, values)
|
|
161
|
+
importance = _clamp(entry.get("importance", 0.5))
|
|
162
|
+
relation = relation_factor(relationship)
|
|
163
|
+
damp = predictable(entry)
|
|
164
|
+
return {"id": str(entry.get("id") or ""),
|
|
165
|
+
"text": str(entry.get("text") or "")[:60],
|
|
166
|
+
"ts": round(_num(entry.get("ts"), 0.0), 3),
|
|
167
|
+
"surprise": surprise,
|
|
168
|
+
"importance": round(importance, 4),
|
|
169
|
+
"relation": relation,
|
|
170
|
+
"predictable": damp,
|
|
171
|
+
"score": round(surprise * importance * relation * damp, 6)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def rank(entries, values=None, relationship=None, limit=RANK_LIMIT,
|
|
175
|
+
enabled_override=None):
|
|
176
|
+
"""Top candidates by indicative priority (or the old newest-first order)."""
|
|
177
|
+
rows = [row for row in (entries or []) if isinstance(row, dict)]
|
|
178
|
+
if enabled_override is False:
|
|
179
|
+
old = sorted(rows, key=lambda row: -_num(row.get("ts"), 0.0))
|
|
180
|
+
return [{"id": str(row.get("id") or ""),
|
|
181
|
+
"text": str(row.get("text") or "")[:60],
|
|
182
|
+
"ts": round(_num(row.get("ts"), 0.0), 3),
|
|
183
|
+
"score": 0.0, "legacy": True}
|
|
184
|
+
for row in old[:max(1, int(limit))]]
|
|
185
|
+
scored = [score_entry(row, values, relationship) for row in rows]
|
|
186
|
+
scored.sort(key=lambda row: (-row["score"], -row["ts"]))
|
|
187
|
+
return scored[:max(1, int(limit))]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def recall_weight(entry, values=None):
|
|
191
|
+
"""Multiplier for one recall candidate (1.0 when nothing is surprising).
|
|
192
|
+
|
|
193
|
+
Only content that is itself far from the current expectation is lifted, so
|
|
194
|
+
a surprising field does not boost every memory equally.
|
|
195
|
+
"""
|
|
196
|
+
surprise = entry_surprise(entry, values)
|
|
197
|
+
if surprise < SURPRISE_FLOOR:
|
|
198
|
+
return 1.0
|
|
199
|
+
return round(min(2.0, 1.0 + 0.6 * (surprise - SURPRISE_FLOOR)), 4)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def summary(entries, values=None, relationship=None, limit=RANK_LIMIT,
|
|
203
|
+
enabled_override=None):
|
|
204
|
+
"""Read-only /state block: the top rows plus the live field surprise."""
|
|
205
|
+
on = True if enabled_override is None else bool(enabled_override)
|
|
206
|
+
field_row = field_surprise(values)
|
|
207
|
+
return {"schema": SCHEMA, "enabled": on,
|
|
208
|
+
"field_surprise": field_row,
|
|
209
|
+
"top": rank(entries, values, relationship, limit,
|
|
210
|
+
enabled_override=on)}
|
|
@@ -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}
|