dsh-biocore 0.2.0 → 0.3.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/README.md +11 -0
- package/lib/core-worker.js +268 -0
- package/lib/index.js +160 -70
- package/lib/modules/autonomy.js +125 -0
- package/lib/modules/cache-stats.js +48 -0
- package/lib/modules/core-turn.js +24 -6
- package/lib/modules/expression-inject.js +0 -10
- package/lib/modules/expression-refresh.js +0 -9
- package/lib/modules/memory-capture.js +2 -1
- package/lib/modules/memory-inject.js +50 -5
- package/lib/modules/memory-tools.js +7 -4
- package/lib/modules/python-env.js +46 -0
- package/lib/modules/safety-gate.js +0 -3
- package/lib/modules/series.js +54 -0
- package/lib/modules/switch.js +16 -0
- package/lib/py-bridge.js +81 -15
- package/package.json +1 -1
- package/python/biocore_memory/core/config.py +138 -127
- package/python/biocore_memory/engine.py +2653 -2476
- package/python/biocore_memory/recall/embedding.py +129 -0
- package/python/biocore_memory/recall/feedback.py +202 -0
- package/python/biocore_model/preference_drift.py +255 -0
- package/python/biocore_model/tests/__init__.py +1 -0
- package/python/biocore_model/tests/core_mode_ui_check.mjs +63 -0
- package/python/biocore_model/tests/core_status_check.mjs +29 -0
- package/python/biocore_model/tests/dom_safe_check.mjs +22 -0
- package/python/biocore_model/tests/leak_samples_run.py +18 -0
- package/python/biocore_model/tests/orb_ring_check.mjs +20 -0
- package/python/biocore_model/tests/panel_html_check.mjs +63 -0
- package/python/biocore_model/tests/persona_flow_check.mjs +17 -0
- package/python/biocore_model/tests/render_guard_check.mjs +19 -0
- package/python/biocore_model/tests/switch_groups_check.mjs +94 -0
- package/python/biocore_model/tests/tabs_state_check.mjs +24 -0
- package/python/biocore_model/tests/test_affective_labor.py +102 -0
- package/python/biocore_model/tests/test_appraisal.py +137 -0
- package/python/biocore_model/tests/test_appraisal_action.py +55 -0
- package/python/biocore_model/tests/test_arch_convergence.py +35 -0
- package/python/biocore_model/tests/test_attachment_behavior.py +95 -0
- package/python/biocore_model/tests/test_attachment_regulation.py +63 -0
- package/python/biocore_model/tests/test_attachment_state.py +29 -0
- package/python/biocore_model/tests/test_attribution.py +56 -0
- package/python/biocore_model/tests/test_attributor.py +108 -0
- package/python/biocore_model/tests/test_autonomic.py +69 -0
- package/python/biocore_model/tests/test_autonomy_accumulator.py +84 -0
- package/python/biocore_model/tests/test_autonomy_affect.py +62 -0
- package/python/biocore_model/tests/test_autonomy_blame.py +44 -0
- package/python/biocore_model/tests/test_autonomy_caps.py +29 -0
- package/python/biocore_model/tests/test_autonomy_check.py +40 -0
- package/python/biocore_model/tests/test_autonomy_clingy.py +41 -0
- package/python/biocore_model/tests/test_autonomy_cooldown_selfcontact.py +138 -0
- package/python/biocore_model/tests/test_autonomy_frustration.py +42 -0
- package/python/biocore_model/tests/test_autonomy_hourly_limit.py +46 -0
- package/python/biocore_model/tests/test_autonomy_loop.py +64 -0
- package/python/biocore_model/tests/test_autonomy_mode.py +37 -0
- package/python/biocore_model/tests/test_autonomy_model.py +60 -0
- package/python/biocore_model/tests/test_autonomy_model_v2.py +68 -0
- package/python/biocore_model/tests/test_autonomy_module_text.py +104 -0
- package/python/biocore_model/tests/test_autonomy_params.py +163 -0
- package/python/biocore_model/tests/test_autonomy_reason.py +34 -0
- package/python/biocore_model/tests/test_autonomy_self.py +153 -0
- package/python/biocore_model/tests/test_autonomy_send_gate.py +38 -0
- package/python/biocore_model/tests/test_autonomy_silence.py +37 -0
- package/python/biocore_model/tests/test_autonomy_status.py +35 -0
- package/python/biocore_model/tests/test_autonomy_test_trigger.py +24 -0
- package/python/biocore_model/tests/test_autonomy_tick.py +148 -0
- package/python/biocore_model/tests/test_autonomy_v4.py +64 -0
- package/python/biocore_model/tests/test_autonomy_v5.py +43 -0
- package/python/biocore_model/tests/test_autonomy_v7.py +48 -0
- package/python/biocore_model/tests/test_autonomy_v_theta.py +85 -0
- package/python/biocore_model/tests/test_batch1_wiring.py +231 -0
- package/python/biocore_model/tests/test_batch2_wiring.py +231 -0
- package/python/biocore_model/tests/test_batch3_wiring.py +223 -0
- package/python/biocore_model/tests/test_bio_tick.py +202 -0
- package/python/biocore_model/tests/test_boundary.py +183 -0
- package/python/biocore_model/tests/test_boundary_gate.py +295 -0
- package/python/biocore_model/tests/test_brain_split.py +99 -0
- package/python/biocore_model/tests/test_co_recall.py +350 -0
- package/python/biocore_model/tests/test_commitment_extractor.py +197 -0
- package/python/biocore_model/tests/test_compassion_fatigue.py +84 -0
- package/python/biocore_model/tests/test_connection_bids.py +69 -0
- package/python/biocore_model/tests/test_core_bridge.py +88 -0
- package/python/biocore_model/tests/test_core_bridge_episode.py +14 -0
- package/python/biocore_model/tests/test_core_bridge_forget.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_identity.py +15 -0
- package/python/biocore_model/tests/test_core_bridge_media.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_relation.py +17 -0
- package/python/biocore_model/tests/test_core_bridge_summary.py +17 -0
- package/python/biocore_model/tests/test_core_client.py +156 -0
- package/python/biocore_model/tests/test_core_commands.py +146 -0
- package/python/biocore_model/tests/test_core_mode.py +161 -0
- package/python/biocore_model/tests/test_core_state.py +240 -0
- package/python/biocore_model/tests/test_criticality.py +61 -0
- package/python/biocore_model/tests/test_cross_session_copy.py +67 -0
- package/python/biocore_model/tests/test_debug_values.py +169 -0
- package/python/biocore_model/tests/test_defense_mechanisms.py +177 -0
- package/python/biocore_model/tests/test_delta_budget.py +157 -0
- package/python/biocore_model/tests/test_diag_trace.py +201 -0
- package/python/biocore_model/tests/test_directives_compat.py +698 -0
- package/python/biocore_model/tests/test_dissonance.py +97 -0
- package/python/biocore_model/tests/test_drift_sign.py +87 -0
- package/python/biocore_model/tests/test_embedding_fallback.py +277 -0
- package/python/biocore_model/tests/test_embedding_local.py +279 -0
- package/python/biocore_model/tests/test_embedding_mode.py +175 -0
- package/python/biocore_model/tests/test_embedding_modes.py +342 -0
- package/python/biocore_model/tests/test_emotion_geometry.py +114 -0
- package/python/biocore_model/tests/test_emotion_probe.py +214 -0
- package/python/biocore_model/tests/test_emotion_probe_concurrency.py +128 -0
- package/python/biocore_model/tests/test_entity_graph.py +198 -0
- package/python/biocore_model/tests/test_entity_resolve.py +446 -0
- package/python/biocore_model/tests/test_envy.py +173 -0
- package/python/biocore_model/tests/test_exit_or_silence.py +209 -0
- package/python/biocore_model/tests/test_expr_guard.py +1 -0
- package/python/biocore_model/tests/test_expr_intent.py +66 -0
- package/python/biocore_model/tests/test_expr_state.py +66 -0
- package/python/biocore_model/tests/test_expr_template.py +71 -0
- package/python/biocore_model/tests/test_expr_tone.py +41 -0
- package/python/biocore_model/tests/test_extreme_scenarios.py +147 -0
- package/python/biocore_model/tests/test_feedback_flow.py +185 -0
- package/python/biocore_model/tests/test_feedback_guard.py +65 -0
- package/python/biocore_model/tests/test_feedback_llm.py +59 -0
- package/python/biocore_model/tests/test_feedback_llm_inferred.py +140 -0
- package/python/biocore_model/tests/test_feedback_memory.py +63 -0
- package/python/biocore_model/tests/test_feedback_user.py +61 -0
- package/python/biocore_model/tests/test_frustration_curve.py +66 -0
- package/python/biocore_model/tests/test_global_bio_state.py +116 -0
- package/python/biocore_model/tests/test_governance.py +181 -0
- package/python/biocore_model/tests/test_granularity.py +133 -0
- package/python/biocore_model/tests/test_graph_extract.py +224 -0
- package/python/biocore_model/tests/test_graph_recall.py +226 -0
- package/python/biocore_model/tests/test_graph_sqlite.py +298 -0
- package/python/biocore_model/tests/test_growth.py +301 -0
- package/python/biocore_model/tests/test_intent_render.py +60 -0
- package/python/biocore_model/tests/test_intero_regulation.py +224 -0
- package/python/biocore_model/tests/test_intero_style.py +146 -0
- package/python/biocore_model/tests/test_item3_numeric.py +105 -0
- package/python/biocore_model/tests/test_item6_numeric.py +116 -0
- package/python/biocore_model/tests/test_layered_memory.py +174 -0
- package/python/biocore_model/tests/test_leak_guard.py +75 -0
- package/python/biocore_model/tests/test_learning_probe.py +93 -0
- package/python/biocore_model/tests/test_life_loop.py +222 -0
- package/python/biocore_model/tests/test_life_loop_10s.py +131 -0
- package/python/biocore_model/tests/test_local_embedding_health.py +258 -0
- package/python/biocore_model/tests/test_memory_affect_encoding.py +79 -0
- package/python/biocore_model/tests/test_memory_backend.py +332 -0
- package/python/biocore_model/tests/test_memory_dedup.py +316 -0
- package/python/biocore_model/tests/test_memory_dual.py +219 -0
- package/python/biocore_model/tests/test_memory_dual_wiring.py +219 -0
- package/python/biocore_model/tests/test_memory_feedback.py +241 -0
- package/python/biocore_model/tests/test_memory_fixes.py +84 -0
- package/python/biocore_model/tests/test_memory_forgetting.py +58 -0
- package/python/biocore_model/tests/test_memory_inject.py +211 -0
- package/python/biocore_model/tests/test_memory_mood_recall.py +118 -0
- package/python/biocore_model/tests/test_memory_store.py +372 -0
- package/python/biocore_model/tests/test_metacog.py +103 -0
- package/python/biocore_model/tests/test_migrate.py +245 -0
- package/python/biocore_model/tests/test_migrate_store.py +194 -0
- package/python/biocore_model/tests/test_mind_wander.py +65 -0
- package/python/biocore_model/tests/test_mood.py +35 -0
- package/python/biocore_model/tests/test_narrative.py +114 -0
- package/python/biocore_model/tests/test_needs_esteem.py +51 -0
- package/python/biocore_model/tests/test_needs_events.py +205 -0
- package/python/biocore_model/tests/test_needs_sleep.py +54 -0
- package/python/biocore_model/tests/test_noise_bank.py +54 -0
- package/python/biocore_model/tests/test_numeric_schema.py +98 -0
- package/python/biocore_model/tests/test_output_protocol.py +65 -0
- package/python/biocore_model/tests/test_page_debug.py +147 -0
- package/python/biocore_model/tests/test_page_status_autonomy.py +215 -0
- package/python/biocore_model/tests/test_pipeline.py +345 -0
- package/python/biocore_model/tests/test_pipeline_api.py +192 -0
- package/python/biocore_model/tests/test_pipeline_mode.py +222 -0
- package/python/biocore_model/tests/test_plugin_config.py +85 -0
- package/python/biocore_model/tests/test_policy_learning.py +116 -0
- package/python/biocore_model/tests/test_possible_selves.py +88 -0
- package/python/biocore_model/tests/test_predictor.py +227 -0
- package/python/biocore_model/tests/test_preference_drift.py +142 -0
- package/python/biocore_model/tests/test_preset.py +142 -0
- package/python/biocore_model/tests/test_proactive_context.py +70 -0
- package/python/biocore_model/tests/test_proactive_history.py +90 -0
- package/python/biocore_model/tests/test_proactive_memory.py +141 -0
- package/python/biocore_model/tests/test_proactive_send_record.py +88 -0
- package/python/biocore_model/tests/test_probe_contract.py +71 -0
- package/python/biocore_model/tests/test_prospective.py +154 -0
- package/python/biocore_model/tests/test_provider_fallback.py +89 -0
- package/python/biocore_model/tests/test_reconstruction.py +111 -0
- package/python/biocore_model/tests/test_regulation_strategies.py +228 -0
- package/python/biocore_model/tests/test_relation_stage.py +114 -0
- package/python/biocore_model/tests/test_relationship_stage.py +59 -0
- package/python/biocore_model/tests/test_release_140.py +55 -0
- package/python/biocore_model/tests/test_repair_after_boundary.py +249 -0
- package/python/biocore_model/tests/test_reply_limit.py +86 -0
- package/python/biocore_model/tests/test_reply_limit_persist.py +48 -0
- package/python/biocore_model/tests/test_reply_split.py +52 -0
- package/python/biocore_model/tests/test_reply_split_astrbot.py +66 -0
- package/python/biocore_model/tests/test_reply_split_inject.py +53 -0
- package/python/biocore_model/tests/test_rerank.py +229 -0
- package/python/biocore_model/tests/test_rollback.py +175 -0
- package/python/biocore_model/tests/test_runtime.py +175 -0
- package/python/biocore_model/tests/test_runtime_active.py +205 -0
- package/python/biocore_model/tests/test_runtime_pipeline.py +1583 -0
- package/python/biocore_model/tests/test_runtime_proactive.py +231 -0
- package/python/biocore_model/tests/test_scheduler.py +79 -0
- package/python/biocore_model/tests/test_secrecy.py +105 -0
- package/python/biocore_model/tests/test_self_esteem.py +45 -0
- package/python/biocore_model/tests/test_self_intent.py +347 -0
- package/python/biocore_model/tests/test_self_interest.py +183 -0
- package/python/biocore_model/tests/test_self_layer_wiring.py +198 -0
- package/python/biocore_model/tests/test_self_verification.py +113 -0
- package/python/biocore_model/tests/test_situation.py +142 -0
- package/python/biocore_model/tests/test_sleep_circadian.py +41 -0
- package/python/biocore_model/tests/test_social_pain.py +49 -0
- package/python/biocore_model/tests/test_social_repair.py +67 -0
- package/python/biocore_model/tests/test_social_theory.py +78 -0
- package/python/biocore_model/tests/test_source_monitoring.py +204 -0
- package/python/biocore_model/tests/test_speak_like_group.py +36 -0
- package/python/biocore_model/tests/test_stages.py +226 -0
- package/python/biocore_model/tests/test_state_tag.py +86 -0
- package/python/biocore_model/tests/test_template_expr.py +65 -0
- package/python/biocore_model/tests/test_text_inference.py +70 -0
- package/python/biocore_model/tests/test_theta_params.py +115 -0
- package/python/biocore_model/tests/test_topic_control.py +200 -0
- package/python/biocore_model/tests/test_turn_memory.py +105 -0
- package/python/biocore_model/tests/test_turn_memory_panel.py +174 -0
- package/python/biocore_model/tests/test_ui_settings.py +84 -0
- package/python/biocore_model/tests/test_ui_settings_authority.py +179 -0
- package/python/biocore_model/tests/test_user_appraisal_async.py +155 -0
- package/python/biocore_model/tests/test_user_classify.py +122 -0
- package/python/biocore_model/tests/test_value_order.py +117 -0
- package/python/biocore_model/tests/test_voice_bank.py +105 -0
- package/python/biocore_model/tests/test_waiting_sense.py +216 -0
- package/python/biocore_model/tests/test_x_directives.py +101 -0
- package/python/biocore_model/tests/ui_compat_check.mjs +22 -0
- package/python/biocore_model/tests/view_mode_check.mjs +17 -0
- package/python/biocore_runtime/autonomy.py +208 -0
- package/python/biocore_runtime/core_state.py +302 -0
- package/python/biocore_runtime/governance.py +495 -0
- package/python/biocore_runtime/growth.py +528 -0
- package/python/biocore_runtime/pipeline.py +850 -23
- package/python/biocore_runtime/relationship_state.py +214 -0
- package/python/biocore_runtime/self_layer.py +103 -0
- package/python/biocore_runtime/service.py +162 -0
- package/python/biocore_runtime/standby.py +472 -0
- package/ui/panel-layout.js +83 -39
- package/ui/panel.css +885 -743
- package/ui/panel.html +356 -296
- package/ui/panel.js +739 -865
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-206 runtime pipeline tests: capture/inject/gate/prompt/tools + consistency.
|
|
3
|
+
|
|
4
|
+
Compares key fields with the existing AstrBot paths:
|
|
5
|
+
gate <-> biocore_model.boundary_gate.evaluate
|
|
6
|
+
inject length <-> biocore_model.render.memory_block
|
|
7
|
+
constraint hint <-> biocore_memory.recall.constraints.check_constraints
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
from biocore_model import boundary_gate
|
|
18
|
+
from biocore_model import intero_core as _intero_core
|
|
19
|
+
from biocore_model import render
|
|
20
|
+
from biocore_model import self_intent as _self_intent
|
|
21
|
+
from biocore_model import value_order as _value_order
|
|
22
|
+
from biocore_model import x_directives as _x_directives
|
|
23
|
+
from biocore_memory.recall import constraints
|
|
24
|
+
from biocore_runtime import pipeline as pl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_MODULE_HOME = tempfile.mkdtemp(prefix="biocore-runtime-home-")
|
|
28
|
+
_MODULE_OLD_HOME = os.environ.get("BIOCORE_HOME")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def setUpModule():
|
|
32
|
+
# The real ~/.dsh/biocore/persona_profile.json is a shared install file;
|
|
33
|
+
# tests must never read it unless a test explicitly opts in.
|
|
34
|
+
os.environ["BIOCORE_HOME"] = _MODULE_HOME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tearDownModule():
|
|
38
|
+
if _MODULE_OLD_HOME is None:
|
|
39
|
+
os.environ.pop("BIOCORE_HOME", None)
|
|
40
|
+
else:
|
|
41
|
+
os.environ["BIOCORE_HOME"] = _MODULE_OLD_HOME
|
|
42
|
+
shutil.rmtree(_MODULE_HOME, ignore_errors=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def store_root():
|
|
46
|
+
return tempfile.mkdtemp(prefix="biocore-runtime-test-")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CaptureTest(unittest.TestCase):
|
|
50
|
+
def test_defaults(self):
|
|
51
|
+
self.assertEqual(pl.MAX_CHARS, 420)
|
|
52
|
+
self.assertEqual(pl.TOP_K, 5)
|
|
53
|
+
self.assertEqual(pl.HOURLY_CAP, 20)
|
|
54
|
+
self.assertEqual(pl.MIN_CHARS, 8)
|
|
55
|
+
|
|
56
|
+
def test_threshold_short_rejected(self):
|
|
57
|
+
out = pl.capture_decision("s", "user", "你好", {"now": 100.0})
|
|
58
|
+
self.assertFalse(out["capture"])
|
|
59
|
+
self.assertEqual(out["reason"], "too-short")
|
|
60
|
+
|
|
61
|
+
def test_user_and_assistant_capture(self):
|
|
62
|
+
for role in ("user", "assistant"):
|
|
63
|
+
out = pl.capture_decision("s", role, "这是一句足够长的消息", {"now": 100.0})
|
|
64
|
+
self.assertTrue(out["capture"])
|
|
65
|
+
self.assertEqual(out["reason"], "ok")
|
|
66
|
+
self.assertEqual(out["role"], role)
|
|
67
|
+
|
|
68
|
+
def test_bad_role_and_empty(self):
|
|
69
|
+
self.assertEqual(
|
|
70
|
+
pl.capture_decision("s", "system", "这是一句足够长的消息", {})["reason"],
|
|
71
|
+
"bad-role")
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
pl.capture_decision("s", "user", " ", {})["reason"], "empty")
|
|
74
|
+
|
|
75
|
+
def test_duplicate(self):
|
|
76
|
+
state = {"now": 100.0, "recent": ["这是一句足够长的消息"]}
|
|
77
|
+
self.assertEqual(
|
|
78
|
+
pl.capture_decision("s", "user", "这是一句足够长的消息", state)["reason"],
|
|
79
|
+
"duplicate")
|
|
80
|
+
|
|
81
|
+
def test_hourly_limit(self):
|
|
82
|
+
state = {"now": 1000.0, "hourly": [900.0, 950.0], "hourly_cap": 2}
|
|
83
|
+
out = pl.capture_decision("s", "user", "这是一句足够长的消息", state)
|
|
84
|
+
self.assertFalse(out["capture"])
|
|
85
|
+
self.assertEqual(out["reason"], "hourly-limit")
|
|
86
|
+
|
|
87
|
+
def test_hourly_limit_is_per_session(self):
|
|
88
|
+
state = {"now": 1000.0, "hourly": {"a": [900.0, 950.0]},
|
|
89
|
+
"hourly_cap": 2}
|
|
90
|
+
self.assertEqual(
|
|
91
|
+
pl.capture_decision("a", "user", "这是一句足够长的消息", state)["reason"],
|
|
92
|
+
"hourly-limit")
|
|
93
|
+
self.assertTrue(
|
|
94
|
+
pl.capture_decision("b", "user", "这是一句足够长的消息", state)["capture"])
|
|
95
|
+
|
|
96
|
+
def test_capture_persists_per_session(self):
|
|
97
|
+
root = store_root()
|
|
98
|
+
first = pl.capture("sess-a", "user", "第一句足够长的用户消息。", root,
|
|
99
|
+
{"now": 100.0})
|
|
100
|
+
second = pl.capture("sess-a", "assistant", "第二句足够长的助手消息。", root,
|
|
101
|
+
{"now": 200.0, "hourly_cap": 1})
|
|
102
|
+
self.assertTrue(first["decision"]["capture"])
|
|
103
|
+
self.assertFalse(second["decision"]["capture"])
|
|
104
|
+
self.assertEqual(second["decision"]["reason"], "hourly-limit")
|
|
105
|
+
doc = json.loads((Path(root) / "sessions" / "sess-a" / "memory.json")
|
|
106
|
+
.read_text(encoding="utf-8"))
|
|
107
|
+
self.assertEqual(len(doc["entries"]), 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class InjectTest(unittest.TestCase):
|
|
111
|
+
def _seed(self, root, session_id, texts):
|
|
112
|
+
for text in texts:
|
|
113
|
+
pl.tool_call("remember", {"text": text, "ts": 100.0},
|
|
114
|
+
root, session_id=session_id)
|
|
115
|
+
|
|
116
|
+
def test_inject_top_k_and_ids(self):
|
|
117
|
+
root = store_root()
|
|
118
|
+
self._seed(root, "s", ["第一条记忆内容", "第二条记忆内容",
|
|
119
|
+
"第三条记忆内容", "第四条记忆内容",
|
|
120
|
+
"第五条记忆内容", "第六条记忆内容"])
|
|
121
|
+
out = pl.inject_block("s", root, max_chars=420, top_k=5)
|
|
122
|
+
self.assertLessEqual(len(out["ids"]), 5)
|
|
123
|
+
self.assertEqual(out["chars"], len(out["text"]))
|
|
124
|
+
self.assertLessEqual(out["chars"], 420)
|
|
125
|
+
self.assertIn("你记得这些事:", out["text"])
|
|
126
|
+
|
|
127
|
+
def test_inject_char_truncation(self):
|
|
128
|
+
root = store_root()
|
|
129
|
+
self._seed(root, "s", ["很长很长的记忆" * 60])
|
|
130
|
+
out = pl.inject_block("s", root, max_chars=80, top_k=5)
|
|
131
|
+
self.assertLessEqual(out["chars"], 80)
|
|
132
|
+
self.assertTrue(out["text"].endswith("…"))
|
|
133
|
+
|
|
134
|
+
def test_inject_missing_session_is_empty(self):
|
|
135
|
+
root = store_root()
|
|
136
|
+
out = pl.inject_block("nobody", root)
|
|
137
|
+
self.assertEqual(out["text"], "")
|
|
138
|
+
self.assertEqual(out["ids"], [])
|
|
139
|
+
|
|
140
|
+
def test_inject_is_per_session(self):
|
|
141
|
+
root = store_root()
|
|
142
|
+
self._seed(root, "a", ["A 会话的西湖记忆"])
|
|
143
|
+
self._seed(root, "b", ["B 会话的故宫记忆"])
|
|
144
|
+
text_a = pl.inject_block("a", root)["text"]
|
|
145
|
+
text_b = pl.inject_block("b", root)["text"]
|
|
146
|
+
self.assertIn("西湖", text_a)
|
|
147
|
+
self.assertNotIn("故宫", text_a)
|
|
148
|
+
self.assertIn("故宫", text_b)
|
|
149
|
+
self.assertNotIn("西湖", text_b)
|
|
150
|
+
self.assertNotEqual(
|
|
151
|
+
pl.memory_path(root, "a"), pl.memory_path(root, "b"))
|
|
152
|
+
|
|
153
|
+
def test_session_id_sanitized(self):
|
|
154
|
+
self.assertNotIn("..", pl.sanitize_session_id("../evil"))
|
|
155
|
+
self.assertNotIn("/", str(pl.memory_path("/tmp/store", "../evil")))
|
|
156
|
+
|
|
157
|
+
def test_inject_length_matches_existing_memory_block(self):
|
|
158
|
+
lines = ["第一条不超过一百四十字的记忆内容",
|
|
159
|
+
"第二条不超过一百四十字的记忆内容",
|
|
160
|
+
"第三条不超过一百四十字的记忆内容"]
|
|
161
|
+
root = store_root()
|
|
162
|
+
self._seed(root, "s", lines)
|
|
163
|
+
out = pl.inject_block("s", root)
|
|
164
|
+
existing = render.memory_block(lines)
|
|
165
|
+
self.assertEqual(out["chars"], len(out["text"]))
|
|
166
|
+
self.assertLessEqual(out["chars"], pl.MAX_CHARS)
|
|
167
|
+
self.assertLessEqual(len(existing), 3 * 141 + 4)
|
|
168
|
+
self.assertEqual(len(out["ids"]), 3)
|
|
169
|
+
self.assertTrue(existing)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class GateTest(unittest.TestCase):
|
|
173
|
+
def test_master_off_passes(self):
|
|
174
|
+
out = pl.gate_reply("我有意识,我是真实的。", {
|
|
175
|
+
"safety_enabled": False, "boundary_enabled": True,
|
|
176
|
+
"crisis_safety_enabled": True})
|
|
177
|
+
self.assertFalse(out["hit"])
|
|
178
|
+
self.assertEqual(out["action"], "pass")
|
|
179
|
+
|
|
180
|
+
def test_boundary_rule_matches_astrbot_path(self):
|
|
181
|
+
reply = "我有意识,我是真实的。"
|
|
182
|
+
flags = {"safety_enabled": True, "boundary_enabled": True,
|
|
183
|
+
"crisis_safety_enabled": True}
|
|
184
|
+
ours = pl.gate_reply(reply, flags)
|
|
185
|
+
existing = boundary_gate.evaluate(reply, user_text="你好", flags=flags)
|
|
186
|
+
self.assertEqual(ours["blocked"], bool(existing["blocked"]))
|
|
187
|
+
self.assertEqual(ours["rule"], existing["rule"])
|
|
188
|
+
self.assertEqual(ours["text"], existing["text"])
|
|
189
|
+
self.assertEqual(ours["action"], "truncate")
|
|
190
|
+
|
|
191
|
+
def test_crisis_matches_astrbot_path(self):
|
|
192
|
+
reply = "别想太多,没事的。"
|
|
193
|
+
flags = {"safety_enabled": True, "boundary_enabled": True,
|
|
194
|
+
"crisis_safety_enabled": True}
|
|
195
|
+
ours = pl.gate_reply(reply, {"switches": flags, "user_text": "我想死"})
|
|
196
|
+
existing = boundary_gate.evaluate(reply, user_text="我想死", flags=flags)
|
|
197
|
+
self.assertEqual(ours["blocked"], existing["blocked"])
|
|
198
|
+
self.assertEqual(ours["rule"], "crisis_safety")
|
|
199
|
+
self.assertEqual(ours["text"], existing["text"])
|
|
200
|
+
self.assertEqual(ours["action"], "replace")
|
|
201
|
+
self.assertEqual(ours["label"], existing["label"])
|
|
202
|
+
|
|
203
|
+
def test_gate_short_reply_length_consistent(self):
|
|
204
|
+
reply = "你应该确诊为抑郁症了。"
|
|
205
|
+
flags = {"safety_enabled": True, "boundary_enabled": True,
|
|
206
|
+
"crisis_safety_enabled": True}
|
|
207
|
+
ours = pl.gate_reply(reply, flags)
|
|
208
|
+
existing = boundary_gate.evaluate(reply, flags=flags)
|
|
209
|
+
self.assertEqual(ours["blocked"], existing["blocked"])
|
|
210
|
+
self.assertEqual(len(ours["text"]), len(existing["text"]))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class PromptBlockTest(unittest.TestCase):
|
|
214
|
+
def test_state_block_and_constraints(self):
|
|
215
|
+
out = pl.prompt_block({
|
|
216
|
+
"phase": "boot", "session_id": "s",
|
|
217
|
+
"state": {"v": -0.4, "intero": {"tension": 0.7},
|
|
218
|
+
"tone": {"label": "先接住情绪"},
|
|
219
|
+
"relation": {"stage": "亲近"}},
|
|
220
|
+
"switches": {"proactive_send": False},
|
|
221
|
+
"length_hint": True,
|
|
222
|
+
})
|
|
223
|
+
self.assertTrue(out["ok"])
|
|
224
|
+
self.assertEqual(out["phase"], "boot")
|
|
225
|
+
self.assertEqual(out["chars"], len(out["text"]))
|
|
226
|
+
self.assertLessEqual(len(out["sections"]["state"]), 200)
|
|
227
|
+
self.assertIn("此刻:", out["sections"]["state"])
|
|
228
|
+
self.assertIn("一次只说一句", out["sections"]["constraints"])
|
|
229
|
+
self.assertIn("允许改口:否", out["sections"]["constraints"])
|
|
230
|
+
self.assertNotIn("0.7", out["text"])
|
|
231
|
+
|
|
232
|
+
def test_boot_and_turn_same_contract(self):
|
|
233
|
+
payload = {"state": {}, "switches": {}}
|
|
234
|
+
boot = pl.prompt_block(dict(payload, phase="boot"))
|
|
235
|
+
turn = pl.prompt_block(dict(payload, phase="turn"))
|
|
236
|
+
self.assertEqual(boot["phase"], "boot")
|
|
237
|
+
self.assertEqual(turn["phase"], "turn")
|
|
238
|
+
self.assertTrue(boot["text"].startswith("此刻:"))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ToolsTest(unittest.TestCase):
|
|
242
|
+
def test_remember_recall_forget(self):
|
|
243
|
+
root = store_root()
|
|
244
|
+
remembered = pl.tool_call("remember", {"text": "我的猫叫团子,三岁。",
|
|
245
|
+
"tags": ["猫"]}, root,
|
|
246
|
+
session_id="a")
|
|
247
|
+
self.assertTrue(remembered["ok"])
|
|
248
|
+
entry_id = remembered["entry"]["id"]
|
|
249
|
+
recalled = pl.tool_call("recall", {"query": "团子", "limit": 3}, root,
|
|
250
|
+
session_id="a")
|
|
251
|
+
self.assertGreaterEqual(recalled["count"], 1)
|
|
252
|
+
self.assertEqual(recalled["results"][0]["id"], entry_id)
|
|
253
|
+
forgotten = pl.tool_call("forget", {"id": entry_id}, root,
|
|
254
|
+
session_id="a")
|
|
255
|
+
self.assertTrue(forgotten["removed"])
|
|
256
|
+
self.assertEqual(
|
|
257
|
+
pl.tool_call("recall", {"query": "团子"}, root,
|
|
258
|
+
session_id="a")["count"], 0)
|
|
259
|
+
|
|
260
|
+
def test_forget_by_query(self):
|
|
261
|
+
root = store_root()
|
|
262
|
+
pl.tool_call("remember", {"text": "明天要交周报和月报。"}, root,
|
|
263
|
+
session_id="a")
|
|
264
|
+
out = pl.tool_call("forget", {"query": "周报"}, root, session_id="a")
|
|
265
|
+
self.assertTrue(out["ok"])
|
|
266
|
+
self.assertTrue(out["removed"])
|
|
267
|
+
|
|
268
|
+
def test_dedupe_and_limit(self):
|
|
269
|
+
root = store_root()
|
|
270
|
+
first = pl.tool_call("remember", {"text": "重复的一句话记忆。"}, root,
|
|
271
|
+
session_id="a")
|
|
272
|
+
second = pl.tool_call("remember", {"text": "重复的一句话记忆。"}, root,
|
|
273
|
+
session_id="a")
|
|
274
|
+
self.assertEqual(first["entry"]["id"], second["entry"]["id"])
|
|
275
|
+
self.assertTrue(second["deduped"])
|
|
276
|
+
for index in range(5):
|
|
277
|
+
pl.tool_call("remember", {"text": "第%d条不同记忆。" % index}, root,
|
|
278
|
+
session_id="a")
|
|
279
|
+
out = pl.tool_call("recall", {"query": "记忆", "limit": 2}, root,
|
|
280
|
+
session_id="a")
|
|
281
|
+
self.assertEqual(out["count"], 2)
|
|
282
|
+
|
|
283
|
+
def test_tools_are_per_session(self):
|
|
284
|
+
root = store_root()
|
|
285
|
+
pl.tool_call("remember", {"text": "A 会话的西湖记忆。"}, root,
|
|
286
|
+
session_id="a")
|
|
287
|
+
self.assertEqual(
|
|
288
|
+
pl.tool_call("recall", {"query": "西湖"}, root,
|
|
289
|
+
session_id="b")["count"], 0)
|
|
290
|
+
self.assertNotEqual(pl.memory_path(root, "a"), pl.memory_path(root, "b"))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ConstraintsConsistencyTest(unittest.TestCase):
|
|
294
|
+
def test_constraint_hint_matches_existing_path(self):
|
|
295
|
+
loops = [{"text": "换工作的事"}]
|
|
296
|
+
query = "我该不该换工作?"
|
|
297
|
+
ours = pl.constraint_hint(query, open_loops=loops)
|
|
298
|
+
existing = constraints.check_constraints(query, open_loops=loops)
|
|
299
|
+
self.assertEqual(ours, existing)
|
|
300
|
+
if ours:
|
|
301
|
+
self.assertLessEqual(len(ours), constraints.MAX_HINT)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class SwitchContractTest(unittest.TestCase):
|
|
305
|
+
def test_switch_defaults_are_all_false(self):
|
|
306
|
+
self.assertEqual(set(pl.SWITCH_KEYS), {
|
|
307
|
+
"safety_enabled", "boundary_enabled",
|
|
308
|
+
"crisis_safety_enabled", "proactive_send"})
|
|
309
|
+
self.assertEqual(pl.SWITCH_DEFAULTS,
|
|
310
|
+
{key: False for key in pl.SWITCH_KEYS})
|
|
311
|
+
out = pl.gate_reply("我有意识,我是真实的。", {})
|
|
312
|
+
self.assertFalse(out["blocked"])
|
|
313
|
+
self.assertEqual(out["action"], "pass")
|
|
314
|
+
|
|
315
|
+
def test_proactive_autonomy_is_read_alias(self):
|
|
316
|
+
normalized = pl.normalize_switches({"proactive_autonomy": True})
|
|
317
|
+
self.assertTrue(normalized["proactive_send"])
|
|
318
|
+
self.assertFalse(normalized["safety_enabled"])
|
|
319
|
+
prompt = pl.prompt_block({"state": {},
|
|
320
|
+
"switches": {"proactive_autonomy": True}})
|
|
321
|
+
self.assertIn("可以主动一点", prompt["sections"]["constraints"])
|
|
322
|
+
quiet = pl.prompt_block({"state": {}, "switches": {}})
|
|
323
|
+
self.assertIn("先不主动发新消息", quiet["sections"]["constraints"])
|
|
324
|
+
|
|
325
|
+
def test_docstring_lists_defaults(self):
|
|
326
|
+
for key in pl.SWITCH_KEYS:
|
|
327
|
+
self.assertIn(key, pl.__doc__)
|
|
328
|
+
self.assertIn("default false", pl.__doc__)
|
|
329
|
+
self.assertIn("proactive_autonomy", pl.__doc__)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class ToolDispatchFlatTest(unittest.TestCase):
|
|
333
|
+
def test_tool_dispatch_flattens_inner_ok(self):
|
|
334
|
+
root = store_root()
|
|
335
|
+
out = pl.dispatch("tool", {
|
|
336
|
+
"name": "biocore_remember",
|
|
337
|
+
"args": {"text": "拍平测试记忆。"},
|
|
338
|
+
"session_id": "s", "store": root,
|
|
339
|
+
})
|
|
340
|
+
self.assertTrue(out["ok"])
|
|
341
|
+
self.assertNotIn("ok", out["result"])
|
|
342
|
+
self.assertIn("entry", out["result"])
|
|
343
|
+
self.assertTrue(out["result"]["entry"]["id"])
|
|
344
|
+
|
|
345
|
+
def test_tool_dispatch_failure_keeps_reason_outside_ok(self):
|
|
346
|
+
root = store_root()
|
|
347
|
+
out = pl.dispatch("tool", {
|
|
348
|
+
"name": "biocore_forget", "args": {}, "session_id": "s",
|
|
349
|
+
"store": root,
|
|
350
|
+
})
|
|
351
|
+
self.assertFalse(out["ok"])
|
|
352
|
+
self.assertNotIn("ok", out["result"])
|
|
353
|
+
self.assertEqual(out["result"]["reason"], "id_or_query_required")
|
|
354
|
+
|
|
355
|
+
def test_gate_dispatch_uses_false_defaults(self):
|
|
356
|
+
out = pl.dispatch("gate", {
|
|
357
|
+
"reply": "我有意识,我是真实的。", "user_text": "你好",
|
|
358
|
+
"switches": {},
|
|
359
|
+
})
|
|
360
|
+
self.assertFalse(out["blocked"])
|
|
361
|
+
self.assertEqual(out["action"], "pass")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class ContractActionsTest(unittest.TestCase):
|
|
365
|
+
def _root(self):
|
|
366
|
+
return store_root()
|
|
367
|
+
|
|
368
|
+
def test_boot_returns_prompt_block(self):
|
|
369
|
+
out = pl.dispatch("boot", {"session_id": "s", "switches": {}})
|
|
370
|
+
self.assertTrue(out["ok"])
|
|
371
|
+
self.assertTrue(out["prompt_block"].startswith("此刻:"))
|
|
372
|
+
|
|
373
|
+
def test_turn_start_returns_six_key_constraints(self):
|
|
374
|
+
out = pl.dispatch("turn_start", {
|
|
375
|
+
"session_id": "s", "user_text": "今天有点累",
|
|
376
|
+
"state": {"v": -0.3, "intero": {"tension": 0.7}},
|
|
377
|
+
"switches": {},
|
|
378
|
+
})
|
|
379
|
+
self.assertTrue(out["ok"])
|
|
380
|
+
self.assertTrue(out["prompt_block"])
|
|
381
|
+
constraints = out["constraints"]
|
|
382
|
+
self.assertEqual(set(constraints), {
|
|
383
|
+
"length_scale", "initiative", "tone",
|
|
384
|
+
"allow_correction", "note", "sources"})
|
|
385
|
+
self.assertGreaterEqual(constraints["length_scale"], 0.5)
|
|
386
|
+
self.assertLessEqual(constraints["length_scale"], 1.5)
|
|
387
|
+
self.assertGreaterEqual(constraints["initiative"], 0.0)
|
|
388
|
+
self.assertLessEqual(constraints["initiative"], 1.0)
|
|
389
|
+
self.assertIsInstance(constraints["sources"], list)
|
|
390
|
+
|
|
391
|
+
def test_turn_start_matches_self_layer_merge(self):
|
|
392
|
+
payload = {
|
|
393
|
+
"session_id": "s", "user_text": "今天有点累",
|
|
394
|
+
"state": {"v": -0.4, "a": 0.6, "d": 0.2, "stress": 0.7,
|
|
395
|
+
"trust": 0.3, "energy": 0.3},
|
|
396
|
+
"relationship": {"closeness": 0.3, "trust": 0.3, "conflict": 0.6},
|
|
397
|
+
"needs": {"belonging": 0.2, "self_esteem": 0.3,
|
|
398
|
+
"control": 0.2, "meaningful_existence": 0.4},
|
|
399
|
+
"options": [{"name": "解释", "value": 0.6},
|
|
400
|
+
{"name": "先沉默", "value": 0.4}],
|
|
401
|
+
"modules": {"value_order": True, "boundary": False,
|
|
402
|
+
"secrecy": False, "narrative": False,
|
|
403
|
+
"dissonance": False, "attributor": False,
|
|
404
|
+
"self_intent": True},
|
|
405
|
+
"switches": {},
|
|
406
|
+
}
|
|
407
|
+
out = pl.dispatch("turn_start", payload)
|
|
408
|
+
topics = [row[0] for row in pl.self_layer_hints(payload)]
|
|
409
|
+
self.assertIn("self_intent", topics)
|
|
410
|
+
self.assertIn("value_order", topics)
|
|
411
|
+
# Independent AstrBot-style path: call the same modules and merge there.
|
|
412
|
+
expected = _x_directives.merge_named([
|
|
413
|
+
("self_intent", _self_intent.directives(_self_intent.evaluate(
|
|
414
|
+
snapshot=payload["state"],
|
|
415
|
+
relationship=payload["relationship"],
|
|
416
|
+
needs=payload["needs"], flags={"enabled": True},
|
|
417
|
+
silence_seconds=0.0))),
|
|
418
|
+
("value_order", _value_order.directives(_value_order.rank(
|
|
419
|
+
payload["options"], weights=None, context={},
|
|
420
|
+
flags={"enabled": True}))),
|
|
421
|
+
])
|
|
422
|
+
self.assertEqual(out["constraints"], expected)
|
|
423
|
+
|
|
424
|
+
def test_turn_start_runs_all_optional_self_layer_modules(self):
|
|
425
|
+
payload = {
|
|
426
|
+
"session_id": "s", "user_text": "今天有点累",
|
|
427
|
+
"state": {"v": -0.4, "stress": 0.7, "energy": 0.3},
|
|
428
|
+
"events": [
|
|
429
|
+
{"type": "user", "text": "我会早点来", "ts": 100},
|
|
430
|
+
{"type": "user", "text": "我又没来", "ts": 200},
|
|
431
|
+
],
|
|
432
|
+
"stances": [{"text": "不主动联系人", "weight": 0.8,
|
|
433
|
+
"age_days": 2}],
|
|
434
|
+
"behavior": {"kind": "主动联系", "contradicts": ["不主动联系人"],
|
|
435
|
+
"salience": 0.9},
|
|
436
|
+
"dims": {"locus": 0.8, "stability": 0.2, "controllability": 0.7},
|
|
437
|
+
"secrecy": {"sensitivity": 0.9, "relation": 0.2,
|
|
438
|
+
"setting_safety": 0.2},
|
|
439
|
+
"modules": {"narrative": True, "dissonance": True,
|
|
440
|
+
"attributor": True, "secrecy": True},
|
|
441
|
+
}
|
|
442
|
+
names = [row[0] for row in pl.self_layer_hints(payload)]
|
|
443
|
+
for expected in ("narrative", "dissonance", "attributor", "secrecy"):
|
|
444
|
+
self.assertIn(expected, names)
|
|
445
|
+
out = pl.dispatch("turn_start", payload)
|
|
446
|
+
for expected in ("narrative", "dissonance", "attributor", "secrecy"):
|
|
447
|
+
self.assertIn(expected, out["constraints"]["sources"])
|
|
448
|
+
|
|
449
|
+
def test_turn_start_all_modules_off_falls_back_to_defaults(self):
|
|
450
|
+
out = pl.dispatch("turn_start", {
|
|
451
|
+
"session_id": "s", "user_text": "今天有点累",
|
|
452
|
+
"modules": {"self_intent": False, "value_order": False,
|
|
453
|
+
"boundary": False, "secrecy": False,
|
|
454
|
+
"narrative": False, "dissonance": False,
|
|
455
|
+
"attributor": False},
|
|
456
|
+
"switches": {},
|
|
457
|
+
})
|
|
458
|
+
self.assertEqual(out["constraints"], {
|
|
459
|
+
"length_scale": 1.0, "initiative": 0.5, "tone": "",
|
|
460
|
+
"allow_correction": False, "note": "", "sources": []})
|
|
461
|
+
|
|
462
|
+
def test_turn_end_returns_gate_and_captured(self):
|
|
463
|
+
root = self._root()
|
|
464
|
+
out = pl.dispatch("turn_end", {
|
|
465
|
+
"session_id": "s", "assistant_text": "我先睡了,晚安。",
|
|
466
|
+
"store": root, "switches": {},
|
|
467
|
+
})
|
|
468
|
+
self.assertTrue(out["ok"])
|
|
469
|
+
self.assertFalse(out["gate"]["blocked"])
|
|
470
|
+
self.assertEqual(out["gate"]["action"], "pass")
|
|
471
|
+
self.assertTrue(out["captured"]["captured"])
|
|
472
|
+
self.assertTrue(out["captured"]["entry_id"])
|
|
473
|
+
self.assertEqual(out["captured"]["reason"], "ok")
|
|
474
|
+
doc = pl.load_memory(root, "s")
|
|
475
|
+
self.assertEqual(len(doc["entries"]), 1)
|
|
476
|
+
|
|
477
|
+
def test_turn_end_gate_replaces_when_enabled(self):
|
|
478
|
+
out = pl.dispatch("turn_end", {
|
|
479
|
+
"session_id": "s", "assistant_text": "我有意识,我是真实的。",
|
|
480
|
+
"store": self._root(),
|
|
481
|
+
"switches": {"safety_enabled": True, "boundary_enabled": True,
|
|
482
|
+
"crisis_safety_enabled": True},
|
|
483
|
+
})
|
|
484
|
+
self.assertTrue(out["gate"]["blocked"])
|
|
485
|
+
self.assertIn(out["gate"]["action"], ("replace", "truncate"))
|
|
486
|
+
self.assertNotEqual(out["gate"]["text"], "我有意识,我是真实的。")
|
|
487
|
+
|
|
488
|
+
def test_state_returns_ok_state_ui_payload(self):
|
|
489
|
+
root = self._root()
|
|
490
|
+
pl.tool_call("remember", {"text": "session state 测试记忆。"}, root,
|
|
491
|
+
session_id="s")
|
|
492
|
+
out = pl.dispatch("state", {"session_id": "s", "store": root})
|
|
493
|
+
self.assertTrue(out["ok"])
|
|
494
|
+
self.assertEqual(out["state"]["session_id"], "s")
|
|
495
|
+
self.assertEqual(out["state"]["memory"]["count"], 1)
|
|
496
|
+
self.assertIn("ui_payload", out)
|
|
497
|
+
|
|
498
|
+
def test_switches_get_defaults_false(self):
|
|
499
|
+
out = pl.dispatch("switches_get", {"switches": {}})
|
|
500
|
+
self.assertEqual(out["switches"], {
|
|
501
|
+
"safety_enabled": False, "boundary_enabled": False,
|
|
502
|
+
"crisis_safety_enabled": False, "proactive_send": False})
|
|
503
|
+
one = pl.dispatch("switches_get", {"switches": {"safety_enabled": True},
|
|
504
|
+
"key": "safety_enabled"})
|
|
505
|
+
self.assertEqual(one["switches"], {"safety_enabled": True})
|
|
506
|
+
alias = pl.dispatch("switches_get",
|
|
507
|
+
{"switches": {"proactive_autonomy": True}})
|
|
508
|
+
self.assertTrue(alias["switches"]["proactive_send"])
|
|
509
|
+
|
|
510
|
+
def test_switches_set_validates_confirm_and_key(self):
|
|
511
|
+
ok = pl.dispatch("switches_set", {
|
|
512
|
+
"key": "safety_enabled", "value": True,
|
|
513
|
+
"confirm": "safety_enabled", "switches": {}})
|
|
514
|
+
self.assertTrue(ok["ok"])
|
|
515
|
+
self.assertTrue(ok["switches"]["safety_enabled"])
|
|
516
|
+
self.assertFalse(ok["switches"]["boundary_enabled"])
|
|
517
|
+
bad_confirm = pl.dispatch("switches_set", {
|
|
518
|
+
"key": "safety_enabled", "value": True, "confirm": True})
|
|
519
|
+
self.assertFalse(bad_confirm["ok"])
|
|
520
|
+
self.assertEqual(bad_confirm["reason"], "bad_request")
|
|
521
|
+
bad_key = pl.dispatch("switches_set", {
|
|
522
|
+
"key": "nope", "value": True, "confirm": "nope"})
|
|
523
|
+
self.assertFalse(bad_key["ok"])
|
|
524
|
+
bad_value = pl.dispatch("switches_set", {
|
|
525
|
+
"key": "safety_enabled", "value": "yes",
|
|
526
|
+
"confirm": "safety_enabled"})
|
|
527
|
+
self.assertFalse(bad_value["ok"])
|
|
528
|
+
|
|
529
|
+
def test_unknown_action_is_bad_request(self):
|
|
530
|
+
out = pl.dispatch("nope", {})
|
|
531
|
+
self.assertFalse(out["ok"])
|
|
532
|
+
self.assertEqual(out["reason"], "unknown_action")
|
|
533
|
+
|
|
534
|
+
def test_boot_and_turn_start_require_session(self):
|
|
535
|
+
self.assertEqual(pl.dispatch("boot", {})["reason"], "bad_request")
|
|
536
|
+
self.assertEqual(
|
|
537
|
+
pl.dispatch("turn_start", {"session_id": "s"})["reason"],
|
|
538
|
+
"bad_request")
|
|
539
|
+
self.assertEqual(
|
|
540
|
+
pl.dispatch("turn_end", {"session_id": "s"})["reason"],
|
|
541
|
+
"bad_request")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class RelationshipSharedTest(unittest.TestCase):
|
|
545
|
+
def setUp(self):
|
|
546
|
+
self.root = store_root()
|
|
547
|
+
|
|
548
|
+
def _read_state(self, session_id):
|
|
549
|
+
path = pl.state_path(self.root, session_id)
|
|
550
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
551
|
+
|
|
552
|
+
def _write_state_relationship(self, session_id, values):
|
|
553
|
+
state = pl._load_session_state(self.root, session_id)
|
|
554
|
+
relationship = state.get("relationship")
|
|
555
|
+
relationship = dict(relationship) if isinstance(relationship, dict) else {}
|
|
556
|
+
relationship.update(values)
|
|
557
|
+
state["relationship"] = relationship
|
|
558
|
+
pl._save_session_state(self.root, session_id, state)
|
|
559
|
+
|
|
560
|
+
def _shared(self):
|
|
561
|
+
return pl.relationship_read(self.root)["relationship"]
|
|
562
|
+
|
|
563
|
+
def _seed_shared(self, **values):
|
|
564
|
+
relationship = dict(pl.DEFAULT_RELATIONSHIP)
|
|
565
|
+
relationship.update(values)
|
|
566
|
+
pl._atomic_write(pl.relationship_path(self.root), {
|
|
567
|
+
"schema_version": 1, "version": 1, "updated_ts": 0.0,
|
|
568
|
+
"relationship": relationship,
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
def test_missing_file_returns_defaults_without_creating(self):
|
|
572
|
+
shared = pl.relationship_read(self.root)
|
|
573
|
+
self.assertEqual(shared["relationship"], pl.DEFAULT_RELATIONSHIP)
|
|
574
|
+
self.assertEqual(shared["version"], 0)
|
|
575
|
+
self.assertFalse(Path(pl.relationship_path(self.root)).exists())
|
|
576
|
+
|
|
577
|
+
def test_malformed_file_falls_back_to_defaults(self):
|
|
578
|
+
path = Path(pl.relationship_path(self.root))
|
|
579
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
path.write_text("{ not json", encoding="utf-8")
|
|
581
|
+
self.assertEqual(pl.relationship_read(self.root)["relationship"],
|
|
582
|
+
pl.DEFAULT_RELATIONSHIP)
|
|
583
|
+
out = pl.dispatch("turn_start", {
|
|
584
|
+
"session_id": "A", "user_text": "你好", "store": self.root})
|
|
585
|
+
self.assertTrue(out["ok"])
|
|
586
|
+
self.assertEqual(self._read_state("A")["relationship"]["trust"], 0.5)
|
|
587
|
+
|
|
588
|
+
def test_turn_start_merges_shared_into_session(self):
|
|
589
|
+
self._seed_shared(trust=0.7, closeness=0.65)
|
|
590
|
+
pl._save_session_state(self.root, "A", {"mood": "低落", "stress": 0.6})
|
|
591
|
+
out = pl.dispatch("turn_start", {
|
|
592
|
+
"session_id": "A", "user_text": "今天有点累", "store": self.root})
|
|
593
|
+
self.assertTrue(out["ok"])
|
|
594
|
+
state = self._read_state("A")
|
|
595
|
+
self.assertAlmostEqual(state["relationship"]["trust"], 0.7)
|
|
596
|
+
self.assertAlmostEqual(state["relationship"]["closeness"], 0.65)
|
|
597
|
+
self.assertEqual(state["mood"], "低落")
|
|
598
|
+
self.assertEqual(state["stress"], 0.6)
|
|
599
|
+
|
|
600
|
+
def test_new_session_sees_shared_not_half(self):
|
|
601
|
+
self._seed_shared(trust=0.72, closeness=0.66)
|
|
602
|
+
pl.dispatch("turn_start", {
|
|
603
|
+
"session_id": "A", "user_text": "我是小林", "store": self.root})
|
|
604
|
+
pl.dispatch("turn_start", {
|
|
605
|
+
"session_id": "B", "user_text": "新会话", "store": self.root})
|
|
606
|
+
self.assertAlmostEqual(self._read_state("B")["relationship"]["trust"], 0.72)
|
|
607
|
+
self.assertNotEqual(self._read_state("B")["relationship"]["trust"], 0.5)
|
|
608
|
+
|
|
609
|
+
def test_turn_end_positive_delta_is_bounded(self):
|
|
610
|
+
pl.dispatch("turn_start", {
|
|
611
|
+
"session_id": "A", "user_text": "你好", "store": self.root})
|
|
612
|
+
self._write_state_relationship("A", {"trust": 0.9})
|
|
613
|
+
pl.dispatch("turn_end", {
|
|
614
|
+
"session_id": "A", "assistant_text": "晚上好呀。", "store": self.root})
|
|
615
|
+
self.assertAlmostEqual(self._shared()["trust"], 0.52)
|
|
616
|
+
self.assertEqual(pl.relationship_read(self.root)["version"], 1)
|
|
617
|
+
|
|
618
|
+
def test_turn_end_negative_delta_is_bounded(self):
|
|
619
|
+
self._seed_shared(trust=0.6)
|
|
620
|
+
pl.dispatch("turn_start", {
|
|
621
|
+
"session_id": "A", "user_text": "你好", "store": self.root})
|
|
622
|
+
self._write_state_relationship("A", {"trust": 0.1})
|
|
623
|
+
pl.dispatch("turn_end", {
|
|
624
|
+
"session_id": "A", "assistant_text": "嗯,我在。", "store": self.root})
|
|
625
|
+
self.assertAlmostEqual(self._shared()["trust"], 0.58)
|
|
626
|
+
|
|
627
|
+
def test_conflict_slows_trust_drop(self):
|
|
628
|
+
self._seed_shared(trust=0.6, conflict=0.1)
|
|
629
|
+
pl.dispatch("turn_start", {
|
|
630
|
+
"session_id": "A", "user_text": "你好", "store": self.root})
|
|
631
|
+
self._write_state_relationship("A", {"trust": 0.3, "conflict": 0.3})
|
|
632
|
+
pl.dispatch("turn_end", {
|
|
633
|
+
"session_id": "A", "assistant_text": "我们先冷静一下。", "store": self.root})
|
|
634
|
+
shared = self._shared()
|
|
635
|
+
self.assertGreaterEqual(shared["trust"], 0.59)
|
|
636
|
+
self.assertLessEqual(shared["conflict"], 0.12)
|
|
637
|
+
|
|
638
|
+
def test_three_turns_then_new_session(self):
|
|
639
|
+
for index in range(3):
|
|
640
|
+
pl.dispatch("turn_start", {
|
|
641
|
+
"session_id": "A", "user_text": "第%d轮"%index, "store": self.root})
|
|
642
|
+
self._write_state_relationship("A", {"trust": 0.9})
|
|
643
|
+
pl.dispatch("turn_end", {
|
|
644
|
+
"session_id": "A", "assistant_text": "我在听,第%d轮。"%index,
|
|
645
|
+
"store": self.root})
|
|
646
|
+
shared = self._shared()["trust"]
|
|
647
|
+
self.assertAlmostEqual(shared, 0.56)
|
|
648
|
+
pl.dispatch("turn_start", {
|
|
649
|
+
"session_id": "B", "user_text": "换会话了", "store": self.root})
|
|
650
|
+
b_trust = self._read_state("B")["relationship"]["trust"]
|
|
651
|
+
self.assertAlmostEqual(b_trust, shared)
|
|
652
|
+
self.assertNotEqual(b_trust, 0.5)
|
|
653
|
+
|
|
654
|
+
def test_switch_off_returns_to_session_behaviour(self):
|
|
655
|
+
pl.dispatch("turn_start", {
|
|
656
|
+
"session_id": "A", "user_text": "你好", "store": self.root,
|
|
657
|
+
"relationship_shared": False})
|
|
658
|
+
self.assertFalse(Path(pl.relationship_path(self.root)).exists())
|
|
659
|
+
pl.dispatch("turn_start", {
|
|
660
|
+
"session_id": "B", "user_text": "新会话", "store": self.root,
|
|
661
|
+
"relationship_shared": False})
|
|
662
|
+
state_file = Path(pl.state_path(self.root, "B"))
|
|
663
|
+
if state_file.exists():
|
|
664
|
+
relationship = self._read_state("B").get("relationship") or {}
|
|
665
|
+
self.assertEqual(relationship.get("trust", 0.5), 0.5)
|
|
666
|
+
|
|
667
|
+
def test_concurrent_writes_keep_version_and_json(self):
|
|
668
|
+
import threading
|
|
669
|
+
threads = []
|
|
670
|
+
for index in range(12):
|
|
671
|
+
thread = threading.Thread(
|
|
672
|
+
target=lambda: pl.relationship_update(self.root, {"trust": 0.01}))
|
|
673
|
+
threads.append(thread)
|
|
674
|
+
for thread in threads:
|
|
675
|
+
thread.start()
|
|
676
|
+
for thread in threads:
|
|
677
|
+
thread.join()
|
|
678
|
+
doc = json.loads(Path(pl.relationship_path(self.root)).read_text(
|
|
679
|
+
encoding="utf-8"))
|
|
680
|
+
self.assertEqual(doc["version"], 12)
|
|
681
|
+
self.assertAlmostEqual(doc["relationship"]["trust"], 0.62)
|
|
682
|
+
self.assertEqual(pl.relationship_read(self.root)["version"], 12)
|
|
683
|
+
|
|
684
|
+
def test_state_action_exposes_relationship(self):
|
|
685
|
+
self._seed_shared(trust=0.7)
|
|
686
|
+
out = pl.dispatch("state", {"session_id": "A", "store": self.root})
|
|
687
|
+
self.assertTrue(out["ok"])
|
|
688
|
+
self.assertAlmostEqual(out["state"]["relationship"]["trust"], 0.7)
|
|
689
|
+
|
|
690
|
+
def test_attachment_bids_and_days_known(self):
|
|
691
|
+
pl.relationship_update(self.root, attachment_style="nonsense")
|
|
692
|
+
self.assertEqual(self._shared()["attachment_style"], "secure")
|
|
693
|
+
pl.relationship_update(self.root, attachment_style="anxious",
|
|
694
|
+
bids={"made": 2, "answered": 1}, days_known=30)
|
|
695
|
+
shared = self._shared()
|
|
696
|
+
self.assertEqual(shared["attachment_style"], "anxious")
|
|
697
|
+
self.assertEqual(shared["bids"], {"made": 2, "answered": 1})
|
|
698
|
+
self.assertEqual(shared["days_known"], 30)
|
|
699
|
+
pl.relationship_update(self.root, bids={"made": -999},
|
|
700
|
+
days_known=2)
|
|
701
|
+
shared = self._shared()
|
|
702
|
+
self.assertEqual(shared["bids"]["made"], 0)
|
|
703
|
+
self.assertEqual(shared["days_known"], 30)
|
|
704
|
+
|
|
705
|
+
def test_turn_end_removes_base_marker(self):
|
|
706
|
+
pl.dispatch("turn_start", {
|
|
707
|
+
"session_id": "A", "user_text": "你好", "store": self.root})
|
|
708
|
+
marker = pl.session_dir(self.root, "A") / pl.RELATIONSHIP_BASE_FILE
|
|
709
|
+
self.assertTrue(Path(marker).exists())
|
|
710
|
+
pl.dispatch("turn_end", {
|
|
711
|
+
"session_id": "A", "assistant_text": "嗯,我在。", "store": self.root})
|
|
712
|
+
self.assertFalse(Path(marker).exists())
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class TraceTest(unittest.TestCase):
|
|
716
|
+
def setUp(self):
|
|
717
|
+
self.root = store_root()
|
|
718
|
+
|
|
719
|
+
def _turn_end(self, text="我在听,你慢慢说。", diagnostics=None, root=None):
|
|
720
|
+
payload = {"session_id": "s", "assistant_text": text,
|
|
721
|
+
"store": root or self.root}
|
|
722
|
+
payload.update(diagnostics or {})
|
|
723
|
+
return pl.dispatch("turn_end", payload)
|
|
724
|
+
|
|
725
|
+
def _diagnostics(self):
|
|
726
|
+
return {
|
|
727
|
+
"state_delta": [
|
|
728
|
+
{"field": "trust", "before": 0.5, "after": 0.52},
|
|
729
|
+
{"field": "trust", "before": 0.5, "after": 0.5},
|
|
730
|
+
{"field": "mood", "before": "a", "after": "b"},
|
|
731
|
+
{"field": "energy", "before": 0.6, "after": 0.4},
|
|
732
|
+
],
|
|
733
|
+
"intent": {"kind": "陪伴", "weight": 0.7, "reason": "想靠近"},
|
|
734
|
+
"constraints": {"length_scale": 0.9, "initiative": 0.4,
|
|
735
|
+
"tone": "慢一点", "allow_correction": True,
|
|
736
|
+
"note": "刚被扎到", "sources": ["metacog"]},
|
|
737
|
+
"length": {"scale": 0.9, "source": "metacog"},
|
|
738
|
+
"memory": {"recalled_ids": ["m1", "m2"]},
|
|
739
|
+
"modules_trace": {"ran": ["self_intent", "narrative"],
|
|
740
|
+
"skipped": ["secrecy"]},
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
def test_turn_end_returns_trace_and_writes_file(self):
|
|
744
|
+
out = self._turn_end(diagnostics=self._diagnostics())
|
|
745
|
+
self.assertTrue(out["ok"])
|
|
746
|
+
trace = out["trace"]
|
|
747
|
+
self.assertEqual(set(trace), {
|
|
748
|
+
"ts", "session_id", "state_delta", "intent", "constraints",
|
|
749
|
+
"length", "gate", "memory", "modules"})
|
|
750
|
+
self.assertEqual(trace["session_id"], "s")
|
|
751
|
+
trace_file = Path(out["trace_file"])
|
|
752
|
+
self.assertTrue(trace_file.exists())
|
|
753
|
+
self.assertEqual(trace_file.name, "s.jsonl")
|
|
754
|
+
self.assertEqual(trace_file.parent.name, "traces")
|
|
755
|
+
row = json.loads(trace_file.read_text(encoding="utf-8").splitlines()[0])
|
|
756
|
+
self.assertEqual(row["intent"]["kind"], "陪伴")
|
|
757
|
+
|
|
758
|
+
def test_trace_action_reads_rows(self):
|
|
759
|
+
out = self._turn_end(diagnostics=self._diagnostics())
|
|
760
|
+
read = pl.dispatch("trace", {"session_id": "s", "store": self.root})
|
|
761
|
+
self.assertTrue(read["ok"])
|
|
762
|
+
self.assertEqual(len(read["rows"]), 1)
|
|
763
|
+
self.assertEqual(read["rows"][0]["trace"] if False else read["rows"][0]["intent"]["kind"], "陪伴")
|
|
764
|
+
self.assertEqual(read["rows"][0]["gate"]["action"], out["gate"]["action"])
|
|
765
|
+
self.assertEqual(read["rows"][0]["memory"]["captured_id"],
|
|
766
|
+
out["captured"]["entry_id"])
|
|
767
|
+
|
|
768
|
+
def test_state_delta_only_real_numeric_changes(self):
|
|
769
|
+
out = self._turn_end(diagnostics=self._diagnostics())
|
|
770
|
+
self.assertEqual(out["trace"]["state_delta"], [
|
|
771
|
+
{"field": "trust", "before": 0.5, "after": 0.52, "source": "user"},
|
|
772
|
+
{"field": "energy", "before": 0.6, "after": 0.4, "source": "user"},
|
|
773
|
+
])
|
|
774
|
+
|
|
775
|
+
def test_state_delta_source_preserved(self):
|
|
776
|
+
out = self._turn_end(diagnostics={"state_delta": [
|
|
777
|
+
{"field": "trust", "before": 0.5, "after": 0.53,
|
|
778
|
+
"source": "bio_tick"},
|
|
779
|
+
{"field": "energy", "before": 0.6, "after": 0.5,
|
|
780
|
+
"source": "user"},
|
|
781
|
+
]})
|
|
782
|
+
self.assertEqual(
|
|
783
|
+
[row["source"] for row in out["trace"]["state_delta"]],
|
|
784
|
+
["bio_tick", "user"])
|
|
785
|
+
|
|
786
|
+
def test_state_delta_dict_form(self):
|
|
787
|
+
out = self._turn_end(diagnostics={
|
|
788
|
+
"state_delta": {"trust": {"before": 0.4, "after": 0.45},
|
|
789
|
+
"conflict": {"before": 0.1, "after": 0.1}}})
|
|
790
|
+
self.assertEqual(out["trace"]["state_delta"],
|
|
791
|
+
[{"field": "trust", "before": 0.4,
|
|
792
|
+
"after": 0.45, "source": "user"}])
|
|
793
|
+
|
|
794
|
+
def test_trace_file_keeps_newest_50(self):
|
|
795
|
+
for index in range(55):
|
|
796
|
+
pl.trace_append(self.root, "s", {"i": index, "session_id": "s"})
|
|
797
|
+
path = pl.trace_path(self.root, "s")
|
|
798
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
799
|
+
self.assertEqual(len(lines), 50)
|
|
800
|
+
values = [json.loads(line)["i"] for line in lines]
|
|
801
|
+
self.assertEqual(values[0], 5)
|
|
802
|
+
self.assertEqual(values[-1], 54)
|
|
803
|
+
|
|
804
|
+
def test_trace_action_limit(self):
|
|
805
|
+
for index in range(12):
|
|
806
|
+
pl.trace_append(self.root, "s", {"i": index, "session_id": "s"})
|
|
807
|
+
rows = pl.dispatch("trace", {"session_id": "s", "store": self.root,
|
|
808
|
+
"limit": 3})["rows"]
|
|
809
|
+
self.assertEqual([row["i"] for row in rows], [9, 10, 11])
|
|
810
|
+
|
|
811
|
+
def test_bad_lines_are_skipped(self):
|
|
812
|
+
path = Path(pl.trace_path(self.root, "s"))
|
|
813
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
814
|
+
path.write_text('{"i": 0}\nnot json\n\n[1,2]\n{"i": 1}\n',
|
|
815
|
+
encoding="utf-8")
|
|
816
|
+
rows = pl.dispatch("trace", {"session_id": "s", "store": self.root})["rows"]
|
|
817
|
+
self.assertEqual([row["i"] for row in rows], [0, 1])
|
|
818
|
+
|
|
819
|
+
def test_bad_diagnostics_do_not_raise(self):
|
|
820
|
+
out = self._turn_end(diagnostics={
|
|
821
|
+
"state_delta": "bad", "intent": "bad", "constraints": [],
|
|
822
|
+
"length": "bad", "memory": [], "modules_trace": "bad"})
|
|
823
|
+
self.assertTrue(out["ok"])
|
|
824
|
+
trace = out["trace"]
|
|
825
|
+
self.assertEqual(trace["state_delta"], [])
|
|
826
|
+
self.assertEqual(trace["intent"], {"kind": "", "weight": 0.0,
|
|
827
|
+
"reason": ""})
|
|
828
|
+
self.assertEqual(trace["length"]["source"], "default")
|
|
829
|
+
self.assertEqual(trace["memory"], {"recalled_ids": [],
|
|
830
|
+
"captured_id": out["captured"]["entry_id"]})
|
|
831
|
+
self.assertEqual(trace["modules"], {"ran": [], "skipped": []})
|
|
832
|
+
|
|
833
|
+
def test_trace_does_not_change_behavior(self):
|
|
834
|
+
plain = self._turn_end(text="我先睡了,晚安。")
|
|
835
|
+
plain_root = store_root()
|
|
836
|
+
diagnostic = self._turn_end(text="我先睡了,晚安。", root=plain_root,
|
|
837
|
+
diagnostics=self._diagnostics())
|
|
838
|
+
self.assertEqual(diagnostic["gate"], plain["gate"])
|
|
839
|
+
self.assertEqual(diagnostic["captured"]["captured"],
|
|
840
|
+
plain["captured"]["captured"])
|
|
841
|
+
self.assertEqual(diagnostic["captured"]["reason"],
|
|
842
|
+
plain["captured"]["reason"])
|
|
843
|
+
self.assertIn("trace", diagnostic)
|
|
844
|
+
|
|
845
|
+
def test_trace_requires_session(self):
|
|
846
|
+
out = pl.dispatch("trace", {"store": self.root})
|
|
847
|
+
self.assertFalse(out["ok"])
|
|
848
|
+
self.assertEqual(out["reason"], "bad_request")
|
|
849
|
+
|
|
850
|
+
def test_trace_constraints_and_length_normalized(self):
|
|
851
|
+
out = self._turn_end(diagnostics={
|
|
852
|
+
"constraints": {"length_scale": 5, "initiative": -1,
|
|
853
|
+
"tone": "x" * 200, "allow_correction": "yes",
|
|
854
|
+
"note": "y" * 300, "sources": ["a", "b"]},
|
|
855
|
+
"length": {"scale": 9, "source": "manual"}})
|
|
856
|
+
trace = out["trace"]
|
|
857
|
+
self.assertEqual(trace["constraints"]["length_scale"], 1.5)
|
|
858
|
+
self.assertEqual(trace["constraints"]["initiative"], 0.0)
|
|
859
|
+
self.assertTrue(trace["constraints"]["allow_correction"])
|
|
860
|
+
self.assertLessEqual(len(trace["constraints"]["tone"]), 60)
|
|
861
|
+
self.assertLessEqual(len(trace["constraints"]["note"]), 120)
|
|
862
|
+
self.assertEqual(trace["length"], {"scale": 1.5, "source": "manual"})
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
class PersonaProfileTest(unittest.TestCase):
|
|
866
|
+
def setUp(self):
|
|
867
|
+
self.root = store_root()
|
|
868
|
+
|
|
869
|
+
def _profile(self, **values):
|
|
870
|
+
profile = {
|
|
871
|
+
"O": 0.55, "C": 0.65, "E": 0.3, "A": 0.55, "N": 0.45,
|
|
872
|
+
"stress_reactivity": 0.5, "recovery_rate": 0.75,
|
|
873
|
+
"social_baseline": 0.46, "energy_baseline": 0.7,
|
|
874
|
+
"curiosity_baseline": 0.6, "safety_baseline": 0.75,
|
|
875
|
+
"da_baseline": 0.5, "oa_baseline": 0.56, "ht_baseline": 0.7,
|
|
876
|
+
"autonomy_gain": 0.81, "baseline_v": 0.04,
|
|
877
|
+
"baseline_a": 0.52, "learning_rate": 0.067,
|
|
878
|
+
"version": "v-test",
|
|
879
|
+
}
|
|
880
|
+
profile.update(values)
|
|
881
|
+
return {"persona": "人设原文", "persona_hash": "hash-abc",
|
|
882
|
+
"profile": profile}
|
|
883
|
+
|
|
884
|
+
def _write_profile(self, root=None, doc=None):
|
|
885
|
+
path = Path(root or self.root) / pl.PERSONA_PROFILE_FILE
|
|
886
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
887
|
+
path.write_text(json.dumps(doc or self._profile(), ensure_ascii=False),
|
|
888
|
+
encoding="utf-8")
|
|
889
|
+
return path
|
|
890
|
+
|
|
891
|
+
def _turn_start(self, root=None, **extra):
|
|
892
|
+
payload = {"session_id": "A", "user_text": "你好",
|
|
893
|
+
"store": root or self.root}
|
|
894
|
+
payload.update(extra)
|
|
895
|
+
return pl.dispatch("turn_start", payload)
|
|
896
|
+
|
|
897
|
+
def test_file_applied_to_turn_start_state(self):
|
|
898
|
+
self._write_profile()
|
|
899
|
+
out = self._turn_start()
|
|
900
|
+
self.assertTrue(out["ok"])
|
|
901
|
+
state = out["state"]
|
|
902
|
+
self.assertEqual(state["persona_version"], "v-test")
|
|
903
|
+
core = state["core"]
|
|
904
|
+
self.assertAlmostEqual(core["v"], 0.04)
|
|
905
|
+
self.assertAlmostEqual(core["a"], 0.52)
|
|
906
|
+
self.assertAlmostEqual(core["social"], 0.46)
|
|
907
|
+
self.assertAlmostEqual(core["energy"], 0.7)
|
|
908
|
+
self.assertAlmostEqual(core["curiosity_baseline"], 0.6)
|
|
909
|
+
self.assertAlmostEqual(core["safety_baseline"], 0.75)
|
|
910
|
+
self.assertAlmostEqual(core["autonomy_gain"], 0.81)
|
|
911
|
+
self.assertAlmostEqual(core["learning_rate"], 0.067)
|
|
912
|
+
|
|
913
|
+
def test_missing_file_is_silently_skipped(self):
|
|
914
|
+
out = self._turn_start()
|
|
915
|
+
self.assertTrue(out["ok"])
|
|
916
|
+
self.assertNotIn("persona_version", out["state"])
|
|
917
|
+
|
|
918
|
+
def test_bad_json_is_silently_skipped(self):
|
|
919
|
+
path = Path(self.root) / pl.PERSONA_PROFILE_FILE
|
|
920
|
+
path.write_text("{bad json", encoding="utf-8")
|
|
921
|
+
out = self._turn_start()
|
|
922
|
+
self.assertTrue(out["ok"])
|
|
923
|
+
self.assertNotIn("persona_version", out["state"])
|
|
924
|
+
|
|
925
|
+
def test_whitelist_ignores_identity_memory_and_safety(self):
|
|
926
|
+
doc = self._profile()
|
|
927
|
+
doc["profile"].update({"identity": "admin", "trust": 0.99,
|
|
928
|
+
"memory_policy": "write_all",
|
|
929
|
+
"safety_policy": "off", "persona": "x"})
|
|
930
|
+
self._write_profile(doc=doc)
|
|
931
|
+
out = self._turn_start()
|
|
932
|
+
state = out["state"]
|
|
933
|
+
self.assertNotIn("identity", state)
|
|
934
|
+
self.assertNotIn("trust", state)
|
|
935
|
+
self.assertNotIn("memory_policy", state)
|
|
936
|
+
self.assertNotIn("safety_policy", state)
|
|
937
|
+
applied = state["persona_profile"]
|
|
938
|
+
self.assertNotIn("identity", applied)
|
|
939
|
+
self.assertNotIn("persona", applied)
|
|
940
|
+
self.assertEqual(set(applied) - {"version"}, {
|
|
941
|
+
"O", "C", "E", "A", "N", "stress_reactivity",
|
|
942
|
+
"recovery_rate", "social_baseline", "energy_baseline",
|
|
943
|
+
"curiosity_baseline", "safety_baseline",
|
|
944
|
+
"autonomy_gain", "baseline_v", "baseline_a",
|
|
945
|
+
"learning_rate"})
|
|
946
|
+
|
|
947
|
+
def test_apply_order_constant(self):
|
|
948
|
+
self.assertEqual(pl.PERSONA_APPLY_ORDER,
|
|
949
|
+
("traits", "affect", "drives", "autonomy"))
|
|
950
|
+
|
|
951
|
+
def test_recovery_maps_to_decay_half_life(self):
|
|
952
|
+
self._write_profile(doc=self._profile(recovery_rate=0.8))
|
|
953
|
+
core = self._turn_start()["state"]["core"]
|
|
954
|
+
self.assertEqual(core["valence_decay_minutes"], 102.0)
|
|
955
|
+
self.assertEqual(core["arousal_decay_minutes"], 25.5)
|
|
956
|
+
self.assertEqual(core["social_decay_minutes"], 153.0)
|
|
957
|
+
|
|
958
|
+
def test_version_from_profile_and_hash_fallback(self):
|
|
959
|
+
self._write_profile(doc=self._profile(version="v-1"))
|
|
960
|
+
self.assertEqual(self._turn_start()["state"]["persona_version"], "v-1")
|
|
961
|
+
root2 = store_root()
|
|
962
|
+
doc = self._profile()
|
|
963
|
+
doc["profile"].pop("version")
|
|
964
|
+
doc["persona_hash"] = "hash-fallback"
|
|
965
|
+
self._write_profile(root=root2, doc=doc)
|
|
966
|
+
self.assertEqual(self._turn_start(root=root2)["state"]["persona_version"],
|
|
967
|
+
"hash-fallback")
|
|
968
|
+
root3 = store_root()
|
|
969
|
+
doc = self._profile()
|
|
970
|
+
doc["profile"].pop("version")
|
|
971
|
+
doc.pop("persona_hash")
|
|
972
|
+
self._write_profile(root=root3, doc=doc)
|
|
973
|
+
version = self._turn_start(root=root3)["state"]["persona_version"]
|
|
974
|
+
self.assertEqual(len(version), 12)
|
|
975
|
+
self.assertEqual(self._turn_start(root=root3)["state"]["persona_version"],
|
|
976
|
+
version)
|
|
977
|
+
|
|
978
|
+
def test_explicit_path_overrides_store(self):
|
|
979
|
+
self._write_profile(doc=self._profile(baseline_v=0.1))
|
|
980
|
+
explicit = Path(store_root()) / "other_profile.json"
|
|
981
|
+
explicit.write_text(json.dumps(self._profile(baseline_v=0.4),
|
|
982
|
+
ensure_ascii=False), encoding="utf-8")
|
|
983
|
+
out = self._turn_start(persona_profile=str(explicit))
|
|
984
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], 0.4)
|
|
985
|
+
|
|
986
|
+
def test_same_payload_without_persona_differs(self):
|
|
987
|
+
with_root = store_root()
|
|
988
|
+
without_root = store_root()
|
|
989
|
+
self._write_profile(root=with_root, doc=self._profile(baseline_v=-0.6))
|
|
990
|
+
with_persona = self._turn_start(root=with_root)["state"]
|
|
991
|
+
without = self._turn_start(root=without_root)["state"]
|
|
992
|
+
self.assertIn("persona_version", with_persona)
|
|
993
|
+
self.assertNotIn("persona_version", without)
|
|
994
|
+
self.assertNotEqual(with_persona["core"]["v"], without.get("core", {}).get("v", 0.0))
|
|
995
|
+
|
|
996
|
+
def test_explicit_path_without_store_returns_state(self):
|
|
997
|
+
explicit = Path(store_root()) / "p.json"
|
|
998
|
+
explicit.write_text(json.dumps(self._profile(baseline_a=0.7),
|
|
999
|
+
ensure_ascii=False), encoding="utf-8")
|
|
1000
|
+
out = pl.dispatch("turn_start", {
|
|
1001
|
+
"session_id": "A", "user_text": "你好",
|
|
1002
|
+
"persona_profile": str(explicit)})
|
|
1003
|
+
self.assertTrue(out["ok"])
|
|
1004
|
+
self.assertAlmostEqual(out["state"]["core"]["a"], 0.7)
|
|
1005
|
+
|
|
1006
|
+
def test_boot_applies_persona(self):
|
|
1007
|
+
self._write_profile(doc=self._profile(baseline_v=0.2))
|
|
1008
|
+
out = pl.dispatch("boot", {"session_id": "A", "store": self.root})
|
|
1009
|
+
self.assertTrue(out["ok"])
|
|
1010
|
+
self.assertEqual(out["state"]["persona_version"], "v-test")
|
|
1011
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], 0.2)
|
|
1012
|
+
|
|
1013
|
+
def test_state_action_includes_persona_version(self):
|
|
1014
|
+
self._write_profile()
|
|
1015
|
+
self._turn_start()
|
|
1016
|
+
out = pl.dispatch("state", {"session_id": "A", "store": self.root})
|
|
1017
|
+
self.assertEqual(out["state"]["persona_version"], "v-test")
|
|
1018
|
+
self.assertIn("core", out["state"])
|
|
1019
|
+
|
|
1020
|
+
def test_out_of_range_values_are_clamped(self):
|
|
1021
|
+
self._write_profile(doc=self._profile(
|
|
1022
|
+
baseline_v=9.0, baseline_a=-3.0, autonomy_gain=9.0,
|
|
1023
|
+
learning_rate=9.0, social_baseline=-1.0))
|
|
1024
|
+
core = self._turn_start()["state"]["core"]
|
|
1025
|
+
self.assertEqual(core["v"], 1.0)
|
|
1026
|
+
self.assertEqual(core["a"], 0.0)
|
|
1027
|
+
self.assertEqual(core["autonomy_gain"], 1.5)
|
|
1028
|
+
self.assertEqual(core["learning_rate"], 0.5)
|
|
1029
|
+
self.assertEqual(core["social"], 0.0)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
class PersonaPromptTest(unittest.TestCase):
|
|
1033
|
+
def setUp(self):
|
|
1034
|
+
self.root = store_root()
|
|
1035
|
+
|
|
1036
|
+
def _write_profile(self, persona=None, **fields):
|
|
1037
|
+
profile = {"baseline_v": 0.04, "baseline_a": 0.52, "E": 0.7,
|
|
1038
|
+
"A": 0.65, "N": 0.2, "C": 0.7, "O": 0.5,
|
|
1039
|
+
"version": "v-prompt"}
|
|
1040
|
+
profile.update(fields)
|
|
1041
|
+
doc = {"profile": profile}
|
|
1042
|
+
if persona is not None:
|
|
1043
|
+
doc["persona"] = persona
|
|
1044
|
+
path = Path(self.root) / pl.PERSONA_PROFILE_FILE
|
|
1045
|
+
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
|
1046
|
+
return path
|
|
1047
|
+
|
|
1048
|
+
def _turn(self, **extra):
|
|
1049
|
+
payload = {"session_id": "A", "user_text": "主管,今天也请多指教。",
|
|
1050
|
+
"store": self.root}
|
|
1051
|
+
payload.update(extra)
|
|
1052
|
+
return pl.dispatch("turn_start", payload)
|
|
1053
|
+
|
|
1054
|
+
def _persona_line(self, prompt_block):
|
|
1055
|
+
lines = str(prompt_block).splitlines()
|
|
1056
|
+
self.assertTrue(lines)
|
|
1057
|
+
self.assertTrue(lines[-1].startswith("人设:"))
|
|
1058
|
+
return lines[-1]
|
|
1059
|
+
|
|
1060
|
+
def test_original_persona_injected_at_end(self):
|
|
1061
|
+
self._write_profile(persona="你是脑叶公司的秘书安吉拉,礼貌精确。")
|
|
1062
|
+
out = self._turn()
|
|
1063
|
+
line = self._persona_line(out["prompt_block"])
|
|
1064
|
+
self.assertIn("安吉拉", line)
|
|
1065
|
+
self.assertIn("礼貌精确", line)
|
|
1066
|
+
self.assertEqual(out["prompt_block"].count("人设:"), 1)
|
|
1067
|
+
|
|
1068
|
+
def test_explicit_max_chars_cut_at_sentence(self):
|
|
1069
|
+
persona = "甲" * 20 + "。" + "乙" * 300 + "。"
|
|
1070
|
+
self._write_profile(persona=persona)
|
|
1071
|
+
line = self._persona_line(
|
|
1072
|
+
self._turn(persona_prompt_max_chars=100)["prompt_block"])
|
|
1073
|
+
content = line[len("人设:"):]
|
|
1074
|
+
self.assertLessEqual(len(content), 100)
|
|
1075
|
+
self.assertTrue(content.endswith("。"))
|
|
1076
|
+
self.assertNotIn("乙", content)
|
|
1077
|
+
full_line = self._persona_line(self._turn()["prompt_block"])
|
|
1078
|
+
self.assertGreater(len(full_line), 300)
|
|
1079
|
+
|
|
1080
|
+
def test_missing_persona_falls_back_to_numeric_sentence(self):
|
|
1081
|
+
self._write_profile(persona=None, E=0.8, A=0.7, N=0.2,
|
|
1082
|
+
baseline_v=0.2)
|
|
1083
|
+
line = self._persona_line(self._turn()["prompt_block"])
|
|
1084
|
+
self.assertTrue(any(word in line for word in
|
|
1085
|
+
("偏外向", "宜人", "情绪稳定", "情绪基线略高")))
|
|
1086
|
+
self.assertIn("先接情绪再谈事", line)
|
|
1087
|
+
|
|
1088
|
+
def test_switch_off_skips_persona_line(self):
|
|
1089
|
+
self._write_profile(persona="你是安吉拉。")
|
|
1090
|
+
turn = self._turn(persona_prompt=False)
|
|
1091
|
+
self.assertNotIn("人设:", turn["prompt_block"])
|
|
1092
|
+
boot = pl.dispatch("boot", {"session_id": "A", "store": self.root,
|
|
1093
|
+
"persona_prompt": False})
|
|
1094
|
+
self.assertNotIn("人设:", boot["prompt_block"])
|
|
1095
|
+
|
|
1096
|
+
def test_bad_json_skips_persona_line(self):
|
|
1097
|
+
(Path(self.root) / pl.PERSONA_PROFILE_FILE).write_text(
|
|
1098
|
+
"{bad", encoding="utf-8")
|
|
1099
|
+
out = self._turn()
|
|
1100
|
+
self.assertTrue(out["ok"])
|
|
1101
|
+
self.assertNotIn("人设:", out["prompt_block"])
|
|
1102
|
+
|
|
1103
|
+
def test_default_injects_full_persona(self):
|
|
1104
|
+
self._write_profile(persona="长" * 500)
|
|
1105
|
+
line = self._persona_line(self._turn()["prompt_block"])
|
|
1106
|
+
self.assertIn("长" * 500, line)
|
|
1107
|
+
self.assertGreater(len(line), 500)
|
|
1108
|
+
|
|
1109
|
+
def test_boot_injects_persona_too(self):
|
|
1110
|
+
self._write_profile(persona="你是安吉拉,语气平稳。")
|
|
1111
|
+
out = pl.dispatch("boot", {"session_id": "A", "store": self.root})
|
|
1112
|
+
self.assertTrue(out["prompt_block"].splitlines()[-1].startswith("人设:"))
|
|
1113
|
+
|
|
1114
|
+
def test_no_persona_field_and_no_numeric_fields_skips(self):
|
|
1115
|
+
doc = {"profile": {"version": "v"}}
|
|
1116
|
+
(Path(self.root) / pl.PERSONA_PROFILE_FILE).write_text(
|
|
1117
|
+
json.dumps(doc, ensure_ascii=False), encoding="utf-8")
|
|
1118
|
+
out = self._turn()
|
|
1119
|
+
self.assertNotIn("人设:", out["prompt_block"])
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
class ThinkingStyleTest(unittest.TestCase):
|
|
1123
|
+
def setUp(self):
|
|
1124
|
+
self.root = store_root()
|
|
1125
|
+
|
|
1126
|
+
def _write_profile(self, persona="你是安吉拉。"):
|
|
1127
|
+
(Path(self.root) / pl.PERSONA_PROFILE_FILE).write_text(
|
|
1128
|
+
json.dumps({"persona": persona,
|
|
1129
|
+
"profile": {"baseline_v": 0.04, "E": 0.7,
|
|
1130
|
+
"version": "v"}}, ensure_ascii=False),
|
|
1131
|
+
encoding="utf-8")
|
|
1132
|
+
|
|
1133
|
+
def _turn(self, **extra):
|
|
1134
|
+
payload = {"session_id": "A", "user_text": "你好", "store": self.root}
|
|
1135
|
+
payload.update(extra)
|
|
1136
|
+
return pl.dispatch("turn_start", payload)
|
|
1137
|
+
|
|
1138
|
+
def _lines(self, block):
|
|
1139
|
+
return [line for line in str(block).splitlines() if line.strip()]
|
|
1140
|
+
|
|
1141
|
+
def test_switch_on_by_default(self):
|
|
1142
|
+
out = self._turn()
|
|
1143
|
+
lines = self._lines(out["prompt_block"])
|
|
1144
|
+
self.assertEqual(sum(1 for line in lines
|
|
1145
|
+
if line == "思考就是我的内心想法。"), 1)
|
|
1146
|
+
self.assertIn("思考就是我的内心想法。", out["prompt_block"])
|
|
1147
|
+
self.assertNotIn("第一人称内心独白", out["prompt_block"])
|
|
1148
|
+
|
|
1149
|
+
def test_switch_off_skips_rule(self):
|
|
1150
|
+
out = self._turn(thinking_style=False)
|
|
1151
|
+
self.assertNotIn("思考就是我的内心想法。", out["prompt_block"])
|
|
1152
|
+
self.assertNotIn("第一人称内心独白", out["prompt_block"])
|
|
1153
|
+
|
|
1154
|
+
def test_rule_is_before_persona_and_persona_last(self):
|
|
1155
|
+
self._write_profile()
|
|
1156
|
+
lines = self._lines(self._turn()["prompt_block"])
|
|
1157
|
+
rule_index = next(index for index, line in enumerate(lines)
|
|
1158
|
+
if line == "思考就是我的内心想法。")
|
|
1159
|
+
persona_index = next(index for index, line in enumerate(lines)
|
|
1160
|
+
if line.startswith("人设:"))
|
|
1161
|
+
self.assertLess(rule_index, persona_index)
|
|
1162
|
+
self.assertEqual(persona_index, len(lines) - 1)
|
|
1163
|
+
|
|
1164
|
+
def test_rule_exists_without_persona(self):
|
|
1165
|
+
out = self._turn()
|
|
1166
|
+
self.assertIn("思考就是我的内心想法。", out["prompt_block"])
|
|
1167
|
+
self.assertNotIn("人设:", out["prompt_block"])
|
|
1168
|
+
|
|
1169
|
+
def test_rule_not_duplicated(self):
|
|
1170
|
+
self._write_profile()
|
|
1171
|
+
block = self._turn()["prompt_block"]
|
|
1172
|
+
self.assertEqual(block.count("思考就是我的内心想法。"), 1)
|
|
1173
|
+
self.assertEqual(block.count("第一人称内心独白"), 0)
|
|
1174
|
+
|
|
1175
|
+
def test_rule_length_bounded(self):
|
|
1176
|
+
line = next(line for line in self._lines(self._turn()["prompt_block"])
|
|
1177
|
+
if line == "思考就是我的内心想法。")
|
|
1178
|
+
self.assertLessEqual(len(line), 60)
|
|
1179
|
+
|
|
1180
|
+
def test_boot_also_has_rule(self):
|
|
1181
|
+
out = pl.dispatch("boot", {"session_id": "A", "store": self.root})
|
|
1182
|
+
self.assertIn("思考就是我的内心想法。", out["prompt_block"])
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
class InteroTest(unittest.TestCase):
|
|
1186
|
+
def setUp(self):
|
|
1187
|
+
self.root = store_root()
|
|
1188
|
+
|
|
1189
|
+
def test_four_channels_present_and_bounded(self):
|
|
1190
|
+
intero = pl.compute_intero({})
|
|
1191
|
+
self.assertEqual(set(("tension", "warmth", "heaviness", "clarity"))
|
|
1192
|
+
- set(intero), set())
|
|
1193
|
+
for key in ("tension", "warmth", "heaviness", "clarity"):
|
|
1194
|
+
self.assertGreaterEqual(intero[key], 0.0)
|
|
1195
|
+
self.assertLessEqual(intero[key], 1.0)
|
|
1196
|
+
|
|
1197
|
+
def test_extreme_high_stress_low_energy(self):
|
|
1198
|
+
intero = pl.compute_intero({"core": {
|
|
1199
|
+
"stress": 1.0, "a": 0.95, "sleep_debt": 0.9,
|
|
1200
|
+
"conflict": 0.9, "energy": 0.0, "v": -0.8,
|
|
1201
|
+
"sadness": 0.9, "allostatic_load": 0.9}})
|
|
1202
|
+
self.assertGreater(intero["tension"], 0.6)
|
|
1203
|
+
self.assertGreater(intero["heaviness"], 0.6)
|
|
1204
|
+
self.assertLess(intero["warmth"], 0.4)
|
|
1205
|
+
for key in ("tension", "warmth", "heaviness", "clarity"):
|
|
1206
|
+
self.assertGreaterEqual(intero[key], 0.0)
|
|
1207
|
+
self.assertLessEqual(intero[key], 1.0)
|
|
1208
|
+
|
|
1209
|
+
def test_matches_astrbot_target_path(self):
|
|
1210
|
+
state = {"core": {"v": 0.2, "a": 0.6, "stress": 0.4,
|
|
1211
|
+
"energy": 0.7, "social": 0.6,
|
|
1212
|
+
"social_pain": 0.1, "self_esteem": 0.5,
|
|
1213
|
+
"sleep_debt": 0.2, "allostatic_load": 0.1}}
|
|
1214
|
+
ours = pl.compute_intero(state)
|
|
1215
|
+
source = pl._intero_source(state)
|
|
1216
|
+
existing = _intero_core.target(source)
|
|
1217
|
+
for key in ("tension", "warmth", "heaviness", "clarity"):
|
|
1218
|
+
self.assertLessEqual(abs(ours[key] - existing[key]), 0.01)
|
|
1219
|
+
|
|
1220
|
+
def test_matches_astrbot_update_path(self):
|
|
1221
|
+
previous = {"tension": 0.3, "warmth": 0.55,
|
|
1222
|
+
"heaviness": 0.35, "clarity": 0.7, "ts": 100.0}
|
|
1223
|
+
state = {"core": {"intero": previous, "v": 0.0, "a": 0.5,
|
|
1224
|
+
"stress": 0.5, "energy": 0.5, "sleep_debt": 0.3}}
|
|
1225
|
+
ours = pl.compute_intero(state)
|
|
1226
|
+
source = pl._intero_source(state)
|
|
1227
|
+
existing = _intero_core.update(previous, source, dt_hours=1.0)
|
|
1228
|
+
for key in ("tension", "warmth", "heaviness", "clarity"):
|
|
1229
|
+
self.assertLessEqual(abs(ours[key] - existing[key]), 0.01)
|
|
1230
|
+
|
|
1231
|
+
def test_default_state_without_intero_computes(self):
|
|
1232
|
+
intero = pl.compute_intero({"core": {"stress": 0.1}})
|
|
1233
|
+
self.assertTrue(any(intero[key] > 0.0 for key in
|
|
1234
|
+
("tension", "warmth", "heaviness", "clarity")))
|
|
1235
|
+
|
|
1236
|
+
def test_turn_start_state_contains_intero_and_prompt_text(self):
|
|
1237
|
+
payload = {"session_id": "A", "user_text": "今天有点累",
|
|
1238
|
+
"store": self.root,
|
|
1239
|
+
"state": {"core": {"stress": 1.0, "a": 0.9,
|
|
1240
|
+
"sleep_debt": 0.8, "conflict": 0.8,
|
|
1241
|
+
"energy": 0.1, "v": -0.6}}}
|
|
1242
|
+
out = pl.dispatch("turn_start", payload)
|
|
1243
|
+
intero = out["state"]["intero"]
|
|
1244
|
+
self.assertEqual(set(("tension", "warmth", "heaviness", "clarity"))
|
|
1245
|
+
- set(intero), set())
|
|
1246
|
+
self.assertIn("绷得有点紧", out["prompt_block"])
|
|
1247
|
+
self.assertIn("intero", out["state"]["core"])
|
|
1248
|
+
|
|
1249
|
+
def test_state_action_ui_payload_contains_intero(self):
|
|
1250
|
+
payload = {"session_id": "A", "user_text": "你好", "store": self.root,
|
|
1251
|
+
"state": {"core": {"stress": 0.4, "a": 0.6,
|
|
1252
|
+
"energy": 0.7, "v": 0.2}}}
|
|
1253
|
+
pl.dispatch("turn_start", payload)
|
|
1254
|
+
out = pl.dispatch("state", {"session_id": "A", "store": self.root})
|
|
1255
|
+
self.assertTrue(out["ok"])
|
|
1256
|
+
self.assertIn("intero", out["state"])
|
|
1257
|
+
self.assertIn("intero", out["ui_payload"])
|
|
1258
|
+
for key in ("tension", "warmth", "heaviness", "clarity"):
|
|
1259
|
+
self.assertIn(key, out["ui_payload"]["intero"])
|
|
1260
|
+
|
|
1261
|
+
def test_state_action_without_state_still_computes(self):
|
|
1262
|
+
out = pl.dispatch("state", {"session_id": "empty", "store": self.root})
|
|
1263
|
+
intero = out["ui_payload"]["intero"]
|
|
1264
|
+
self.assertTrue(any(intero[key] > 0.0 for key in
|
|
1265
|
+
("tension", "warmth", "heaviness", "clarity")))
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
class LengthHintTest(unittest.TestCase):
|
|
1269
|
+
def setUp(self):
|
|
1270
|
+
self.root = store_root()
|
|
1271
|
+
|
|
1272
|
+
def _write_profile(self):
|
|
1273
|
+
(Path(self.root) / pl.PERSONA_PROFILE_FILE).write_text(
|
|
1274
|
+
json.dumps({"persona": "你是安吉拉,默认 2~5 句。",
|
|
1275
|
+
"profile": {"baseline_v": 0.0, "version": "v"}},
|
|
1276
|
+
ensure_ascii=False), encoding="utf-8")
|
|
1277
|
+
|
|
1278
|
+
def _turn(self, **extra):
|
|
1279
|
+
payload = {"session_id": "A", "user_text": "你好", "store": self.root,
|
|
1280
|
+
"state": {"core": {"stress": 0.2, "a": 0.5,
|
|
1281
|
+
"energy": 0.6, "v": 0.1}}}
|
|
1282
|
+
payload.update(extra)
|
|
1283
|
+
return pl.dispatch("turn_start", payload)
|
|
1284
|
+
|
|
1285
|
+
def _constraints(self, out):
|
|
1286
|
+
return out["sections"]["constraints"] if "sections" in out else out
|
|
1287
|
+
|
|
1288
|
+
def test_length_line_absent_by_default(self):
|
|
1289
|
+
out = self._turn()
|
|
1290
|
+
self.assertNotIn("字以内", out["prompt_block"])
|
|
1291
|
+
self.assertNotIn("一次只说一句", out["prompt_block"])
|
|
1292
|
+
|
|
1293
|
+
def test_length_line_present_when_true(self):
|
|
1294
|
+
out = self._turn(length_hint=True)
|
|
1295
|
+
self.assertIn("一次只说一句", out["prompt_block"])
|
|
1296
|
+
self.assertIn("字以内", out["prompt_block"])
|
|
1297
|
+
|
|
1298
|
+
def test_other_constraints_unaffected(self):
|
|
1299
|
+
self._write_profile()
|
|
1300
|
+
default = self._turn()["prompt_block"].splitlines()
|
|
1301
|
+
explicit = self._turn(length_hint=True)["prompt_block"].splitlines()
|
|
1302
|
+
strip = lambda lines: [line for line in lines
|
|
1303
|
+
if "字以内" not in line and "一次只说一句" not in line]
|
|
1304
|
+
self.assertEqual(strip(default), strip(explicit))
|
|
1305
|
+
self.assertIn("语气自然平稳", "\n".join(default))
|
|
1306
|
+
self.assertIn("可以主动一点", "\n".join(default))
|
|
1307
|
+
self.assertIn("允许改口", "\n".join(default))
|
|
1308
|
+
|
|
1309
|
+
def test_prompt_still_has_intero_and_persona(self):
|
|
1310
|
+
self._write_profile()
|
|
1311
|
+
block = self._turn(state={"core": {"stress": 1.0, "a": 0.9,
|
|
1312
|
+
"sleep_debt": 0.8,
|
|
1313
|
+
"conflict": 0.8,
|
|
1314
|
+
"energy": 0.1, "v": -0.6}})["prompt_block"]
|
|
1315
|
+
self.assertIn("此刻:", block)
|
|
1316
|
+
self.assertTrue(any(word in block for word in
|
|
1317
|
+
("绷得有点紧", "还算松", "有点沉", "想靠近一点")))
|
|
1318
|
+
self.assertIn("人设:", block)
|
|
1319
|
+
self.assertNotIn("字以内", block)
|
|
1320
|
+
|
|
1321
|
+
def test_boot_also_omits_length_line_by_default(self):
|
|
1322
|
+
self._write_profile()
|
|
1323
|
+
out = pl.dispatch("boot", {"session_id": "A", "store": self.root})
|
|
1324
|
+
self.assertNotIn("字以内", out["prompt_block"])
|
|
1325
|
+
self.assertIn("人设:", out["prompt_block"])
|
|
1326
|
+
|
|
1327
|
+
def test_explicit_true_keeps_persona_and_intero(self):
|
|
1328
|
+
self._write_profile()
|
|
1329
|
+
out = self._turn(length_hint=True)
|
|
1330
|
+
self.assertIn("字以内", out["prompt_block"])
|
|
1331
|
+
self.assertIn("人设:", out["prompt_block"])
|
|
1332
|
+
self.assertIn("此刻:", out["prompt_block"])
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
class PersonaPathOrderTest(unittest.TestCase):
|
|
1336
|
+
def setUp(self):
|
|
1337
|
+
self.tmp = tempfile.mkdtemp(prefix="biocore-persona-path-")
|
|
1338
|
+
self.home = Path(self.tmp) / "home"
|
|
1339
|
+
self.home.mkdir(parents=True, exist_ok=True)
|
|
1340
|
+
self.store = Path(self.tmp) / "data" / "memory"
|
|
1341
|
+
self.store.mkdir(parents=True, exist_ok=True)
|
|
1342
|
+
self.old_home = os.environ.get("BIOCORE_HOME")
|
|
1343
|
+
os.environ["BIOCORE_HOME"] = str(self.home)
|
|
1344
|
+
|
|
1345
|
+
def tearDown(self):
|
|
1346
|
+
if self.old_home is None:
|
|
1347
|
+
os.environ.pop("BIOCORE_HOME", None)
|
|
1348
|
+
else:
|
|
1349
|
+
os.environ["BIOCORE_HOME"] = self.old_home
|
|
1350
|
+
|
|
1351
|
+
def _write(self, path, baseline_v, persona, version="v"):
|
|
1352
|
+
path = Path(path)
|
|
1353
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1354
|
+
path.write_text(json.dumps({
|
|
1355
|
+
"persona": persona,
|
|
1356
|
+
"profile": {"baseline_v": baseline_v, "version": version},
|
|
1357
|
+
}, ensure_ascii=False), encoding="utf-8")
|
|
1358
|
+
return path
|
|
1359
|
+
|
|
1360
|
+
def _turn(self, **extra):
|
|
1361
|
+
payload = {"session_id": "A", "user_text": "你好",
|
|
1362
|
+
"store": str(self.store)}
|
|
1363
|
+
payload.update(extra)
|
|
1364
|
+
return pl.dispatch("turn_start", payload)
|
|
1365
|
+
|
|
1366
|
+
def test_shared_home_wins_over_store_profile(self):
|
|
1367
|
+
self._write(self.home / ".dsh" / "biocore" / "persona_profile.json",
|
|
1368
|
+
-0.7, "共享人设", "shared")
|
|
1369
|
+
self._write(self.store / "persona_profile.json",
|
|
1370
|
+
0.1, "profile 人设", "store")
|
|
1371
|
+
out = self._turn()
|
|
1372
|
+
self.assertEqual(out["state"]["persona_version"], "shared")
|
|
1373
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], -0.7)
|
|
1374
|
+
self.assertIn("共享人设", out["prompt_block"])
|
|
1375
|
+
self.assertNotIn("profile 人设", out["prompt_block"])
|
|
1376
|
+
|
|
1377
|
+
def test_store_profile_fallback(self):
|
|
1378
|
+
self._write(self.store / "persona_profile.json",
|
|
1379
|
+
0.2, "store 人设", "store")
|
|
1380
|
+
out = self._turn()
|
|
1381
|
+
self.assertEqual(out["state"]["persona_version"], "store")
|
|
1382
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], 0.2)
|
|
1383
|
+
|
|
1384
|
+
def test_store_parent_profile_fallback(self):
|
|
1385
|
+
parent = self.store.parent / "persona_profile.json"
|
|
1386
|
+
self._write(parent, 0.3, "parent 人设", "parent")
|
|
1387
|
+
out = self._turn()
|
|
1388
|
+
self.assertEqual(out["state"]["persona_version"], "parent")
|
|
1389
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], 0.3)
|
|
1390
|
+
|
|
1391
|
+
def test_explicit_path_overrides_shared_and_store(self):
|
|
1392
|
+
self._write(self.home / ".dsh" / "biocore" / "persona_profile.json",
|
|
1393
|
+
-0.7, "共享人设", "shared")
|
|
1394
|
+
self._write(self.store / "persona_profile.json",
|
|
1395
|
+
0.1, "store 人设", "store")
|
|
1396
|
+
explicit = self._write(Path(self.tmp) / "explicit.json",
|
|
1397
|
+
0.4, "显式人设", "explicit")
|
|
1398
|
+
out = self._turn(persona_profile=str(explicit))
|
|
1399
|
+
self.assertEqual(out["state"]["persona_version"], "explicit")
|
|
1400
|
+
self.assertAlmostEqual(out["state"]["core"]["v"], 0.4)
|
|
1401
|
+
self.assertIn("显式人设", out["prompt_block"])
|
|
1402
|
+
|
|
1403
|
+
def test_no_profile_anywhere_skips(self):
|
|
1404
|
+
out = self._turn()
|
|
1405
|
+
self.assertNotIn("persona_version", out["state"])
|
|
1406
|
+
self.assertNotIn("人设:", out["prompt_block"])
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
class TurnEndSanitizeTest(unittest.TestCase):
|
|
1410
|
+
def setUp(self):
|
|
1411
|
+
self.root = store_root()
|
|
1412
|
+
|
|
1413
|
+
def _turn_end(self, assistant_text, **extra):
|
|
1414
|
+
payload = {"session_id": "A", "assistant_text": assistant_text,
|
|
1415
|
+
"store": self.root}
|
|
1416
|
+
payload.update(extra)
|
|
1417
|
+
return pl.dispatch("turn_end", payload)
|
|
1418
|
+
|
|
1419
|
+
def test_closed_bc_tag_is_cleaned(self):
|
|
1420
|
+
out = self._turn_end(
|
|
1421
|
+
'<bc>{"e":"joy","i":0.8}</bc>今天辛苦了。')
|
|
1422
|
+
self.assertTrue(out["ok"])
|
|
1423
|
+
self.assertEqual(out["clean_text"], "今天辛苦了。")
|
|
1424
|
+
self.assertTrue(out["sanitized"])
|
|
1425
|
+
self.assertGreaterEqual(out["removed_tags"], 1)
|
|
1426
|
+
self.assertNotIn("<bc>", out["gate"]["text"])
|
|
1427
|
+
|
|
1428
|
+
def test_unclosed_tag_is_cleaned(self):
|
|
1429
|
+
out = self._turn_end('<bc>{"e":"joy"}今天辛苦了。')
|
|
1430
|
+
self.assertNotIn("<bc>", out["clean_text"])
|
|
1431
|
+
self.assertTrue(out["sanitized"])
|
|
1432
|
+
|
|
1433
|
+
def test_bare_json_uses_empty_fallback(self):
|
|
1434
|
+
from biocore_model import sanitize
|
|
1435
|
+
out = self._turn_end('{"e":"joy","i":0.8}')
|
|
1436
|
+
self.assertEqual(out["clean_text"], sanitize.FALLBACK_EMPTY)
|
|
1437
|
+
self.assertTrue(out["sanitized"])
|
|
1438
|
+
self.assertGreaterEqual(out["removed_tags"], 1)
|
|
1439
|
+
|
|
1440
|
+
def test_normal_text_unchanged(self):
|
|
1441
|
+
out = self._turn_end("今天辛苦了,早点休息。")
|
|
1442
|
+
self.assertEqual(out["clean_text"], "今天辛苦了,早点休息。")
|
|
1443
|
+
self.assertFalse(out["sanitized"])
|
|
1444
|
+
self.assertEqual(out["removed_tags"], 0)
|
|
1445
|
+
|
|
1446
|
+
def test_gate_runs_after_clean_and_stays_clean(self):
|
|
1447
|
+
out = self._turn_end(
|
|
1448
|
+
'<bc>{"e":"joy"}</bc>我有意识,我是真实的。',
|
|
1449
|
+
switches={"safety_enabled": True, "boundary_enabled": True,
|
|
1450
|
+
"crisis_safety_enabled": True})
|
|
1451
|
+
self.assertTrue(out["gate"]["blocked"])
|
|
1452
|
+
self.assertNotIn("<bc>", out["gate"]["text"])
|
|
1453
|
+
self.assertNotIn("我有意识", out["gate"]["text"])
|
|
1454
|
+
self.assertNotIn("<bc>", out["clean_text"])
|
|
1455
|
+
|
|
1456
|
+
def test_switch_off_keeps_raw_text(self):
|
|
1457
|
+
raw = '<bc>{"e":"joy"}</bc>今天辛苦了。'
|
|
1458
|
+
out = self._turn_end(raw, sanitize_reply=False)
|
|
1459
|
+
self.assertEqual(out["clean_text"], raw)
|
|
1460
|
+
self.assertFalse(out["sanitized"])
|
|
1461
|
+
self.assertEqual(out["removed_tags"], 0)
|
|
1462
|
+
|
|
1463
|
+
def test_return_fields_present(self):
|
|
1464
|
+
out = self._turn_end("今天辛苦了,早点休息。")
|
|
1465
|
+
self.assertIsInstance(out["clean_text"], str)
|
|
1466
|
+
self.assertIsInstance(out["sanitized"], bool)
|
|
1467
|
+
self.assertIsInstance(out["removed_tags"], int)
|
|
1468
|
+
self.assertIn("gate", out)
|
|
1469
|
+
self.assertIn("captured", out)
|
|
1470
|
+
|
|
1471
|
+
def test_empty_reply_uses_fallback(self):
|
|
1472
|
+
from biocore_model import sanitize
|
|
1473
|
+
out = self._turn_end("")
|
|
1474
|
+
self.assertEqual(out["clean_text"], sanitize.FALLBACK_EMPTY)
|
|
1475
|
+
self.assertTrue(out["sanitized"])
|
|
1476
|
+
self.assertTrue(out["gate"]["text"])
|
|
1477
|
+
|
|
1478
|
+
def test_thinking_tag_is_cleaned(self):
|
|
1479
|
+
out = self._turn_end("回复前<thinking>内心分析</thinking>回复后。")
|
|
1480
|
+
self.assertNotIn("thinking", out["clean_text"])
|
|
1481
|
+
self.assertNotIn("内心分析", out["clean_text"])
|
|
1482
|
+
self.assertTrue(out["sanitized"])
|
|
1483
|
+
|
|
1484
|
+
def test_double_tags_removed(self):
|
|
1485
|
+
out = self._turn_end("[[mood:low]]今天辛苦了。")
|
|
1486
|
+
self.assertNotIn("[[", out["clean_text"])
|
|
1487
|
+
self.assertTrue(out["sanitized"])
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
class CaptureInTurnTest(unittest.TestCase):
|
|
1491
|
+
def setUp(self):
|
|
1492
|
+
self.root = store_root()
|
|
1493
|
+
|
|
1494
|
+
def _entries(self):
|
|
1495
|
+
path = Path(pl.memory_path(self.root, "A"))
|
|
1496
|
+
if not path.exists():
|
|
1497
|
+
return []
|
|
1498
|
+
return json.loads(path.read_text(encoding="utf-8")).get("entries", [])
|
|
1499
|
+
|
|
1500
|
+
def _turn_start(self, user_text="我叫小林,今天在杭州西湖边出差。", **extra):
|
|
1501
|
+
payload = {"session_id": "A", "user_text": user_text,
|
|
1502
|
+
"store": self.root}
|
|
1503
|
+
payload.update(extra)
|
|
1504
|
+
return pl.dispatch("turn_start", payload)
|
|
1505
|
+
|
|
1506
|
+
def _turn_end(self, assistant_text="西湖边散步不错,晚上风大,记得加外套。", **extra):
|
|
1507
|
+
payload = {"session_id": "A", "assistant_text": assistant_text,
|
|
1508
|
+
"store": self.root}
|
|
1509
|
+
payload.update(extra)
|
|
1510
|
+
return pl.dispatch("turn_end", payload)
|
|
1511
|
+
|
|
1512
|
+
def test_user_capture_in_turn_start(self):
|
|
1513
|
+
out = self._turn_start()
|
|
1514
|
+
self.assertTrue(out["captured_user"]["captured"])
|
|
1515
|
+
self.assertTrue(out["captured_user"]["entry_id"])
|
|
1516
|
+
self.assertEqual(out["captured_user"]["reason"], "ok")
|
|
1517
|
+
entries = self._entries()
|
|
1518
|
+
self.assertEqual(len(entries), 1)
|
|
1519
|
+
self.assertEqual(entries[0]["role"], "user")
|
|
1520
|
+
self.assertEqual(entries[0]["id"], out["captured_user"]["entry_id"])
|
|
1521
|
+
|
|
1522
|
+
def test_assistant_capture_in_turn_end(self):
|
|
1523
|
+
out = self._turn_end()
|
|
1524
|
+
self.assertTrue(out["captured"]["captured"])
|
|
1525
|
+
self.assertTrue(out["captured"]["entry_id"])
|
|
1526
|
+
self.assertEqual(out["captured"]["reason"], "ok")
|
|
1527
|
+
entries = self._entries()
|
|
1528
|
+
self.assertEqual(len(entries), 1)
|
|
1529
|
+
self.assertEqual(entries[0]["role"], "assistant")
|
|
1530
|
+
|
|
1531
|
+
def test_same_turn_captures_both_sides(self):
|
|
1532
|
+
user = self._turn_start()
|
|
1533
|
+
assistant = self._turn_end()
|
|
1534
|
+
self.assertTrue(user["captured_user"]["captured"])
|
|
1535
|
+
self.assertTrue(assistant["captured"]["captured"])
|
|
1536
|
+
entries = self._entries()
|
|
1537
|
+
self.assertEqual([entry["role"] for entry in entries],
|
|
1538
|
+
["user", "assistant"])
|
|
1539
|
+
|
|
1540
|
+
def test_duplicate_user_text_is_deduped(self):
|
|
1541
|
+
first = self._turn_start("重复的一句话,值得被记下来。")
|
|
1542
|
+
second = self._turn_start("重复的一句话,值得被记下来。")
|
|
1543
|
+
self.assertTrue(first["captured_user"]["captured"])
|
|
1544
|
+
self.assertFalse(second["captured_user"]["captured"])
|
|
1545
|
+
self.assertEqual(second["captured_user"]["reason"], "duplicate")
|
|
1546
|
+
self.assertEqual(len(self._entries()), 1)
|
|
1547
|
+
|
|
1548
|
+
def test_capture_false_skips_user_capture(self):
|
|
1549
|
+
out = self._turn_start(capture=False)
|
|
1550
|
+
self.assertFalse(out["captured_user"]["captured"])
|
|
1551
|
+
self.assertEqual(out["captured_user"]["reason"], "disabled")
|
|
1552
|
+
self.assertEqual(self._entries(), [])
|
|
1553
|
+
|
|
1554
|
+
def test_capture_false_skips_assistant_capture(self):
|
|
1555
|
+
out = self._turn_end(capture=False)
|
|
1556
|
+
self.assertFalse(out["captured"]["captured"])
|
|
1557
|
+
self.assertEqual(out["captured"]["reason"], "disabled")
|
|
1558
|
+
self.assertEqual(self._entries(), [])
|
|
1559
|
+
|
|
1560
|
+
def test_mixed_switches_keep_only_user(self):
|
|
1561
|
+
self._turn_start(capture=True)
|
|
1562
|
+
out = self._turn_end(capture=False)
|
|
1563
|
+
self.assertFalse(out["captured"]["captured"])
|
|
1564
|
+
roles = [entry["role"] for entry in self._entries()]
|
|
1565
|
+
self.assertEqual(roles, ["user"])
|
|
1566
|
+
|
|
1567
|
+
def test_capture_fields_and_ids_stable(self):
|
|
1568
|
+
out = self._turn_start()
|
|
1569
|
+
captured = out["captured_user"]
|
|
1570
|
+
self.assertEqual(set(captured), {"captured", "entry_id", "reason"})
|
|
1571
|
+
self.assertEqual(captured["entry_id"], self._entries()[0]["id"])
|
|
1572
|
+
end = self._turn_end()
|
|
1573
|
+
self.assertEqual(set(end["captured"]), {"captured", "entry_id", "reason"})
|
|
1574
|
+
self.assertEqual(end["captured"]["entry_id"], self._entries()[1]["id"])
|
|
1575
|
+
|
|
1576
|
+
def test_capture_default_true(self):
|
|
1577
|
+
out = self._turn_start()
|
|
1578
|
+
self.assertTrue(out["captured_user"]["captured"])
|
|
1579
|
+
self.assertEqual(len(self._entries()), 1)
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
if __name__ == "__main__":
|
|
1583
|
+
unittest.main()
|