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 CHANGED
@@ -28,6 +28,7 @@ import { SERIES_ROUTE_PATH, handleSeriesRoute } from './modules/series.js'
28
28
  import { readCacheStats } from './modules/cache-stats.js'
29
29
  import { startCoreWorker, stopCoreWorker } from './core-worker.js'
30
30
  import { runAutonomyTick } from './modules/autonomy.js'
31
+ import { drainOutbox } from './modules/standby.js'
31
32
  import { queueSessionTurn } from './modules/turn-queue.js'
32
33
  import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
33
34
  import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
@@ -193,6 +194,8 @@ export function readFullConfig(dataDir) {
193
194
  rerank: normalizeRerank(source.rerank),
194
195
  expression: normalizeExpression(source.expression),
195
196
  autonomy: normalizeAutonomy(source.autonomy),
197
+ standby: source.standby && typeof source.standby === 'object' ? source.standby : {},
198
+ standbyEnabled: source.standbyEnabled === true,
196
199
  sanitize_reply: source.sanitize_reply !== false,
197
200
  }
198
201
  }
@@ -389,6 +392,10 @@ function coreConfigPayload(dataDir) {
389
392
  rerank: config.rerank,
390
393
  expression: config.expression,
391
394
  autonomy: config.autonomy,
395
+ standby: {
396
+ ...(config.standby && typeof config.standby === 'object' ? config.standby : {}),
397
+ standby_enabled: config.standbyEnabled === true,
398
+ },
392
399
  sanitize_reply: config.sanitize_reply !== false,
393
400
  switches,
394
401
  recall_mode: 'query',
@@ -762,8 +769,10 @@ export async function handleStats(req, res, options) {
762
769
  } catch { reply = null }
763
770
  const body = Object.assign({ ok: false, reason: 'core_unavailable' },
764
771
  reply && typeof reply === 'object' ? reply : {})
772
+ // contract migration 1: the core computes cache stats; the legacy file is a
773
+ // read-only fallback for one week, never a source of new numbers.
765
774
  try {
766
- if (typeof opts.dataDir === 'string' && opts.dataDir) {
775
+ if (!body.cache && typeof opts.dataDir === 'string' && opts.dataDir) {
767
776
  body.cache = readCacheStats(opts.dataDir)
768
777
  }
769
778
  } catch { /* display only */ }
@@ -992,6 +1001,26 @@ export function apply(ctx, config) {
992
1001
  keep(() => { if (timer !== null) { clearInterval(timer); timer = null } })
993
1002
  }
994
1003
 
1004
+ // task-215: standby is off by default; when on, the core writes the outbox
1005
+ // and this adapter only drains it into results.
1006
+ if (config && config.standbyEnabled === true) {
1007
+ let standbyTimer = null
1008
+ const standbyTick = async () => {
1009
+ try {
1010
+ await runtimeAction('standby_tick', {
1011
+ store: resolveMemoryDir(dataDir),
1012
+ data_dir: dataDir,
1013
+ session_id: 'standby',
1014
+ now: Date.now() / 1000,
1015
+ })
1016
+ drainOutbox(dataDir)
1017
+ } catch { /* standby never disturbs a turn */ }
1018
+ }
1019
+ standbyTimer = setInterval(() => { void standbyTick() }, 5 * 60 * 1000)
1020
+ if (typeof standbyTimer.unref === 'function') standbyTimer.unref()
1021
+ keep(() => { if (standbyTimer !== null) { clearInterval(standbyTimer); standbyTimer = null } })
1022
+ }
1023
+
995
1024
  return () => {
996
1025
  for (const dispose of disposers) {
997
1026
  try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
@@ -0,0 +1,61 @@
1
+ /** task-215: adapter side of standby -- read outbox, record results only.
2
+ *
3
+ * The core writes <dataDir>/proactive/outbox.jsonl. This module never decides
4
+ * whether to speak and never sends externally; it records one execution result
5
+ * per outbox row into <dataDir>/proactive/results.jsonl.
6
+ */
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ export function proactiveDir(dataDir) {
11
+ return join(String(dataDir || '').trim(), 'proactive')
12
+ }
13
+
14
+ export function outboxPath(dataDir) {
15
+ return join(proactiveDir(dataDir), 'outbox.jsonl')
16
+ }
17
+
18
+ export function resultsPath(dataDir) {
19
+ return join(proactiveDir(dataDir), 'results.jsonl')
20
+ }
21
+
22
+ function readLines(path) {
23
+ try {
24
+ if (!existsSync(path)) return []
25
+ return readFileSync(path, 'utf8').split('\n').filter(Boolean)
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ /** Record never-seen outbox rows; returns how many were recorded. */
32
+ export function drainOutbox(dataDir, options) {
33
+ const opts = options || {}
34
+ const rows = readLines(outboxPath(dataDir))
35
+ const done = readLines(resultsPath(dataDir)).length
36
+ if (rows.length <= done) return { ok: true, recorded: 0, pending: 0 }
37
+ const path = resultsPath(dataDir)
38
+ try {
39
+ mkdirSync(dirname(path), { recursive: true })
40
+ for (let index = done; index < rows.length; index += 1) {
41
+ let row = {}
42
+ try { row = JSON.parse(rows[index]) } catch { row = {} }
43
+ appendFileSync(path, JSON.stringify({
44
+ ts: row.ts || 0,
45
+ index,
46
+ delivered: false,
47
+ reason: 'outbox_only',
48
+ result: { ok: true, external: false },
49
+ }) + '\n', 'utf8')
50
+ }
51
+ return { ok: true, recorded: rows.length - done, pending: 0 }
52
+ } catch {
53
+ return { ok: false, recorded: 0, pending: rows.length - done }
54
+ }
55
+ }
56
+
57
+ export function standbySummary(dataDir) {
58
+ const outbox = readLines(outboxPath(dataDir)).length
59
+ const results = readLines(resultsPath(dataDir)).length
60
+ return { pending: Math.max(0, outbox - results), outbox, results }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-biocore",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "BioCore DSH UI: per-session state tab, standalone instrument panel, /api/biocore/state, session/event one_turn updates, /bio sync.",
6
6
  "main": "lib/index.js",
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
 
7
- TIERING_ENABLED = False
7
+ TIERING_ENABLED = True
8
8
  HOT_BUDGET = 80
9
9
  WARM_BUDGET = 200
10
10
  TIER_UNLIMITED = 10 ** 9
@@ -12,6 +12,7 @@ HOT_AGE_SECONDS = 24 * 3600
12
12
  WARM_AGE_SECONDS = 30 * 86400
13
13
  HOT_IMPORTANCE = 0.80
14
14
  HOT_HITS = 5
15
+ HOT_EXIT_HITS = 3 # hysteresis: a hot entry leaves only below this
15
16
 
16
17
 
17
18
  def _num(entry, keys, default=0.0):
@@ -33,8 +34,12 @@ def _age(entry, now):
33
34
  return max(0.0, float(now) - last)
34
35
 
35
36
 
36
- def tier_of(entry, now=None):
37
- """Return 'hot' | 'warm' | 'cold'; disabled tiering is neutral ('warm')."""
37
+ def tier_of(entry, now=None, previous=None):
38
+ """Return 'hot' | 'warm' | 'cold'; disabled tiering is neutral ('warm').
39
+
40
+ Hysteresis (task-36): a hot entry stays hot until hits drop below
41
+ HOT_EXIT_HITS, so a value hovering at the boundary cannot flicker.
42
+ """
38
43
  if not TIERING_ENABLED or not isinstance(entry, dict):
39
44
  return "warm"
40
45
  try:
@@ -42,6 +47,12 @@ def tier_of(entry, now=None):
42
47
  age = _age(entry, stamp)
43
48
  importance = _num(entry, ("importance",), 0.5)
44
49
  hits = _num(entry, ("hits", "visits", "visit_count"), 0.0)
50
+ if previous == "hot":
51
+ if hits >= HOT_EXIT_HITS:
52
+ return "hot"
53
+ if age <= WARM_AGE_SECONDS or hits >= 2:
54
+ return "warm"
55
+ return "cold"
45
56
  if age <= HOT_AGE_SECONDS or importance >= HOT_IMPORTANCE or hits >= HOT_HITS:
46
57
  return "hot"
47
58
  if age <= WARM_AGE_SECONDS or hits >= 2:
@@ -23,7 +23,12 @@ class ReplySanitizer:
23
23
 
24
24
  CONTROL_TAG_PATTERNS = [
25
25
  re.compile(r"<\s*(?:bc|biocore)\b[^>]*>.*?</\s*(?:bc|biocore)\s*>", re.I | re.S),
26
- re.compile(r"<\s*(?:bc|biocore)\b[^>]*>[^\n]*", re.I),
26
+ re.compile(r"<\s*(?:bc|biocore)\b[^>]*>[^\n]*", re.I),
27
+ # Reminder tags are instructions for the model, never user-visible text.
28
+ re.compile(r"<\s*(?:system_reminder|reminder|system)\b[^>]*>.*?</\s*(?:system_reminder|reminder|system)\s*>", re.I | re.S),
29
+ re.compile(r"<\s*(?:system_reminder|reminder|system)\b[^>]*>[^\n]*", re.I),
30
+ # Models sometimes echo the instruction header verbatim.
31
+ re.compile(r"^\s*【[^】]*】\s*$", re.M),
27
32
  re.compile(r"<\s*(?:thinking|think|thought|reasoning|analysis)\b[^>]*>.*?</\s*(?:thinking|think|thought|reasoning|analysis)\s*>", re.I | re.S),
28
33
  re.compile(r"<\s*(?:thinking|think|thought|reasoning|analysis)\b[^>]*>[^\n]*", re.I),
29
34
  re.compile(r"<\s*(?:思考|分析|推理|内心独白)\s*>.*?</\s*(?:思考|分析|推理|内心独白)\s*>", re.S),
@@ -0,0 +1,137 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Contract migration 1: cache stats + series are computed by the core."""
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from biocore_runtime import cache_stats, core_state, pipeline
10
+
11
+
12
+ class CacheCase(unittest.TestCase):
13
+ def setUp(self):
14
+ self.tmp = tempfile.TemporaryDirectory()
15
+ self.data_dir = Path(self.tmp.name) / "data"
16
+ self.store = self.data_dir / "memory"
17
+ (self.store / "sessions").mkdir(parents=True)
18
+
19
+ def tearDown(self):
20
+ self.tmp.cleanup()
21
+
22
+ def event(self, **values):
23
+ row = {"prompt_tokens": 100, "output_tokens": 20,
24
+ "cache_read": 60, "cache_write": 40}
25
+ row.update(values)
26
+ return cache_stats.record_event(self.store, row,
27
+ now=values.pop("ts", time.time()))
28
+
29
+ def test_accumulates_events(self):
30
+ now = time.time()
31
+ cache_stats.record_event(self.store, {"prompt_tokens": 10,
32
+ "output_tokens": 2, "cache_read": 4, "cache_write": 1}, now=now)
33
+ cache_stats.record_event(self.store, {"prompt_tokens": 20,
34
+ "output_tokens": 3, "cache_read": 6, "cache_write": 2}, now=now)
35
+ out = cache_stats.compute(self.store, now=now)
36
+ self.assertEqual(out["turns"], 2)
37
+ self.assertEqual(out["prompt_tokens"], 30)
38
+ self.assertEqual(out["output_tokens"], 5)
39
+ self.assertEqual(out["cache_read"], 10)
40
+ self.assertEqual(out["cache_write"], 3)
41
+ self.assertEqual(out["source"], "usage_events")
42
+
43
+ def test_hit_rate_ratio(self):
44
+ now = time.time()
45
+ cache_stats.record_event(self.store, {"prompt_tokens": 100,
46
+ "output_tokens": 10, "cache_read": 75, "cache_write": 25}, now=now)
47
+ self.assertAlmostEqual(cache_stats.compute(self.store, now=now)["hit_rate"],
48
+ 0.75, places=4)
49
+
50
+ def test_hit_rate_falls_back_to_prompt_tokens(self):
51
+ now = time.time()
52
+ cache_stats.record_event(self.store, {"prompt_tokens": 100,
53
+ "output_tokens": 10, "cache_read": 50, "cache_write": 0}, now=now)
54
+ self.assertAlmostEqual(cache_stats.compute(self.store, now=now)["hit_rate"],
55
+ 0.5, places=4)
56
+
57
+ def test_hit_rate_zero_when_empty(self):
58
+ self.assertEqual(cache_stats.compute(self.store)["hit_rate"], 0.0)
59
+
60
+ def test_hit_rate_is_clamped(self):
61
+ now = time.time()
62
+ cache_stats.record_event(self.store, {"prompt_tokens": 10,
63
+ "output_tokens": 1, "cache_read": 50, "cache_write": 0}, now=now)
64
+ rate = cache_stats.compute(self.store, now=now)["hit_rate"]
65
+ self.assertGreaterEqual(rate, 0.0)
66
+ self.assertLessEqual(rate, 1.0)
67
+
68
+ def test_window_excludes_old_events(self):
69
+ now = time.time()
70
+ cache_stats.record_event(self.store, {"prompt_tokens": 10,
71
+ "cache_read": 5}, now=now - 8 * 86400)
72
+ cache_stats.record_event(self.store, {"prompt_tokens": 20,
73
+ "cache_read": 10}, now=now)
74
+ out = cache_stats.compute(self.store, now=now)
75
+ self.assertEqual(out["turns"], 1)
76
+ self.assertEqual(out["prompt_tokens"], 20)
77
+
78
+ def test_legacy_file_is_read_only_fallback(self):
79
+ now = time.time()
80
+ (self.data_dir / "cache_stats.json").write_text(json.dumps({
81
+ "turns": 4, "prompt_tokens": 400, "output_tokens": 40,
82
+ "cache_read": 200, "cache_write": 200, "hit_rate": 0.5,
83
+ "updated_at": now}), encoding="utf-8")
84
+ out = cache_stats.compute(self.store, now=now)
85
+ self.assertEqual(out["source"], "legacy_file")
86
+ self.assertEqual(out["turns"], 4)
87
+ self.assertEqual(out["hit_rate"], 0.5)
88
+
89
+ def test_usage_event_action_appends(self):
90
+ out = pipeline.dispatch("usage_event", {
91
+ "store": str(self.store), "prompt_tokens": 30,
92
+ "cache_read": 15, "cache_write": 5, "now": time.time()})
93
+ self.assertTrue(out["ok"])
94
+ self.assertEqual(out["cache"]["cache_read"], 15)
95
+
96
+ def test_stats_action_includes_core_cache(self):
97
+ cache_stats.record_event(self.store, {"prompt_tokens": 10,
98
+ "cache_read": 5, "cache_write": 5}, now=time.time())
99
+ out = pipeline.dispatch("stats", {"store": str(self.store)})
100
+ self.assertIn("cache", out)
101
+ self.assertEqual(out["cache"]["source"], "usage_events")
102
+ self.assertEqual(out["cache"]["hit_rate"], 0.5)
103
+
104
+
105
+ class SeriesCase(unittest.TestCase):
106
+ def setUp(self):
107
+ self.tmp = tempfile.TemporaryDirectory()
108
+ self.data_dir = Path(self.tmp.name) / "data"
109
+ self.store = self.data_dir / "memory"
110
+ (self.store / "sessions").mkdir(parents=True)
111
+
112
+ def tearDown(self):
113
+ self.tmp.cleanup()
114
+
115
+ def test_empty_series_arrays(self):
116
+ out = pipeline.dispatch("series", {"store": str(self.store),
117
+ "data_dir": str(self.data_dir)})
118
+ self.assertTrue(out["ok"])
119
+ for key in ("ts", "v", "tension", "warmth", "heaviness", "clarity"):
120
+ self.assertIsInstance(out["series"][key], list)
121
+ self.assertEqual(out["series"][key], [])
122
+
123
+ def test_series_points_from_core_state(self):
124
+ path = core_state.path_for(data_dir=str(self.data_dir))
125
+ now = time.time()
126
+ core_state.apply(path, absolute={"v": 0.2, "stress": 0.4}, now=now)
127
+ core_state.apply(path, absolute={"v": 0.3, "stress": 0.2}, now=now + 60)
128
+ out = pipeline.dispatch("series", {"store": str(self.store),
129
+ "data_dir": str(self.data_dir),
130
+ "limit": 24})
131
+ self.assertTrue(out["points"])
132
+ self.assertLessEqual(len(out["points"]), 24)
133
+ self.assertEqual(len(out["series"]["ts"]), len(out["points"]))
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -0,0 +1,153 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-36 + task-78: tier hysteresis, reconsolidation, minimal repair loop."""
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from biocore_memory.store import tiering
10
+ from biocore_runtime import lifecycle, pipeline
11
+
12
+
13
+ def write_json(path, data):
14
+ path = Path(path)
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
17
+
18
+
19
+ def entry(i, text="记得这件事", ts=None, hits=1, importance=0.5):
20
+ return {"id": i, "text": text, "role": "user",
21
+ "ts": ts or (time.time() - 3600), "importance": importance,
22
+ "hits": hits}
23
+
24
+
25
+ class Case(unittest.TestCase):
26
+ def setUp(self):
27
+ self.tmp = tempfile.TemporaryDirectory()
28
+ self.data_dir = Path(self.tmp.name) / "data"
29
+ self.store = self.data_dir / "memory"
30
+ (self.store / "sessions" / "s1").mkdir(parents=True)
31
+ self.persona = Path(self.tmp.name) / "missing.json"
32
+
33
+ def tearDown(self):
34
+ self.tmp.cleanup()
35
+
36
+ def write_entries(self, entries):
37
+ write_json(self.store / "sessions" / "s1" / "memory.json",
38
+ {"entries": entries})
39
+ return entries
40
+
41
+ def read_entries(self):
42
+ return json.loads((self.store / "sessions" / "s1" / "memory.json")
43
+ .read_text(encoding="utf-8"))["entries"]
44
+
45
+ def payload(self, **extra):
46
+ data = {"store": str(self.store), "session_id": "s1",
47
+ "persona_profile": str(self.persona)}
48
+ data.update(extra)
49
+ return data
50
+
51
+
52
+ class TierTest(Case):
53
+ def test_enter_hot_at_five_hits(self):
54
+ self.assertEqual(tiering.tier_of(entry("a", hits=5), now=time.time()),
55
+ "hot")
56
+
57
+ def test_stays_hot_between_three_and_five(self):
58
+ now = time.time()
59
+ self.assertEqual(tiering.tier_of(entry("a", hits=4), now=now,
60
+ previous="hot"), "hot")
61
+
62
+ def test_exits_hot_below_three(self):
63
+ now = time.time()
64
+ self.assertNotEqual(tiering.tier_of(entry("a", hits=2), now=now,
65
+ previous="hot"), "hot")
66
+
67
+ def test_tick_counts_entered_exited(self):
68
+ now = time.time()
69
+ old_ts = time.time() - 3 * 86400
70
+ rows = [entry("a", hits=5), entry("b", hits=2, ts=old_ts)]
71
+ first = lifecycle.tier_tick(self.store, rows, now=now)
72
+ self.assertEqual(first["counts"]["hot"], 1)
73
+ rows[0]["hits"] = 1
74
+ second = lifecycle.tier_tick(self.store, rows, now=now + 1)
75
+ self.assertGreaterEqual(second["exited"], 1)
76
+ summary = lifecycle.tier_summary(self.store)
77
+ self.assertIn("hot", summary)
78
+ self.assertIn("exited", summary)
79
+
80
+
81
+ class ReconsolidationTest(Case):
82
+ def test_updates_recalled_entry(self):
83
+ now = time.time()
84
+ self.write_entries([entry("a", "苹果很甜", hits=1)])
85
+ lifecycle.record_recall(self.store, "s1", ["a"], now=now)
86
+ result = lifecycle.reconsolidate(self.store, now=now + 60)
87
+ self.assertEqual(result["updated"], 1)
88
+ row = self.read_entries()[0]
89
+ self.assertGreater(row.get("regenerated_at", 0), 0)
90
+ self.assertEqual(row.get("reconsolidation_count"), 1)
91
+ self.assertGreater(row.get("importance", 0), 0.5)
92
+ self.assertEqual(len(self.read_entries()), 1)
93
+
94
+ def test_idempotent_second_call(self):
95
+ now = time.time()
96
+ self.write_entries([entry("a", hits=1)])
97
+ lifecycle.record_recall(self.store, "s1", ["a"], now=now)
98
+ lifecycle.reconsolidate(self.store, now=now + 60)
99
+ again = lifecycle.reconsolidate(self.store, now=now + 61)
100
+ self.assertEqual(again["updated"], 0)
101
+ self.assertGreaterEqual(again["skipped"], 1)
102
+
103
+ def test_window_expires(self):
104
+ now = time.time()
105
+ self.write_entries([entry("a", hits=1)])
106
+ lifecycle.record_recall(self.store, "s1", ["a"], now=now - 7 * 3600)
107
+ result = lifecycle.reconsolidate(self.store, now=now)
108
+ self.assertEqual(result["updated"], 0)
109
+ self.assertEqual(result["reason"], "window")
110
+
111
+ def test_no_recall_ids(self):
112
+ self.write_entries([entry("a")])
113
+ result = lifecycle.reconsolidate(self.store, now=time.time())
114
+ self.assertEqual(result["updated"], 0)
115
+
116
+
117
+ class RepairTest(Case):
118
+ def test_arm_and_consume_once(self):
119
+ now = time.time()
120
+ self.assertTrue(lifecycle.arm_repair(self.store, "boundary", now=now))
121
+ lines = lifecycle.repair_lines(self.store, now=now + 1)
122
+ self.assertTrue(lines)
123
+ self.assertTrue(any("修复" in line for line in lines))
124
+ self.assertEqual(lifecycle.repair_lines(self.store, now=now + 2), [])
125
+
126
+ def test_cooldown_blocks_rearm(self):
127
+ now = time.time()
128
+ lifecycle.arm_repair(self.store, "boundary", now=now)
129
+ lifecycle.repair_lines(self.store, now=now + 1)
130
+ self.assertFalse(lifecycle.arm_repair(self.store, "boundary",
131
+ now=now + 2))
132
+
133
+ def test_expired_repair_returns_empty(self):
134
+ now = time.time()
135
+ lifecycle.arm_repair(self.store, "boundary", now=now)
136
+ self.assertEqual(lifecycle.repair_lines(self.store,
137
+ now=now + 31 * 60), [])
138
+
139
+ def test_state_exposes_lifecycle(self):
140
+ now = time.time()
141
+ self.write_entries([entry("a", hits=5)])
142
+ lifecycle.record_recall(self.store, "s1", ["a"], now=now)
143
+ lifecycle.tier_tick(self.store, [entry("a", hits=5)], now=now)
144
+ pipeline.dispatch("turn_end", self.payload(
145
+ user_text="好", assistant_text="嗯。", capture=False))
146
+ body = pipeline.dispatch("state", self.payload())["state"]
147
+ self.assertIn("tiers", body)
148
+ self.assertIn("reconsolidation", body)
149
+ self.assertIn("repair", body)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()
@@ -0,0 +1,143 @@
1
+ # -*- coding: utf-8 -*-
2
+ """task-62: normalized channels, chunks, shards, predictive exploration."""
3
+ import json
4
+ import tempfile
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from biocore_runtime import pipeline, retrieval_plus as rp
10
+
11
+
12
+ def entry(i, text, ts=None):
13
+ return {"id": i, "text": text, "role": "user",
14
+ "ts": ts if ts is not None else time.time()}
15
+
16
+
17
+ class Case(unittest.TestCase):
18
+ def setUp(self):
19
+ self.tmp = tempfile.TemporaryDirectory()
20
+ self.data_dir = Path(self.tmp.name) / "data"
21
+ self.store = self.data_dir / "memory"
22
+ (self.store / "sessions" / "s1").mkdir(parents=True)
23
+ self.now = time.time()
24
+
25
+ def tearDown(self):
26
+ self.tmp.cleanup()
27
+
28
+
29
+ class NormalizeTest(Case):
30
+ def test_min_max(self):
31
+ self.assertEqual(rp.normalize([0.0, 5.0, 10.0]), [0.0, 0.5, 1.0])
32
+
33
+ def test_flat_positive_is_one(self):
34
+ self.assertEqual(rp.normalize([2.0, 2.0]), [1.0, 1.0])
35
+
36
+ def test_flat_zero_is_zero(self):
37
+ self.assertEqual(rp.normalize([0.0, 0.0]), [0.0, 0.0])
38
+
39
+ def test_channels_are_independent(self):
40
+ rows = [entry("a", "苹果 苹果 苹果"), entry("b", "香蕉")]
41
+ ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
42
+ by_id = {item["entry"]["id"]: item for item in ranked}
43
+ self.assertEqual(by_id["a"]["channels"]["bm25"], 1.0)
44
+ self.assertEqual(by_id["b"]["channels"]["bm25"], 0.0)
45
+ self.assertIn("vector", by_id["a"]["channels"])
46
+
47
+
48
+ class ChunkTest(Case):
49
+ def test_sentences_split(self):
50
+ chunks = rp.split_chunks("第一句。第二句!第三句?")
51
+ self.assertEqual(len(chunks), 3)
52
+
53
+ def test_index_persists(self):
54
+ rp.build_chunks(self.store, [entry("a", "第一句。第二句。")])
55
+ doc = json.loads((self.data_dir / "retrieval" / "chunks.json")
56
+ .read_text(encoding="utf-8"))
57
+ self.assertIn("a", doc["entries"])
58
+ self.assertEqual(len(doc["entries"]["a"]["chunks"]), 2)
59
+
60
+ def test_best_chunk_snippet(self):
61
+ rows = [entry("a", "今天很累。苹果很甜,想再买一次。")]
62
+ ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
63
+ self.assertIn("苹果", ranked[0]["chunk"])
64
+ self.assertGreaterEqual(ranked[0]["chunk_hits"], 1)
65
+
66
+
67
+ class ShardTest(Case):
68
+ def test_session_shard(self):
69
+ shards = rp.assign_shards(self.store, [entry("a", "x")], session_id="s1")
70
+ self.assertEqual(shards["a"], "s-s1")
71
+ self.assertTrue((self.data_dir / "retrieval" / "shards"
72
+ / "s-s1.json").exists())
73
+
74
+ def test_month_shard_without_session(self):
75
+ row = entry("a", "x", ts=time.mktime((2024, 3, 15, 12, 0, 0, 0, 0, -1)))
76
+ shards = rp.assign_shards(self.store, [row])
77
+ self.assertEqual(shards["a"], "2024-03")
78
+
79
+ def test_rollover_over_cap(self):
80
+ rows = [entry(str(index), "x") for index in range(5)]
81
+ shards = rp.assign_shards(self.store, rows, session_id="s1", limit=2)
82
+ names = list(shards.values())
83
+ self.assertEqual(names.count("s-s1"), 2)
84
+ self.assertIn("s-s1-r1", names)
85
+ self.assertIn("s-s1-r2", names)
86
+
87
+
88
+ class PredictionTest(Case):
89
+ def test_expectation_weights(self):
90
+ rp.record_query(self.store, "苹果 咖啡")
91
+ rp.record_query(self.store, "苹果 咖啡 甜")
92
+ rows = [entry("expected", "苹果 咖啡"), entry("novel", "维修 螺丝")]
93
+ ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
94
+ by_id = {item["entry"]["id"]: item for item in ranked}
95
+ self.assertGreater(by_id["novel"]["expectation_weight"],
96
+ by_id["expected"]["expectation_weight"])
97
+
98
+ def test_topic_history_bounded(self):
99
+ for index in range(25):
100
+ rp.record_query(self.store, "话题%d" % index)
101
+ doc = json.loads((self.data_dir / "topic_history.json")
102
+ .read_text(encoding="utf-8"))
103
+ self.assertEqual(len(doc["turns"]), 20)
104
+
105
+ def test_off_fallback(self):
106
+ rows = [entry("a", "苹果"), entry("b", "香蕉")]
107
+ ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now,
108
+ enabled=False)
109
+ self.assertEqual([item["entry"]["id"] for item in ranked], ["a", "b"])
110
+ self.assertEqual(ranked[0]["reason"], "fallback")
111
+
112
+
113
+ class DebugTest(Case):
114
+ def test_debug_rows_and_state(self):
115
+ rows = [entry("a", "苹果很甜"), entry("b", "香蕉")]
116
+ ranked = rp.rank(self.store, "s1", "苹果", rows, now=self.now)
117
+ rp.record_debug(self.store, "苹果", ranked, now=self.now)
118
+ summary = rp.summary(self.store)
119
+ self.assertEqual(summary["query"], "苹果")
120
+ for key in ("channels", "reason", "chunk", "chunk_hits", "shard"):
121
+ self.assertIn(key, summary["rows"][0])
122
+ body = pipeline.dispatch("state", {
123
+ "store": str(self.store), "session_id": "s1",
124
+ "persona_profile": str(Path(self.tmp.name) / "missing.json")})["state"]
125
+ self.assertIn("recall_debug", body)
126
+
127
+ def test_switch_off_keeps_engine_order(self):
128
+ rows = [entry("old", "苹果 很甜", ts=self.now - 100),
129
+ entry("new", "苹果 今天", ts=self.now)]
130
+ (self.store / "sessions" / "s1" / "memory.json").write_text(
131
+ json.dumps({"entries": rows}), encoding="utf-8")
132
+ on = pipeline.inject_block("s1", str(self.store), query="苹果",
133
+ mode="query", top_k=2)
134
+ path = self.data_dir / "core_config.json"
135
+ path.write_text(json.dumps({"switches": {"retrieval_plus": False}}),
136
+ encoding="utf-8")
137
+ off = pipeline.inject_block("s1", str(self.store), query="苹果",
138
+ mode="query", top_k=2)
139
+ self.assertEqual(len(on["ids"]), len(off["ids"]))
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main()