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.
Files changed (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {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, Event, HotLog, Indices, Persistence
7
+ from aethergraph.contracts.services.memory import Distiller, HotLog
9
8
  from aethergraph.contracts.storage.doc_store import DocStore
10
- from aethergraph.core.runtime.runtime_metering import current_meter_context, current_metering
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 # optional model override
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", s.get("ts"))
169
- tw_to = tw.get("to", s.get("ts"))
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
- # (Optional) strip ```json fences if present
173
- stripped = body.strip()
174
- if stripped.startswith("```"):
175
- # very minimal fence strip; you can refine later
176
- stripped = stripped.strip("`")
177
- # fall back to original if this gets too messy
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
- lines.append(f"Summary {idx} [{tw_from} → {tw_to}]:\n{body_for_prompt}\n")
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 period), produce a meta-summary "
189
- "that captures long-term themes, stable user facts, and persistent open loops."
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
- # 1) Load existing long-term summary JSONs from DocStore
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
- summaries: list[dict[str, Any]] = []
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
- summaries.append(doc) # type: ignore[arg-type]
103
+ loaded.append(doc) # type: ignore[arg-type]
248
104
  except Exception:
249
105
  continue
250
106
 
251
- if not summaries:
107
+ if not loaded:
252
108
  return {}
253
109
 
254
- # Optional: filter by min_signal if present in saved JSON
255
- filtered: list[dict[str, Any]] = []
256
- for s in summaries:
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
- if not filtered:
270
- return {}
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
- # Keep order as loaded (already sorted by filename)
273
- kept = filtered
123
+ if not kept:
124
+ return {}
274
125
 
275
- # 2) Derive aggregated time window
276
- first_from = None
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
- start = tw.get("from") or s.get("ts")
281
- end = tw.get("to") or s.get("ts")
282
- if start:
283
- first_from = start if first_from is None else min(first_from, start)
284
- if end:
285
- last_to = end if last_to is None else max(last_to, end)
286
- if first_from is None:
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
- # 3) Build prompt and call LLM
137
+ # Build prompt and call LLM (respect model override)
292
138
  messages = self._build_prompt_from_saved(kept)
293
- summary_json_str, usage = await self.llm.chat(messages)
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
- "source_summary_uris": [
318
- # reconstruct the URI pattern we originally use
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": len(kept),
186
+ "num_source_summaries": summary_obj["num_source_summaries"],
187
+ "preview": preview,
188
+ "ts": ts,
398
189
  }