aethergraph 0.1.0a3__py3-none-any.whl → 0.1.0a4__py3-none-any.whl
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.
- aethergraph/api/v1/artifacts.py +23 -4
- aethergraph/api/v1/schemas.py +7 -0
- aethergraph/api/v1/session.py +123 -4
- aethergraph/config/config.py +2 -0
- aethergraph/config/search.py +49 -0
- aethergraph/contracts/services/channel.py +18 -1
- aethergraph/contracts/services/execution.py +58 -0
- aethergraph/contracts/services/llm.py +26 -0
- aethergraph/contracts/services/memory.py +10 -4
- aethergraph/contracts/services/planning.py +53 -0
- aethergraph/contracts/storage/event_log.py +8 -0
- aethergraph/contracts/storage/search_backend.py +47 -0
- aethergraph/contracts/storage/vector_index.py +73 -0
- aethergraph/core/graph/action_spec.py +76 -0
- aethergraph/core/graph/graph_fn.py +75 -2
- aethergraph/core/graph/graphify.py +74 -2
- aethergraph/core/runtime/graph_runner.py +2 -1
- aethergraph/core/runtime/node_context.py +66 -3
- aethergraph/core/runtime/node_services.py +8 -0
- aethergraph/core/runtime/run_manager.py +263 -271
- aethergraph/core/runtime/run_types.py +54 -1
- aethergraph/core/runtime/runtime_env.py +35 -14
- aethergraph/core/runtime/runtime_services.py +308 -18
- aethergraph/plugins/agents/default_chat_agent.py +266 -74
- aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
- aethergraph/plugins/channel/adapters/webui.py +69 -21
- aethergraph/plugins/channel/routes/webui_routes.py +8 -48
- aethergraph/runtime/__init__.py +12 -0
- aethergraph/server/app_factory.py +3 -0
- aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
- aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
- aethergraph/server/ui_static/index.html +2 -2
- aethergraph/services/artifacts/facade.py +157 -21
- aethergraph/services/artifacts/types.py +35 -0
- aethergraph/services/artifacts/utils.py +42 -0
- aethergraph/services/channel/channel_bus.py +3 -1
- aethergraph/services/channel/event_hub copy.py +55 -0
- aethergraph/services/channel/event_hub.py +81 -0
- aethergraph/services/channel/factory.py +3 -2
- aethergraph/services/channel/session.py +709 -74
- aethergraph/services/container/default_container.py +69 -7
- aethergraph/services/execution/__init__.py +0 -0
- aethergraph/services/execution/local_python.py +118 -0
- aethergraph/services/indices/__init__.py +0 -0
- aethergraph/services/indices/global_indices.py +21 -0
- aethergraph/services/indices/scoped_indices.py +292 -0
- aethergraph/services/llm/generic_client.py +342 -46
- aethergraph/services/llm/generic_embed_client.py +359 -0
- aethergraph/services/llm/types.py +3 -1
- aethergraph/services/memory/distillers/llm_long_term.py +60 -109
- aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
- aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
- aethergraph/services/memory/distillers/long_term.py +48 -131
- aethergraph/services/memory/distillers/long_term_v1.py +170 -0
- aethergraph/services/memory/facade/chat.py +18 -8
- aethergraph/services/memory/facade/core.py +159 -19
- aethergraph/services/memory/facade/distillation.py +86 -31
- aethergraph/services/memory/facade/retrieval.py +100 -1
- aethergraph/services/memory/factory.py +4 -1
- aethergraph/services/planning/__init__.py +0 -0
- aethergraph/services/planning/action_catalog.py +271 -0
- aethergraph/services/planning/bindings.py +56 -0
- aethergraph/services/planning/dependency_index.py +65 -0
- aethergraph/services/planning/flow_validator.py +263 -0
- aethergraph/services/planning/graph_io_adapter.py +150 -0
- aethergraph/services/planning/input_parser.py +312 -0
- aethergraph/services/planning/missing_inputs.py +28 -0
- aethergraph/services/planning/node_planner.py +613 -0
- aethergraph/services/planning/orchestrator.py +112 -0
- aethergraph/services/planning/plan_executor.py +506 -0
- aethergraph/services/planning/plan_types.py +321 -0
- aethergraph/services/planning/planner.py +617 -0
- aethergraph/services/planning/planner_service.py +369 -0
- aethergraph/services/planning/planning_context_builder.py +43 -0
- aethergraph/services/planning/quick_actions.py +29 -0
- aethergraph/services/planning/routers/__init__.py +0 -0
- aethergraph/services/planning/routers/simple_router.py +26 -0
- aethergraph/services/rag/facade.py +0 -3
- aethergraph/services/scope/scope.py +30 -30
- aethergraph/services/scope/scope_factory.py +15 -7
- aethergraph/services/skills/__init__.py +0 -0
- aethergraph/services/skills/skill_registry.py +465 -0
- aethergraph/services/skills/skills.py +220 -0
- aethergraph/services/skills/utils.py +194 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
- aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
- aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
- aethergraph/storage/memory/event_persist.py +42 -2
- aethergraph/storage/memory/fs_persist.py +32 -2
- aethergraph/storage/search_backend/__init__.py +0 -0
- aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
- aethergraph/storage/search_backend/null_backend.py +34 -0
- aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
- aethergraph/storage/search_backend/utils.py +31 -0
- aethergraph/storage/search_factory.py +75 -0
- aethergraph/storage/vector_index/faiss_index.py +72 -4
- aethergraph/storage/vector_index/sqlite_index.py +521 -52
- aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
- aethergraph/storage/vector_index/utils.py +22 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
- aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
- aethergraph/services/eventhub/event_hub.py +0 -76
- aethergraph/services/llm/generic_client copy.py +0 -691
- aethergraph/services/prompts/file_store.py +0 -41
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from aethergraph.contracts.services.llm import LLMClientProtocol
|
|
8
|
+
from aethergraph.contracts.services.memory import Distiller, Event, HotLog
|
|
9
|
+
from aethergraph.contracts.storage.doc_store import DocStore
|
|
10
|
+
|
|
11
|
+
# metering
|
|
12
|
+
from aethergraph.services.memory.facade.utils import now_iso
|
|
13
|
+
from aethergraph.services.memory.utils import _summary_doc_id
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMLongTermSummarizer(Distiller):
|
|
17
|
+
"""
|
|
18
|
+
LLM-based long-term summarizer.
|
|
19
|
+
|
|
20
|
+
Flow:
|
|
21
|
+
1) Pull recent events from HotLog.
|
|
22
|
+
2) Filter by kind/tag/signal.
|
|
23
|
+
3) Build a prompt that shows the most important events as a transcript.
|
|
24
|
+
4) Call LLM to generate a structured summary.
|
|
25
|
+
5) Save summary JSON via Persistence.save_json(uri).
|
|
26
|
+
6) Emit a long_term_summary Event pointing to summary_uri.
|
|
27
|
+
|
|
28
|
+
This is complementary to RAG:
|
|
29
|
+
- LLM distiller compresses sequences into a digest.
|
|
30
|
+
- RAG uses many such digests + raw docs for retrieval.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
llm: LLMClientProtocol,
|
|
37
|
+
summary_kind: str = "long_term_summary",
|
|
38
|
+
summary_tag: str = "session",
|
|
39
|
+
include_kinds: list[str] | None = None,
|
|
40
|
+
include_tags: list[str] | None = None,
|
|
41
|
+
max_events: int = 200,
|
|
42
|
+
min_signal: float = 0.0,
|
|
43
|
+
model: str | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.llm = llm
|
|
46
|
+
self.summary_kind = summary_kind
|
|
47
|
+
self.summary_tag = summary_tag
|
|
48
|
+
self.include_kinds = include_kinds
|
|
49
|
+
self.include_tags = include_tags
|
|
50
|
+
self.max_events = max_events
|
|
51
|
+
self.min_signal = min_signal
|
|
52
|
+
self.model = model # optional model override
|
|
53
|
+
|
|
54
|
+
def _filter_events(self, events: Iterable[Event]) -> list[Event]:
|
|
55
|
+
out: list[Event] = []
|
|
56
|
+
kinds = set(self.include_kinds) if self.include_kinds else None
|
|
57
|
+
tags = set(self.include_tags) if self.include_tags else None
|
|
58
|
+
|
|
59
|
+
for e in events:
|
|
60
|
+
if kinds is not None and e.kind not in kinds:
|
|
61
|
+
continue
|
|
62
|
+
if tags is not None:
|
|
63
|
+
if not e.tags:
|
|
64
|
+
continue
|
|
65
|
+
if not tags.issubset(set(e.tags)):
|
|
66
|
+
continue
|
|
67
|
+
if (e.signal or 0.0) < self.min_signal:
|
|
68
|
+
continue
|
|
69
|
+
out.append(e)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
def _build_prompt(self, events: list[Event]) -> list[dict[str, str]]:
|
|
73
|
+
"""
|
|
74
|
+
Convert events into a chat-style context for summarization.
|
|
75
|
+
|
|
76
|
+
We keep it model-agnostic: a list of {role, content} messages.
|
|
77
|
+
"""
|
|
78
|
+
lines: list[str] = []
|
|
79
|
+
|
|
80
|
+
for e in events:
|
|
81
|
+
role = e.stage or e.kind or "event"
|
|
82
|
+
if e.text:
|
|
83
|
+
lines.append(f"[{role}] {e.text}")
|
|
84
|
+
|
|
85
|
+
transcript = "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
system = (
|
|
88
|
+
"You are a log summarizer for an agent's memory. "
|
|
89
|
+
"Given a chronological transcript of events, produce a concise summary "
|
|
90
|
+
"of what happened, key themes, important user facts, and open TODOs."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
user = (
|
|
94
|
+
"Here is the recent event transcript:\n\n"
|
|
95
|
+
f"{transcript}\n\n"
|
|
96
|
+
"Return a JSON object with keys: "
|
|
97
|
+
"`summary` (string), "
|
|
98
|
+
"`key_facts` (list of strings), "
|
|
99
|
+
"`open_loops` (list of strings)."
|
|
100
|
+
"Do not use markdown or include explanations or context outside the JSON."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
{"role": "system", "content": system},
|
|
105
|
+
{"role": "user", "content": user},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
async def distill(
|
|
109
|
+
self,
|
|
110
|
+
run_id: str,
|
|
111
|
+
timeline_id: str,
|
|
112
|
+
scope_id: str = None,
|
|
113
|
+
*,
|
|
114
|
+
hotlog: HotLog,
|
|
115
|
+
docs: DocStore,
|
|
116
|
+
**kw: Any,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
# 1) fetch more events than needed, then filter
|
|
119
|
+
raw = await hotlog.recent(timeline_id, kinds=None, limit=self.max_events * 2)
|
|
120
|
+
kept = self._filter_events(raw)
|
|
121
|
+
if not kept:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
kept = kept[-self.max_events :]
|
|
125
|
+
first_ts = kept[0].ts
|
|
126
|
+
last_ts = kept[-1].ts
|
|
127
|
+
|
|
128
|
+
# 2) Build prompt and call LLM
|
|
129
|
+
messages = self._build_prompt(kept)
|
|
130
|
+
|
|
131
|
+
# LLMClientProtocol: assume chat(...) returns (text, usage)
|
|
132
|
+
summary_json_str, usage = await self.llm.chat(
|
|
133
|
+
messages,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# 3) Parse LLM JSON response
|
|
137
|
+
try:
|
|
138
|
+
payload = json.loads(summary_json_str)
|
|
139
|
+
except Exception:
|
|
140
|
+
payload = {
|
|
141
|
+
"summary": summary_json_str,
|
|
142
|
+
"key_facts": [],
|
|
143
|
+
"open_loops": [],
|
|
144
|
+
}
|
|
145
|
+
ts = now_iso()
|
|
146
|
+
|
|
147
|
+
summary_obj = {
|
|
148
|
+
"type": self.summary_kind,
|
|
149
|
+
"version": 1,
|
|
150
|
+
"run_id": run_id,
|
|
151
|
+
"scope_id": scope_id or run_id,
|
|
152
|
+
"summary_tag": self.summary_tag,
|
|
153
|
+
"ts": ts,
|
|
154
|
+
"time_window": {"from": first_ts, "to": last_ts},
|
|
155
|
+
"num_events": len(kept),
|
|
156
|
+
"source_event_ids": [e.event_id for e in kept],
|
|
157
|
+
"summary": payload.get("summary", ""),
|
|
158
|
+
"key_facts": payload.get("key_facts", []),
|
|
159
|
+
"open_loops": payload.get("open_loops", []),
|
|
160
|
+
"llm_usage": usage,
|
|
161
|
+
"llm_model": self.llm.model if hasattr(self.llm, "model") else None,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
scope = scope_id or run_id
|
|
165
|
+
doc_id = _summary_doc_id(scope, self.summary_tag, ts)
|
|
166
|
+
await docs.put(doc_id, summary_obj)
|
|
167
|
+
|
|
168
|
+
# 4) Emit summary Event with preview + uri in data
|
|
169
|
+
text = summary_obj["summary"] or ""
|
|
170
|
+
preview = text[:2000] + (" …[truncated]" if len(text) > 2000 else "")
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"summary_doc_id": doc_id,
|
|
174
|
+
"summary_kind": self.summary_kind,
|
|
175
|
+
"summary_tag": self.summary_tag,
|
|
176
|
+
"time_window": summary_obj["time_window"],
|
|
177
|
+
"num_events": len(kept),
|
|
178
|
+
"preview": preview,
|
|
179
|
+
"ts": ts,
|
|
180
|
+
}
|
|
@@ -1,70 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Iterable
|
|
4
3
|
import json
|
|
5
4
|
from typing import Any
|
|
6
5
|
|
|
7
6
|
from aethergraph.contracts.services.llm import LLMClientProtocol
|
|
8
|
-
from aethergraph.contracts.services.memory import Distiller,
|
|
7
|
+
from aethergraph.contracts.services.memory import Distiller, HotLog
|
|
9
8
|
from aethergraph.contracts.storage.doc_store import DocStore
|
|
10
|
-
from aethergraph.
|
|
11
|
-
from aethergraph.services.memory.distillers.long_term import ar_summary_uri
|
|
12
|
-
from aethergraph.services.memory.facade.utils import now_iso, stable_event_id
|
|
9
|
+
from aethergraph.services.memory.facade.utils import now_iso
|
|
13
10
|
from aethergraph.services.memory.utils import _summary_doc_id, _summary_prefix
|
|
14
11
|
|
|
15
|
-
"""
|
|
16
|
-
Meta-summary pipeline (multi-scale memory):
|
|
17
|
-
|
|
18
|
-
1) Raw events (chat_user / chat_assistant) are recorded via `mem.record(...)`.
|
|
19
|
-
2) `mem.distill_long_term(...)` compresses recent events into JSON summaries under:
|
|
20
|
-
mem/<scope_id>/summaries/<summary_tag>/...
|
|
21
|
-
e.g. summary_tag="session" → session-level long-term summaries.
|
|
22
|
-
3) `mem.distill_meta_summary(...)` loads those saved summaries from disk and asks the LLM
|
|
23
|
-
to produce a higher-level "summary of summaries" (meta summary), written under:
|
|
24
|
-
mem/<scope_id>/summaries/<meta_tag>/...
|
|
25
|
-
|
|
26
|
-
ASCII view:
|
|
27
|
-
|
|
28
|
-
[events in HotLog + Persistence]
|
|
29
|
-
│
|
|
30
|
-
▼
|
|
31
|
-
distill_long_term(...)
|
|
32
|
-
│
|
|
33
|
-
▼
|
|
34
|
-
file://mem/<scope>/summaries/session/*.json (long_term_summary)
|
|
35
|
-
│
|
|
36
|
-
▼
|
|
37
|
-
distill_meta_summary(...)
|
|
38
|
-
│
|
|
39
|
-
▼
|
|
40
|
-
file://mem/<scope>/summaries/meta/*.json (meta_summary: summary of summaries)
|
|
41
|
-
|
|
42
|
-
You control time scales via `summary_tag` (e.g. "session", "weekly", "meta") and
|
|
43
|
-
`scope_id` (e.g. user+persona).
|
|
44
|
-
"""
|
|
45
|
-
|
|
46
12
|
|
|
47
13
|
class LLMMetaSummaryDistiller(Distiller):
|
|
48
|
-
"""
|
|
49
|
-
LLM-based "summary of summaries" distiller.
|
|
50
|
-
|
|
51
|
-
Intended use:
|
|
52
|
-
- Input: previously generated summary Events (e.g. kind="long_term_summary").
|
|
53
|
-
- Output: higher-level meta summary (e.g. kind="meta_summary") for a broader time scale.
|
|
54
|
-
|
|
55
|
-
Example:
|
|
56
|
-
- Source: summary_tag="session" (daily/session summaries)
|
|
57
|
-
- Target: summary_tag="meta" (multi-session / weekly/monthly view)
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
14
|
def __init__(
|
|
61
15
|
self,
|
|
62
16
|
*,
|
|
63
17
|
llm: LLMClientProtocol,
|
|
64
|
-
# Source summaries (what we are compressing)
|
|
65
18
|
source_kind: str = "long_term_summary",
|
|
66
19
|
source_tag: str = "session",
|
|
67
|
-
# Target summary (what we produce)
|
|
68
20
|
summary_kind: str = "meta_summary",
|
|
69
21
|
summary_tag: str = "meta",
|
|
70
22
|
max_summaries: int = 20,
|
|
@@ -78,115 +30,33 @@ class LLMMetaSummaryDistiller(Distiller):
|
|
|
78
30
|
self.summary_tag = summary_tag
|
|
79
31
|
self.max_summaries = max_summaries
|
|
80
32
|
self.min_signal = min_signal
|
|
81
|
-
self.model = model
|
|
82
|
-
|
|
83
|
-
def _filter_source_summaries(self, events: Iterable[Event]) -> list[Event]:
|
|
84
|
-
"""
|
|
85
|
-
Keep only summary Events matching:
|
|
86
|
-
- kind == source_kind
|
|
87
|
-
- tags include source_tag (and ideally 'summary')
|
|
88
|
-
- signal >= min_signal
|
|
89
|
-
"""
|
|
90
|
-
out: list[Event] = []
|
|
91
|
-
for e in events:
|
|
92
|
-
if e.kind != self.source_kind:
|
|
93
|
-
continue
|
|
94
|
-
if (e.signal or 0.0) < self.min_signal:
|
|
95
|
-
continue
|
|
96
|
-
tags = set(e.tags or [])
|
|
97
|
-
if self.source_tag and self.source_tag not in tags:
|
|
98
|
-
continue
|
|
99
|
-
# Optional, but helps avoid mixing random summaries:
|
|
100
|
-
# require generic "summary" tag if present in your existing pipeline.
|
|
101
|
-
# if "summary" not in tags:
|
|
102
|
-
# continue
|
|
103
|
-
out.append(e)
|
|
104
|
-
return out
|
|
105
|
-
|
|
106
|
-
def _build_prompt(self, summaries: list[Event]) -> list[dict[str, str]]:
|
|
107
|
-
"""
|
|
108
|
-
Convert summary Events into a chat prompt for the LLM.
|
|
109
|
-
|
|
110
|
-
We use:
|
|
111
|
-
- e.text as the main human-readable summary preview.
|
|
112
|
-
- e.data.get("time_window") if present.
|
|
113
|
-
"""
|
|
114
|
-
|
|
115
|
-
lines: list[str] = []
|
|
116
|
-
|
|
117
|
-
for idx, e in enumerate(summaries, start=1):
|
|
118
|
-
tw = (e.data or {}).get("time_window") if e.data else None
|
|
119
|
-
tw_from = (tw or {}).get("from", e.ts)
|
|
120
|
-
tw_to = (tw or {}).get("to", e.ts)
|
|
121
|
-
body = e.text or ""
|
|
122
|
-
lines.append(f"Summary {idx} [{tw_from} → {tw_to}]:\n{body}\n")
|
|
123
|
-
|
|
124
|
-
transcript = "\n\n".join(lines)
|
|
125
|
-
|
|
126
|
-
system = (
|
|
127
|
-
"You are a higher-level summarizer over an agent's existing summaries. "
|
|
128
|
-
"Given multiple prior summaries (each covering a period of time), you "
|
|
129
|
-
"should produce a concise, higher-level meta-summary capturing: "
|
|
130
|
-
" - long-term themes and patterns, "
|
|
131
|
-
" - important user facts that remain true, "
|
|
132
|
-
" - long-running goals or open loops."
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
user = (
|
|
136
|
-
"Here are several previous summaries, each describing a time window:"
|
|
137
|
-
"\n\n"
|
|
138
|
-
f"{transcript}\n\n"
|
|
139
|
-
"Return a JSON object with keys: "
|
|
140
|
-
"`summary` (string), "
|
|
141
|
-
"`key_facts` (list of strings), "
|
|
142
|
-
"`open_loops` (list of strings). "
|
|
143
|
-
"Do not use markdown or include explanations outside the JSON."
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
return [
|
|
147
|
-
{"role": "system", "content": system},
|
|
148
|
-
{"role": "user", "content": user},
|
|
149
|
-
]
|
|
33
|
+
self.model = model
|
|
150
34
|
|
|
151
35
|
def _build_prompt_from_saved(self, summaries: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
152
|
-
"""
|
|
153
|
-
Build an LLM prompt from persisted summary JSONs.
|
|
154
|
-
|
|
155
|
-
Each summary dict is the JSON you showed:
|
|
156
|
-
{
|
|
157
|
-
"type": "long_term_summary",
|
|
158
|
-
"summary_tag": "session",
|
|
159
|
-
"summary": "...",
|
|
160
|
-
"time_window": {...},
|
|
161
|
-
...
|
|
162
|
-
}
|
|
163
|
-
"""
|
|
164
36
|
lines: list[str] = []
|
|
165
|
-
|
|
166
37
|
for idx, s in enumerate(summaries, start=1):
|
|
167
38
|
tw = s.get("time_window") or {}
|
|
168
|
-
tw_from = tw.get("from"
|
|
169
|
-
tw_to = tw.get("to"
|
|
170
|
-
body = s.get("summary", "") or ""
|
|
39
|
+
tw_from = tw.get("from") or s.get("ts")
|
|
40
|
+
tw_to = tw.get("to") or s.get("ts")
|
|
171
41
|
|
|
172
|
-
# (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
body_for_prompt = stripped or body
|
|
179
|
-
else:
|
|
180
|
-
body_for_prompt = body
|
|
42
|
+
# Support both "summary" (LLM distiller) and "text" (non-LLM distiller)
|
|
43
|
+
body = (s.get("summary") or s.get("text") or "").strip()
|
|
44
|
+
|
|
45
|
+
# Minimal fence stripping if someone stored fenced content
|
|
46
|
+
if body.startswith("```"):
|
|
47
|
+
body = body.strip().strip("`").strip()
|
|
181
48
|
|
|
182
|
-
|
|
49
|
+
if len(body) > 2000:
|
|
50
|
+
body = body[:2000] + "…"
|
|
51
|
+
|
|
52
|
+
lines.append(f"Summary {idx} [{tw_from} → {tw_to}]:\n{body}\n")
|
|
183
53
|
|
|
184
54
|
transcript = "\n\n".join(lines)
|
|
185
55
|
|
|
186
56
|
system = (
|
|
187
57
|
"You are a higher-level summarizer over an agent's existing long-term summaries. "
|
|
188
|
-
"Given multiple prior summaries (each describing a
|
|
189
|
-
"
|
|
58
|
+
"Given multiple prior summaries (each describing a time window), produce a meta-summary "
|
|
59
|
+
"capturing long-term themes, stable user facts, and persistent open loops."
|
|
190
60
|
)
|
|
191
61
|
|
|
192
62
|
user = (
|
|
@@ -199,36 +69,22 @@ class LLMMetaSummaryDistiller(Distiller):
|
|
|
199
69
|
"Do not include any extra explanation outside the JSON."
|
|
200
70
|
)
|
|
201
71
|
|
|
202
|
-
return [
|
|
203
|
-
{"role": "system", "content": system},
|
|
204
|
-
{"role": "user", "content": user},
|
|
205
|
-
]
|
|
72
|
+
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
206
73
|
|
|
207
74
|
async def distill(
|
|
208
75
|
self,
|
|
209
76
|
run_id: str,
|
|
210
77
|
timeline_id: str,
|
|
211
|
-
scope_id: str = None,
|
|
78
|
+
scope_id: str | None = None,
|
|
212
79
|
*,
|
|
213
80
|
hotlog: HotLog,
|
|
214
|
-
persistence: Persistence,
|
|
215
|
-
indices: Indices,
|
|
216
81
|
docs: DocStore,
|
|
217
82
|
**kw: Any,
|
|
218
83
|
) -> dict[str, Any]:
|
|
219
|
-
"""
|
|
220
|
-
Distill method following the Distiller protocol.
|
|
221
|
-
|
|
222
|
-
IMPORTANT:
|
|
223
|
-
- This implementation is optimized for FSPersistence and reads
|
|
224
|
-
previously saved summary JSONs from:
|
|
225
|
-
mem/<scope_id>/summaries/<source_tag>/*.json
|
|
226
|
-
- If a different Persistence is used, we currently bail out.
|
|
227
|
-
"""
|
|
228
84
|
scope = scope_id or run_id
|
|
229
85
|
prefix = _summary_prefix(scope, self.source_tag)
|
|
230
86
|
|
|
231
|
-
#
|
|
87
|
+
# Load persisted long-term summaries from DocStore
|
|
232
88
|
try:
|
|
233
89
|
all_ids = await docs.list()
|
|
234
90
|
except Exception:
|
|
@@ -239,68 +95,59 @@ class LLMMetaSummaryDistiller(Distiller):
|
|
|
239
95
|
return {}
|
|
240
96
|
|
|
241
97
|
chosen_ids = candidates[-self.max_summaries :]
|
|
242
|
-
|
|
98
|
+
loaded: list[dict[str, Any]] = []
|
|
243
99
|
for doc_id in chosen_ids:
|
|
244
100
|
try:
|
|
245
101
|
doc = await docs.get(doc_id)
|
|
246
102
|
if doc is not None:
|
|
247
|
-
|
|
103
|
+
loaded.append(doc) # type: ignore[arg-type]
|
|
248
104
|
except Exception:
|
|
249
105
|
continue
|
|
250
106
|
|
|
251
|
-
if not
|
|
107
|
+
if not loaded:
|
|
252
108
|
return {}
|
|
253
109
|
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
for s in
|
|
257
|
-
sig = (
|
|
258
|
-
float(s.get("signal", 0.0)) if isinstance(s.get("signal"), int | float) else 1.0
|
|
259
|
-
) # default 1.0
|
|
260
|
-
if sig < self.min_signal:
|
|
261
|
-
continue
|
|
262
|
-
# Also enforce type/tag consistency:
|
|
110
|
+
# Enforce consistency + min_signal if present
|
|
111
|
+
kept: list[dict[str, Any]] = []
|
|
112
|
+
for s in loaded:
|
|
263
113
|
if s.get("type") != self.source_kind:
|
|
264
114
|
continue
|
|
265
115
|
if s.get("summary_tag") != self.source_tag:
|
|
266
116
|
continue
|
|
267
|
-
filtered.append(s)
|
|
268
117
|
|
|
269
|
-
|
|
270
|
-
|
|
118
|
+
sig_val = s.get("signal", None)
|
|
119
|
+
if isinstance(sig_val, (int, float)) and float(sig_val) < self.min_signal: # noqa: UP038
|
|
120
|
+
continue
|
|
121
|
+
kept.append(s)
|
|
271
122
|
|
|
272
|
-
|
|
273
|
-
|
|
123
|
+
if not kept:
|
|
124
|
+
return {}
|
|
274
125
|
|
|
275
|
-
#
|
|
276
|
-
|
|
277
|
-
last_to = None
|
|
278
|
-
for s in kept:
|
|
126
|
+
# Derive aggregated time window safely
|
|
127
|
+
def _pick_time(s: dict[str, Any], key: str) -> str | None:
|
|
279
128
|
tw = s.get("time_window") or {}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if
|
|
287
|
-
first_from = kept[0].get("ts")
|
|
288
|
-
if last_to is None:
|
|
289
|
-
last_to = kept[-1].get("ts")
|
|
129
|
+
return tw.get(key) or s.get("ts")
|
|
130
|
+
|
|
131
|
+
times_from = [t for t in (_pick_time(s, "from") for s in kept) if t]
|
|
132
|
+
times_to = [t for t in (_pick_time(s, "to") for s in kept) if t]
|
|
133
|
+
|
|
134
|
+
first_from = min(times_from) if times_from else (kept[0].get("ts") or now_iso())
|
|
135
|
+
last_to = max(times_to) if times_to else (kept[-1].get("ts") or now_iso())
|
|
290
136
|
|
|
291
|
-
#
|
|
137
|
+
# Build prompt and call LLM (respect model override)
|
|
292
138
|
messages = self._build_prompt_from_saved(kept)
|
|
293
|
-
|
|
139
|
+
try:
|
|
140
|
+
if self.model:
|
|
141
|
+
summary_json_str, usage = await self.llm.chat(messages, model=self.model) # type: ignore[arg-type]
|
|
142
|
+
else:
|
|
143
|
+
summary_json_str, usage = await self.llm.chat(messages)
|
|
144
|
+
except TypeError:
|
|
145
|
+
summary_json_str, usage = await self.llm.chat(messages)
|
|
294
146
|
|
|
295
|
-
# 4) Parse LLM JSON response
|
|
296
147
|
try:
|
|
297
148
|
payload = json.loads(summary_json_str)
|
|
298
149
|
except Exception:
|
|
299
|
-
payload = {
|
|
300
|
-
"summary": summary_json_str,
|
|
301
|
-
"key_facts": [],
|
|
302
|
-
"open_loops": [],
|
|
303
|
-
}
|
|
150
|
+
payload = {"summary": summary_json_str, "key_facts": [], "open_loops": []}
|
|
304
151
|
|
|
305
152
|
ts = now_iso()
|
|
306
153
|
summary_obj = {
|
|
@@ -314,85 +161,29 @@ class LLMMetaSummaryDistiller(Distiller):
|
|
|
314
161
|
"ts": ts,
|
|
315
162
|
"time_window": {"from": first_from, "to": last_to},
|
|
316
163
|
"num_source_summaries": len(kept),
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
# (this assumes summaries were written under ar_summary_uri)
|
|
320
|
-
ar_summary_uri(scope, self.source_tag, s.get("ts", ts))
|
|
321
|
-
for s in kept
|
|
322
|
-
],
|
|
164
|
+
# ✅ store doc_ids you actually read (truth)
|
|
165
|
+
"source_summary_doc_ids": chosen_ids[-len(kept) :],
|
|
323
166
|
"summary": payload.get("summary", ""),
|
|
324
167
|
"key_facts": payload.get("key_facts", []),
|
|
325
168
|
"open_loops": payload.get("open_loops", []),
|
|
326
169
|
"llm_usage": usage,
|
|
327
170
|
"llm_model": getattr(self.llm, "model", None),
|
|
171
|
+
"llm_model_override": self.model,
|
|
172
|
+
"min_signal": self.min_signal,
|
|
328
173
|
}
|
|
329
174
|
|
|
330
175
|
doc_id = _summary_doc_id(scope, self.summary_tag, ts)
|
|
331
176
|
await docs.put(doc_id, summary_obj)
|
|
332
177
|
|
|
333
|
-
# 5) Emit meta_summary Event
|
|
334
178
|
text = summary_obj["summary"] or ""
|
|
335
179
|
preview = text[:2000] + (" …[truncated]" if len(text) > 2000 else "")
|
|
336
180
|
|
|
337
|
-
evt = Event(
|
|
338
|
-
event_id="",
|
|
339
|
-
ts=ts,
|
|
340
|
-
run_id=run_id,
|
|
341
|
-
scope_id=scope,
|
|
342
|
-
kind=self.summary_kind,
|
|
343
|
-
stage="summary_llm_meta",
|
|
344
|
-
text=preview,
|
|
345
|
-
tags=["summary", "llm", self.summary_tag],
|
|
346
|
-
data={
|
|
347
|
-
"summary_doc_id": doc_id,
|
|
348
|
-
"summary_tag": self.summary_tag,
|
|
349
|
-
"time_window": summary_obj["time_window"],
|
|
350
|
-
"num_source_summaries": len(kept),
|
|
351
|
-
"source_summary_kind": self.source_kind,
|
|
352
|
-
"source_summary_tag": self.source_tag,
|
|
353
|
-
},
|
|
354
|
-
metrics={"num_source_summaries": len(kept)},
|
|
355
|
-
severity=2,
|
|
356
|
-
signal=0.8,
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
evt.event_id = stable_event_id(
|
|
360
|
-
{
|
|
361
|
-
"ts": ts,
|
|
362
|
-
"run_id": run_id,
|
|
363
|
-
"kind": self.summary_kind,
|
|
364
|
-
"summary_tag": self.summary_tag,
|
|
365
|
-
"preview": preview[:200],
|
|
366
|
-
}
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
await hotlog.append(timeline_id, evt, ttl_s=7 * 24 * 3600, limit=1000)
|
|
370
|
-
await persistence.append_event(timeline_id, evt)
|
|
371
|
-
|
|
372
|
-
# Metering: record summary event
|
|
373
|
-
try:
|
|
374
|
-
meter = current_metering()
|
|
375
|
-
ctx = current_meter_context.get()
|
|
376
|
-
user_id = ctx.get("user_id")
|
|
377
|
-
org_id = ctx.get("org_id")
|
|
378
|
-
|
|
379
|
-
await meter.record_event(
|
|
380
|
-
user_id=user_id,
|
|
381
|
-
org_id=org_id,
|
|
382
|
-
run_id=run_id,
|
|
383
|
-
scope_id=scope,
|
|
384
|
-
kind=f"memory.{self.summary_kind}", # e.g. "memory.long_term_summary"
|
|
385
|
-
)
|
|
386
|
-
except Exception:
|
|
387
|
-
import logging
|
|
388
|
-
|
|
389
|
-
logger = logging.getLogger("aethergraph.services.memory.distillers.llm_meta_summary")
|
|
390
|
-
logger.error("Failed to record metering event for llm_meta_summary")
|
|
391
|
-
|
|
392
181
|
return {
|
|
393
182
|
"summary_doc_id": doc_id,
|
|
394
183
|
"summary_kind": self.summary_kind,
|
|
395
184
|
"summary_tag": self.summary_tag,
|
|
396
185
|
"time_window": summary_obj["time_window"],
|
|
397
|
-
"num_source_summaries":
|
|
186
|
+
"num_source_summaries": summary_obj["num_source_summaries"],
|
|
187
|
+
"preview": preview,
|
|
188
|
+
"ts": ts,
|
|
398
189
|
}
|