dsh-biocore 0.1.0 → 0.1.2
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 +2 -2
- package/lib/py/bridge.py +15 -7
- package/lib/py-bridge.js +4 -3
- package/package.json +3 -2
- package/python/biocore_memory/__init__.py +21 -0
- package/python/biocore_memory/adapters/__init__.py +2 -0
- package/python/biocore_memory/adapters/astrbot.py +24 -0
- package/python/biocore_memory/adapters/dsh_cli.py +335 -0
- package/python/biocore_memory/co_recall.py +324 -0
- package/python/biocore_memory/consolidate/__init__.py +11 -0
- package/python/biocore_memory/consolidate/gated.py +104 -0
- package/python/biocore_memory/consolidate/neighbors.py +68 -0
- package/python/biocore_memory/consolidate/reflection.py +45 -0
- package/python/biocore_memory/consolidate/tree.py +95 -0
- package/python/biocore_memory/consolidate/writeback.py +181 -0
- package/python/biocore_memory/core/__init__.py +4 -0
- package/python/biocore_memory/core/config.py +127 -0
- package/python/biocore_memory/core/permissions.py +69 -0
- package/python/biocore_memory/core/types.py +116 -0
- package/python/biocore_memory/core/util.py +26 -0
- package/python/biocore_memory/embedding_local.py +447 -0
- package/python/biocore_memory/embedding_mode.py +156 -0
- package/python/biocore_memory/engine.py +2476 -0
- package/python/biocore_memory/engine_helpers.py +266 -0
- package/python/biocore_memory/event/__init__.py +3 -0
- package/python/biocore_memory/event/_types.py +16 -0
- package/python/biocore_memory/event/consistency.py +117 -0
- package/python/biocore_memory/event/extract.py +48 -0
- package/python/biocore_memory/event/media.py +111 -0
- package/python/biocore_memory/event/notes.py +85 -0
- package/python/biocore_memory/event/notes_link.py +87 -0
- package/python/biocore_memory/event/vector.py +69 -0
- package/python/biocore_memory/evict/__init__.py +3 -0
- package/python/biocore_memory/evict/adaptive.py +73 -0
- package/python/biocore_memory/evict/consequence.py +58 -0
- package/python/biocore_memory/evict/heat.py +54 -0
- package/python/biocore_memory/evict/inhibition.py +105 -0
- package/python/biocore_memory/evict/policy.py +188 -0
- package/python/biocore_memory/evict/score.py +23 -0
- package/python/biocore_memory/identity/__init__.py +6 -0
- package/python/biocore_memory/identity/api.py +99 -0
- package/python/biocore_memory/identity/history.py +9 -0
- package/python/biocore_memory/identity/ledger.py +191 -0
- package/python/biocore_memory/identity/narrative.py +358 -0
- package/python/biocore_memory/identity/snapshot.py +13 -0
- package/python/biocore_memory/identity/writeback.py +61 -0
- package/python/biocore_memory/index/__init__.py +3 -0
- package/python/biocore_memory/index/cue_index.py +80 -0
- package/python/biocore_memory/index/entities.py +85 -0
- package/python/biocore_memory/index/entity_boost.py +104 -0
- package/python/biocore_memory/index/graph.py +172 -0
- package/python/biocore_memory/recall/__init__.py +5 -0
- package/python/biocore_memory/recall/api.py +65 -0
- package/python/biocore_memory/recall/bm25.py +118 -0
- package/python/biocore_memory/recall/channels.py +98 -0
- package/python/biocore_memory/recall/complexity.py +62 -0
- package/python/biocore_memory/recall/constraints.py +75 -0
- package/python/biocore_memory/recall/embedding.py +745 -0
- package/python/biocore_memory/recall/expand.py +26 -0
- package/python/biocore_memory/recall/filters.py +40 -0
- package/python/biocore_memory/recall/fusion.py +18 -0
- package/python/biocore_memory/recall/mmr.py +70 -0
- package/python/biocore_memory/recall/ppr.py +85 -0
- package/python/biocore_memory/recall/retrieval_practice.py +102 -0
- package/python/biocore_memory/relay/__init__.py +2 -0
- package/python/biocore_memory/relay/card.py +51 -0
- package/python/biocore_memory/slots/__init__.py +18 -0
- package/python/biocore_memory/slots/admission.py +64 -0
- package/python/biocore_memory/slots/boundary.py +128 -0
- package/python/biocore_memory/slots/evict_bridge.py +106 -0
- package/python/biocore_memory/slots/registry.py +135 -0
- package/python/biocore_memory/slots/switch.py +42 -0
- package/python/biocore_memory/store/__init__.py +4 -0
- package/python/biocore_memory/store/audit.py +74 -0
- package/python/biocore_memory/store/compress.py +81 -0
- package/python/biocore_memory/store/dedupe.py +178 -0
- package/python/biocore_memory/store/json_store.py +154 -0
- package/python/biocore_memory/store/media.py +145 -0
- package/python/biocore_memory/store/provenance.py +73 -0
- package/python/biocore_memory/store/rate_limit.py +93 -0
- package/python/biocore_memory/store/security.py +107 -0
- package/python/biocore_memory/store/source_consistency.py +123 -0
- package/python/biocore_memory/store/tiering.py +61 -0
- package/python/biocore_memory/store/tiering_exec.py +50 -0
- package/python/biocore_memory/store/tombstone.py +23 -0
- package/python/biocore_memory/store/versions.py +69 -0
- package/python/biocore_memory/thread/__init__.py +2 -0
- package/python/biocore_memory/thread/card.py +58 -0
- package/python/biocore_model/__init__.py +37 -0
- package/python/biocore_model/affective_labor.py +162 -0
- package/python/biocore_model/appraisal.py +257 -0
- package/python/biocore_model/appraisal_action.py +78 -0
- package/python/biocore_model/archive_queue.py +60 -0
- package/python/biocore_model/attachment_behavior.py +130 -0
- package/python/biocore_model/attachment_regulation.py +75 -0
- package/python/biocore_model/attachment_state.py +41 -0
- package/python/biocore_model/attribution.py +73 -0
- package/python/biocore_model/attributor.py +162 -0
- package/python/biocore_model/autonomic.py +91 -0
- package/python/biocore_model/autonomy_affect.py +51 -0
- package/python/biocore_model/autonomy_check.py +67 -0
- package/python/biocore_model/autonomy_loop.py +122 -0
- package/python/biocore_model/autonomy_mode.py +114 -0
- package/python/biocore_model/autonomy_model.py +733 -0
- package/python/biocore_model/autonomy_params.py +131 -0
- package/python/biocore_model/autonomy_reason.py +73 -0
- package/python/biocore_model/autonomy_tick.py +227 -0
- package/python/biocore_model/bio_appraisal.py +49 -0
- package/python/biocore_model/bio_autonomy_state.py +171 -0
- package/python/biocore_model/bio_config.py +221 -0
- package/python/biocore_model/bio_const.py +128 -0
- package/python/biocore_model/bio_daily.py +267 -0
- package/python/biocore_model/bio_emotion.py +538 -0
- package/python/biocore_model/bio_emotion2.py +214 -0
- package/python/biocore_model/bio_goal.py +220 -0
- package/python/biocore_model/bio_identity.py +82 -0
- package/python/biocore_model/bio_noise.py +119 -0
- package/python/biocore_model/bio_physiology2.py +114 -0
- package/python/biocore_model/bio_snapshot.py +324 -0
- package/python/biocore_model/bio_state.py +272 -0
- package/python/biocore_model/bio_tick.py +310 -0
- package/python/biocore_model/bio_util.py +82 -0
- package/python/biocore_model/blocks.py +264 -0
- package/python/biocore_model/boundary.py +251 -0
- package/python/biocore_model/boundary_gate.py +188 -0
- package/python/biocore_model/commitment_extractor.py +517 -0
- package/python/biocore_model/compassion_fatigue.py +84 -0
- package/python/biocore_model/connection_bids.py +120 -0
- package/python/biocore_model/core_bridge.py +177 -0
- package/python/biocore_model/core_client.py +127 -0
- package/python/biocore_model/core_commands.py +112 -0
- package/python/biocore_model/core_mode.py +104 -0
- package/python/biocore_model/core_mode_ui.mjs +104 -0
- package/python/biocore_model/criticality.py +81 -0
- package/python/biocore_model/defense_mechanisms.py +272 -0
- package/python/biocore_model/delta_budget.py +99 -0
- package/python/biocore_model/diag_trace.py +274 -0
- package/python/biocore_model/dissonance.py +134 -0
- package/python/biocore_model/emotion_geometry.py +108 -0
- package/python/biocore_model/emotion_probe.py +209 -0
- package/python/biocore_model/entity_graph.py +931 -0
- package/python/biocore_model/entity_resolve.py +260 -0
- package/python/biocore_model/envy.py +193 -0
- package/python/biocore_model/exit_or_silence.py +230 -0
- package/python/biocore_model/expr_guard.py +24 -0
- package/python/biocore_model/expr_intent.py +77 -0
- package/python/biocore_model/expr_state.py +204 -0
- package/python/biocore_model/expr_template.py +105 -0
- package/python/biocore_model/expr_tone.py +81 -0
- package/python/biocore_model/extreme_scenarios.py +205 -0
- package/python/biocore_model/feedback_flow.py +227 -0
- package/python/biocore_model/feedback_guard.py +129 -0
- package/python/biocore_model/feedback_llm.py +163 -0
- package/python/biocore_model/feedback_memory.py +96 -0
- package/python/biocore_model/feedback_user.py +93 -0
- package/python/biocore_model/frustration_curve.py +110 -0
- package/python/biocore_model/granularity.py +283 -0
- package/python/biocore_model/graph_extract.py +248 -0
- package/python/biocore_model/graph_recall.py +221 -0
- package/python/biocore_model/intent_render.py +91 -0
- package/python/biocore_model/intero_core.py +235 -0
- package/python/biocore_model/intero_style.py +116 -0
- package/python/biocore_model/layered_memory.py +297 -0
- package/python/biocore_model/learning_probe.py +120 -0
- package/python/biocore_model/memory_dual.py +224 -0
- package/python/biocore_model/memory_forgetting.py +125 -0
- package/python/biocore_model/memory_store.py +1399 -0
- package/python/biocore_model/metacog.py +153 -0
- package/python/biocore_model/migrate.py +733 -0
- package/python/biocore_model/migrate_store.py +337 -0
- package/python/biocore_model/mind_wander.py +111 -0
- package/python/biocore_model/mood.py +48 -0
- package/python/biocore_model/narrative.py +172 -0
- package/python/biocore_model/needs_esteem.py +116 -0
- package/python/biocore_model/needs_events.py +137 -0
- package/python/biocore_model/needs_sleep.py +72 -0
- package/python/biocore_model/noise_bank.py +95 -0
- package/python/biocore_model/persona_flow.mjs +25 -0
- package/python/biocore_model/pipeline.py +240 -0
- package/python/biocore_model/pipeline_api.py +200 -0
- package/python/biocore_model/plugin_config.py +83 -0
- package/python/biocore_model/plugin_routes.py +1435 -0
- package/python/biocore_model/policy_learning.py +132 -0
- package/python/biocore_model/possible_selves.py +110 -0
- package/python/biocore_model/predictor.py +178 -0
- package/python/biocore_model/preset.py +205 -0
- package/python/biocore_model/proactive_context.py +153 -0
- package/python/biocore_model/prompt_rules.py +90 -0
- package/python/biocore_model/prospective.py +183 -0
- package/python/biocore_model/provider_fallback.py +97 -0
- package/python/biocore_model/reconstruction.py +132 -0
- package/python/biocore_model/regulation_loop.py +179 -0
- package/python/biocore_model/regulation_strategies.py +294 -0
- package/python/biocore_model/relation_stage.py +130 -0
- package/python/biocore_model/relationship_stage.py +95 -0
- package/python/biocore_model/render.py +901 -0
- package/python/biocore_model/render_guard.mjs +34 -0
- package/python/biocore_model/repair_after_boundary.py +350 -0
- package/python/biocore_model/reply_limit.py +183 -0
- package/python/biocore_model/reply_split.py +203 -0
- package/python/biocore_model/rollback.py +143 -0
- package/python/biocore_model/runtime.py +479 -0
- package/python/biocore_model/sanitize.py +200 -0
- package/python/biocore_model/scheduler.py +123 -0
- package/python/biocore_model/schema.py +120 -0
- package/python/biocore_model/secrecy.py +145 -0
- package/python/biocore_model/self_esteem.py +50 -0
- package/python/biocore_model/self_intent.py +437 -0
- package/python/biocore_model/self_interest.py +263 -0
- package/python/biocore_model/self_verification.py +178 -0
- package/python/biocore_model/services.py +78 -0
- package/python/biocore_model/situation.py +254 -0
- package/python/biocore_model/sleep_circadian.py +116 -0
- package/python/biocore_model/social_pain.py +69 -0
- package/python/biocore_model/social_repair.py +111 -0
- package/python/biocore_model/social_theory.py +131 -0
- package/python/biocore_model/source_monitoring.py +292 -0
- package/python/biocore_model/stages.py +487 -0
- package/python/biocore_model/state_tag.py +231 -0
- package/python/biocore_model/switch_groups.mjs +201 -0
- package/python/biocore_model/tabs_state.mjs +37 -0
- package/python/biocore_model/template_expr.py +252 -0
- package/python/biocore_model/tools/caps_check.mjs +23 -0
- package/python/biocore_model/tools/init_symbols_check.mjs +11 -0
- package/python/biocore_model/tools/missing_ids.mjs +16 -0
- package/python/biocore_model/tools/scope_check.mjs +19 -0
- package/python/biocore_model/topic_control.py +268 -0
- package/python/biocore_model/turn_memory.py +152 -0
- package/python/biocore_model/ui_compat.mjs +48 -0
- package/python/biocore_model/ui_settings.py +266 -0
- package/python/biocore_model/ui_settings.py.bak_authority +108 -0
- package/python/biocore_model/value_order.py +186 -0
- package/python/biocore_model/view_mode.mjs +8 -0
- package/python/biocore_model/voice_bank.py +177 -0
- package/python/biocore_model/waiting_sense.py +229 -0
- package/python/biocore_model/x_directives.py +142 -0
- package/python/biocore_runtime/__init__.py +7 -0
- package/python/biocore_runtime/pipeline.py +1915 -0
- package/python/dsh_adapter/README.md +96 -0
- package/python/dsh_adapter/adapter.py +1077 -0
- package/python/dsh_adapter/cli.py +107 -0
- package/python/dsh_adapter/data/demo_state.json +164 -0
- package/python/dsh_adapter/data/verify_state.json +164 -0
- package/python/dsh_adapter/tests/__init__.py +1 -0
- package/python/dsh_adapter/tests/test_adapter.py +494 -0
- package/ui/panel-layout.js +3 -3
- package/ui/panel.css +53 -6
- package/ui/panel.html +33 -32
- package/ui/panel.js +9 -1
- package/lib/py/__pycache__/bridge.cpython-310.pyc +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""task-187: co-recall statistics -- first step: count and mark, never merge.
|
|
3
|
+
|
|
4
|
+
Two memories that keep being recalled in the same batch probably point at the
|
|
5
|
+
same thing. This module only keeps the score; it never changes recall, never
|
|
6
|
+
drops an entry, never merges anything and never calls a model:
|
|
7
|
+
|
|
8
|
+
- pure functions: normalize_id / unique_ids / pair_key / pairs turn one recall
|
|
9
|
+
batch into unordered id pairs (a duplicate inside one batch counts once);
|
|
10
|
+
- thin storage layer: table co_recall(pair_a, pair_b, count, first_ts, last_ts)
|
|
11
|
+
in memory/memory.sqlite3, upserted once per batch;
|
|
12
|
+
- candidates(threshold=3, window_days=30): pairs that crossed the threshold
|
|
13
|
+
inside the recent window (read only);
|
|
14
|
+
- mark_candidates(): writes co_recall_marks (a separate table) so the panel can
|
|
15
|
+
show "mergeable candidates", without touching entries;
|
|
16
|
+
- read_marked() / stats() / readout(): read-only entry points for the panel or
|
|
17
|
+
a command. Wiring into main.py is intentionally left for the caller.
|
|
18
|
+
|
|
19
|
+
Hard boundaries: no LLM call, no change to the recall output or its thresholds,
|
|
20
|
+
no import of main.py. IO lives in the storage layer on purpose.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sqlite3
|
|
25
|
+
import time
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
DEFAULT_THRESHOLD = 3
|
|
30
|
+
DEFAULT_WINDOW_DAYS = 30.0
|
|
31
|
+
SQLITE_FILE = "memory.sqlite3"
|
|
32
|
+
TABLE_RECALL = "co_recall"
|
|
33
|
+
TABLE_MARKS = "co_recall_marks"
|
|
34
|
+
DAY_SECONDS = 86400.0
|
|
35
|
+
MAX_LIMIT = 500
|
|
36
|
+
|
|
37
|
+
_PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
|
38
|
+
DEFAULT_DB_PATH = _PLUGIN_ROOT / "memory" / SQLITE_FILE
|
|
39
|
+
|
|
40
|
+
SCHEMA_SQL = """
|
|
41
|
+
CREATE TABLE IF NOT EXISTS co_recall(
|
|
42
|
+
pair_a TEXT NOT NULL,
|
|
43
|
+
pair_b TEXT NOT NULL,
|
|
44
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
first_ts REAL NOT NULL,
|
|
46
|
+
last_ts REAL NOT NULL,
|
|
47
|
+
PRIMARY KEY (pair_a, pair_b)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_co_recall_last ON co_recall(last_ts);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_co_recall_count ON co_recall(count);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS co_recall_marks(
|
|
52
|
+
pair_a TEXT NOT NULL,
|
|
53
|
+
pair_b TEXT NOT NULL,
|
|
54
|
+
marked_ts REAL NOT NULL,
|
|
55
|
+
threshold INTEGER NOT NULL,
|
|
56
|
+
window_days REAL NOT NULL,
|
|
57
|
+
PRIMARY KEY (pair_a, pair_b)
|
|
58
|
+
);
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------ pure functions
|
|
63
|
+
def _num(value, default=0.0):
|
|
64
|
+
try:
|
|
65
|
+
number = float(value)
|
|
66
|
+
except Exception:
|
|
67
|
+
return float(default)
|
|
68
|
+
if number != number or number in (float("inf"), float("-inf")):
|
|
69
|
+
return float(default)
|
|
70
|
+
return number
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_id(value):
|
|
74
|
+
"""One memory id as a clean string; containers and blanks become ''."""
|
|
75
|
+
if value is None or isinstance(value, (dict, list, tuple, set)):
|
|
76
|
+
return ""
|
|
77
|
+
return str(value).strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def unique_ids(ids):
|
|
81
|
+
"""Preserve order, drop blanks and duplicates (one batch, one occurrence)."""
|
|
82
|
+
seen = []
|
|
83
|
+
if not isinstance(ids, (list, tuple, set)):
|
|
84
|
+
return seen
|
|
85
|
+
for raw in ids:
|
|
86
|
+
ident = normalize_id(raw)
|
|
87
|
+
if ident and ident not in seen:
|
|
88
|
+
seen.append(ident)
|
|
89
|
+
return seen
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pair_key(a, b):
|
|
93
|
+
"""Unordered key (a, b) with a < b; None when it is not a real pair."""
|
|
94
|
+
left = normalize_id(a)
|
|
95
|
+
right = normalize_id(b)
|
|
96
|
+
if not left or not right or left == right:
|
|
97
|
+
return None
|
|
98
|
+
return (left, right) if left < right else (right, left)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pairs(ids):
|
|
102
|
+
"""All unordered pairs from one recall batch, sorted and de-duplicated."""
|
|
103
|
+
row = unique_ids(ids)
|
|
104
|
+
found = set()
|
|
105
|
+
for index in range(len(row)):
|
|
106
|
+
for other in range(index + 1, len(row)):
|
|
107
|
+
key = pair_key(row[index], row[other])
|
|
108
|
+
if key is not None:
|
|
109
|
+
found.add(key)
|
|
110
|
+
return sorted(found)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def qualifies(count, last_ts, threshold=DEFAULT_THRESHOLD,
|
|
114
|
+
window_days=DEFAULT_WINDOW_DAYS, now=None):
|
|
115
|
+
"""Pure version of the candidates rule (read-only, no storage)."""
|
|
116
|
+
limit = max(1, int(_num(threshold, DEFAULT_THRESHOLD)))
|
|
117
|
+
days = _num(window_days, DEFAULT_WINDOW_DAYS)
|
|
118
|
+
current = _now(now)
|
|
119
|
+
if int(_num(count, 0.0)) < limit:
|
|
120
|
+
return False
|
|
121
|
+
if days <= 0.0:
|
|
122
|
+
return True
|
|
123
|
+
return _num(last_ts, 0.0) >= current - days * DAY_SECONDS
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _now(now=None):
|
|
127
|
+
try:
|
|
128
|
+
return float(now if now is not None else time.time())
|
|
129
|
+
except Exception:
|
|
130
|
+
return time.time()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ----------------------------------------------------------- storage layer
|
|
134
|
+
def default_db_path():
|
|
135
|
+
"""Where a plugin data dir keeps memory/memory.sqlite3."""
|
|
136
|
+
return DEFAULT_DB_PATH
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def ensure_schema(conn):
|
|
140
|
+
"""Create the two additive tables; safe to call on every open."""
|
|
141
|
+
conn.executescript(SCHEMA_SQL)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def connect(db_path=None):
|
|
146
|
+
"""Open (and initialize) the memory DB. Caller owns the connection."""
|
|
147
|
+
path = DEFAULT_DB_PATH if db_path is None else Path(db_path)
|
|
148
|
+
if str(path) != ":memory:":
|
|
149
|
+
try:
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
conn = sqlite3.connect(str(path), timeout=5.0)
|
|
154
|
+
conn.row_factory = sqlite3.Row
|
|
155
|
+
try:
|
|
156
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
157
|
+
except sqlite3.Error:
|
|
158
|
+
pass
|
|
159
|
+
ensure_schema(conn)
|
|
160
|
+
return conn
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextmanager
|
|
164
|
+
def _session(db_path=None, conn=None):
|
|
165
|
+
owned = conn is None
|
|
166
|
+
db = conn if conn is not None else connect(db_path)
|
|
167
|
+
if not owned:
|
|
168
|
+
ensure_schema(db)
|
|
169
|
+
try:
|
|
170
|
+
yield db
|
|
171
|
+
if owned:
|
|
172
|
+
db.commit()
|
|
173
|
+
finally:
|
|
174
|
+
if owned:
|
|
175
|
+
db.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def record_recall(ids, ts=None, db_path=None, conn=None):
|
|
179
|
+
"""Count one recall batch. Unordered pairs; duplicate ids counted once."""
|
|
180
|
+
batch = pairs(ids)
|
|
181
|
+
stamp = _now(ts)
|
|
182
|
+
row = unique_ids(ids)
|
|
183
|
+
if not batch:
|
|
184
|
+
return {"pairs": 0, "ids": len(row), "ts": stamp}
|
|
185
|
+
with _session(db_path, conn) as db:
|
|
186
|
+
db.executemany(
|
|
187
|
+
"INSERT INTO co_recall(pair_a, pair_b, count, first_ts, last_ts) "
|
|
188
|
+
"VALUES(?, ?, 1, ?, ?) "
|
|
189
|
+
"ON CONFLICT(pair_a, pair_b) DO UPDATE SET "
|
|
190
|
+
"count = co_recall.count + 1, last_ts = excluded.last_ts",
|
|
191
|
+
[(left, right, stamp, stamp) for left, right in batch])
|
|
192
|
+
return {"pairs": len(batch), "ids": len(row), "ts": stamp}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _threshold(value):
|
|
196
|
+
return max(1, int(_num(value, DEFAULT_THRESHOLD)))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _window_days(value):
|
|
200
|
+
return max(0.0, _num(value, DEFAULT_WINDOW_DAYS))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _row_dict(row, current):
|
|
204
|
+
left = str(row["pair_a"])
|
|
205
|
+
right = str(row["pair_b"])
|
|
206
|
+
last_ts = _num(row["last_ts"], 0.0)
|
|
207
|
+
return {
|
|
208
|
+
"pair": (left, right),
|
|
209
|
+
"pair_a": left,
|
|
210
|
+
"pair_b": right,
|
|
211
|
+
"count": int(_num(row["count"], 0.0)),
|
|
212
|
+
"first_ts": round(_num(row["first_ts"], 0.0), 3),
|
|
213
|
+
"last_ts": round(last_ts, 3),
|
|
214
|
+
"age_days": round(max(0.0, (current - last_ts) / DAY_SECONDS), 2),
|
|
215
|
+
"marked": bool(row["marked"]),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def candidates(threshold=DEFAULT_THRESHOLD,
|
|
220
|
+
window_days=DEFAULT_WINDOW_DAYS, now=None, db_path=None,
|
|
221
|
+
conn=None):
|
|
222
|
+
"""Pairs with count >= threshold whose last co-recall is inside the window."""
|
|
223
|
+
limit = _threshold(threshold)
|
|
224
|
+
days = _window_days(window_days)
|
|
225
|
+
current = _now(now)
|
|
226
|
+
sql = ("SELECT r.pair_a, r.pair_b, r.count, r.first_ts, r.last_ts, "
|
|
227
|
+
"CASE WHEN m.pair_a IS NULL THEN 0 ELSE 1 END AS marked "
|
|
228
|
+
"FROM co_recall r LEFT JOIN co_recall_marks m "
|
|
229
|
+
"ON m.pair_a = r.pair_a AND m.pair_b = r.pair_b "
|
|
230
|
+
"WHERE r.count >= ?")
|
|
231
|
+
args = [limit]
|
|
232
|
+
if days > 0.0:
|
|
233
|
+
sql += " AND r.last_ts >= ?"
|
|
234
|
+
args.append(current - days * DAY_SECONDS)
|
|
235
|
+
sql += " ORDER BY r.count DESC, r.last_ts DESC, r.pair_a ASC, r.pair_b ASC"
|
|
236
|
+
with _session(db_path, conn) as db:
|
|
237
|
+
rows = db.execute(sql, args).fetchall()
|
|
238
|
+
return [_row_dict(row, current) for row in rows]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def mark_candidates(threshold=DEFAULT_THRESHOLD,
|
|
242
|
+
window_days=DEFAULT_WINDOW_DAYS, now=None, db_path=None,
|
|
243
|
+
conn=None):
|
|
244
|
+
"""Mark over-threshold pairs as mergeable; no deletion, no merging."""
|
|
245
|
+
limit = _threshold(threshold)
|
|
246
|
+
days = _window_days(window_days)
|
|
247
|
+
current = _now(now)
|
|
248
|
+
rows = candidates(limit, days, current, db_path, conn)
|
|
249
|
+
if not rows:
|
|
250
|
+
return {"marked": 0, "threshold": limit, "window_days": days,
|
|
251
|
+
"pairs": []}
|
|
252
|
+
with _session(db_path, conn) as db:
|
|
253
|
+
db.executemany(
|
|
254
|
+
"INSERT INTO co_recall_marks(pair_a, pair_b, marked_ts, threshold, "
|
|
255
|
+
"window_days) VALUES(?, ?, ?, ?, ?) "
|
|
256
|
+
"ON CONFLICT(pair_a, pair_b) DO UPDATE SET "
|
|
257
|
+
"marked_ts = excluded.marked_ts, threshold = excluded.threshold, "
|
|
258
|
+
"window_days = excluded.window_days",
|
|
259
|
+
[(row["pair_a"], row["pair_b"], current, limit, days)
|
|
260
|
+
for row in rows])
|
|
261
|
+
return {"marked": len(rows), "threshold": limit, "window_days": days,
|
|
262
|
+
"pairs": rows}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def read_marked(db_path=None, conn=None, limit=None):
|
|
266
|
+
"""Read-only: every marked pair, newest mark first."""
|
|
267
|
+
sql = ("SELECT m.pair_a, m.pair_b, m.marked_ts, m.threshold, "
|
|
268
|
+
"m.window_days, COALESCE(r.count, 0) AS count, "
|
|
269
|
+
"COALESCE(r.first_ts, 0.0) AS first_ts, "
|
|
270
|
+
"COALESCE(r.last_ts, 0.0) AS last_ts "
|
|
271
|
+
"FROM co_recall_marks m LEFT JOIN co_recall r "
|
|
272
|
+
"ON r.pair_a = m.pair_a AND r.pair_b = m.pair_b "
|
|
273
|
+
"ORDER BY m.marked_ts DESC, m.pair_a ASC, m.pair_b ASC")
|
|
274
|
+
args = []
|
|
275
|
+
if limit is not None:
|
|
276
|
+
size = int(_num(limit, 0.0))
|
|
277
|
+
if size > 0:
|
|
278
|
+
sql += " LIMIT ?"
|
|
279
|
+
args.append(min(size, MAX_LIMIT))
|
|
280
|
+
with _session(db_path, conn) as db:
|
|
281
|
+
rows = db.execute(sql, args).fetchall()
|
|
282
|
+
out = []
|
|
283
|
+
for row in rows:
|
|
284
|
+
left = str(row["pair_a"])
|
|
285
|
+
right = str(row["pair_b"])
|
|
286
|
+
out.append({
|
|
287
|
+
"pair": (left, right),
|
|
288
|
+
"pair_a": left,
|
|
289
|
+
"pair_b": right,
|
|
290
|
+
"count": int(_num(row["count"], 0.0)),
|
|
291
|
+
"marked_ts": round(_num(row["marked_ts"], 0.0), 3),
|
|
292
|
+
"threshold": int(_num(row["threshold"], DEFAULT_THRESHOLD)),
|
|
293
|
+
"window_days": round(_num(row["window_days"],
|
|
294
|
+
DEFAULT_WINDOW_DAYS), 2),
|
|
295
|
+
})
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def stats(db_path=None, conn=None):
|
|
300
|
+
"""Read-only totals: pairs, co-recall events, marked pairs."""
|
|
301
|
+
with _session(db_path, conn) as db:
|
|
302
|
+
row = db.execute("SELECT COUNT(*) AS pairs, "
|
|
303
|
+
"COALESCE(SUM(count), 0) AS recalls "
|
|
304
|
+
"FROM co_recall").fetchone()
|
|
305
|
+
marked = db.execute("SELECT COUNT(*) AS marked "
|
|
306
|
+
"FROM co_recall_marks").fetchone()
|
|
307
|
+
return {
|
|
308
|
+
"pairs": int(_num(row["pairs"], 0.0)),
|
|
309
|
+
"recalls": int(_num(row["recalls"], 0.0)),
|
|
310
|
+
"marked": int(_num(marked["marked"], 0.0)),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def readout(threshold=DEFAULT_THRESHOLD, window_days=DEFAULT_WINDOW_DAYS,
|
|
315
|
+
now=None, db_path=None, conn=None, limit=20):
|
|
316
|
+
"""Bounded read-only view for a panel page or a command reply."""
|
|
317
|
+
limit = max(1, min(int(_num(limit, 20.0)), MAX_LIMIT))
|
|
318
|
+
rows = candidates(threshold, window_days, now, db_path, conn)
|
|
319
|
+
return {
|
|
320
|
+
"threshold": _threshold(threshold),
|
|
321
|
+
"window_days": round(_window_days(window_days), 2),
|
|
322
|
+
"stats": stats(db_path, conn),
|
|
323
|
+
"candidates": rows[:limit],
|
|
324
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# OWNER: engine-core
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Consolidation mechanisms (default OFF, pure functions, no engine wiring)."""
|
|
4
|
+
from .gated import should_consolidate
|
|
5
|
+
from .tree import build_summary
|
|
6
|
+
from .reflection import propose
|
|
7
|
+
from .neighbors import strengthen
|
|
8
|
+
from .writeback import apply_neighbors, apply_reflections, write_tree
|
|
9
|
+
|
|
10
|
+
__all__ = ["should_consolidate", "build_summary", "propose", "strengthen",
|
|
11
|
+
"apply_reflections", "apply_neighbors", "write_tree"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""RecMem-style consolidation gate (pure, read-only, default OFF)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
GATED_ENABLED = False
|
|
8
|
+
WINDOW_SECONDS = 7 * 86400.0
|
|
9
|
+
MIN_TOPIC_REPEATS = 3
|
|
10
|
+
MIN_GAP_SECONDS = 6 * 3600.0
|
|
11
|
+
COSINE_MERGE = 0.80
|
|
12
|
+
MAX_ITEMS = 200
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ts(entry):
|
|
16
|
+
try:
|
|
17
|
+
return float(entry.get("ts") or entry.get("created_ts") or 0.0)
|
|
18
|
+
except Exception:
|
|
19
|
+
return 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tags(entry):
|
|
23
|
+
raw = entry.get("tags")
|
|
24
|
+
if not isinstance(raw, list):
|
|
25
|
+
raw = (entry.get("metadata") or {}).get("tags") if isinstance(entry.get("metadata"), dict) else []
|
|
26
|
+
return {str(x).strip().lower() for x in (raw or []) if str(x).strip()}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _topic(entry):
|
|
30
|
+
for key in ("topic", "event_key", "event_name", "thread"):
|
|
31
|
+
value = str(entry.get(key) or "").strip().lower()
|
|
32
|
+
if value:
|
|
33
|
+
return value
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _vector(entry):
|
|
38
|
+
vec = entry.get("vector")
|
|
39
|
+
if isinstance(vec, (list, tuple)) and vec:
|
|
40
|
+
return list(vec)
|
|
41
|
+
meta = entry.get("metadata")
|
|
42
|
+
if isinstance(meta, dict):
|
|
43
|
+
vec = meta.get("vector")
|
|
44
|
+
if isinstance(vec, (list, tuple)) and vec:
|
|
45
|
+
return list(vec)
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cosine(a, b):
|
|
50
|
+
try:
|
|
51
|
+
if not a or not b or len(a) != len(b):
|
|
52
|
+
return 0.0
|
|
53
|
+
dot = sum(float(x) * float(y) for x, y in zip(a, b))
|
|
54
|
+
na = sum(float(x) ** 2 for x in a) ** 0.5
|
|
55
|
+
nb = sum(float(y) ** 2 for y in b) ** 0.5
|
|
56
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
57
|
+
except Exception:
|
|
58
|
+
return 0.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _same_topic(a, b):
|
|
62
|
+
ta, tb = _tags(a), _tags(b)
|
|
63
|
+
if ta and tb and (ta & tb):
|
|
64
|
+
return True
|
|
65
|
+
topic_a, topic_b = _topic(a), _topic(b)
|
|
66
|
+
if topic_a and topic_a == topic_b:
|
|
67
|
+
return True
|
|
68
|
+
return _cosine(_vector(a), _vector(b)) >= COSINE_MERGE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _last_consolidated(entries):
|
|
72
|
+
last = 0.0
|
|
73
|
+
for entry in entries:
|
|
74
|
+
for key in ("last_consolidated_ts", "consolidated_ts", "consolidate_ts"):
|
|
75
|
+
try:
|
|
76
|
+
last = max(last, float(entry.get(key) or 0.0))
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
return last
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def should_consolidate(entries, now=None):
|
|
83
|
+
"""True only when >=3 same-topic items in 7d and last pass was >=6h ago."""
|
|
84
|
+
if not GATED_ENABLED:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
rows = [e for e in (entries or []) if isinstance(e, dict)][:MAX_ITEMS]
|
|
88
|
+
if len(rows) < MIN_TOPIC_REPEATS:
|
|
89
|
+
return False
|
|
90
|
+
stamp = float(now if now is not None else time.time())
|
|
91
|
+
recent = [e for e in rows if 0.0 <= stamp - _ts(e) <= WINDOW_SECONDS]
|
|
92
|
+
if len(recent) < MIN_TOPIC_REPEATS:
|
|
93
|
+
return False
|
|
94
|
+
last = _last_consolidated(recent)
|
|
95
|
+
if last and stamp - last < MIN_GAP_SECONDS:
|
|
96
|
+
return False
|
|
97
|
+
for i, entry in enumerate(recent):
|
|
98
|
+
same = 1 + sum(1 for j, other in enumerate(recent)
|
|
99
|
+
if j != i and _same_topic(entry, other))
|
|
100
|
+
if same >= MIN_TOPIC_REPEATS:
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""A-MEM-style neighbor strengthening proposals (read-only)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
NEIGHBORS_ENABLED = False
|
|
6
|
+
COSINE_LINK = 0.80
|
|
7
|
+
MAX_LINKS = 5
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _tags(entry):
|
|
11
|
+
raw = entry.get("tags")
|
|
12
|
+
if not isinstance(raw, list):
|
|
13
|
+
raw = (entry.get("metadata") or {}).get("tags") if isinstance(entry.get("metadata"), dict) else []
|
|
14
|
+
return {str(x).strip().lower() for x in (raw or []) if str(x).strip()}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _topic(entry):
|
|
18
|
+
for key in ("topic", "event_key", "event_name"):
|
|
19
|
+
value = str(entry.get(key) or "").strip().lower()
|
|
20
|
+
if value:
|
|
21
|
+
return value
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cosine(a, b):
|
|
26
|
+
try:
|
|
27
|
+
if not a or not b or len(a) != len(b):
|
|
28
|
+
return 0.0
|
|
29
|
+
dot = sum(float(x) * float(y) for x, y in zip(a, b))
|
|
30
|
+
na = sum(float(x) ** 2 for x in a) ** 0.5
|
|
31
|
+
nb = sum(float(y) ** 2 for y in b) ** 0.5
|
|
32
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
33
|
+
except Exception:
|
|
34
|
+
return 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strengthen(entry, neighbors):
|
|
38
|
+
"""Suggest bounded links for an entry; returns {entry_id, link_ids, weight}."""
|
|
39
|
+
result = {"entry_id": str((entry or {}).get("id") or "") if isinstance(entry, dict) else "",
|
|
40
|
+
"link_ids": [], "weight": 0.0}
|
|
41
|
+
if not NEIGHBORS_ENABLED or not isinstance(entry, dict):
|
|
42
|
+
return result
|
|
43
|
+
try:
|
|
44
|
+
base_tags, base_topic = _tags(entry), _topic(entry)
|
|
45
|
+
base_vec = entry.get("vector") if isinstance(entry.get("vector"), list) else []
|
|
46
|
+
scored = []
|
|
47
|
+
for other in neighbors or []:
|
|
48
|
+
if not isinstance(other, dict):
|
|
49
|
+
continue
|
|
50
|
+
oid = str(other.get("id") or "")
|
|
51
|
+
if not oid or oid == result["entry_id"]:
|
|
52
|
+
continue
|
|
53
|
+
sim = _cosine(base_vec, other.get("vector") or [])
|
|
54
|
+
if base_tags & _tags(other):
|
|
55
|
+
sim = max(sim, 0.80)
|
|
56
|
+
if base_topic and base_topic == _topic(other):
|
|
57
|
+
sim = max(sim, 0.80)
|
|
58
|
+
if sim >= COSINE_LINK:
|
|
59
|
+
scored.append((sim, oid))
|
|
60
|
+
scored.sort(key=lambda row: (-row[0], row[1]))
|
|
61
|
+
links = [oid for _sim, oid in scored[:MAX_LINKS]]
|
|
62
|
+
weight = round(max(0.0, min(1.0, sum(s for s, _ in scored[:MAX_LINKS])
|
|
63
|
+
/ max(1, len(links)))), 4) if links else 0.0
|
|
64
|
+
result["link_ids"] = links
|
|
65
|
+
result["weight"] = weight
|
|
66
|
+
return result
|
|
67
|
+
except Exception:
|
|
68
|
+
return result
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Reflection stage: propose experience candidates from repeated events (no writes)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
REFLECTION_ENABLED = False
|
|
6
|
+
MIN_REPEATS = 2
|
|
7
|
+
MAX_PROPOSALS = 5
|
|
8
|
+
MAX_TEXT = 120
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _key(entry):
|
|
12
|
+
for key in ("event_key", "topic", "event_name"):
|
|
13
|
+
value = str(entry.get(key) or "").strip()
|
|
14
|
+
if value:
|
|
15
|
+
return value[:60]
|
|
16
|
+
return " ".join(str(entry.get("text") or "").split())[:60]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def propose(entries):
|
|
20
|
+
"""Return [{text, source_ids}] experience candidates; never writes storage."""
|
|
21
|
+
if not REFLECTION_ENABLED:
|
|
22
|
+
return []
|
|
23
|
+
try:
|
|
24
|
+
groups = {}
|
|
25
|
+
for entry in entries or []:
|
|
26
|
+
if not isinstance(entry, dict):
|
|
27
|
+
continue
|
|
28
|
+
key = _key(entry)
|
|
29
|
+
if key:
|
|
30
|
+
groups.setdefault(key, []).append(entry)
|
|
31
|
+
out = []
|
|
32
|
+
for key, items in sorted(groups.items(), key=lambda kv: -len(kv[1])):
|
|
33
|
+
if len(items) < MIN_REPEATS:
|
|
34
|
+
continue
|
|
35
|
+
sample = " ".join(str(items[-1].get("text") or "").split())[:60]
|
|
36
|
+
text = "经验:关于「%s」的重复经历共 %d 次;最近一次:%s" % (
|
|
37
|
+
key[:40], len(items), sample)
|
|
38
|
+
out.append({"text": text[:MAX_TEXT],
|
|
39
|
+
"source_ids": [str(x.get("id") or "") for x in items[:8]
|
|
40
|
+
if str(x.get("id") or "")]})
|
|
41
|
+
if len(out) >= MAX_PROPOSALS:
|
|
42
|
+
break
|
|
43
|
+
return out
|
|
44
|
+
except Exception:
|
|
45
|
+
return []
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""TiMem-style three-level temporal tree summaries (pure, no writes)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
TREE_ENABLED = False
|
|
8
|
+
THRESHOLD_L1 = 10
|
|
9
|
+
THRESHOLD_L2 = 5
|
|
10
|
+
THRESHOLD_L3 = 3
|
|
11
|
+
MAX_SOURCE_IDS = 8
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ts(entry):
|
|
15
|
+
try:
|
|
16
|
+
return float(entry.get("ts") or 0.0)
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _day(entry):
|
|
22
|
+
try:
|
|
23
|
+
return time.strftime("%Y-%m-%d", time.gmtime(_ts(entry)))
|
|
24
|
+
except Exception:
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _key(entry):
|
|
29
|
+
for key in ("event_key", "topic", "event_name", "thread"):
|
|
30
|
+
value = str(entry.get(key) or "").strip()
|
|
31
|
+
if value:
|
|
32
|
+
return value[:60]
|
|
33
|
+
text = " ".join(str(entry.get("text") or "").split())
|
|
34
|
+
return text[:40] or "untitled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raw(rows):
|
|
38
|
+
return [{"id": str(e.get("id") or ""), "text": str(e.get("text") or "")[:120],
|
|
39
|
+
"ts": _ts(e)} for e in rows]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _group(rows, key_fn):
|
|
43
|
+
groups = {}
|
|
44
|
+
for entry in rows:
|
|
45
|
+
key = str(key_fn(entry) or "")
|
|
46
|
+
if not key:
|
|
47
|
+
continue
|
|
48
|
+
groups.setdefault(key, []).append(entry)
|
|
49
|
+
out = []
|
|
50
|
+
for key, items in groups.items():
|
|
51
|
+
items = sorted(items, key=_ts)
|
|
52
|
+
out.append({"key": key, "count": len(items),
|
|
53
|
+
"source_ids": [str(x.get("id") or "") for x in items[:MAX_SOURCE_IDS]
|
|
54
|
+
if str(x.get("id") or "")],
|
|
55
|
+
"ts_min": _ts(items[0]), "ts_max": _ts(items[-1]),
|
|
56
|
+
"sample": str(items[-1].get("text") or "")[:120]})
|
|
57
|
+
out.sort(key=lambda row: (-row["count"], row["key"]))
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_summary(entries, level=1):
|
|
62
|
+
"""Build a level summary only when its threshold is exceeded.
|
|
63
|
+
|
|
64
|
+
Failure/disabled returns the original rows unchanged (items + generated=False).
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
rows = [e for e in (entries or []) if isinstance(e, dict)]
|
|
68
|
+
except Exception:
|
|
69
|
+
rows = []
|
|
70
|
+
try:
|
|
71
|
+
lvl = int(level if level is not None else 1)
|
|
72
|
+
except Exception:
|
|
73
|
+
lvl = 1
|
|
74
|
+
base = {"level": lvl, "items": _raw(rows), "generated": False,
|
|
75
|
+
"enabled": bool(TREE_ENABLED)}
|
|
76
|
+
if not TREE_ENABLED:
|
|
77
|
+
return base
|
|
78
|
+
try:
|
|
79
|
+
if lvl <= 1:
|
|
80
|
+
if len(rows) <= THRESHOLD_L1:
|
|
81
|
+
return base
|
|
82
|
+
return {"level": 1, "items": _raw(rows)[:THRESHOLD_L1],
|
|
83
|
+
"generated": True, "enabled": True, "truncated": len(rows)}
|
|
84
|
+
if lvl == 2:
|
|
85
|
+
if len(rows) <= THRESHOLD_L2:
|
|
86
|
+
return base
|
|
87
|
+
return {"level": 2, "items": _group(rows, _key),
|
|
88
|
+
"generated": True, "enabled": True}
|
|
89
|
+
if len(rows) <= THRESHOLD_L3:
|
|
90
|
+
return base
|
|
91
|
+
return {"level": 3, "items": _group(rows, _day),
|
|
92
|
+
"generated": True, "enabled": True}
|
|
93
|
+
except Exception:
|
|
94
|
+
return {"level": lvl, "items": _raw(rows), "generated": False,
|
|
95
|
+
"enabled": bool(TREE_ENABLED), "error": "summary_failed"}
|