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.
Files changed (33) hide show
  1. {etchmem-0.1.4 → etchmem-0.1.6}/PKG-INFO +20 -2
  2. {etchmem-0.1.4 → etchmem-0.1.6}/README.md +19 -1
  3. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/consolidate/worker.py +13 -0
  4. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/engine.py +71 -0
  5. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/PKG-INFO +20 -2
  6. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/SOURCES.txt +1 -0
  7. {etchmem-0.1.4 → etchmem-0.1.6}/pyproject.toml +1 -1
  8. etchmem-0.1.6/tests/test_export.py +182 -0
  9. {etchmem-0.1.4 → etchmem-0.1.6}/LICENSE +0 -0
  10. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/__init__.py +0 -0
  11. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/config.py +0 -0
  12. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/__init__.py +0 -0
  13. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/base.py +0 -0
  14. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/connector/remote.py +0 -0
  15. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/consolidate/__init__.py +0 -0
  16. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/models.py +0 -0
  17. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/recall/__init__.py +0 -0
  18. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/recall/recaller.py +0 -0
  19. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/__init__.py +0 -0
  20. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/base.py +0 -0
  21. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/stores/chroma_store.py +0 -0
  22. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/__init__.py +0 -0
  23. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/affectability.py +0 -0
  24. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem/util/hashing.py +0 -0
  25. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/dependency_links.txt +0 -0
  26. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/requires.txt +0 -0
  27. {etchmem-0.1.4 → etchmem-0.1.6}/etchmem.egg-info/top_level.txt +0 -0
  28. {etchmem-0.1.4 → etchmem-0.1.6}/setup.cfg +0 -0
  29. {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_consolidate_idempotent.py +0 -0
  30. {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_formation_buckets.py +0 -0
  31. {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_recall.py +0 -0
  32. {etchmem-0.1.4 → etchmem-0.1.6}/tests/test_reconsolidation.py +0 -0
  33. {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.4
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 exactly **three methods**.
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 exactly **three methods**.
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.4
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 exactly **three methods**.
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
  ---
@@ -24,6 +24,7 @@ etchmem/util/__init__.py
24
24
  etchmem/util/affectability.py
25
25
  etchmem/util/hashing.py
26
26
  tests/test_consolidate_idempotent.py
27
+ tests/test_export.py
27
28
  tests/test_formation_buckets.py
28
29
  tests/test_recall.py
29
30
  tests/test_reconsolidation.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "etchmem"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "A pluggable, embeddable reconsolidating skill memory engine for AI agents"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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