etchmem 0.1.4__tar.gz → 0.1.6__tar.gz
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.
- {etchmem-0.1.4 → etchmem-0.1.6}/PKG-INFO +20 -2
- {etchmem-0.1.4 → etchmem-0.1.6}/README.md +19 -1
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/consolidate/worker.py +13 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/engine.py +71 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/PKG-INFO +20 -2
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/SOURCES.txt +1 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/pyproject.toml +1 -1
- etchmem-0.1.6/tests/test_export.py +182 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/LICENSE +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/config.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/base.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/remote.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/consolidate/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/models.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/recall/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/recall/recaller.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/base.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/chroma_store.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/__init__.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/affectability.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/hashing.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/dependency_links.txt +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/requires.txt +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/top_level.txt +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/setup.cfg +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_consolidate_idempotent.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_formation_buckets.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_recall.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_reconsolidation.py +0 -0
- {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_remember_recall.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etchmem
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A pluggable, embeddable reconsolidating skill memory engine for AI agents
|
|
5
5
|
Author: Andrey Olishchuk
|
|
6
6
|
License: MIT
|
|
@@ -46,7 +46,7 @@ pip install etchmem
|
|
|
46
46
|
|
|
47
47
|
`etchmem` gives your agent a persistent memory that matures through living. You deposit raw observations — facts, outcomes, feedback — scoped to a skill or left general. You retrieve with natural-language queries. Periodically you consolidate, and the engine synthesizes scattered signal into compact knowledge articles, rewriting anything that has been recalled against fresh context.
|
|
48
48
|
|
|
49
|
-
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes
|
|
49
|
+
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes a small, deliberate API: remember, recall, consolidate, export, and stats.
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
@@ -88,6 +88,18 @@ Retrieves relevant knowledge and emits a recall-event for future reconsolidation
|
|
|
88
88
|
|
|
89
89
|
Runs the consolidation worker: clusters the buffer, forms new injected articles, reconsolidates recalled articles against fresh context, hard-deletes superseded ones. Returns a summary dict with counts (`formed`, `reconsolidated`, `dropped`, `kept`, `flushed`, `superseded`). Requires an LLM API key.
|
|
90
90
|
|
|
91
|
+
### `export() → dict`
|
|
92
|
+
|
|
93
|
+
Serializes the entire injected knowledge store to disk. Writes one `<id>.json` file per synthesized article into `.etchmem/export/<UTC-timestamp>/` next to the data directory. No LLM call — cheap.
|
|
94
|
+
|
|
95
|
+
Returns `{"export_dir": "...", "count": N, "documents": [...]}`.
|
|
96
|
+
|
|
97
|
+
Use this to transfer institutional memory to another agent, or to produce fine-tuning data shaped by real task outcomes rather than hand-authored examples. Experience becomes a portable artifact.
|
|
98
|
+
|
|
99
|
+
### `stats() → dict`
|
|
100
|
+
|
|
101
|
+
Returns live collection sizes for all three tiers: `{"relational": N, "buffer": N, "injected": N}`. Useful for monitoring and debugging.
|
|
102
|
+
|
|
91
103
|
---
|
|
92
104
|
|
|
93
105
|
## Hello world
|
|
@@ -120,6 +132,12 @@ print(summary)
|
|
|
120
132
|
# will be blended in during reconsolidation.
|
|
121
133
|
results = engine.recall("Eiffel Tower visitors")
|
|
122
134
|
print(results[0].content)
|
|
135
|
+
|
|
136
|
+
# Export the full knowledge store — one JSON file per synthesized article.
|
|
137
|
+
# Transfer to another agent, or use as fine-tuning data.
|
|
138
|
+
result = engine.export()
|
|
139
|
+
print(result["export_dir"]) # e.g. /your/project/.etchmem/export/20260525T120000Z
|
|
140
|
+
print(result["count"]) # 1
|
|
123
141
|
```
|
|
124
142
|
|
|
125
143
|
---
|
|
@@ -18,7 +18,7 @@ pip install etchmem
|
|
|
18
18
|
|
|
19
19
|
`etchmem` gives your agent a persistent memory that matures through living. You deposit raw observations — facts, outcomes, feedback — scoped to a skill or left general. You retrieve with natural-language queries. Periodically you consolidate, and the engine synthesizes scattered signal into compact knowledge articles, rewriting anything that has been recalled against fresh context.
|
|
20
20
|
|
|
21
|
-
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes
|
|
21
|
+
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes a small, deliberate API: remember, recall, consolidate, export, and stats.
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -60,6 +60,18 @@ Retrieves relevant knowledge and emits a recall-event for future reconsolidation
|
|
|
60
60
|
|
|
61
61
|
Runs the consolidation worker: clusters the buffer, forms new injected articles, reconsolidates recalled articles against fresh context, hard-deletes superseded ones. Returns a summary dict with counts (`formed`, `reconsolidated`, `dropped`, `kept`, `flushed`, `superseded`). Requires an LLM API key.
|
|
62
62
|
|
|
63
|
+
### `export() → dict`
|
|
64
|
+
|
|
65
|
+
Serializes the entire injected knowledge store to disk. Writes one `<id>.json` file per synthesized article into `.etchmem/export/<UTC-timestamp>/` next to the data directory. No LLM call — cheap.
|
|
66
|
+
|
|
67
|
+
Returns `{"export_dir": "...", "count": N, "documents": [...]}`.
|
|
68
|
+
|
|
69
|
+
Use this to transfer institutional memory to another agent, or to produce fine-tuning data shaped by real task outcomes rather than hand-authored examples. Experience becomes a portable artifact.
|
|
70
|
+
|
|
71
|
+
### `stats() → dict`
|
|
72
|
+
|
|
73
|
+
Returns live collection sizes for all three tiers: `{"relational": N, "buffer": N, "injected": N}`. Useful for monitoring and debugging.
|
|
74
|
+
|
|
63
75
|
---
|
|
64
76
|
|
|
65
77
|
## Hello world
|
|
@@ -92,6 +104,12 @@ print(summary)
|
|
|
92
104
|
# will be blended in during reconsolidation.
|
|
93
105
|
results = engine.recall("Eiffel Tower visitors")
|
|
94
106
|
print(results[0].content)
|
|
107
|
+
|
|
108
|
+
# Export the full knowledge store — one JSON file per synthesized article.
|
|
109
|
+
# Transfer to another agent, or use as fine-tuning data.
|
|
110
|
+
result = engine.export()
|
|
111
|
+
print(result["export_dir"]) # e.g. /your/project/.etchmem/export/20260525T120000Z
|
|
112
|
+
print(result["count"]) # 1
|
|
95
113
|
```
|
|
96
114
|
|
|
97
115
|
---
|
|
@@ -52,8 +52,14 @@ accurate knowledge article from a set of raw signal fragments.
|
|
|
52
52
|
{fragments}
|
|
53
53
|
|
|
54
54
|
## Instructions
|
|
55
|
+
- Summarize all stored information properly: compress each point to its main
|
|
56
|
+
idea while keeping the same words and phrasing from the fragments wherever
|
|
57
|
+
possible.
|
|
55
58
|
- Write a single, cohesive knowledge article that captures all key facts.
|
|
56
59
|
- Do NOT invent facts not present in the fragments.
|
|
60
|
+
- Opinions, preferences, and subjective claims must NOT be stated as facts.
|
|
61
|
+
Attribute them explicitly, e.g. "There is an opinion that grapefruits are
|
|
62
|
+
the best" — never "Grapefruits are the best."
|
|
57
63
|
- Each fragment may be prefixed with a timestamp like `[21 Jun 2025, 15:25 UTC]`.
|
|
58
64
|
Preserve these timestamps verbatim inline, anchoring the facts they precede,
|
|
59
65
|
e.g. "As of 21 Jun 2025, 15:25 UTC, the deployment was rolled back."
|
|
@@ -75,9 +81,16 @@ accurate and incorporates the new information.
|
|
|
75
81
|
{fresh}
|
|
76
82
|
|
|
77
83
|
## Instructions
|
|
84
|
+
- Write ONLY what appears in the existing article and fresh context. Do NOT
|
|
85
|
+
add new facts, inferences, or knowledge from your own training.
|
|
86
|
+
- Summarize properly: compress to the main idea of each point while keeping
|
|
87
|
+
the same words and phrasing from the source material wherever possible.
|
|
78
88
|
- Preserve ALL load-bearing facts from the existing article.
|
|
79
89
|
- Incorporate relevant new information from the fresh context.
|
|
80
90
|
- Remove outdated claims only when the fresh context explicitly supersedes them.
|
|
91
|
+
- Opinions, preferences, and subjective claims must NOT be stated as facts.
|
|
92
|
+
Attribute them explicitly, e.g. "There is an opinion that grapefruits are
|
|
93
|
+
the best" — never "Grapefruits are the best."
|
|
81
94
|
- Fresh context fragments may be prefixed with a timestamp like `[21 Jun 2025, 15:25 UTC]`.
|
|
82
95
|
Preserve these timestamps verbatim inline, anchoring the facts they precede.
|
|
83
96
|
Also carry forward any timestamps already present in the existing article.
|
|
@@ -176,6 +176,77 @@ class Engine:
|
|
|
176
176
|
self._connector = build_connector(self._cfg)
|
|
177
177
|
self._connector_built = True
|
|
178
178
|
|
|
179
|
+
def export(self) -> dict[str, Any]:
|
|
180
|
+
"""
|
|
181
|
+
Serialize the entire injected knowledge store as JSON.
|
|
182
|
+
|
|
183
|
+
Writes every synthesized article, tag, and metadata from the
|
|
184
|
+
*injected* collection into individual ``.json`` files under::
|
|
185
|
+
|
|
186
|
+
.etchmem/export/<UTC-timestamp>/ (one file per document)
|
|
187
|
+
|
|
188
|
+
The export root is a sibling of ``config.data_dir`` named
|
|
189
|
+
``.etchmem``, so it lives next to the Chroma data directory in
|
|
190
|
+
the working tree rather than inside it.
|
|
191
|
+
|
|
192
|
+
Use the export to:
|
|
193
|
+
- Transfer institutional memory to another agent.
|
|
194
|
+
- Use accumulated experience directly as LLM fine-tuning data:
|
|
195
|
+
knowledge shaped by real task outcomes, not hand-authored
|
|
196
|
+
examples. Experience becomes a transferable artifact.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
dict with keys:
|
|
200
|
+
|
|
201
|
+
``export_dir``
|
|
202
|
+
Absolute path of the timestamped directory that was
|
|
203
|
+
created.
|
|
204
|
+
``count``
|
|
205
|
+
Number of documents written.
|
|
206
|
+
``documents``
|
|
207
|
+
List of dicts, one per exported article, each containing
|
|
208
|
+
``id``, ``content``, ``source_hashes``, ``skill``,
|
|
209
|
+
``tags``, ``metadata``, and ``created_at``.
|
|
210
|
+
"""
|
|
211
|
+
import datetime
|
|
212
|
+
import json as _json
|
|
213
|
+
import os
|
|
214
|
+
|
|
215
|
+
# Derive export root: <parent-of-data_dir>/.etchmem/export/<ts>
|
|
216
|
+
data_dir_abs = os.path.abspath(self._cfg.data_dir)
|
|
217
|
+
parent = os.path.dirname(data_dir_abs)
|
|
218
|
+
timestamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
|
219
|
+
export_dir = os.path.join(parent, ".etchmem", "export", timestamp)
|
|
220
|
+
os.makedirs(export_dir, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
# Fetch all InjectedArticle documents
|
|
223
|
+
from etchmem.models import InjectedArticle
|
|
224
|
+
raw = self._injected.get_all(filters={"doc_type": "injected"})
|
|
225
|
+
|
|
226
|
+
documents: list[dict[str, Any]] = []
|
|
227
|
+
for item in raw:
|
|
228
|
+
if not isinstance(item, InjectedArticle):
|
|
229
|
+
continue
|
|
230
|
+
doc_dict: dict[str, Any] = {
|
|
231
|
+
"id": item.id,
|
|
232
|
+
"content": item.content,
|
|
233
|
+
"source_hashes": sorted(item.source_hashes),
|
|
234
|
+
"skill": item.skill,
|
|
235
|
+
"tags": item.tags,
|
|
236
|
+
"metadata": item.metadata,
|
|
237
|
+
"created_at": item.created_at,
|
|
238
|
+
}
|
|
239
|
+
file_path = os.path.join(export_dir, f"{item.id}.json")
|
|
240
|
+
with open(file_path, "w", encoding="utf-8") as fh:
|
|
241
|
+
_json.dump(doc_dict, fh, indent=2, ensure_ascii=False)
|
|
242
|
+
documents.append(doc_dict)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"export_dir": export_dir,
|
|
246
|
+
"count": len(documents),
|
|
247
|
+
"documents": documents,
|
|
248
|
+
}
|
|
249
|
+
|
|
179
250
|
# ── Convenience properties ────────────────────────────────────────────
|
|
180
251
|
|
|
181
252
|
@property
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etchmem
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A pluggable, embeddable reconsolidating skill memory engine for AI agents
|
|
5
5
|
Author: Andrey Olishchuk
|
|
6
6
|
License: MIT
|
|
@@ -46,7 +46,7 @@ pip install etchmem
|
|
|
46
46
|
|
|
47
47
|
`etchmem` gives your agent a persistent memory that matures through living. You deposit raw observations — facts, outcomes, feedback — scoped to a skill or left general. You retrieve with natural-language queries. Periodically you consolidate, and the engine synthesizes scattered signal into compact knowledge articles, rewriting anything that has been recalled against fresh context.
|
|
48
48
|
|
|
49
|
-
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes
|
|
49
|
+
It is not a vector database and not an agent runtime. It sits above [ChromaDB](https://www.trychroma.com/), which handles all storage and embedding, and exposes a small, deliberate API: remember, recall, consolidate, export, and stats.
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
@@ -88,6 +88,18 @@ Retrieves relevant knowledge and emits a recall-event for future reconsolidation
|
|
|
88
88
|
|
|
89
89
|
Runs the consolidation worker: clusters the buffer, forms new injected articles, reconsolidates recalled articles against fresh context, hard-deletes superseded ones. Returns a summary dict with counts (`formed`, `reconsolidated`, `dropped`, `kept`, `flushed`, `superseded`). Requires an LLM API key.
|
|
90
90
|
|
|
91
|
+
### `export() → dict`
|
|
92
|
+
|
|
93
|
+
Serializes the entire injected knowledge store to disk. Writes one `<id>.json` file per synthesized article into `.etchmem/export/<UTC-timestamp>/` next to the data directory. No LLM call — cheap.
|
|
94
|
+
|
|
95
|
+
Returns `{"export_dir": "...", "count": N, "documents": [...]}`.
|
|
96
|
+
|
|
97
|
+
Use this to transfer institutional memory to another agent, or to produce fine-tuning data shaped by real task outcomes rather than hand-authored examples. Experience becomes a portable artifact.
|
|
98
|
+
|
|
99
|
+
### `stats() → dict`
|
|
100
|
+
|
|
101
|
+
Returns live collection sizes for all three tiers: `{"relational": N, "buffer": N, "injected": N}`. Useful for monitoring and debugging.
|
|
102
|
+
|
|
91
103
|
---
|
|
92
104
|
|
|
93
105
|
## Hello world
|
|
@@ -120,6 +132,12 @@ print(summary)
|
|
|
120
132
|
# will be blended in during reconsolidation.
|
|
121
133
|
results = engine.recall("Eiffel Tower visitors")
|
|
122
134
|
print(results[0].content)
|
|
135
|
+
|
|
136
|
+
# Export the full knowledge store — one JSON file per synthesized article.
|
|
137
|
+
# Transfer to another agent, or use as fine-tuning data.
|
|
138
|
+
result = engine.export()
|
|
139
|
+
print(result["export_dir"]) # e.g. /your/project/.etchmem/export/20260525T120000Z
|
|
140
|
+
print(result["count"]) # 1
|
|
123
141
|
```
|
|
124
142
|
|
|
125
143
|
---
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for engine.export().
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- Empty injected store produces export_dir, count=0, documents=[].
|
|
6
|
+
- Export directory is created under .etchmem/export/<timestamp>/.
|
|
7
|
+
- Each injected article is written as a separate JSON file.
|
|
8
|
+
- JSON file content matches the InjectedArticle fields.
|
|
9
|
+
- Multiple articles all appear in the export.
|
|
10
|
+
- Return dict has the expected keys and shapes.
|
|
11
|
+
- Repeated export calls create distinct timestamped directories.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from etchmem import Config, Engine
|
|
22
|
+
from etchmem.models import InjectedArticle
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Fixture ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def engine(tmp_path):
|
|
29
|
+
"""Engine backed by a fresh temporary Chroma directory, using offline
|
|
30
|
+
hash embeddings (no model download required)."""
|
|
31
|
+
cfg = Config(
|
|
32
|
+
data_dir=str(tmp_path / "skillmem"),
|
|
33
|
+
llm_provider="auto",
|
|
34
|
+
openai_api_key=None,
|
|
35
|
+
anthropic_api_key=None,
|
|
36
|
+
embedding_function="hash",
|
|
37
|
+
)
|
|
38
|
+
return Engine(config=cfg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _inject_article(engine: Engine, article_id: str, content: str, **kwargs) -> InjectedArticle:
|
|
42
|
+
"""Helper: directly upsert an InjectedArticle into the injected store."""
|
|
43
|
+
article = InjectedArticle(
|
|
44
|
+
id=article_id,
|
|
45
|
+
content=content,
|
|
46
|
+
source_hashes=frozenset(kwargs.get("source_hashes", [article_id + "_src"])),
|
|
47
|
+
skill=kwargs.get("skill", None),
|
|
48
|
+
tags=kwargs.get("tags", {}),
|
|
49
|
+
metadata=kwargs.get("metadata", {}),
|
|
50
|
+
created_at=kwargs.get("created_at", time.time()),
|
|
51
|
+
)
|
|
52
|
+
engine._injected.upsert([article])
|
|
53
|
+
return article
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Tests ─────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def test_export_empty_store_returns_zero_count(engine):
|
|
59
|
+
"""export() on an empty injected store should return count=0."""
|
|
60
|
+
result = engine.export()
|
|
61
|
+
assert result["count"] == 0
|
|
62
|
+
assert result["documents"] == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_export_creates_directory(engine, tmp_path):
|
|
66
|
+
"""export() must create the .etchmem/export/<timestamp> directory."""
|
|
67
|
+
result = engine.export()
|
|
68
|
+
assert os.path.isdir(result["export_dir"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_export_dir_is_under_etchmem(engine, tmp_path):
|
|
72
|
+
"""The export directory must live under .etchmem/export/ next to the data_dir."""
|
|
73
|
+
result = engine.export()
|
|
74
|
+
# data_dir is tmp_path/"skillmem"; its parent is tmp_path
|
|
75
|
+
expected_root = os.path.join(str(tmp_path), ".etchmem", "export")
|
|
76
|
+
assert result["export_dir"].startswith(expected_root)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_export_single_article_creates_one_file(engine):
|
|
80
|
+
"""One InjectedArticle → one .json file in the export directory."""
|
|
81
|
+
_inject_article(engine, "abc123", "Python uses reference counting.")
|
|
82
|
+
result = engine.export()
|
|
83
|
+
|
|
84
|
+
assert result["count"] == 1
|
|
85
|
+
export_dir = result["export_dir"]
|
|
86
|
+
files = [f for f in os.listdir(export_dir) if f.endswith(".json")]
|
|
87
|
+
assert len(files) == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_export_file_named_after_article_id(engine):
|
|
91
|
+
"""Each exported file must be named <article_id>.json."""
|
|
92
|
+
art_id = "myarticleid"
|
|
93
|
+
_inject_article(engine, art_id, "Some synthesized content.")
|
|
94
|
+
result = engine.export()
|
|
95
|
+
|
|
96
|
+
export_dir = result["export_dir"]
|
|
97
|
+
assert os.path.isfile(os.path.join(export_dir, f"{art_id}.json"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_export_json_content_matches_article(engine):
|
|
101
|
+
"""The JSON file content must faithfully reproduce all article fields."""
|
|
102
|
+
art = _inject_article(
|
|
103
|
+
engine,
|
|
104
|
+
"detailed_id",
|
|
105
|
+
"ChromaDB stores embeddings on disk.",
|
|
106
|
+
source_hashes=["hash_a", "hash_b"],
|
|
107
|
+
skill="code-reviewer",
|
|
108
|
+
tags={"topic": "databases", "access_level": "public"},
|
|
109
|
+
metadata={"source": "https://docs.trychroma.com"},
|
|
110
|
+
)
|
|
111
|
+
result = engine.export()
|
|
112
|
+
|
|
113
|
+
file_path = os.path.join(result["export_dir"], f"{art.id}.json")
|
|
114
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
115
|
+
data = json.load(fh)
|
|
116
|
+
|
|
117
|
+
assert data["id"] == art.id
|
|
118
|
+
assert data["content"] == art.content
|
|
119
|
+
assert set(data["source_hashes"]) == art.source_hashes
|
|
120
|
+
assert data["skill"] == art.skill
|
|
121
|
+
assert data["tags"] == art.tags
|
|
122
|
+
assert data["metadata"] == art.metadata
|
|
123
|
+
assert isinstance(data["created_at"], float)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_export_multiple_articles(engine):
|
|
127
|
+
"""All InjectedArticles in the store must be exported."""
|
|
128
|
+
ids = ["art_one", "art_two", "art_three"]
|
|
129
|
+
for aid in ids:
|
|
130
|
+
_inject_article(engine, aid, f"Content for {aid}.")
|
|
131
|
+
|
|
132
|
+
result = engine.export()
|
|
133
|
+
|
|
134
|
+
assert result["count"] == len(ids)
|
|
135
|
+
assert len(result["documents"]) == len(ids)
|
|
136
|
+
exported_ids = {doc["id"] for doc in result["documents"]}
|
|
137
|
+
assert exported_ids == set(ids)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_export_return_dict_has_required_keys(engine):
|
|
141
|
+
"""export() must return a dict with export_dir, count, and documents."""
|
|
142
|
+
result = engine.export()
|
|
143
|
+
assert "export_dir" in result
|
|
144
|
+
assert "count" in result
|
|
145
|
+
assert "documents" in result
|
|
146
|
+
assert isinstance(result["export_dir"], str)
|
|
147
|
+
assert isinstance(result["count"], int)
|
|
148
|
+
assert isinstance(result["documents"], list)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_export_document_dict_has_required_fields(engine):
|
|
152
|
+
"""Each document dict in the return value must have all expected fields."""
|
|
153
|
+
_inject_article(engine, "field_check", "Testing field presence.")
|
|
154
|
+
result = engine.export()
|
|
155
|
+
|
|
156
|
+
doc = result["documents"][0]
|
|
157
|
+
for field in ("id", "content", "source_hashes", "skill", "tags", "metadata", "created_at"):
|
|
158
|
+
assert field in doc, f"Missing field: {field}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_repeated_exports_use_distinct_directories(engine):
|
|
162
|
+
"""Two export() calls must produce different timestamped directories."""
|
|
163
|
+
_inject_article(engine, "repeat_art", "Some knowledge.")
|
|
164
|
+
result_a = engine.export()
|
|
165
|
+
time.sleep(1.1) # ensure the UTC second ticks over
|
|
166
|
+
result_b = engine.export()
|
|
167
|
+
|
|
168
|
+
assert result_a["export_dir"] != result_b["export_dir"]
|
|
169
|
+
assert os.path.isdir(result_a["export_dir"])
|
|
170
|
+
assert os.path.isdir(result_b["export_dir"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_export_source_hashes_is_sorted_list(engine):
|
|
174
|
+
"""source_hashes in the JSON output must be a sorted list (JSON-serialisable)."""
|
|
175
|
+
_inject_article(
|
|
176
|
+
engine, "hash_order", "Hashes sorted.",
|
|
177
|
+
source_hashes=["zzz", "aaa", "mmm"],
|
|
178
|
+
)
|
|
179
|
+
result = engine.export()
|
|
180
|
+
doc = result["documents"][0]
|
|
181
|
+
assert doc["source_hashes"] == sorted(doc["source_hashes"])
|
|
182
|
+
assert isinstance(doc["source_hashes"], list)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|