aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a3__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/__init__.py +4 -10
- aethergraph/__main__.py +296 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +196 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a3.dist-info/RECORD +356 -0
- aethergraph-0.1.0a3.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1539 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
from aethergraph.contracts.services.llm import LLMClientProtocol
|
|
13
|
+
from aethergraph.contracts.services.memory import Event, HotLog, Indices, Persistence
|
|
14
|
+
from aethergraph.contracts.storage.artifact_store import AsyncArtifactStore
|
|
15
|
+
from aethergraph.contracts.storage.doc_store import DocStore
|
|
16
|
+
from aethergraph.core.runtime.runtime_metering import current_metering
|
|
17
|
+
from aethergraph.services.rag.facade import RAGFacade
|
|
18
|
+
from aethergraph.services.scope.scope import Scope
|
|
19
|
+
|
|
20
|
+
from .utils import _summary_prefix
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
MemoryFacade coordinates core memory services for a specific run/session.
|
|
24
|
+
|
|
25
|
+
┌───────────────────────────┐
|
|
26
|
+
│ Agent / Graph │
|
|
27
|
+
│ (tools, flows, chat) │
|
|
28
|
+
└───────────┬───────────────┘
|
|
29
|
+
│ emits Event
|
|
30
|
+
▼
|
|
31
|
+
┌─────────────────┐
|
|
32
|
+
│ MemoryFacade │
|
|
33
|
+
│ (per run_id) │
|
|
34
|
+
└───────┬─────────┘
|
|
35
|
+
record_raw/record/write_result
|
|
36
|
+
│
|
|
37
|
+
┌──────────────┼─────────────────┐
|
|
38
|
+
▼ ▼ ▼
|
|
39
|
+
┌────────────┐ ┌─────────────┐ ┌──────────────┐
|
|
40
|
+
│ HotLog │ │ FSPersistence│ │ Indices │
|
|
41
|
+
│ (KV ring) │ │ (JSONL, FS) │ │ (name/topic) │
|
|
42
|
+
└────┬───────┘ └──────┬──────┘ └──────┬───────┘
|
|
43
|
+
│ │ │
|
|
44
|
+
│ │ distillers read │
|
|
45
|
+
│ ▼ │
|
|
46
|
+
│ ┌───────────────────┐ │
|
|
47
|
+
│ │ Distillers │ │
|
|
48
|
+
│ │ (LongTerm, LLM) │ │
|
|
49
|
+
│ └─────────┬─────────┘ │
|
|
50
|
+
│ │ │
|
|
51
|
+
│ save_json() │ │ update()
|
|
52
|
+
│ ▼ │
|
|
53
|
+
│ ┌─────────────────────┐ │
|
|
54
|
+
│ │ Summary JSON (FS) │ │
|
|
55
|
+
│ └─────────────────────┘ │
|
|
56
|
+
│ │ │
|
|
57
|
+
│ │ (optional) │
|
|
58
|
+
│ ▼ │
|
|
59
|
+
│ ┌────────────────────┐ │
|
|
60
|
+
└────────▶│ Summary Event │◀──┘
|
|
61
|
+
│ (kind=long_term_*) │
|
|
62
|
+
└────────────────────┘
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def now_iso() -> str:
|
|
69
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def stable_event_id(parts: dict[str, Any]) -> str:
|
|
73
|
+
blob = json.dumps(parts, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
74
|
+
return hashlib.sha256(blob).hexdigest()[:24]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _short_hash(s: str, n: int = 8) -> str:
|
|
78
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:n]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _slug(s: str) -> str:
|
|
82
|
+
s = unicodedata.normalize("NFKC", str(s)).strip()
|
|
83
|
+
s = s.replace(" ", "-")
|
|
84
|
+
s = _SAFE.sub("-", s)
|
|
85
|
+
return s.strip("-") or "default"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_sticky(path: str) -> dict:
|
|
89
|
+
try:
|
|
90
|
+
with open(path, encoding="utf-8") as f:
|
|
91
|
+
return json.load(f)
|
|
92
|
+
except Exception:
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _save_sticky(path: str, m: dict):
|
|
97
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
98
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
99
|
+
json.dump(m, f, ensure_ascii=False, indent=2)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MemoryFacade:
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
run_id: str,
|
|
107
|
+
session_id: str | None,
|
|
108
|
+
graph_id: str | None,
|
|
109
|
+
node_id: str | None,
|
|
110
|
+
scope: Scope | None = None,
|
|
111
|
+
hotlog: HotLog,
|
|
112
|
+
persistence: Persistence,
|
|
113
|
+
indices: Indices,
|
|
114
|
+
docs: DocStore,
|
|
115
|
+
artifact_store: AsyncArtifactStore,
|
|
116
|
+
hot_limit: int = 1000,
|
|
117
|
+
hot_ttl_s: int = 7 * 24 * 3600,
|
|
118
|
+
default_signal_threshold: float = 0.0,
|
|
119
|
+
logger=None,
|
|
120
|
+
rag: RAGFacade | None = None,
|
|
121
|
+
llm: LLMClientProtocol | None = None,
|
|
122
|
+
):
|
|
123
|
+
self.run_id = run_id
|
|
124
|
+
self.session_id = session_id
|
|
125
|
+
self.graph_id = graph_id
|
|
126
|
+
self.node_id = node_id
|
|
127
|
+
self.scope = scope
|
|
128
|
+
self.hotlog = hotlog
|
|
129
|
+
self.persistence = persistence
|
|
130
|
+
self.indices = indices
|
|
131
|
+
self.docs = docs
|
|
132
|
+
self.artifacts = artifact_store
|
|
133
|
+
self.hot_limit = hot_limit
|
|
134
|
+
self.hot_ttl_s = hot_ttl_s
|
|
135
|
+
self.default_signal_threshold = default_signal_threshold
|
|
136
|
+
self.logger = logger
|
|
137
|
+
self.rag = rag
|
|
138
|
+
self.llm = llm # optional LLM service for RAG answering, etc.
|
|
139
|
+
|
|
140
|
+
# order of precedence for memory scope ID:
|
|
141
|
+
self.memory_scope_id = (
|
|
142
|
+
self.scope.memory_scope_id() if self.scope else self.session_id or self.run_id
|
|
143
|
+
)
|
|
144
|
+
self.timeline_id = self.memory_scope_id or self.run_id # key for timeline events
|
|
145
|
+
|
|
146
|
+
# ---------- recording ----------
|
|
147
|
+
async def record_raw(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
base: dict[str, Any],
|
|
151
|
+
text: str | None = None,
|
|
152
|
+
metrics: dict[str, float] | None = None,
|
|
153
|
+
) -> Event:
|
|
154
|
+
ts = now_iso()
|
|
155
|
+
|
|
156
|
+
# 1) Derive identity/execution dimentions from Scope
|
|
157
|
+
dims: dict[str, str] = {}
|
|
158
|
+
if self.scope is not None:
|
|
159
|
+
dims = self.scope.metering_dimensions()
|
|
160
|
+
|
|
161
|
+
run_id = base.get("run_id") or dims.get("run_id") or self.run_id
|
|
162
|
+
graph_id = base.get("graph_id") or dims.get("graph_id") or self.graph_id
|
|
163
|
+
node_id = base.get("node_id") or dims.get("node_id") or self.node_id
|
|
164
|
+
session_id = base.get("session_id") or dims.get("session_id") or self.session_id
|
|
165
|
+
|
|
166
|
+
user_id = base.get("user_id") or dims.get("user_id")
|
|
167
|
+
org_id = base.get("org_id") or dims.get("org_id")
|
|
168
|
+
client_id = base.get("client_id") or dims.get("client_id")
|
|
169
|
+
app_id = base.get("app_id") or dims.get("app_id")
|
|
170
|
+
|
|
171
|
+
# Memory scope key (for multi-tenant memory within a run)
|
|
172
|
+
scope_id = base.get("scope_id") or self.memory_scope_id or session_id or run_id
|
|
173
|
+
|
|
174
|
+
base.setdefault("run_id", run_id)
|
|
175
|
+
base.setdefault("graph_id", graph_id)
|
|
176
|
+
base.setdefault("node_id", node_id)
|
|
177
|
+
base.setdefault("scope_id", scope_id)
|
|
178
|
+
base.setdefault("user_id", user_id)
|
|
179
|
+
base.setdefault("org_id", org_id)
|
|
180
|
+
base.setdefault("client_id", client_id)
|
|
181
|
+
base.setdefault("app_id", app_id)
|
|
182
|
+
base.setdefault("session_id", session_id)
|
|
183
|
+
|
|
184
|
+
severity = int(base.get("severity", 2))
|
|
185
|
+
signal = base.get("signal")
|
|
186
|
+
if signal is None:
|
|
187
|
+
signal = self._estimate_signal(text=text, metrics=metrics, severity=severity)
|
|
188
|
+
|
|
189
|
+
# ensure kind is always present
|
|
190
|
+
kind = base.get("kind") or "misc"
|
|
191
|
+
|
|
192
|
+
eid = stable_event_id(
|
|
193
|
+
{
|
|
194
|
+
"ts": ts,
|
|
195
|
+
"run_id": base["run_id"],
|
|
196
|
+
"graph_id": base.get("graph_id"),
|
|
197
|
+
"node_id": base.get("node_id"),
|
|
198
|
+
"tool": base.get("tool"),
|
|
199
|
+
"kind": kind,
|
|
200
|
+
"stage": base.get("stage"),
|
|
201
|
+
"severity": severity,
|
|
202
|
+
"text": (text or "")[:6000],
|
|
203
|
+
"metrics_present": bool(metrics),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
evt = Event(
|
|
208
|
+
event_id=eid,
|
|
209
|
+
ts=ts,
|
|
210
|
+
run_id=run_id,
|
|
211
|
+
scope_id=scope_id,
|
|
212
|
+
user_id=user_id,
|
|
213
|
+
org_id=org_id,
|
|
214
|
+
client_id=client_id,
|
|
215
|
+
app_id=app_id,
|
|
216
|
+
session_id=session_id,
|
|
217
|
+
kind=kind,
|
|
218
|
+
stage=base.get("stage"),
|
|
219
|
+
text=text,
|
|
220
|
+
tags=base.get("tags"),
|
|
221
|
+
data=base.get("data"),
|
|
222
|
+
metrics=metrics,
|
|
223
|
+
graph_id=graph_id,
|
|
224
|
+
node_id=node_id,
|
|
225
|
+
tool=base.get("tool"),
|
|
226
|
+
topic=base.get("topic"),
|
|
227
|
+
severity=severity,
|
|
228
|
+
signal=signal,
|
|
229
|
+
inputs=base.get("inputs"),
|
|
230
|
+
outputs=base.get("outputs"),
|
|
231
|
+
embedding=base.get("embedding"),
|
|
232
|
+
pii_flags=base.get("pii_flags"),
|
|
233
|
+
version=2,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# 2) persist to HotLog + Persistence
|
|
237
|
+
await self.hotlog.append(self.timeline_id, evt, ttl_s=self.hot_ttl_s, limit=self.hot_limit)
|
|
238
|
+
await self.persistence.append_event(self.timeline_id, evt)
|
|
239
|
+
|
|
240
|
+
# Metering hook
|
|
241
|
+
try:
|
|
242
|
+
meter = current_metering()
|
|
243
|
+
await meter.record_event(
|
|
244
|
+
scope=self.scope,
|
|
245
|
+
scope_id=scope_id,
|
|
246
|
+
kind=f"memory.{kind}",
|
|
247
|
+
)
|
|
248
|
+
except Exception:
|
|
249
|
+
if self.logger:
|
|
250
|
+
self.logger.exception("Error recording metering event in MemoryFacade.record_raw")
|
|
251
|
+
return evt
|
|
252
|
+
|
|
253
|
+
async def record(
|
|
254
|
+
self,
|
|
255
|
+
kind: str,
|
|
256
|
+
data: Any,
|
|
257
|
+
tags: list[str] | None = None,
|
|
258
|
+
severity: int = 2,
|
|
259
|
+
stage: str | None = None,
|
|
260
|
+
inputs_ref=None,
|
|
261
|
+
outputs_ref=None,
|
|
262
|
+
metrics: dict[str, float] | None = None,
|
|
263
|
+
signal: float | None = None,
|
|
264
|
+
text: str | None = None, # optional override
|
|
265
|
+
) -> Event:
|
|
266
|
+
"""
|
|
267
|
+
Convenience wrapper around record_raw() with common fields.
|
|
268
|
+
|
|
269
|
+
- kind : logical kind (e.g. "user_msg", "tool_call", "chat_turn")
|
|
270
|
+
- data : JSON-serializable content, or string
|
|
271
|
+
- tags : optional list of labels
|
|
272
|
+
- severity : 1=low, 2=medium, 3=high
|
|
273
|
+
- stage : optional stage (user/assistant/system/etc.)
|
|
274
|
+
- inputs_ref / outputs_ref : optional Value[] references
|
|
275
|
+
- metrics : numeric map (latency, tokens, etc.)
|
|
276
|
+
- signal : optional override for signal strength
|
|
277
|
+
- text : optional preview text override (if None, derived from data)
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
# 1) derive short preview text
|
|
281
|
+
if text is None and data is not None:
|
|
282
|
+
if isinstance(data, str):
|
|
283
|
+
text = data
|
|
284
|
+
else:
|
|
285
|
+
try:
|
|
286
|
+
raw = json.dumps(data, ensure_ascii=False)
|
|
287
|
+
text = raw
|
|
288
|
+
except Exception as e:
|
|
289
|
+
text = f"<unserializable data: {e!s}>"
|
|
290
|
+
if self.logger:
|
|
291
|
+
self.logger.warning(text)
|
|
292
|
+
|
|
293
|
+
# 2) optionally truncate preview text (enforce token discipline)
|
|
294
|
+
if text and len(text) > 2000:
|
|
295
|
+
text = text[:2000] + " …[truncated]"
|
|
296
|
+
|
|
297
|
+
# 3) full structured payload in Event.data when possible
|
|
298
|
+
data_field: dict[str, Any] | None = None
|
|
299
|
+
if isinstance(data, dict):
|
|
300
|
+
data_field = data
|
|
301
|
+
elif data is not None and not isinstance(data, str):
|
|
302
|
+
# store under "value" if it's JSON-serializable
|
|
303
|
+
try:
|
|
304
|
+
json.dumps(data, ensure_ascii=False)
|
|
305
|
+
data_field = {"value": data}
|
|
306
|
+
except Exception:
|
|
307
|
+
data_field = {"repr": repr(data)}
|
|
308
|
+
|
|
309
|
+
base: dict[str, Any] = dict(
|
|
310
|
+
kind=kind,
|
|
311
|
+
stage=stage,
|
|
312
|
+
severity=severity,
|
|
313
|
+
tags=tags or [],
|
|
314
|
+
data=data_field,
|
|
315
|
+
inputs=inputs_ref,
|
|
316
|
+
outputs=outputs_ref,
|
|
317
|
+
)
|
|
318
|
+
if signal is not None:
|
|
319
|
+
base["signal"] = signal
|
|
320
|
+
|
|
321
|
+
return await self.record_raw(base=base, text=text, metrics=metrics)
|
|
322
|
+
|
|
323
|
+
# ------------ chat recording ------------
|
|
324
|
+
async def record_chat(
|
|
325
|
+
self,
|
|
326
|
+
role: Literal["user", "assistant", "system", "tool"],
|
|
327
|
+
text: str,
|
|
328
|
+
*,
|
|
329
|
+
tags: list[str] | None = None,
|
|
330
|
+
data: dict[str, Any] | None = None,
|
|
331
|
+
severity: int = 2,
|
|
332
|
+
signal: float | None = None,
|
|
333
|
+
) -> Event:
|
|
334
|
+
"""
|
|
335
|
+
Record a single chat turn in a normalized way.
|
|
336
|
+
|
|
337
|
+
- role: "user" | "assistant" | "system" | "tool"
|
|
338
|
+
- text: primary message text
|
|
339
|
+
- tags: optional extra tags (we always add "chat")
|
|
340
|
+
- data: extra JSON payload merged into {"role", "text"}
|
|
341
|
+
"""
|
|
342
|
+
extra_tags = ["chat"]
|
|
343
|
+
if tags:
|
|
344
|
+
extra_tags.extend(tags)
|
|
345
|
+
payload: dict[str, Any] = {"role": role, "text": text}
|
|
346
|
+
if data:
|
|
347
|
+
payload.update(data)
|
|
348
|
+
|
|
349
|
+
return await self.record(
|
|
350
|
+
kind="chat.turn",
|
|
351
|
+
text=text,
|
|
352
|
+
data=payload,
|
|
353
|
+
tags=extra_tags,
|
|
354
|
+
severity=severity,
|
|
355
|
+
stage=role,
|
|
356
|
+
signal=signal,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def record_chat_user(
|
|
360
|
+
self,
|
|
361
|
+
text: str,
|
|
362
|
+
*,
|
|
363
|
+
tags: list[str] | None = None,
|
|
364
|
+
data: dict[str, Any] | None = None,
|
|
365
|
+
severity: int = 2,
|
|
366
|
+
signal: float | None = None,
|
|
367
|
+
) -> Event:
|
|
368
|
+
"""DX sugar: record a user chat turn."""
|
|
369
|
+
return await self.record_chat(
|
|
370
|
+
"user",
|
|
371
|
+
text,
|
|
372
|
+
tags=tags,
|
|
373
|
+
data=data,
|
|
374
|
+
severity=severity,
|
|
375
|
+
signal=signal,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
async def record_chat_assistant(
|
|
379
|
+
self,
|
|
380
|
+
text: str,
|
|
381
|
+
*,
|
|
382
|
+
tags: list[str] | None = None,
|
|
383
|
+
data: dict[str, Any] | None = None,
|
|
384
|
+
severity: int = 2,
|
|
385
|
+
signal: float | None = None,
|
|
386
|
+
) -> Event:
|
|
387
|
+
"""DX sugar: record an assistant chat turn."""
|
|
388
|
+
return await self.record_chat(
|
|
389
|
+
"assistant",
|
|
390
|
+
text,
|
|
391
|
+
tags=tags,
|
|
392
|
+
data=data,
|
|
393
|
+
severity=severity,
|
|
394
|
+
signal=signal,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
async def record_chat_system(
|
|
398
|
+
self,
|
|
399
|
+
text: str,
|
|
400
|
+
*,
|
|
401
|
+
tags: list[str] | None = None,
|
|
402
|
+
data: dict[str, Any] | None = None,
|
|
403
|
+
severity: int = 1,
|
|
404
|
+
signal: float | None = None,
|
|
405
|
+
) -> Event:
|
|
406
|
+
"""DX sugar: record a system message."""
|
|
407
|
+
return await self.record_chat(
|
|
408
|
+
"system",
|
|
409
|
+
text,
|
|
410
|
+
tags=tags,
|
|
411
|
+
data=data,
|
|
412
|
+
severity=severity,
|
|
413
|
+
signal=signal,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
async def record_chat_tool(
|
|
417
|
+
self,
|
|
418
|
+
tool_name: str,
|
|
419
|
+
text: str,
|
|
420
|
+
*,
|
|
421
|
+
tags: list[str] | None = None,
|
|
422
|
+
data: dict[str, Any] | None = None,
|
|
423
|
+
severity: int = 2,
|
|
424
|
+
signal: float | None = None,
|
|
425
|
+
) -> Event:
|
|
426
|
+
"""
|
|
427
|
+
DX sugar: record a tool-related message as a chat turn.
|
|
428
|
+
|
|
429
|
+
Adds tag "tool:<tool_name>" and records tool_name in data.
|
|
430
|
+
"""
|
|
431
|
+
tool_tags = list(tags or [])
|
|
432
|
+
tool_tags.append(f"tool:{tool_name}")
|
|
433
|
+
payload: dict[str, Any] = {"tool_name": tool_name}
|
|
434
|
+
if data:
|
|
435
|
+
payload.update(data)
|
|
436
|
+
|
|
437
|
+
return await self.record_chat(
|
|
438
|
+
"tool",
|
|
439
|
+
text,
|
|
440
|
+
tags=tool_tags,
|
|
441
|
+
data=payload,
|
|
442
|
+
severity=severity,
|
|
443
|
+
signal=signal,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def recent_chat(
|
|
447
|
+
self,
|
|
448
|
+
*,
|
|
449
|
+
limit: int = 50,
|
|
450
|
+
roles: Sequence[str] | None = None,
|
|
451
|
+
) -> list[dict[str, Any]]:
|
|
452
|
+
"""
|
|
453
|
+
Return the last `limit` chat.turns as a normalized list.
|
|
454
|
+
|
|
455
|
+
Each item: {"ts", "role", "text", "tags"}.
|
|
456
|
+
|
|
457
|
+
- roles: optional filter on role (e.g. {"user", "assistant"}).
|
|
458
|
+
"""
|
|
459
|
+
events = await self.recent(kinds=["chat.turn"], limit=limit)
|
|
460
|
+
out: list[dict[str, Any]] = []
|
|
461
|
+
|
|
462
|
+
for e in events:
|
|
463
|
+
# 1) Resolve role (from stage or data)
|
|
464
|
+
role = (
|
|
465
|
+
getattr(e, "stage", None)
|
|
466
|
+
or ((e.data or {}).get("role") if getattr(e, "data", None) else None)
|
|
467
|
+
or "user"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if roles is not None and role not in roles:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
# 2) Resolve text:
|
|
474
|
+
# - prefer Event.text
|
|
475
|
+
# - fall back to data["text"]
|
|
476
|
+
raw_text = getattr(e, "text", "") or ""
|
|
477
|
+
if not raw_text and getattr(e, "data", None):
|
|
478
|
+
raw_text = (e.data or {}).get("text", "") or ""
|
|
479
|
+
|
|
480
|
+
out.append(
|
|
481
|
+
{
|
|
482
|
+
"ts": getattr(e, "ts", None),
|
|
483
|
+
"role": role,
|
|
484
|
+
"text": raw_text,
|
|
485
|
+
"tags": list(e.tags or []),
|
|
486
|
+
}
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return out
|
|
490
|
+
|
|
491
|
+
async def chat_history_for_llm(
|
|
492
|
+
self,
|
|
493
|
+
*,
|
|
494
|
+
limit: int = 20,
|
|
495
|
+
include_system_summary: bool = True,
|
|
496
|
+
summary_tag: str = "session",
|
|
497
|
+
summary_scope_id: str | None = None,
|
|
498
|
+
max_summaries: int = 3,
|
|
499
|
+
) -> dict[str, Any]:
|
|
500
|
+
"""
|
|
501
|
+
Build a ready-to-send OpenAI-style chat message list.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
{
|
|
505
|
+
"summary": "<combined long-term summary or ''>",
|
|
506
|
+
"messages": [
|
|
507
|
+
{"role": "system", "content": "..."},
|
|
508
|
+
{"role": "user", "content": "..."},
|
|
509
|
+
...
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
Long-term summary handling:
|
|
514
|
+
- We load up to `max_summaries` recent summaries for the tag,
|
|
515
|
+
oldest → newest, and join their text with blank lines.
|
|
516
|
+
"""
|
|
517
|
+
messages: list[dict[str, str]] = []
|
|
518
|
+
summary_text = ""
|
|
519
|
+
|
|
520
|
+
if include_system_summary:
|
|
521
|
+
try:
|
|
522
|
+
summaries = await self.load_recent_summaries(
|
|
523
|
+
scope_id=summary_scope_id,
|
|
524
|
+
summary_tag=summary_tag,
|
|
525
|
+
limit=max_summaries,
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
summaries = []
|
|
529
|
+
|
|
530
|
+
parts: list[str] = []
|
|
531
|
+
for s in summaries:
|
|
532
|
+
st = s.get("summary") or s.get("text") or s.get("body") or s.get("value") or ""
|
|
533
|
+
if st:
|
|
534
|
+
parts.append(st)
|
|
535
|
+
|
|
536
|
+
if parts:
|
|
537
|
+
summary_text = "\n\n".join(parts)
|
|
538
|
+
messages.append(
|
|
539
|
+
{
|
|
540
|
+
"role": "system",
|
|
541
|
+
"content": f"Summary of previous context:\n{summary_text}",
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Append recent chat turns
|
|
546
|
+
for item in await self.recent_chat(limit=limit):
|
|
547
|
+
role = item["role"]
|
|
548
|
+
# Map unknown roles (e.g. "tool") to "assistant" by default
|
|
549
|
+
mapped_role = role if role in {"user", "assistant", "system"} else "assistant"
|
|
550
|
+
messages.append({"role": mapped_role, "content": item["text"]})
|
|
551
|
+
|
|
552
|
+
return {"summary": summary_text, "messages": messages}
|
|
553
|
+
|
|
554
|
+
async def build_prompt_segments(
|
|
555
|
+
self,
|
|
556
|
+
*,
|
|
557
|
+
recent_chat_limit: int = 12,
|
|
558
|
+
include_long_term: bool = True,
|
|
559
|
+
summary_tag: str = "session",
|
|
560
|
+
max_summaries: int = 3,
|
|
561
|
+
include_recent_tools: bool = False,
|
|
562
|
+
tool: str | None = None,
|
|
563
|
+
tool_limit: int = 10,
|
|
564
|
+
) -> dict[str, Any]:
|
|
565
|
+
"""
|
|
566
|
+
High-level helper to assemble memory context for prompts.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
{
|
|
570
|
+
"long_term": "<combined summary text or ''>",
|
|
571
|
+
"recent_chat": [ {ts, role, text, tags}, ... ],
|
|
572
|
+
"recent_tools": [ {ts, tool, message, inputs, outputs, tags}, ... ]
|
|
573
|
+
}
|
|
574
|
+
"""
|
|
575
|
+
long_term_text = ""
|
|
576
|
+
if include_long_term:
|
|
577
|
+
try:
|
|
578
|
+
summaries = await self.load_recent_summaries(
|
|
579
|
+
summary_tag=summary_tag,
|
|
580
|
+
limit=max_summaries,
|
|
581
|
+
)
|
|
582
|
+
except Exception:
|
|
583
|
+
summaries = []
|
|
584
|
+
|
|
585
|
+
parts: list[str] = []
|
|
586
|
+
for s in summaries:
|
|
587
|
+
st = s.get("summary") or s.get("text") or s.get("body") or s.get("value") or ""
|
|
588
|
+
if st:
|
|
589
|
+
parts.append(st)
|
|
590
|
+
|
|
591
|
+
if parts:
|
|
592
|
+
# multiple long-term summaries → concatenate oldest→newest
|
|
593
|
+
long_term_text = "\n\n".join(parts)
|
|
594
|
+
|
|
595
|
+
recent_chat = await self.recent_chat(limit=recent_chat_limit)
|
|
596
|
+
|
|
597
|
+
recent_tools: list[dict[str, Any]] = []
|
|
598
|
+
if include_recent_tools:
|
|
599
|
+
events = await self.recent_tool_results(
|
|
600
|
+
tool=tool,
|
|
601
|
+
limit=tool_limit,
|
|
602
|
+
)
|
|
603
|
+
for e in events:
|
|
604
|
+
recent_tools.append(
|
|
605
|
+
{
|
|
606
|
+
"ts": getattr(e, "ts", None),
|
|
607
|
+
"tool": e.tool,
|
|
608
|
+
"message": e.text,
|
|
609
|
+
"inputs": getattr(e, "inputs", None),
|
|
610
|
+
"outputs": getattr(e, "outputs", None),
|
|
611
|
+
"tags": list(e.tags or []),
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
"long_term": long_term_text,
|
|
617
|
+
"recent_chat": recent_chat,
|
|
618
|
+
"recent_tools": recent_tools,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
# ---------- typed result recording ----------
|
|
622
|
+
async def write_result(
|
|
623
|
+
self,
|
|
624
|
+
*,
|
|
625
|
+
tool: str | None = None, # back compatibility with 'topic'
|
|
626
|
+
inputs: list[dict[str, Any]] | None = None,
|
|
627
|
+
outputs: list[dict[str, Any]] | None = None,
|
|
628
|
+
tags: list[str] | None = None,
|
|
629
|
+
metrics: dict[str, float] | None = None,
|
|
630
|
+
message: str | None = None,
|
|
631
|
+
severity: int = 3,
|
|
632
|
+
topic: str | None = None, # alias for tool, backwards compatibility
|
|
633
|
+
) -> Event:
|
|
634
|
+
"""
|
|
635
|
+
Convenience for recording a “tool/agent/flow result” with typed I/O.
|
|
636
|
+
|
|
637
|
+
`tool` : tool/agent/flow identifier (also used by KVIndices.last_outputs_by_topic)
|
|
638
|
+
`inputs` : List[Value]-like dicts
|
|
639
|
+
`outputs` : List[Value]-like dicts
|
|
640
|
+
`tags` : labels like ["rag","qa"] for filtering/search
|
|
641
|
+
"""
|
|
642
|
+
if tool is None and topic is not None:
|
|
643
|
+
tool = topic
|
|
644
|
+
if tool is None:
|
|
645
|
+
raise ValueError("write_result requires a 'tool' (or legacy 'topic') name")
|
|
646
|
+
|
|
647
|
+
inputs = inputs or []
|
|
648
|
+
outputs = outputs or []
|
|
649
|
+
|
|
650
|
+
evt = await self.record_raw(
|
|
651
|
+
base=dict(
|
|
652
|
+
tool=tool,
|
|
653
|
+
kind="tool_result",
|
|
654
|
+
severity=severity,
|
|
655
|
+
tags=tags or [],
|
|
656
|
+
inputs=inputs,
|
|
657
|
+
outputs=outputs,
|
|
658
|
+
),
|
|
659
|
+
text=message,
|
|
660
|
+
metrics=metrics,
|
|
661
|
+
)
|
|
662
|
+
await self.indices.update(self.timeline_id, evt)
|
|
663
|
+
return evt
|
|
664
|
+
|
|
665
|
+
async def write_tool_result(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
tool: str,
|
|
669
|
+
inputs: list[dict[str, Any]] | None = None,
|
|
670
|
+
outputs: list[dict[str, Any]] | None = None,
|
|
671
|
+
tags: list[str] | None = None,
|
|
672
|
+
metrics: dict[str, float] | None = None,
|
|
673
|
+
message: str | None = None,
|
|
674
|
+
severity: int = 3,
|
|
675
|
+
) -> Event:
|
|
676
|
+
"""
|
|
677
|
+
Convenience wrapper around write_result() for tool results.
|
|
678
|
+
"""
|
|
679
|
+
return await self.write_result(
|
|
680
|
+
tool=tool,
|
|
681
|
+
inputs=inputs,
|
|
682
|
+
outputs=outputs,
|
|
683
|
+
tags=tags,
|
|
684
|
+
metrics=metrics,
|
|
685
|
+
message=message,
|
|
686
|
+
severity=severity,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
async def record_tool_result(
|
|
690
|
+
self,
|
|
691
|
+
*,
|
|
692
|
+
tool: str,
|
|
693
|
+
inputs: list[dict[str, Any]] | None = None,
|
|
694
|
+
outputs: list[dict[str, Any]] | None = None,
|
|
695
|
+
tags: list[str] | None = None,
|
|
696
|
+
metrics: dict[str, float] | None = None,
|
|
697
|
+
message: str | None = None,
|
|
698
|
+
severity: int = 3,
|
|
699
|
+
) -> Event:
|
|
700
|
+
"""
|
|
701
|
+
DX-friendly alias for write_tool_result(); prefer this in new code.
|
|
702
|
+
"""
|
|
703
|
+
return await self.write_tool_result(
|
|
704
|
+
tool=tool,
|
|
705
|
+
inputs=inputs,
|
|
706
|
+
outputs=outputs,
|
|
707
|
+
tags=tags,
|
|
708
|
+
metrics=metrics,
|
|
709
|
+
message=message,
|
|
710
|
+
severity=severity,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
async def record_result(
|
|
714
|
+
self,
|
|
715
|
+
*,
|
|
716
|
+
tool: str | None = None,
|
|
717
|
+
inputs: list[dict[str, Any]] | None = None,
|
|
718
|
+
outputs: list[dict[str, Any]] | None = None,
|
|
719
|
+
tags: list[str] | None = None,
|
|
720
|
+
metrics: dict[str, float] | None = None,
|
|
721
|
+
message: str | None = None,
|
|
722
|
+
severity: int = 3,
|
|
723
|
+
) -> Event:
|
|
724
|
+
"""
|
|
725
|
+
Alias for write_result(); symmetric with record_tool_result().
|
|
726
|
+
|
|
727
|
+
Use this when you conceptually have a "result" but don't care whether
|
|
728
|
+
it's a tool vs agent vs flow.
|
|
729
|
+
"""
|
|
730
|
+
return await self.write_result(
|
|
731
|
+
tool=tool,
|
|
732
|
+
inputs=inputs,
|
|
733
|
+
outputs=outputs,
|
|
734
|
+
tags=tags,
|
|
735
|
+
metrics=metrics,
|
|
736
|
+
message=message,
|
|
737
|
+
severity=severity,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
async def last_tool_result(self, tool: str) -> Event | None:
|
|
741
|
+
"""
|
|
742
|
+
Convenience: return the most recent tool_result Event for a given tool.
|
|
743
|
+
"""
|
|
744
|
+
events = await self.recent_tool_results(tool=tool, limit=1)
|
|
745
|
+
return events[-1] if events else None
|
|
746
|
+
|
|
747
|
+
async def recent_tool_result_data(
|
|
748
|
+
self,
|
|
749
|
+
*,
|
|
750
|
+
tool: str,
|
|
751
|
+
limit: int = 10,
|
|
752
|
+
) -> list[dict[str, Any]]:
|
|
753
|
+
"""
|
|
754
|
+
Return a simplified view over recent tool_result events.
|
|
755
|
+
|
|
756
|
+
Each item:
|
|
757
|
+
{"ts", "tool", "message", "inputs", "outputs", "tags"}.
|
|
758
|
+
"""
|
|
759
|
+
events = await self.recent_tool_results(tool=tool, limit=limit)
|
|
760
|
+
out: list[dict[str, Any]] = []
|
|
761
|
+
for e in events:
|
|
762
|
+
out.append(
|
|
763
|
+
{
|
|
764
|
+
"ts": getattr(e, "ts", None),
|
|
765
|
+
"tool": e.tool,
|
|
766
|
+
"message": e.text,
|
|
767
|
+
"inputs": getattr(e, "inputs", None),
|
|
768
|
+
"outputs": getattr(e, "outputs", None),
|
|
769
|
+
"tags": list(e.tags or []),
|
|
770
|
+
}
|
|
771
|
+
)
|
|
772
|
+
return out
|
|
773
|
+
|
|
774
|
+
# ---------- retrieval ----------
|
|
775
|
+
async def recent(self, *, kinds: list[str] | None = None, limit: int = 50) -> list[Event]:
|
|
776
|
+
"""Return recent events from HotLog (most recent last), optionally filtered by kind."""
|
|
777
|
+
return await self.hotlog.recent(self.timeline_id, kinds=kinds, limit=limit)
|
|
778
|
+
|
|
779
|
+
async def recent_data(
|
|
780
|
+
self,
|
|
781
|
+
*,
|
|
782
|
+
kinds: list[str] | None = None,
|
|
783
|
+
tags: list[str] | None = None,
|
|
784
|
+
limit: int = 50,
|
|
785
|
+
) -> list[Any]:
|
|
786
|
+
evts = await self.recent(kinds=kinds, limit=limit)
|
|
787
|
+
if tags:
|
|
788
|
+
want = set(tags)
|
|
789
|
+
evts = [e for e in evts if want.issubset(set(e.tags or []))]
|
|
790
|
+
|
|
791
|
+
out: list[Any] = []
|
|
792
|
+
for e in evts:
|
|
793
|
+
if e.data is not None:
|
|
794
|
+
out.append(e.data)
|
|
795
|
+
elif e.text:
|
|
796
|
+
# last-resort: treat text as JSON if it looks like it, else raw string
|
|
797
|
+
t = e.text.strip()
|
|
798
|
+
if (t.startswith("{") and t.endswith("}")) or (
|
|
799
|
+
t.startswith("[") and t.endswith("]")
|
|
800
|
+
):
|
|
801
|
+
try:
|
|
802
|
+
out.append(json.loads(t))
|
|
803
|
+
continue
|
|
804
|
+
except Exception:
|
|
805
|
+
pass
|
|
806
|
+
out.append(e.text)
|
|
807
|
+
return out
|
|
808
|
+
|
|
809
|
+
async def last_by_name(self, name: str):
|
|
810
|
+
"""Return the last output value by `name` from Indices (fast path)."""
|
|
811
|
+
return await self.indices.last_by_name(self.timeline_id, name)
|
|
812
|
+
|
|
813
|
+
async def last_output_by_name(self, name: str):
|
|
814
|
+
"""Return the last output value (Value.value) by `name` from Indices (fast path)."""
|
|
815
|
+
out = await self.indices.last_by_name(self.timeline_id, name)
|
|
816
|
+
if out is None:
|
|
817
|
+
return None
|
|
818
|
+
return out.get("value") # type: ignore
|
|
819
|
+
|
|
820
|
+
async def last_outputs_by_topic(self, topic: str):
|
|
821
|
+
"""Return the last output map for a given topic (tool/flow/agent) from Indices."""
|
|
822
|
+
return await self.indices.last_outputs_by_topic(self.timeline_id, topic)
|
|
823
|
+
|
|
824
|
+
# replace last_tool_result_outputs
|
|
825
|
+
async def last_tool_result_outputs(self, tool: str) -> dict[str, Any] | None:
|
|
826
|
+
"""
|
|
827
|
+
Convenience wrapper around KVIndices.last_outputs_by_topic for this run.
|
|
828
|
+
Returns the last outputs map for a given tool, or None.
|
|
829
|
+
"""
|
|
830
|
+
return await self.indices.last_outputs_by_topic(self.timeline_id, tool)
|
|
831
|
+
|
|
832
|
+
async def recent_tool_results(
|
|
833
|
+
self,
|
|
834
|
+
*,
|
|
835
|
+
tool: str | None = None,
|
|
836
|
+
tags: list[str] | None = None,
|
|
837
|
+
limit: int = 50,
|
|
838
|
+
) -> list[Event]:
|
|
839
|
+
"""
|
|
840
|
+
Return recent tool_result events from HotLog, optionally filtered by tool name and tags.
|
|
841
|
+
"""
|
|
842
|
+
events = await self.recent(kinds=["tool_result"], limit=limit)
|
|
843
|
+
if tool is not None:
|
|
844
|
+
events = [e for e in events if e.tool == tool]
|
|
845
|
+
if tags:
|
|
846
|
+
want = set(tags)
|
|
847
|
+
events = [e for e in events if want.issubset(set(e.tags or []))]
|
|
848
|
+
return events
|
|
849
|
+
|
|
850
|
+
async def latest_refs_by_kind(self, kind: str, *, limit: int = 50):
|
|
851
|
+
"""Return latest ref outputs by ref.kind (fast path, KV-backed)."""
|
|
852
|
+
return await self.indices.latest_refs_by_kind(self.timeline_id, kind, limit=limit)
|
|
853
|
+
|
|
854
|
+
async def search(
|
|
855
|
+
self,
|
|
856
|
+
*,
|
|
857
|
+
query: str,
|
|
858
|
+
kinds: list[str] | None = None,
|
|
859
|
+
tags: list[str] | None = None,
|
|
860
|
+
limit: int = 100,
|
|
861
|
+
use_embedding: bool = True,
|
|
862
|
+
) -> list[Event]:
|
|
863
|
+
"""
|
|
864
|
+
Search recent events by lexical matching and optional embedding similarity.
|
|
865
|
+
- kinds: optional filter by event kinds
|
|
866
|
+
- tags: optional filter by tags (AND semantics)
|
|
867
|
+
- limit: max number of results to return
|
|
868
|
+
- use_embedding: whether to use embedding-based ranking (requires LLM client)
|
|
869
|
+
|
|
870
|
+
NOTE: This is an in-memory scan of recent events. No indexing is done yet.
|
|
871
|
+
"""
|
|
872
|
+
events = await self.recent(kinds=kinds, limit=limit)
|
|
873
|
+
if tags:
|
|
874
|
+
want = set(tags)
|
|
875
|
+
events = [e for e in events if want.issubset(set(e.tags or []))]
|
|
876
|
+
|
|
877
|
+
query_l = query.lower()
|
|
878
|
+
|
|
879
|
+
# 1) simple fallback: lexical
|
|
880
|
+
lexical_hits = [e for e in events if (e.text or "").lower().find(query_l) >= 0]
|
|
881
|
+
|
|
882
|
+
if not use_embedding:
|
|
883
|
+
return lexical_hits or events
|
|
884
|
+
|
|
885
|
+
raise NotImplementedError("Embedding-based search not implemented yet")
|
|
886
|
+
|
|
887
|
+
# 2) optional: embedding-based ranking (if you embed query + have e.embedding) [stub]
|
|
888
|
+
if not (self.llm and any(e.embedding for e in events)):
|
|
889
|
+
return lexical_hits or events
|
|
890
|
+
|
|
891
|
+
q_emb = await self.llm.embed(query) # TODO: adapt to LLMClientProtocol
|
|
892
|
+
|
|
893
|
+
# compute cosine similarity in Python for now
|
|
894
|
+
def sim(e: Event) -> float:
|
|
895
|
+
if not e.embedding:
|
|
896
|
+
return -1.0
|
|
897
|
+
# naive dot product
|
|
898
|
+
return sum(a * b for a, b in zip(q_emb, e.embedding, strict=False))
|
|
899
|
+
|
|
900
|
+
scored = sorted(events, key=sim, reverse=True)
|
|
901
|
+
return scored[:limit]
|
|
902
|
+
|
|
903
|
+
# ---------- distillation (plug strategies) ----------
|
|
904
|
+
|
|
905
|
+
# ---------- distillation helpers ----------
|
|
906
|
+
async def distill_long_term(
|
|
907
|
+
self,
|
|
908
|
+
scope_id: str | None = None,
|
|
909
|
+
*,
|
|
910
|
+
summary_tag: str = "session",
|
|
911
|
+
summary_kind: str = "long_term_summary",
|
|
912
|
+
include_kinds: list[str] | None = None,
|
|
913
|
+
include_tags: list[str] | None = None,
|
|
914
|
+
max_events: int = 200,
|
|
915
|
+
min_signal: float | None = None,
|
|
916
|
+
use_llm: bool = False,
|
|
917
|
+
) -> dict[str, Any]:
|
|
918
|
+
"""
|
|
919
|
+
Run the generic LongTermSummarizer over this run's memory and persist a summary.
|
|
920
|
+
|
|
921
|
+
Returns a descriptor like:
|
|
922
|
+
{
|
|
923
|
+
"uri": "file://mem/<run_id>/summaries/<tag>/<ts>.json",
|
|
924
|
+
"summary_kind": "...",
|
|
925
|
+
"summary_tag": "...",
|
|
926
|
+
"time_window": {...},
|
|
927
|
+
"num_events": N,
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
This is suitable for:
|
|
931
|
+
- soft re-hydration (load summary into a new run),
|
|
932
|
+
- RAG promotion,
|
|
933
|
+
- or analytics.
|
|
934
|
+
"""
|
|
935
|
+
scope_id = scope_id or self.memory_scope_id # order of precedence
|
|
936
|
+
if use_llm:
|
|
937
|
+
if not self.llm:
|
|
938
|
+
raise RuntimeError("LLM client not configured in MemoryFacade for LLM distillation")
|
|
939
|
+
from aethergraph.services.memory.distillers.llm_long_term import LLMLongTermSummarizer
|
|
940
|
+
|
|
941
|
+
d = LLMLongTermSummarizer(
|
|
942
|
+
llm=self.llm,
|
|
943
|
+
summary_kind=summary_kind,
|
|
944
|
+
summary_tag=summary_tag,
|
|
945
|
+
include_kinds=include_kinds,
|
|
946
|
+
include_tags=include_tags,
|
|
947
|
+
max_events=max_events,
|
|
948
|
+
min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
|
|
949
|
+
)
|
|
950
|
+
return await d.distill(
|
|
951
|
+
run_id=self.run_id,
|
|
952
|
+
timeline_id=self.timeline_id,
|
|
953
|
+
scope_id=scope_id or self.memory_scope_id,
|
|
954
|
+
hotlog=self.hotlog,
|
|
955
|
+
persistence=self.persistence,
|
|
956
|
+
indices=self.indices,
|
|
957
|
+
docs=self.docs,
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
from aethergraph.services.memory.distillers.long_term import LongTermSummarizer
|
|
961
|
+
|
|
962
|
+
# non-LLM path -- structured digest
|
|
963
|
+
d = LongTermSummarizer(
|
|
964
|
+
summary_kind=summary_kind,
|
|
965
|
+
summary_tag=summary_tag,
|
|
966
|
+
include_kinds=include_kinds,
|
|
967
|
+
include_tags=include_tags,
|
|
968
|
+
max_events=max_events,
|
|
969
|
+
min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
|
|
970
|
+
)
|
|
971
|
+
return await d.distill(
|
|
972
|
+
run_id=self.run_id,
|
|
973
|
+
timeline_id=self.timeline_id,
|
|
974
|
+
scope_id=scope_id or self.memory_scope_id,
|
|
975
|
+
hotlog=self.hotlog,
|
|
976
|
+
persistence=self.persistence,
|
|
977
|
+
indices=self.indices,
|
|
978
|
+
docs=self.docs,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
async def distill_meta_summary(
|
|
982
|
+
self,
|
|
983
|
+
scope_id: str | None = None,
|
|
984
|
+
*,
|
|
985
|
+
source_kind: str = "long_term_summary",
|
|
986
|
+
source_tag: str = "session",
|
|
987
|
+
summary_kind: str = "meta_summary",
|
|
988
|
+
summary_tag: str = "meta",
|
|
989
|
+
max_summaries: int = 20,
|
|
990
|
+
min_signal: float | None = None,
|
|
991
|
+
use_llm: bool = True,
|
|
992
|
+
) -> dict[str, Any]:
|
|
993
|
+
"""
|
|
994
|
+
Run an LLM-based meta summarizer over existing summary events.
|
|
995
|
+
|
|
996
|
+
Typical usage:
|
|
997
|
+
- source_kind="long_term_summary", source_tag="session"
|
|
998
|
+
- summary_kind="meta_summary", summary_tag="weekly" or "meta"
|
|
999
|
+
|
|
1000
|
+
Returns a descriptor like:
|
|
1001
|
+
{
|
|
1002
|
+
"uri": "file://mem/<scope_id>/summaries/<summary_tag>/<ts>.json",
|
|
1003
|
+
"summary_kind": "...",
|
|
1004
|
+
"summary_tag": "...",
|
|
1005
|
+
"time_window": {...},
|
|
1006
|
+
"num_source_summaries": N,
|
|
1007
|
+
}
|
|
1008
|
+
"""
|
|
1009
|
+
scope_id = scope_id or self.memory_scope_id # order of precedence
|
|
1010
|
+
|
|
1011
|
+
if not use_llm:
|
|
1012
|
+
# Placeholder for a future non-LLM meta summarizer if desired.
|
|
1013
|
+
raise NotImplementedError("Non-LLM meta summarization is not implemented yet")
|
|
1014
|
+
|
|
1015
|
+
if not self.llm:
|
|
1016
|
+
raise RuntimeError("LLM client not configured in MemoryFacade for meta distillation")
|
|
1017
|
+
|
|
1018
|
+
from aethergraph.services.memory.distillers.llm_meta_summary import (
|
|
1019
|
+
LLMMetaSummaryDistiller,
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
d = LLMMetaSummaryDistiller(
|
|
1023
|
+
llm=self.llm,
|
|
1024
|
+
source_kind=source_kind,
|
|
1025
|
+
source_tag=source_tag,
|
|
1026
|
+
summary_kind=summary_kind,
|
|
1027
|
+
summary_tag=summary_tag,
|
|
1028
|
+
max_summaries=max_summaries,
|
|
1029
|
+
min_signal=min_signal if min_signal is not None else self.default_signal_threshold,
|
|
1030
|
+
)
|
|
1031
|
+
return await d.distill(
|
|
1032
|
+
run_id=self.run_id,
|
|
1033
|
+
timeline_id=self.timeline_id,
|
|
1034
|
+
scope_id=scope_id or self.memory_scope_id,
|
|
1035
|
+
hotlog=self.hotlog,
|
|
1036
|
+
persistence=self.persistence,
|
|
1037
|
+
indices=self.indices,
|
|
1038
|
+
docs=self.docs,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# ---------- RAG facade ----------
|
|
1042
|
+
async def rag_upsert(
|
|
1043
|
+
self, *, corpus_id: str, docs: Sequence[dict[str, Any]], topic: str | None = None
|
|
1044
|
+
) -> dict[str, Any]:
|
|
1045
|
+
"""Upsert documents into RAG corpus via RAG facade, if configured."""
|
|
1046
|
+
if not self.rag:
|
|
1047
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1048
|
+
stats = await self.rag.upsert_docs(corpus_id=corpus_id, docs=list(docs))
|
|
1049
|
+
# Optional write result -- disable for now
|
|
1050
|
+
# self.write_result(
|
|
1051
|
+
# topic=topic or f"rag.upsert.{corpus_id}",
|
|
1052
|
+
# outputs=[{"name": "stats", "kind": "json", "value": stats}],
|
|
1053
|
+
# tags=["rag", "ingest"],
|
|
1054
|
+
# message=f"Upserted {stats.get('chunks',0)} chunks into {corpus_id}"
|
|
1055
|
+
# )
|
|
1056
|
+
return stats
|
|
1057
|
+
|
|
1058
|
+
# ---------- helpers ----------
|
|
1059
|
+
def _estimate_signal(
|
|
1060
|
+
self, *, text: str | None, metrics: dict[str, Any] | None, severity: int
|
|
1061
|
+
) -> float:
|
|
1062
|
+
"""
|
|
1063
|
+
Cheap heuristic to gauge “signal” of an event (0.0–1.0).
|
|
1064
|
+
- Rewards presence/length of text and presence of metrics.
|
|
1065
|
+
- Used as a noise gate in rolling summaries; can be overridden by caller.
|
|
1066
|
+
"""
|
|
1067
|
+
score = 0.15 + 0.1 * severity
|
|
1068
|
+
if text:
|
|
1069
|
+
score += min(len(text) / 400.0, 0.4)
|
|
1070
|
+
if metrics:
|
|
1071
|
+
score += 0.2
|
|
1072
|
+
return max(0.0, min(1.0, score))
|
|
1073
|
+
|
|
1074
|
+
def resolve(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
1075
|
+
"""
|
|
1076
|
+
Synchronous version of parameter resolution (for sync contexts).
|
|
1077
|
+
See `aethergraph.services.memory.resolver.resolve_params` for details.
|
|
1078
|
+
"""
|
|
1079
|
+
from aethergraph.services.memory.resolver import ResolverContext, resolve_params
|
|
1080
|
+
|
|
1081
|
+
rctx = ResolverContext(mem=self)
|
|
1082
|
+
return resolve_params(params, rctx)
|
|
1083
|
+
|
|
1084
|
+
# ----------- RAG: corpus binding & status -----------
|
|
1085
|
+
async def rag_bind(
|
|
1086
|
+
self,
|
|
1087
|
+
*,
|
|
1088
|
+
corpus_id: str | None = None,
|
|
1089
|
+
key: str | None = None,
|
|
1090
|
+
create_if_missing: bool = True,
|
|
1091
|
+
labels: dict | None = None,
|
|
1092
|
+
) -> str:
|
|
1093
|
+
if not self.rag:
|
|
1094
|
+
raise RuntimeError("RAG facade not configured")
|
|
1095
|
+
|
|
1096
|
+
mem_scope = self.memory_scope_id # derived from Scope
|
|
1097
|
+
# dims = self.scope.metering_dimensions() if self.scope else {}
|
|
1098
|
+
|
|
1099
|
+
if corpus_id:
|
|
1100
|
+
cid = corpus_id
|
|
1101
|
+
else:
|
|
1102
|
+
logical_key = key or "default"
|
|
1103
|
+
base = f"{mem_scope}:{logical_key}"
|
|
1104
|
+
cid = f"mem:{_slug(mem_scope)}:{_slug(logical_key)}-{_short_hash(base, 8)}"
|
|
1105
|
+
|
|
1106
|
+
scope_labels = {}
|
|
1107
|
+
if self.scope:
|
|
1108
|
+
scope_labels = self.scope.rag_labels(scope_id=mem_scope)
|
|
1109
|
+
|
|
1110
|
+
meta = {"scope": scope_labels, **(labels or {})}
|
|
1111
|
+
if create_if_missing:
|
|
1112
|
+
await self.rag.add_corpus(cid, meta=meta, scope_labels=scope_labels)
|
|
1113
|
+
return cid
|
|
1114
|
+
|
|
1115
|
+
async def rag_status(self, *, corpus_id: str) -> dict:
|
|
1116
|
+
"""Quick stats about a corpus."""
|
|
1117
|
+
if not self.rag:
|
|
1118
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1119
|
+
# lightweight: count docs/chunks by scanning the jsonl (fast enough for now)
|
|
1120
|
+
return await self.rag.stats(corpus_id)
|
|
1121
|
+
|
|
1122
|
+
async def rag_snapshot(self, *, corpus_id: str, title: str, labels: dict | None = None) -> dict:
|
|
1123
|
+
"""Export corpus into an artifact bundle and return its URI."""
|
|
1124
|
+
if not self.rag:
|
|
1125
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1126
|
+
bundle = await self.rag.export(corpus_id)
|
|
1127
|
+
# Optionally log a tool_result
|
|
1128
|
+
await self.write_result(
|
|
1129
|
+
tool=f"rag.snapshot.{corpus_id}",
|
|
1130
|
+
outputs=[{"name": "bundle_uri", "kind": "uri", "value": bundle.get("uri")}],
|
|
1131
|
+
tags=["rag", "snapshot"],
|
|
1132
|
+
message=title,
|
|
1133
|
+
severity=2,
|
|
1134
|
+
)
|
|
1135
|
+
return bundle
|
|
1136
|
+
|
|
1137
|
+
async def rag_compact(self, *, corpus_id: str, policy: dict | None = None) -> dict:
|
|
1138
|
+
"""
|
|
1139
|
+
Simple compaction policy:
|
|
1140
|
+
- Optionally drop docs by label or min_score
|
|
1141
|
+
- Optional re-embed with a new model
|
|
1142
|
+
For now we just expose reembed() plumbing and a placeholder for pruning.
|
|
1143
|
+
|
|
1144
|
+
NOTE: this function is a placeholder for future compaction strategies.
|
|
1145
|
+
"""
|
|
1146
|
+
if not self.rag:
|
|
1147
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1148
|
+
policy = policy or {}
|
|
1149
|
+
model = policy.get("reembed_model")
|
|
1150
|
+
pruned = 0 # placeholder
|
|
1151
|
+
if model:
|
|
1152
|
+
await self.rag.reembed(corpus_id, model=model)
|
|
1153
|
+
return {"pruned_docs": pruned, "reembedded": bool(model)}
|
|
1154
|
+
|
|
1155
|
+
# ----------- RAG: event → doc promotion -----------
|
|
1156
|
+
async def rag_promote_events(
|
|
1157
|
+
self,
|
|
1158
|
+
*,
|
|
1159
|
+
corpus_id: str,
|
|
1160
|
+
events: list[Event] | None = None,
|
|
1161
|
+
where: dict | None = None,
|
|
1162
|
+
policy: dict | None = None,
|
|
1163
|
+
) -> dict:
|
|
1164
|
+
"""
|
|
1165
|
+
Convert events to documents and upsert.
|
|
1166
|
+
where: optional filter like {"kinds": ["tool_result"], "min_signal": 0.25, "limit": 200}
|
|
1167
|
+
policy: {"min_signal": float} In the future may support more (chunksize, overlap, etc.)
|
|
1168
|
+
"""
|
|
1169
|
+
if not self.rag:
|
|
1170
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1171
|
+
policy = policy or {}
|
|
1172
|
+
min_signal = policy.get("min_signal", self.default_signal_threshold)
|
|
1173
|
+
|
|
1174
|
+
# Select events if not provided
|
|
1175
|
+
if events is None:
|
|
1176
|
+
kinds = (where or {}).get("kinds")
|
|
1177
|
+
limit = int((where or {}).get("limit", 200))
|
|
1178
|
+
recent = await self.recent(kinds=kinds, limit=limit)
|
|
1179
|
+
events = [e for e in recent if (getattr(e, "signal", 0.0) or 0.0) >= float(min_signal)]
|
|
1180
|
+
|
|
1181
|
+
docs: list[dict] = []
|
|
1182
|
+
for e in events:
|
|
1183
|
+
title = f"{e.kind}:{(e.tool or e.stage or 'n/a')}:{e.ts}"
|
|
1184
|
+
scope_labels = (
|
|
1185
|
+
self.scope.rag_labels(scope_id=self.memory_scope_id) if self.scope else {}
|
|
1186
|
+
)
|
|
1187
|
+
labels = {
|
|
1188
|
+
**scope_labels,
|
|
1189
|
+
"kind": e.kind,
|
|
1190
|
+
"tool": e.tool,
|
|
1191
|
+
"stage": e.stage,
|
|
1192
|
+
"severity": e.severity,
|
|
1193
|
+
"run_id": e.run_id,
|
|
1194
|
+
"graph_id": e.graph_id,
|
|
1195
|
+
"node_id": e.node_id,
|
|
1196
|
+
"scope_id": e.scope_id,
|
|
1197
|
+
"tags": list(e.tags or []),
|
|
1198
|
+
}
|
|
1199
|
+
body = e.text
|
|
1200
|
+
if not body:
|
|
1201
|
+
# Fallback to compact JSON of I/O + metrics
|
|
1202
|
+
body = json.dumps(
|
|
1203
|
+
{"inputs": e.inputs, "outputs": e.outputs, "metrics": e.metrics},
|
|
1204
|
+
ensure_ascii=False,
|
|
1205
|
+
)
|
|
1206
|
+
docs.append({"text": body, "title": title, "labels": labels})
|
|
1207
|
+
|
|
1208
|
+
if not docs:
|
|
1209
|
+
return {
|
|
1210
|
+
"added": 0,
|
|
1211
|
+
"chunks": 0,
|
|
1212
|
+
"index": getattr(self.rag.index, "__class__", type("X", (object,), {})).__name__,
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
stats = await self.rag.upsert_docs(corpus_id=corpus_id, docs=docs)
|
|
1216
|
+
# (Optional) write a result for traceability
|
|
1217
|
+
await self.write_result(
|
|
1218
|
+
tool=f"rag.promote.{corpus_id}",
|
|
1219
|
+
outputs=[
|
|
1220
|
+
{"name": "added_docs", "kind": "number", "value": stats.get("added", 0)},
|
|
1221
|
+
{"name": "chunks", "kind": "number", "value": stats.get("chunks", 0)},
|
|
1222
|
+
],
|
|
1223
|
+
tags=["rag", "ingest"],
|
|
1224
|
+
message=f"Promoted {stats.get('added', 0)} events into {corpus_id}",
|
|
1225
|
+
severity=2,
|
|
1226
|
+
)
|
|
1227
|
+
return stats
|
|
1228
|
+
|
|
1229
|
+
# ----------- RAG: search & answer -----------
|
|
1230
|
+
async def rag_search(
|
|
1231
|
+
self,
|
|
1232
|
+
*,
|
|
1233
|
+
corpus_id: str,
|
|
1234
|
+
query: str,
|
|
1235
|
+
k: int = 8,
|
|
1236
|
+
filters: dict | None = None,
|
|
1237
|
+
mode: Literal["hybrid", "dense"] = "hybrid",
|
|
1238
|
+
) -> list[dict]:
|
|
1239
|
+
"""Thin pass-through, but returns serializable dicts."""
|
|
1240
|
+
if not self.rag:
|
|
1241
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1242
|
+
|
|
1243
|
+
scope = self.scope
|
|
1244
|
+
s_filters = scope.rag_filter(scope_id=self.memory_scope_id) if scope else {}
|
|
1245
|
+
if filters:
|
|
1246
|
+
s_filters.update(filters)
|
|
1247
|
+
hits = await self.rag.search(corpus_id, query, k=k, filters=s_filters, mode=mode)
|
|
1248
|
+
return [
|
|
1249
|
+
dict(
|
|
1250
|
+
chunk_id=h.chunk_id,
|
|
1251
|
+
doc_id=h.doc_id,
|
|
1252
|
+
corpus_id=h.corpus_id,
|
|
1253
|
+
score=h.score,
|
|
1254
|
+
text=h.text,
|
|
1255
|
+
meta=h.meta,
|
|
1256
|
+
)
|
|
1257
|
+
for h in hits
|
|
1258
|
+
]
|
|
1259
|
+
|
|
1260
|
+
async def rag_answer(
|
|
1261
|
+
self,
|
|
1262
|
+
*,
|
|
1263
|
+
corpus_id: str,
|
|
1264
|
+
question: str,
|
|
1265
|
+
style: Literal["concise", "detailed"] = "concise",
|
|
1266
|
+
with_citations: bool = True,
|
|
1267
|
+
k: int = 6,
|
|
1268
|
+
) -> dict:
|
|
1269
|
+
"""Answer with citations, then log as a tool_result."""
|
|
1270
|
+
if not self.rag:
|
|
1271
|
+
raise RuntimeError("RAG facade not configured in MemoryFacade")
|
|
1272
|
+
ans = await self.rag.answer(
|
|
1273
|
+
corpus_id=corpus_id,
|
|
1274
|
+
question=question,
|
|
1275
|
+
llm=self.llm,
|
|
1276
|
+
style=style,
|
|
1277
|
+
with_citations=with_citations,
|
|
1278
|
+
k=k,
|
|
1279
|
+
)
|
|
1280
|
+
# Flatten citations into outputs for indices
|
|
1281
|
+
outs = [{"name": "answer", "kind": "text", "value": ans.get("answer", "")}]
|
|
1282
|
+
for i, rc in enumerate(ans.get("resolved_citations", []), start=1):
|
|
1283
|
+
outs.append({"name": f"cite_{i}", "kind": "json", "value": rc})
|
|
1284
|
+
await self.write_result(
|
|
1285
|
+
tool=f"rag.answer.{corpus_id}",
|
|
1286
|
+
outputs=outs,
|
|
1287
|
+
tags=["rag", "qa"],
|
|
1288
|
+
message=f"Q: {question}",
|
|
1289
|
+
metrics=ans.get("usage", {}),
|
|
1290
|
+
severity=2,
|
|
1291
|
+
)
|
|
1292
|
+
return ans
|
|
1293
|
+
|
|
1294
|
+
async def load_last_summary(
|
|
1295
|
+
self,
|
|
1296
|
+
scope_id: str | None = None,
|
|
1297
|
+
*,
|
|
1298
|
+
summary_tag: str = "session",
|
|
1299
|
+
) -> dict[str, Any] | None:
|
|
1300
|
+
"""
|
|
1301
|
+
Load the most recent JSON summary for this memory scope and tag.
|
|
1302
|
+
|
|
1303
|
+
Uses DocStore IDs:
|
|
1304
|
+
mem/{scope_id}/summaries/{summary_tag}/{ts}
|
|
1305
|
+
so it works regardless of persistence backend.
|
|
1306
|
+
"""
|
|
1307
|
+
scope_id = scope_id or self.memory_scope_id
|
|
1308
|
+
prefix = _summary_prefix(scope_id, summary_tag)
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
ids = await self.docs.list()
|
|
1312
|
+
except Exception as e:
|
|
1313
|
+
self.logger and self.logger.warning("load_last_summary: doc_store.list() failed: %s", e)
|
|
1314
|
+
return None
|
|
1315
|
+
|
|
1316
|
+
# Filter and take the latest
|
|
1317
|
+
candidates = [d for d in ids if d.startswith(prefix)]
|
|
1318
|
+
if not candidates:
|
|
1319
|
+
return None
|
|
1320
|
+
|
|
1321
|
+
latest_id = sorted(candidates)[-1]
|
|
1322
|
+
try:
|
|
1323
|
+
return await self.docs.get(latest_id) # type: ignore[return-value]
|
|
1324
|
+
except Exception as e:
|
|
1325
|
+
self.logger and self.logger.warning(
|
|
1326
|
+
"load_last_summary: failed to load %s: %s", latest_id, e
|
|
1327
|
+
)
|
|
1328
|
+
return None
|
|
1329
|
+
|
|
1330
|
+
async def load_recent_summaries(
|
|
1331
|
+
self,
|
|
1332
|
+
scope_id: str | None = None,
|
|
1333
|
+
*,
|
|
1334
|
+
summary_tag: str = "session",
|
|
1335
|
+
limit: int = 3,
|
|
1336
|
+
) -> list[dict[str, Any]]:
|
|
1337
|
+
"""
|
|
1338
|
+
Load up to `limit` most recent JSON summaries for this scope+tag.
|
|
1339
|
+
|
|
1340
|
+
Ordered oldest→newest (so the last item is the most recent).
|
|
1341
|
+
"""
|
|
1342
|
+
scope_id = scope_id or self.memory_scope_id
|
|
1343
|
+
prefix = _summary_prefix(scope_id, summary_tag)
|
|
1344
|
+
|
|
1345
|
+
try:
|
|
1346
|
+
ids = await self.docs.list()
|
|
1347
|
+
except Exception as e:
|
|
1348
|
+
self.logger and self.logger.warning(
|
|
1349
|
+
"load_recent_summaries: doc_store.list() failed: %s", e
|
|
1350
|
+
)
|
|
1351
|
+
return []
|
|
1352
|
+
|
|
1353
|
+
candidates = sorted(d for d in ids if d.startswith(prefix))
|
|
1354
|
+
if not candidates:
|
|
1355
|
+
return []
|
|
1356
|
+
|
|
1357
|
+
chosen = candidates[-limit:]
|
|
1358
|
+
out: list[dict[str, Any]] = []
|
|
1359
|
+
for doc_id in chosen:
|
|
1360
|
+
try:
|
|
1361
|
+
doc = await self.docs.get(doc_id)
|
|
1362
|
+
if doc is not None:
|
|
1363
|
+
out.append(doc) # type: ignore[arg-type]
|
|
1364
|
+
except Exception:
|
|
1365
|
+
continue
|
|
1366
|
+
return out
|
|
1367
|
+
|
|
1368
|
+
async def soft_hydrate_last_summary(
|
|
1369
|
+
self,
|
|
1370
|
+
scope_id: str | None = None,
|
|
1371
|
+
*,
|
|
1372
|
+
summary_tag: str = "session",
|
|
1373
|
+
summary_kind: str = "long_term_summary",
|
|
1374
|
+
) -> dict[str, Any] | None:
|
|
1375
|
+
"""
|
|
1376
|
+
Load the last summary JSON for this tag (if any) and log a small hydrate Event
|
|
1377
|
+
into the current run's HotLog. Returns the loaded summary dict, or None.
|
|
1378
|
+
"""
|
|
1379
|
+
scope_id = scope_id or self.memory_scope_id
|
|
1380
|
+
summary = await self.load_last_summary(scope_id=scope_id, summary_tag=summary_tag)
|
|
1381
|
+
if not summary:
|
|
1382
|
+
return None
|
|
1383
|
+
|
|
1384
|
+
text = summary.get("text") or ""
|
|
1385
|
+
preview = text[:2000] + (" …[truncated]" if len(text) > 2000 else "")
|
|
1386
|
+
|
|
1387
|
+
evt = Event(
|
|
1388
|
+
scope_id=self.memory_scope_id or self.run_id,
|
|
1389
|
+
event_id=stable_event_id(
|
|
1390
|
+
{
|
|
1391
|
+
"ts": now_iso(),
|
|
1392
|
+
"run_id": self.run_id,
|
|
1393
|
+
"kind": f"{summary_kind}_hydrate",
|
|
1394
|
+
"summary_tag": summary_tag,
|
|
1395
|
+
"preview": preview[:200],
|
|
1396
|
+
}
|
|
1397
|
+
),
|
|
1398
|
+
ts=now_iso(),
|
|
1399
|
+
run_id=self.run_id,
|
|
1400
|
+
kind=f"{summary_kind}_hydrate",
|
|
1401
|
+
stage="hydrate",
|
|
1402
|
+
text=preview,
|
|
1403
|
+
tags=["summary", "hydrate", summary_tag],
|
|
1404
|
+
data={"summary": summary},
|
|
1405
|
+
metrics={"num_events": summary.get("num_events", 0)},
|
|
1406
|
+
severity=1,
|
|
1407
|
+
signal=0.4,
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
await self.hotlog.append(self.timeline_id, evt, ttl_s=self.hot_ttl_s, limit=self.hot_limit)
|
|
1411
|
+
await self.persistence.append_event(self.timeline_id, evt)
|
|
1412
|
+
return summary
|
|
1413
|
+
|
|
1414
|
+
# ----- Stubs for future memory facade features -----
|
|
1415
|
+
async def mark_event_important(
|
|
1416
|
+
self,
|
|
1417
|
+
event_id: str,
|
|
1418
|
+
*,
|
|
1419
|
+
reason: str | None = None,
|
|
1420
|
+
topic: str | None = None,
|
|
1421
|
+
) -> None:
|
|
1422
|
+
"""
|
|
1423
|
+
Stub / placeholder:
|
|
1424
|
+
|
|
1425
|
+
Mark a given event as "important" / "core_fact" for future policies.
|
|
1426
|
+
|
|
1427
|
+
Intended future behavior (not implemented yet):
|
|
1428
|
+
- Look up the Event by event_id (via Persistence).
|
|
1429
|
+
- Re-emit an updated Event with an added tag (e.g. "core_fact" or "pinned").
|
|
1430
|
+
- Optionally promote to a fact artifact or RAG doc.
|
|
1431
|
+
|
|
1432
|
+
For now, this is a no-op / NotImplementedError to avoid surprise behavior.
|
|
1433
|
+
"""
|
|
1434
|
+
raise NotImplementedError("mark_event_important is reserved for future memory policy")
|
|
1435
|
+
|
|
1436
|
+
async def save_core_fact_artifact(
|
|
1437
|
+
self,
|
|
1438
|
+
*,
|
|
1439
|
+
scope_id: str,
|
|
1440
|
+
topic: str,
|
|
1441
|
+
fact_id: str,
|
|
1442
|
+
content: dict[str, Any],
|
|
1443
|
+
):
|
|
1444
|
+
"""
|
|
1445
|
+
Stub / placeholder:
|
|
1446
|
+
|
|
1447
|
+
Save a canonical, long-lived fact as a pinned artifact.
|
|
1448
|
+
Intended future behavior:
|
|
1449
|
+
- Use artifacts.save_json(...) to write the fact payload under a
|
|
1450
|
+
stable path like file://mem/<scope_id>/facts/<topic>/<fact_id>.json
|
|
1451
|
+
- Mark the artifact pinned in the index.
|
|
1452
|
+
- Optionally write a tool_result Event referencing this artifact.
|
|
1453
|
+
|
|
1454
|
+
Not implemented yet; provided as an explicit extension hook.
|
|
1455
|
+
"""
|
|
1456
|
+
raise NotImplementedError("save_core_fact_artifact is reserved for future memory policy")
|
|
1457
|
+
|
|
1458
|
+
# ----------- RAG: DX helpers (key-based) -----------
|
|
1459
|
+
async def rag_remember_events(
|
|
1460
|
+
self,
|
|
1461
|
+
*,
|
|
1462
|
+
key: str = "default",
|
|
1463
|
+
where: dict | None = None,
|
|
1464
|
+
policy: dict | None = None,
|
|
1465
|
+
) -> dict:
|
|
1466
|
+
"""
|
|
1467
|
+
High-level: bind a RAG corpus by logical key and promote events into it.
|
|
1468
|
+
|
|
1469
|
+
Example:
|
|
1470
|
+
await mem.rag_remember_events(
|
|
1471
|
+
key="session",
|
|
1472
|
+
where={"kinds": ["tool_result"], "limit": 200},
|
|
1473
|
+
policy={"min_signal": 0.25},
|
|
1474
|
+
)
|
|
1475
|
+
"""
|
|
1476
|
+
corpus_id = await self.rag_bind(key=key, create_if_missing=True)
|
|
1477
|
+
return await self.rag_promote_events(
|
|
1478
|
+
corpus_id=corpus_id,
|
|
1479
|
+
events=None,
|
|
1480
|
+
where=where,
|
|
1481
|
+
policy=policy,
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
async def rag_remember_docs(
|
|
1485
|
+
self,
|
|
1486
|
+
docs: Sequence[dict[str, Any]],
|
|
1487
|
+
*,
|
|
1488
|
+
key: str = "default",
|
|
1489
|
+
labels: dict | None = None,
|
|
1490
|
+
) -> dict[str, Any]:
|
|
1491
|
+
"""
|
|
1492
|
+
High-level: bind a RAG corpus by key and upsert docs into it.
|
|
1493
|
+
"""
|
|
1494
|
+
corpus_id = await self.rag_bind(key=key, create_if_missing=True, labels=labels)
|
|
1495
|
+
return await self.rag_upsert(corpus_id=corpus_id, docs=list(docs))
|
|
1496
|
+
|
|
1497
|
+
async def rag_search_by_key(
|
|
1498
|
+
self,
|
|
1499
|
+
*,
|
|
1500
|
+
key: str = "default",
|
|
1501
|
+
query: str,
|
|
1502
|
+
k: int = 8,
|
|
1503
|
+
filters: dict | None = None,
|
|
1504
|
+
mode: Literal["hybrid", "dense"] = "hybrid",
|
|
1505
|
+
) -> list[dict]:
|
|
1506
|
+
"""
|
|
1507
|
+
High-level: resolve corpus by logical key and run rag_search() on it.
|
|
1508
|
+
"""
|
|
1509
|
+
corpus_id = await self.rag_bind(key=key, create_if_missing=False)
|
|
1510
|
+
return await self.rag_search(
|
|
1511
|
+
corpus_id=corpus_id,
|
|
1512
|
+
query=query,
|
|
1513
|
+
k=k,
|
|
1514
|
+
filters=filters,
|
|
1515
|
+
mode=mode,
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
async def rag_answer_by_key(
|
|
1519
|
+
self,
|
|
1520
|
+
*,
|
|
1521
|
+
key: str = "default",
|
|
1522
|
+
question: str,
|
|
1523
|
+
style: Literal["concise", "detailed"] = "concise",
|
|
1524
|
+
with_citations: bool = True,
|
|
1525
|
+
k: int = 6,
|
|
1526
|
+
) -> dict:
|
|
1527
|
+
"""
|
|
1528
|
+
High-level: RAG QA over a corpus referenced by logical key.
|
|
1529
|
+
|
|
1530
|
+
Internally calls rag_bind(..., create_if_missing=False) and rag_answer().
|
|
1531
|
+
"""
|
|
1532
|
+
corpus_id = await self.rag_bind(key=key, create_if_missing=False)
|
|
1533
|
+
return await self.rag_answer(
|
|
1534
|
+
corpus_id=corpus_id,
|
|
1535
|
+
question=question,
|
|
1536
|
+
style=style,
|
|
1537
|
+
with_citations=with_citations,
|
|
1538
|
+
k=k,
|
|
1539
|
+
)
|