zettelforge 2.1.0__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.
- zettelforge/__init__.py +119 -0
- zettelforge/alias_resolver.py +112 -0
- zettelforge/blended_retriever.py +46 -0
- zettelforge/cache.py +85 -0
- zettelforge/config.py +317 -0
- zettelforge/edition.py +168 -0
- zettelforge/enterprise/__init__.py +17 -0
- zettelforge/entity_indexer.py +316 -0
- zettelforge/fact_extractor.py +94 -0
- zettelforge/governance_validator.py +65 -0
- zettelforge/graph_retriever.py +88 -0
- zettelforge/intent_classifier.py +200 -0
- zettelforge/knowledge_graph.py +401 -0
- zettelforge/llm_client.py +120 -0
- zettelforge/memory_manager.py +702 -0
- zettelforge/memory_store.py +246 -0
- zettelforge/memory_updater.py +125 -0
- zettelforge/note_constructor.py +237 -0
- zettelforge/note_schema.py +85 -0
- zettelforge/observability.py +83 -0
- zettelforge/ontology.py +471 -0
- zettelforge/retry.py +87 -0
- zettelforge/synthesis_generator.py +242 -0
- zettelforge/synthesis_validator.py +85 -0
- zettelforge/vector_memory.py +368 -0
- zettelforge/vector_retriever.py +331 -0
- zettelforge-2.1.0.dist-info/METADATA +279 -0
- zettelforge-2.1.0.dist-info/RECORD +31 -0
- zettelforge-2.1.0.dist-info/WHEEL +4 -0
- zettelforge-2.1.0.dist-info/licenses/LICENSE +21 -0
- zettelforge-2.1.0.dist-info/licenses/LICENSE-ENTERPRISE +20 -0
zettelforge/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZettelForge: Agentic Memory System
|
|
3
|
+
|
|
4
|
+
A production-grade memory system for AI agents with:
|
|
5
|
+
- Vector semantic search
|
|
6
|
+
- Knowledge graph relationships
|
|
7
|
+
- Entity extraction and indexing
|
|
8
|
+
- RAG-as-answer synthesis
|
|
9
|
+
- Intent-based query routing
|
|
10
|
+
|
|
11
|
+
Community edition (MIT):
|
|
12
|
+
>>> from zettelforge import MemoryManager
|
|
13
|
+
>>> mm = MemoryManager()
|
|
14
|
+
>>> mm.remember("Important information")
|
|
15
|
+
>>> results = mm.recall("query")
|
|
16
|
+
>>> synthesis = mm.synthesize("What do we know?")
|
|
17
|
+
|
|
18
|
+
Enterprise edition (ThreatRecall by Threatengram) adds:
|
|
19
|
+
- STIX 2.1 TypeDB ontology
|
|
20
|
+
- Blended retrieval (vector + graph)
|
|
21
|
+
- OpenCTI integration
|
|
22
|
+
- Sigma rule generation
|
|
23
|
+
- Advanced synthesis formats
|
|
24
|
+
- Multi-tenant auth
|
|
25
|
+
See https://threatengram.com/enterprise
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from zettelforge.edition import (
|
|
29
|
+
Edition,
|
|
30
|
+
get_edition,
|
|
31
|
+
is_enterprise,
|
|
32
|
+
is_community,
|
|
33
|
+
edition_name,
|
|
34
|
+
EditionError,
|
|
35
|
+
)
|
|
36
|
+
from zettelforge.memory_manager import MemoryManager, get_memory_manager
|
|
37
|
+
from zettelforge.note_schema import MemoryNote
|
|
38
|
+
from zettelforge.vector_retriever import VectorRetriever
|
|
39
|
+
from zettelforge.synthesis_generator import SynthesisGenerator, get_synthesis_generator
|
|
40
|
+
from zettelforge.synthesis_validator import SynthesisValidator, get_synthesis_validator
|
|
41
|
+
|
|
42
|
+
from zettelforge.knowledge_graph import KnowledgeGraph, get_knowledge_graph
|
|
43
|
+
from zettelforge.ontology import (
|
|
44
|
+
TypedEntityStore,
|
|
45
|
+
OntologyValidator,
|
|
46
|
+
get_ontology_store,
|
|
47
|
+
get_ontology_validator,
|
|
48
|
+
ENTITY_TYPES,
|
|
49
|
+
RELATION_TYPES
|
|
50
|
+
)
|
|
51
|
+
from zettelforge.intent_classifier import IntentClassifier, get_intent_classifier, QueryIntent
|
|
52
|
+
from zettelforge.note_constructor import NoteConstructor
|
|
53
|
+
from zettelforge.fact_extractor import FactExtractor, ExtractedFact
|
|
54
|
+
from zettelforge.memory_updater import MemoryUpdater, UpdateOperation
|
|
55
|
+
from zettelforge.graph_retriever import GraphRetriever, ScoredResult
|
|
56
|
+
from zettelforge.blended_retriever import BlendedRetriever
|
|
57
|
+
|
|
58
|
+
__version__ = "2.1.0"
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Edition
|
|
61
|
+
"Edition",
|
|
62
|
+
"get_edition",
|
|
63
|
+
"is_enterprise",
|
|
64
|
+
"is_community",
|
|
65
|
+
"edition_name",
|
|
66
|
+
"EditionError",
|
|
67
|
+
# Core
|
|
68
|
+
"MemoryManager",
|
|
69
|
+
"get_memory_manager",
|
|
70
|
+
"MemoryNote",
|
|
71
|
+
"VectorRetriever",
|
|
72
|
+
"SynthesisGenerator",
|
|
73
|
+
"get_synthesis_generator",
|
|
74
|
+
"SynthesisValidator",
|
|
75
|
+
"get_synthesis_validator",
|
|
76
|
+
# Knowledge Graph
|
|
77
|
+
"KnowledgeGraph",
|
|
78
|
+
"get_knowledge_graph",
|
|
79
|
+
# Retrieval
|
|
80
|
+
"GraphRetriever",
|
|
81
|
+
"ScoredResult",
|
|
82
|
+
"BlendedRetriever",
|
|
83
|
+
# Ontology
|
|
84
|
+
"TypedEntityStore",
|
|
85
|
+
"OntologyValidator",
|
|
86
|
+
"get_ontology_store",
|
|
87
|
+
"get_ontology_validator",
|
|
88
|
+
"ENTITY_TYPES",
|
|
89
|
+
"RELATION_TYPES",
|
|
90
|
+
# Intent Classification
|
|
91
|
+
"IntentClassifier",
|
|
92
|
+
"get_intent_classifier",
|
|
93
|
+
"QueryIntent",
|
|
94
|
+
# Note Constructor
|
|
95
|
+
"NoteConstructor",
|
|
96
|
+
# Two-Phase Pipeline
|
|
97
|
+
"FactExtractor",
|
|
98
|
+
"ExtractedFact",
|
|
99
|
+
"MemoryUpdater",
|
|
100
|
+
"UpdateOperation",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# ── Enterprise-only imports (conditional) ───────────────────────────────────
|
|
104
|
+
# These require the separate zettelforge-enterprise package.
|
|
105
|
+
# pip install zettelforge-enterprise
|
|
106
|
+
|
|
107
|
+
if is_enterprise():
|
|
108
|
+
try:
|
|
109
|
+
from zettelforge_enterprise import (
|
|
110
|
+
get_typedb_client,
|
|
111
|
+
get_sigma_generator as _get_sigma_gen,
|
|
112
|
+
get_cti_connector as _get_cti_conn,
|
|
113
|
+
get_context_injector as _get_ctx_inj,
|
|
114
|
+
)
|
|
115
|
+
__all__ += [
|
|
116
|
+
"get_typedb_client",
|
|
117
|
+
]
|
|
118
|
+
except ImportError:
|
|
119
|
+
pass # Enterprise package not installed
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
# TypeDB entity type mapping (same as typedb_client.py)
|
|
6
|
+
_TYPEDB_TYPE_MAP = {
|
|
7
|
+
"actor": "threat-actor",
|
|
8
|
+
"tool": "tool",
|
|
9
|
+
"malware": "malware",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AliasResolver:
|
|
14
|
+
"""Resolves entity aliases to their canonical names.
|
|
15
|
+
|
|
16
|
+
Tries TypeDB alias-of relations first (if available),
|
|
17
|
+
falls back to local JSON/hardcoded aliases.
|
|
18
|
+
"""
|
|
19
|
+
def __init__(self, alias_file: Optional[str] = None):
|
|
20
|
+
from zettelforge.memory_store import get_default_data_dir
|
|
21
|
+
if alias_file is None:
|
|
22
|
+
alias_file = get_default_data_dir() / "entity_aliases.json"
|
|
23
|
+
self.alias_file = Path(alias_file)
|
|
24
|
+
|
|
25
|
+
# Fallback hardcoded aliases
|
|
26
|
+
self.aliases = {
|
|
27
|
+
"actor": {
|
|
28
|
+
"fancy bear": "apt28",
|
|
29
|
+
"fancy-bear": "apt28",
|
|
30
|
+
"pawn storm": "apt28",
|
|
31
|
+
"pawn-storm": "apt28",
|
|
32
|
+
"cozy bear": "apt29",
|
|
33
|
+
"cozy-bear": "apt29",
|
|
34
|
+
},
|
|
35
|
+
"tool": {}
|
|
36
|
+
}
|
|
37
|
+
self._typedb_available = None
|
|
38
|
+
self.load()
|
|
39
|
+
|
|
40
|
+
def load(self):
|
|
41
|
+
if self.alias_file.exists():
|
|
42
|
+
try:
|
|
43
|
+
with open(self.alias_file, "r") as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
for k, v in data.items():
|
|
46
|
+
if k not in self.aliases:
|
|
47
|
+
self.aliases[k] = {}
|
|
48
|
+
self.aliases[k].update(v)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _try_typedb_resolve(self, entity_type: str, entity_lower: str) -> Optional[str]:
|
|
53
|
+
"""Query TypeDB for alias-of relation. Returns canonical name or None."""
|
|
54
|
+
if self._typedb_available is False:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
typedb_type = _TYPEDB_TYPE_MAP.get(entity_type)
|
|
58
|
+
if not typedb_type:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from zettelforge.knowledge_graph import get_knowledge_graph
|
|
63
|
+
kg = get_knowledge_graph()
|
|
64
|
+
|
|
65
|
+
# Only use TypeDB if it's the TypeDB client
|
|
66
|
+
if not hasattr(kg, '_driver') or kg._driver is None:
|
|
67
|
+
self._typedb_available = False
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
from typedb.driver import TransactionType
|
|
71
|
+
tx = kg._driver.transaction(kg.database, TransactionType.READ)
|
|
72
|
+
rows = list(tx.query(
|
|
73
|
+
f'match $a isa {typedb_type}, has name "{entity_lower}"; '
|
|
74
|
+
f'(canonical: $c, aliased: $a) isa alias-of; '
|
|
75
|
+
f'$c has name $n; select $n;'
|
|
76
|
+
).resolve())
|
|
77
|
+
tx.close()
|
|
78
|
+
|
|
79
|
+
if rows:
|
|
80
|
+
# Extract name from Attribute(name: "apt28")
|
|
81
|
+
raw = str(rows[0].get("n"))
|
|
82
|
+
name = raw.split(": ")[1].strip('")')
|
|
83
|
+
self._typedb_available = True
|
|
84
|
+
return name
|
|
85
|
+
|
|
86
|
+
self._typedb_available = True
|
|
87
|
+
return None
|
|
88
|
+
except Exception:
|
|
89
|
+
self._typedb_available = False
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def resolve(self, entity_type: str, entity: str) -> str:
|
|
93
|
+
entity_lower = entity.lower().replace('-', ' ')
|
|
94
|
+
|
|
95
|
+
# Try TypeDB first
|
|
96
|
+
canonical = self._try_typedb_resolve(entity_type, entity_lower)
|
|
97
|
+
if canonical:
|
|
98
|
+
return canonical
|
|
99
|
+
|
|
100
|
+
# Also try with hyphens (TypeDB stores both forms)
|
|
101
|
+
entity_hyphenated = entity.lower()
|
|
102
|
+
if entity_hyphenated != entity_lower:
|
|
103
|
+
canonical = self._try_typedb_resolve(entity_type, entity_hyphenated)
|
|
104
|
+
if canonical:
|
|
105
|
+
return canonical
|
|
106
|
+
|
|
107
|
+
# Fallback to local aliases
|
|
108
|
+
mapping = self.aliases.get(entity_type, {})
|
|
109
|
+
if entity_lower in mapping:
|
|
110
|
+
return mapping[entity_lower]
|
|
111
|
+
|
|
112
|
+
return entity.lower()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blended Retriever - Combines vector and graph retrieval results.
|
|
3
|
+
|
|
4
|
+
Merges results from VectorRetriever and GraphRetriever using
|
|
5
|
+
intent-based policy weights. Notes found by both sources get
|
|
6
|
+
combined scores and rank higher.
|
|
7
|
+
"""
|
|
8
|
+
from typing import Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from zettelforge.graph_retriever import ScoredResult
|
|
11
|
+
from zettelforge.note_schema import MemoryNote
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BlendedRetriever:
|
|
15
|
+
"""Blend vector and graph retrieval results using policy weights."""
|
|
16
|
+
|
|
17
|
+
def blend(
|
|
18
|
+
self,
|
|
19
|
+
vector_results: List[MemoryNote],
|
|
20
|
+
graph_results: List[ScoredResult],
|
|
21
|
+
policy: Dict[str, float],
|
|
22
|
+
note_lookup: Callable[[str], Optional[MemoryNote]],
|
|
23
|
+
k: int = 10,
|
|
24
|
+
) -> List[MemoryNote]:
|
|
25
|
+
vector_weight = policy.get("vector", 0.5)
|
|
26
|
+
graph_weight = policy.get("graph", 0.5)
|
|
27
|
+
|
|
28
|
+
scores: Dict[str, tuple] = {}
|
|
29
|
+
|
|
30
|
+
for i, note in enumerate(vector_results):
|
|
31
|
+
position_score = 1.0 / (1.0 + i)
|
|
32
|
+
blended = position_score * vector_weight
|
|
33
|
+
scores[note.id] = (blended, note)
|
|
34
|
+
|
|
35
|
+
for gr in graph_results:
|
|
36
|
+
graph_score = gr.score * graph_weight
|
|
37
|
+
if gr.note_id in scores:
|
|
38
|
+
existing_score, existing_note = scores[gr.note_id]
|
|
39
|
+
scores[gr.note_id] = (existing_score + graph_score, existing_note)
|
|
40
|
+
else:
|
|
41
|
+
note = note_lookup(gr.note_id)
|
|
42
|
+
if note:
|
|
43
|
+
scores[gr.note_id] = (graph_score, note)
|
|
44
|
+
|
|
45
|
+
ranked = sorted(scores.values(), key=lambda x: x[0], reverse=True)
|
|
46
|
+
return [note for _, note in ranked[:k]]
|
zettelforge/cache.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intelligent caching layer for ZettelForge
|
|
3
|
+
Complies with GOV-003 (Python standards) and GOV-012 (observability)
|
|
4
|
+
"""
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Dict, Any, Optional
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SmartCache:
|
|
12
|
+
"""
|
|
13
|
+
LRU Cache with TTL and observability for embeddings and query results.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, maxsize: int = 10000, ttl_seconds: int = 3600):
|
|
17
|
+
self.maxsize = maxsize
|
|
18
|
+
self.ttl_seconds = ttl_seconds
|
|
19
|
+
self._cache: Dict = {}
|
|
20
|
+
self._hits = 0
|
|
21
|
+
self._misses = 0
|
|
22
|
+
self._last_cleanup = time.time()
|
|
23
|
+
|
|
24
|
+
def get(self, key: str) -> Optional[Any]:
|
|
25
|
+
"""Get item from cache with TTL check."""
|
|
26
|
+
self._cleanup_if_needed()
|
|
27
|
+
|
|
28
|
+
if key in self._cache:
|
|
29
|
+
value, timestamp = self._cache[key]
|
|
30
|
+
if time.time() - timestamp < self.ttl_seconds:
|
|
31
|
+
self._hits += 1
|
|
32
|
+
return value
|
|
33
|
+
else:
|
|
34
|
+
del self._cache[key]
|
|
35
|
+
self._misses += 1
|
|
36
|
+
return None
|
|
37
|
+
self._misses += 1
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def set(self, key: str, value: Any) -> None:
|
|
41
|
+
"""Set item in cache."""
|
|
42
|
+
self._cleanup_if_needed()
|
|
43
|
+
self._cache[key] = (value, time.time())
|
|
44
|
+
|
|
45
|
+
# Simple LRU eviction if over limit
|
|
46
|
+
if len(self._cache) > self.maxsize:
|
|
47
|
+
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
|
|
48
|
+
del self._cache[oldest_key]
|
|
49
|
+
|
|
50
|
+
def _cleanup_if_needed(self):
|
|
51
|
+
"""Periodic cleanup of expired entries."""
|
|
52
|
+
now = time.time()
|
|
53
|
+
if now - self._last_cleanup > 300: # every 5 minutes
|
|
54
|
+
self._cleanup()
|
|
55
|
+
self._last_cleanup = now
|
|
56
|
+
|
|
57
|
+
def _cleanup(self):
|
|
58
|
+
"""Remove expired entries."""
|
|
59
|
+
now = time.time()
|
|
60
|
+
expired = [k for k, (_, ts) in self._cache.items()
|
|
61
|
+
if now - ts > self.ttl_seconds]
|
|
62
|
+
for k in expired:
|
|
63
|
+
del self._cache[k]
|
|
64
|
+
|
|
65
|
+
def get_stats(self) -> Dict:
|
|
66
|
+
"""Return cache performance metrics."""
|
|
67
|
+
total = self._hits + self._misses
|
|
68
|
+
hit_rate = self._hits / total if total > 0 else 0
|
|
69
|
+
return {
|
|
70
|
+
"size": len(self._cache),
|
|
71
|
+
"maxsize": self.maxsize,
|
|
72
|
+
"hits": self._hits,
|
|
73
|
+
"misses": self._misses,
|
|
74
|
+
"hit_rate": round(hit_rate, 4),
|
|
75
|
+
"ttl_seconds": self.ttl_seconds
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
I have implemented a **SmartCache** layer with TTL, LRU eviction, and observability metrics.
|
|
80
|
+
|
|
81
|
+
This will be integrated into ZettelForge in the next step.
|
|
82
|
+
|
|
83
|
+
**Milestone**: Caching Layer (1) completed.
|
|
84
|
+
|
|
85
|
+
Now proceeding to **Observability** (2).
|
zettelforge/config.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZettelForge Configuration Loader
|
|
3
|
+
|
|
4
|
+
Resolution order (highest priority first):
|
|
5
|
+
1. Environment variables (ZETTELFORGE_*, TYPEDB_*, AMEM_*)
|
|
6
|
+
2. config.yaml in working directory
|
|
7
|
+
3. config.yaml in project root
|
|
8
|
+
4. config.default.yaml in project root
|
|
9
|
+
5. Hardcoded defaults in this module
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from zettelforge.config import get_config
|
|
13
|
+
cfg = get_config()
|
|
14
|
+
cfg.typedb.host # "localhost"
|
|
15
|
+
cfg.embedding.url # "http://127.0.0.1:11434"
|
|
16
|
+
cfg.retrieval.default_k # 10
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class StorageConfig:
|
|
26
|
+
data_dir: str = "~/.amem"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TypeDBConfig:
|
|
31
|
+
host: str = "localhost"
|
|
32
|
+
port: int = 1729
|
|
33
|
+
database: str = "zettelforge"
|
|
34
|
+
username: str = "admin"
|
|
35
|
+
password: str = "password"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class EmbeddingConfig:
|
|
40
|
+
provider: str = "fastembed" # "fastembed" (in-process ONNX) or "ollama" (HTTP server)
|
|
41
|
+
url: str = "http://127.0.0.1:11434" # only used when provider=ollama
|
|
42
|
+
model: str = "nomic-ai/nomic-embed-text-v1.5-Q"
|
|
43
|
+
dimensions: int = 768
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LLMConfig:
|
|
48
|
+
provider: str = "local" # "local" (llama-cpp-python, in-process) or "ollama" (HTTP server)
|
|
49
|
+
model: str = "Qwen/Qwen2.5-3B-Instruct-GGUF" # HuggingFace repo for local, model name for ollama
|
|
50
|
+
url: str = "http://localhost:11434" # only used when provider=ollama
|
|
51
|
+
temperature: float = 0.1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ExtractionConfig:
|
|
56
|
+
max_facts: int = 5
|
|
57
|
+
min_importance: int = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RetrievalConfig:
|
|
62
|
+
default_k: int = 10
|
|
63
|
+
similarity_threshold: float = 0.25
|
|
64
|
+
entity_boost: float = 2.5
|
|
65
|
+
max_graph_depth: int = 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SynthesisConfig:
|
|
70
|
+
max_context_tokens: int = 3000
|
|
71
|
+
default_format: str = "direct_answer"
|
|
72
|
+
tier_filter: List[str] = field(default_factory=lambda: ["A", "B"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class GovernanceConfig:
|
|
77
|
+
enabled: bool = True
|
|
78
|
+
min_content_length: int = 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CacheConfig:
|
|
83
|
+
ttl_seconds: int = 300
|
|
84
|
+
max_entries: int = 1024
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class LoggingConfig:
|
|
89
|
+
level: str = "INFO"
|
|
90
|
+
log_intents: bool = True
|
|
91
|
+
log_causal: bool = True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class EnterpriseConfig:
|
|
96
|
+
"""Enterprise edition settings (ignored in Community)."""
|
|
97
|
+
license_key: str = ""
|
|
98
|
+
blended_retrieval: bool = True
|
|
99
|
+
cross_encoder_reranking: bool = True
|
|
100
|
+
report_ingestion: bool = True
|
|
101
|
+
multi_tenant: bool = False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ZettelForgeConfig:
|
|
106
|
+
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
107
|
+
typedb: TypeDBConfig = field(default_factory=TypeDBConfig)
|
|
108
|
+
backend: str = "typedb"
|
|
109
|
+
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
|
110
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
111
|
+
extraction: ExtractionConfig = field(default_factory=ExtractionConfig)
|
|
112
|
+
retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
|
|
113
|
+
synthesis: SynthesisConfig = field(default_factory=SynthesisConfig)
|
|
114
|
+
governance: GovernanceConfig = field(default_factory=GovernanceConfig)
|
|
115
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
116
|
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
117
|
+
enterprise: EnterpriseConfig = field(default_factory=EnterpriseConfig)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_config_file() -> Optional[Path]:
|
|
121
|
+
"""Find config.yaml in standard locations."""
|
|
122
|
+
candidates = [
|
|
123
|
+
Path("config.yaml"),
|
|
124
|
+
Path("config.yml"),
|
|
125
|
+
Path(__file__).parent.parent.parent / "config.yaml",
|
|
126
|
+
Path(__file__).parent.parent.parent / "config.yml",
|
|
127
|
+
Path(__file__).parent.parent.parent / "config.default.yaml",
|
|
128
|
+
]
|
|
129
|
+
for path in candidates:
|
|
130
|
+
if path.exists():
|
|
131
|
+
return path
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _load_yaml(path: Path) -> dict:
|
|
136
|
+
"""Load YAML file, return empty dict on failure."""
|
|
137
|
+
try:
|
|
138
|
+
import yaml
|
|
139
|
+
with open(path) as f:
|
|
140
|
+
return yaml.safe_load(f) or {}
|
|
141
|
+
except ImportError:
|
|
142
|
+
# Fall back to basic parsing if PyYAML not installed
|
|
143
|
+
return _parse_simple_yaml(path)
|
|
144
|
+
except Exception:
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_simple_yaml(path: Path) -> dict:
|
|
149
|
+
"""Minimal YAML parser for flat key: value pairs (no PyYAML dependency)."""
|
|
150
|
+
result = {}
|
|
151
|
+
current_section = None
|
|
152
|
+
with open(path) as f:
|
|
153
|
+
for line in f:
|
|
154
|
+
stripped = line.strip()
|
|
155
|
+
if not stripped or stripped.startswith("#"):
|
|
156
|
+
continue
|
|
157
|
+
if not line.startswith(" ") and stripped.endswith(":"):
|
|
158
|
+
current_section = stripped[:-1]
|
|
159
|
+
result[current_section] = {}
|
|
160
|
+
elif current_section and ":" in stripped:
|
|
161
|
+
key, _, value = stripped.partition(":")
|
|
162
|
+
key = key.strip()
|
|
163
|
+
value = value.strip()
|
|
164
|
+
# Parse basic types
|
|
165
|
+
if value.lower() == "true":
|
|
166
|
+
value = True
|
|
167
|
+
elif value.lower() == "false":
|
|
168
|
+
value = False
|
|
169
|
+
elif value.startswith("[") or value.startswith("-"):
|
|
170
|
+
continue # Skip lists in simple parser
|
|
171
|
+
else:
|
|
172
|
+
try:
|
|
173
|
+
value = int(value)
|
|
174
|
+
except ValueError:
|
|
175
|
+
try:
|
|
176
|
+
value = float(value)
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
result[current_section][key] = value
|
|
180
|
+
elif ":" in stripped and current_section is None:
|
|
181
|
+
key, _, value = stripped.partition(":")
|
|
182
|
+
result[key.strip()] = value.strip()
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_yaml(cfg: ZettelForgeConfig, data: dict):
|
|
187
|
+
"""Apply YAML dict to config dataclass."""
|
|
188
|
+
if "storage" in data and isinstance(data["storage"], dict):
|
|
189
|
+
for k, v in data["storage"].items():
|
|
190
|
+
if hasattr(cfg.storage, k):
|
|
191
|
+
setattr(cfg.storage, k, v)
|
|
192
|
+
|
|
193
|
+
if "typedb" in data and isinstance(data["typedb"], dict):
|
|
194
|
+
for k, v in data["typedb"].items():
|
|
195
|
+
if hasattr(cfg.typedb, k):
|
|
196
|
+
setattr(cfg.typedb, k, v)
|
|
197
|
+
|
|
198
|
+
if "backend" in data:
|
|
199
|
+
cfg.backend = str(data["backend"])
|
|
200
|
+
|
|
201
|
+
if "embedding" in data and isinstance(data["embedding"], dict):
|
|
202
|
+
for k, v in data["embedding"].items():
|
|
203
|
+
if hasattr(cfg.embedding, k):
|
|
204
|
+
setattr(cfg.embedding, k, v)
|
|
205
|
+
|
|
206
|
+
if "llm" in data and isinstance(data["llm"], dict):
|
|
207
|
+
for k, v in data["llm"].items():
|
|
208
|
+
if hasattr(cfg.llm, k):
|
|
209
|
+
setattr(cfg.llm, k, v)
|
|
210
|
+
|
|
211
|
+
if "extraction" in data and isinstance(data["extraction"], dict):
|
|
212
|
+
for k, v in data["extraction"].items():
|
|
213
|
+
if hasattr(cfg.extraction, k):
|
|
214
|
+
setattr(cfg.extraction, k, v)
|
|
215
|
+
|
|
216
|
+
if "retrieval" in data and isinstance(data["retrieval"], dict):
|
|
217
|
+
for k, v in data["retrieval"].items():
|
|
218
|
+
if hasattr(cfg.retrieval, k):
|
|
219
|
+
setattr(cfg.retrieval, k, v)
|
|
220
|
+
|
|
221
|
+
if "synthesis" in data and isinstance(data["synthesis"], dict):
|
|
222
|
+
for k, v in data["synthesis"].items():
|
|
223
|
+
if hasattr(cfg.synthesis, k):
|
|
224
|
+
setattr(cfg.synthesis, k, v)
|
|
225
|
+
|
|
226
|
+
if "governance" in data and isinstance(data["governance"], dict):
|
|
227
|
+
for k, v in data["governance"].items():
|
|
228
|
+
if hasattr(cfg.governance, k):
|
|
229
|
+
setattr(cfg.governance, k, v)
|
|
230
|
+
|
|
231
|
+
if "cache" in data and isinstance(data["cache"], dict):
|
|
232
|
+
for k, v in data["cache"].items():
|
|
233
|
+
if hasattr(cfg.cache, k):
|
|
234
|
+
setattr(cfg.cache, k, v)
|
|
235
|
+
|
|
236
|
+
if "logging" in data and isinstance(data["logging"], dict):
|
|
237
|
+
for k, v in data["logging"].items():
|
|
238
|
+
if hasattr(cfg.logging, k):
|
|
239
|
+
setattr(cfg.logging, k, v)
|
|
240
|
+
|
|
241
|
+
if "enterprise" in data and isinstance(data["enterprise"], dict):
|
|
242
|
+
for k, v in data["enterprise"].items():
|
|
243
|
+
if hasattr(cfg.enterprise, k):
|
|
244
|
+
setattr(cfg.enterprise, k, v)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _apply_env(cfg: ZettelForgeConfig):
|
|
248
|
+
"""Apply environment variable overrides (highest priority)."""
|
|
249
|
+
# Storage
|
|
250
|
+
if v := os.environ.get("AMEM_DATA_DIR"):
|
|
251
|
+
cfg.storage.data_dir = v
|
|
252
|
+
|
|
253
|
+
# TypeDB
|
|
254
|
+
if v := os.environ.get("TYPEDB_HOST"):
|
|
255
|
+
cfg.typedb.host = v
|
|
256
|
+
if v := os.environ.get("TYPEDB_PORT"):
|
|
257
|
+
cfg.typedb.port = int(v)
|
|
258
|
+
if v := os.environ.get("TYPEDB_DATABASE"):
|
|
259
|
+
cfg.typedb.database = v
|
|
260
|
+
if v := os.environ.get("TYPEDB_USERNAME"):
|
|
261
|
+
cfg.typedb.username = v
|
|
262
|
+
if v := os.environ.get("TYPEDB_PASSWORD"):
|
|
263
|
+
cfg.typedb.password = v
|
|
264
|
+
|
|
265
|
+
# Backend
|
|
266
|
+
if v := os.environ.get("ZETTELFORGE_BACKEND"):
|
|
267
|
+
cfg.backend = v
|
|
268
|
+
|
|
269
|
+
# Embedding
|
|
270
|
+
if v := os.environ.get("ZETTELFORGE_EMBEDDING_PROVIDER"):
|
|
271
|
+
cfg.embedding.provider = v
|
|
272
|
+
if v := os.environ.get("AMEM_EMBEDDING_URL"):
|
|
273
|
+
cfg.embedding.url = v
|
|
274
|
+
if v := os.environ.get("AMEM_EMBEDDING_MODEL"):
|
|
275
|
+
cfg.embedding.model = v
|
|
276
|
+
|
|
277
|
+
# LLM
|
|
278
|
+
if v := os.environ.get("ZETTELFORGE_LLM_PROVIDER"):
|
|
279
|
+
cfg.llm.provider = v
|
|
280
|
+
if v := os.environ.get("ZETTELFORGE_LLM_MODEL"):
|
|
281
|
+
cfg.llm.model = v
|
|
282
|
+
if v := os.environ.get("ZETTELFORGE_LLM_URL"):
|
|
283
|
+
cfg.llm.url = v
|
|
284
|
+
|
|
285
|
+
# Enterprise
|
|
286
|
+
if v := os.environ.get("THREATENGRAM_LICENSE_KEY"):
|
|
287
|
+
cfg.enterprise.license_key = v
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ── Singleton ──────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
_config: Optional[ZettelForgeConfig] = None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_config() -> ZettelForgeConfig:
|
|
296
|
+
"""Get global configuration. Loads once, caches thereafter."""
|
|
297
|
+
global _config
|
|
298
|
+
if _config is None:
|
|
299
|
+
_config = ZettelForgeConfig()
|
|
300
|
+
|
|
301
|
+
# Layer 1: config file
|
|
302
|
+
config_file = _find_config_file()
|
|
303
|
+
if config_file:
|
|
304
|
+
data = _load_yaml(config_file)
|
|
305
|
+
_apply_yaml(_config, data)
|
|
306
|
+
|
|
307
|
+
# Layer 2: environment variables (override)
|
|
308
|
+
_apply_env(_config)
|
|
309
|
+
|
|
310
|
+
return _config
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def reload_config() -> ZettelForgeConfig:
|
|
314
|
+
"""Force reload configuration from file + environment."""
|
|
315
|
+
global _config
|
|
316
|
+
_config = None
|
|
317
|
+
return get_config()
|