@geravant/sinain 1.13.0 → 1.14.0
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.
- package/.env.example +4 -2
- package/config-shared.js +1 -0
- package/package.json +4 -1
- package/sinain-agent/run.sh +36 -4
- package/sinain-core/src/buffers/feed-buffer.ts +6 -4
- package/sinain-core/src/index.ts +50 -19
- package/sinain-memory/graph_query.py +12 -3
- package/sinain-memory/knowledge_integrator.py +194 -10
- package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
- package/sinain-memory/eval/__init__.py +0 -0
- package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/assertions.py +0 -267
- package/sinain-memory/eval/benchmarks/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
- package/sinain-memory/eval/benchmarks/config.py +0 -23
- package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
- package/sinain-memory/eval/benchmarks/ingest.py +0 -152
- package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
- package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
- package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
- package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
- package/sinain-memory/eval/benchmarks/query.py +0 -193
- package/sinain-memory/eval/benchmarks/report.py +0 -87
- package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
- package/sinain-memory/eval/benchmarks/runner.py +0 -283
- package/sinain-memory/eval/judges/__init__.py +0 -0
- package/sinain-memory/eval/judges/base_judge.py +0 -61
- package/sinain-memory/eval/judges/curation_judge.py +0 -46
- package/sinain-memory/eval/judges/insight_judge.py +0 -48
- package/sinain-memory/eval/judges/mining_judge.py +0 -42
- package/sinain-memory/eval/judges/signal_judge.py +0 -45
- package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
- package/sinain-memory/eval/retrieval_evaluator.py +0 -186
- package/sinain-memory/eval/schemas.py +0 -247
- package/sinain-memory/tests/__init__.py +0 -0
- package/sinain-memory/tests/conftest.py +0 -189
- package/sinain-memory/tests/test_curator_helpers.py +0 -94
- package/sinain-memory/tests/test_embedder.py +0 -210
- package/sinain-memory/tests/test_extract_json.py +0 -124
- package/sinain-memory/tests/test_feedback_computation.py +0 -121
- package/sinain-memory/tests/test_miner_helpers.py +0 -71
- package/sinain-memory/tests/test_module_management.py +0 -458
- package/sinain-memory/tests/test_parsers.py +0 -96
- package/sinain-memory/tests/test_tick_evaluator.py +0 -430
- package/sinain-memory/tests/test_triple_extractor.py +0 -255
- package/sinain-memory/tests/test_triple_ingest.py +0 -191
- package/sinain-memory/tests/test_triple_migrate.py +0 -138
- package/sinain-memory/tests/test_triplestore.py +0 -248
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Retrieval Quality Evaluator — Recall@k and NDCG@k for knowledge graph queries.
|
|
3
|
-
|
|
4
|
-
Inspired by mempalace's LongMemEval benchmark infrastructure. Measures whether the
|
|
5
|
-
right knowledge surfaces when the agent needs it, complementing sinain's existing
|
|
6
|
-
output quality evaluation (schemas + assertions + LLM judges).
|
|
7
|
-
|
|
8
|
-
Usage:
|
|
9
|
-
python3 eval/retrieval_evaluator.py \
|
|
10
|
-
--db memory/knowledge-graph.db \
|
|
11
|
-
--benchmark eval/retrieval_benchmark.jsonl \
|
|
12
|
-
[--k 1,3,5] [--format json|text]
|
|
13
|
-
|
|
14
|
-
Benchmark dataset format (JSONL):
|
|
15
|
-
{"query": "OCR pipeline stalls on macOS 14", "expected_entities": ["fact:sck-capture-fix"], "category": "error-resolution"}
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
import argparse
|
|
19
|
-
import json
|
|
20
|
-
import math
|
|
21
|
-
import sys
|
|
22
|
-
from collections import defaultdict
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def load_benchmark(path: str) -> list[dict]:
|
|
27
|
-
"""Load benchmark QA pairs from JSONL."""
|
|
28
|
-
items = []
|
|
29
|
-
with open(path) as f:
|
|
30
|
-
for line in f:
|
|
31
|
-
line = line.strip()
|
|
32
|
-
if line:
|
|
33
|
-
items.append(json.loads(line))
|
|
34
|
-
return items
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def extract_keywords(query: str) -> list[str]:
|
|
38
|
-
"""Extract search keywords from a natural language query."""
|
|
39
|
-
import re
|
|
40
|
-
words = re.findall(r"[a-zA-Z][a-zA-Z0-9-]+", query.lower())
|
|
41
|
-
stopwords = {"the", "is", "in", "on", "for", "and", "or", "of", "to", "a", "an", "it", "was", "not", "how", "what", "when", "does"}
|
|
42
|
-
return [w for w in words if len(w) > 2 and w not in stopwords]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def dcg_at_k(relevant_positions: list[int], k: int) -> float:
|
|
46
|
-
"""Compute Discounted Cumulative Gain at k."""
|
|
47
|
-
score = 0.0
|
|
48
|
-
for pos in relevant_positions:
|
|
49
|
-
if pos < k:
|
|
50
|
-
score += 1.0 / math.log2(pos + 2) # +2 because position is 0-indexed
|
|
51
|
-
return score
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def ndcg_at_k(relevant_positions: list[int], num_relevant: int, k: int) -> float:
|
|
55
|
-
"""Compute Normalized DCG at k."""
|
|
56
|
-
dcg = dcg_at_k(relevant_positions, k)
|
|
57
|
-
# Ideal DCG: all relevant items at top positions
|
|
58
|
-
ideal_positions = list(range(min(num_relevant, k)))
|
|
59
|
-
idcg = dcg_at_k(ideal_positions, k)
|
|
60
|
-
return dcg / idcg if idcg > 0 else 0.0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def evaluate_retrieval(
|
|
64
|
-
benchmark_path: str,
|
|
65
|
-
db_path: str,
|
|
66
|
-
k_values: list[int] = [1, 3, 5],
|
|
67
|
-
) -> dict:
|
|
68
|
-
"""Run benchmark queries against graph_query.py, compute Recall@k and NDCG@k."""
|
|
69
|
-
# Import graph_query from parent dir
|
|
70
|
-
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
71
|
-
from graph_query import query_facts_by_entities
|
|
72
|
-
|
|
73
|
-
items = load_benchmark(benchmark_path)
|
|
74
|
-
if not items:
|
|
75
|
-
return {"error": "Empty benchmark dataset"}
|
|
76
|
-
|
|
77
|
-
max_k = max(k_values)
|
|
78
|
-
metrics: dict[str, list[float]] = defaultdict(list)
|
|
79
|
-
category_metrics: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
|
|
80
|
-
details: list[dict] = []
|
|
81
|
-
|
|
82
|
-
for item in items:
|
|
83
|
-
query = item["query"]
|
|
84
|
-
expected = set(item.get("expected_entities", []))
|
|
85
|
-
category = item.get("category", "general")
|
|
86
|
-
keywords = extract_keywords(query)
|
|
87
|
-
|
|
88
|
-
if not keywords or not expected:
|
|
89
|
-
continue
|
|
90
|
-
|
|
91
|
-
results = query_facts_by_entities(db_path, keywords, max_facts=max_k)
|
|
92
|
-
result_ids = [r["entityId"] for r in results]
|
|
93
|
-
|
|
94
|
-
# Find positions of relevant results
|
|
95
|
-
relevant_positions = []
|
|
96
|
-
for i, rid in enumerate(result_ids):
|
|
97
|
-
if rid in expected:
|
|
98
|
-
relevant_positions.append(i)
|
|
99
|
-
|
|
100
|
-
for k in k_values:
|
|
101
|
-
hit = any(pos < k for pos in relevant_positions)
|
|
102
|
-
recall = 1.0 if hit else 0.0
|
|
103
|
-
ndcg = ndcg_at_k(relevant_positions, len(expected), k)
|
|
104
|
-
|
|
105
|
-
metrics[f"recall@{k}"].append(recall)
|
|
106
|
-
metrics[f"ndcg@{k}"].append(ndcg)
|
|
107
|
-
category_metrics[category][f"recall@{k}"].append(recall)
|
|
108
|
-
category_metrics[category][f"ndcg@{k}"].append(ndcg)
|
|
109
|
-
|
|
110
|
-
details.append({
|
|
111
|
-
"query": query,
|
|
112
|
-
"category": category,
|
|
113
|
-
"expected": list(expected),
|
|
114
|
-
"retrieved": result_ids[:max_k],
|
|
115
|
-
"hit@1": any(pos < 1 for pos in relevant_positions),
|
|
116
|
-
"hit@5": any(pos < 5 for pos in relevant_positions),
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
# Aggregate
|
|
120
|
-
summary = {
|
|
121
|
-
"total_queries": len(items),
|
|
122
|
-
"evaluated": len(details),
|
|
123
|
-
}
|
|
124
|
-
for metric_name, values in sorted(metrics.items()):
|
|
125
|
-
summary[metric_name] = round(sum(values) / len(values), 4) if values else 0.0
|
|
126
|
-
|
|
127
|
-
# Per-category breakdown
|
|
128
|
-
categories = {}
|
|
129
|
-
for cat, cat_metrics in sorted(category_metrics.items()):
|
|
130
|
-
categories[cat] = {
|
|
131
|
-
"count": len(next(iter(cat_metrics.values()))),
|
|
132
|
-
}
|
|
133
|
-
for metric_name, values in sorted(cat_metrics.items()):
|
|
134
|
-
categories[cat][metric_name] = round(sum(values) / len(values), 4) if values else 0.0
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
"summary": summary,
|
|
138
|
-
"categories": categories,
|
|
139
|
-
"details": details,
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def format_report_text(result: dict) -> str:
|
|
144
|
-
"""Format evaluation result as human-readable text for daily report injection."""
|
|
145
|
-
lines = ["## Retrieval Quality"]
|
|
146
|
-
s = result["summary"]
|
|
147
|
-
for key in sorted(s):
|
|
148
|
-
if key.startswith("recall@") or key.startswith("ndcg@"):
|
|
149
|
-
lines.append(f"- {key}: {s[key]:.2%}")
|
|
150
|
-
|
|
151
|
-
if result.get("categories"):
|
|
152
|
-
lines.append("")
|
|
153
|
-
lines.append("**By category:**")
|
|
154
|
-
for cat, cm in sorted(result["categories"].items()):
|
|
155
|
-
r5 = cm.get("recall@5", 0)
|
|
156
|
-
lines.append(f"- {cat} (n={cm['count']}): recall@5={r5:.0%}")
|
|
157
|
-
|
|
158
|
-
# Weakest category
|
|
159
|
-
cats = result.get("categories", {})
|
|
160
|
-
if cats:
|
|
161
|
-
weakest = min(cats.items(), key=lambda x: x[1].get("recall@5", 1.0))
|
|
162
|
-
if weakest[1].get("recall@5", 1.0) < 0.8:
|
|
163
|
-
lines.append(f"\n**Weakest**: {weakest[0]} ({weakest[1].get('recall@5', 0):.0%})")
|
|
164
|
-
|
|
165
|
-
return "\n".join(lines)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def main() -> None:
|
|
169
|
-
parser = argparse.ArgumentParser(description="Retrieval Quality Evaluator")
|
|
170
|
-
parser.add_argument("--db", required=True, help="Path to knowledge-graph.db")
|
|
171
|
-
parser.add_argument("--benchmark", required=True, help="Path to retrieval_benchmark.jsonl")
|
|
172
|
-
parser.add_argument("--k", default="1,3,5", help="Comma-separated k values for Recall@k")
|
|
173
|
-
parser.add_argument("--format", choices=["json", "text"], default="json", help="Output format")
|
|
174
|
-
args = parser.parse_args()
|
|
175
|
-
|
|
176
|
-
k_values = [int(k) for k in args.k.split(",")]
|
|
177
|
-
result = evaluate_retrieval(args.benchmark, args.db, k_values)
|
|
178
|
-
|
|
179
|
-
if args.format == "text":
|
|
180
|
-
print(format_report_text(result))
|
|
181
|
-
else:
|
|
182
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if __name__ == "__main__":
|
|
186
|
-
main()
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
"""JSON Schema definitions for all sinain-koog script outputs.
|
|
2
|
-
|
|
3
|
-
Each schema corresponds to the JSON printed by output_json() in its respective
|
|
4
|
-
script. Used by tick_evaluator.py for mechanical validation (Tier 1 eval).
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# ---------------------------------------------------------------------------
|
|
12
|
-
# signal_analyzer.py output
|
|
13
|
-
# ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
SIGNAL_ANALYZER_SCHEMA: dict = {
|
|
16
|
-
"type": "object",
|
|
17
|
-
"required": ["signals", "recommendedAction", "idle"],
|
|
18
|
-
"properties": {
|
|
19
|
-
"signals": {
|
|
20
|
-
"type": "array",
|
|
21
|
-
"items": {"type": "string"},
|
|
22
|
-
},
|
|
23
|
-
"recommendedAction": {
|
|
24
|
-
"oneOf": [
|
|
25
|
-
{"type": "null"},
|
|
26
|
-
{
|
|
27
|
-
"type": "object",
|
|
28
|
-
"required": ["action"],
|
|
29
|
-
"properties": {
|
|
30
|
-
"action": {"enum": ["sessions_spawn", "telegram_tip", "skip"]},
|
|
31
|
-
"task": {"type": "string"},
|
|
32
|
-
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
},
|
|
37
|
-
"idle": {"type": "boolean"},
|
|
38
|
-
},
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
# ---------------------------------------------------------------------------
|
|
42
|
-
# feedback_analyzer.py output
|
|
43
|
-
# ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
FEEDBACK_ANALYZER_SCHEMA: dict = {
|
|
46
|
-
"type": "object",
|
|
47
|
-
"required": ["feedbackScores", "effectiveness", "curateDirective"],
|
|
48
|
-
"properties": {
|
|
49
|
-
"feedbackScores": {
|
|
50
|
-
"type": "object",
|
|
51
|
-
"required": ["avg"],
|
|
52
|
-
"properties": {
|
|
53
|
-
"avg": {"type": "number"},
|
|
54
|
-
"high": {"type": "array", "items": {"type": "string"}},
|
|
55
|
-
"low": {"type": "array", "items": {"type": "string"}},
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
"effectiveness": {
|
|
59
|
-
"type": "object",
|
|
60
|
-
"required": ["outputs", "positive", "negative", "neutral", "rate"],
|
|
61
|
-
"properties": {
|
|
62
|
-
"outputs": {"type": "integer", "minimum": 0},
|
|
63
|
-
"positive": {"type": "integer", "minimum": 0},
|
|
64
|
-
"negative": {"type": "integer", "minimum": 0},
|
|
65
|
-
"neutral": {"type": "integer", "minimum": 0},
|
|
66
|
-
"rate": {"type": "number", "minimum": 0, "maximum": 1},
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
"curateDirective": {
|
|
70
|
-
"enum": ["aggressive_prune", "normal", "stability", "insufficient_data"],
|
|
71
|
-
},
|
|
72
|
-
"interpretation": {"type": "string"},
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
# memory_miner.py output
|
|
78
|
-
# ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
MEMORY_MINER_SCHEMA: dict = {
|
|
81
|
-
"type": "object",
|
|
82
|
-
"required": ["findings", "newPatterns"],
|
|
83
|
-
"properties": {
|
|
84
|
-
"findings": {"type": "string"},
|
|
85
|
-
"newPatterns": {"type": "array", "items": {"type": "string"}},
|
|
86
|
-
"contradictions": {"type": "array", "items": {"type": "string"}},
|
|
87
|
-
"preferences": {"type": "array", "items": {"type": "string"}},
|
|
88
|
-
"minedSources": {"type": "array", "items": {"type": "string"}},
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
# ---------------------------------------------------------------------------
|
|
93
|
-
# playbook_curator.py output
|
|
94
|
-
# ---------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
PLAYBOOK_CURATOR_SCHEMA: dict = {
|
|
97
|
-
"type": "object",
|
|
98
|
-
"required": ["changes", "playbookLines"],
|
|
99
|
-
"properties": {
|
|
100
|
-
"changes": {
|
|
101
|
-
"type": "object",
|
|
102
|
-
"required": ["added", "pruned", "promoted"],
|
|
103
|
-
"properties": {
|
|
104
|
-
"added": {"type": "array", "items": {"type": "string"}},
|
|
105
|
-
"pruned": {"type": "array", "items": {"type": "string"}},
|
|
106
|
-
"promoted": {"type": "array", "items": {"type": "string"}},
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
"staleItemActions": {"type": "array", "items": {"type": "string"}},
|
|
110
|
-
"playbookLines": {"type": "integer", "minimum": 0},
|
|
111
|
-
"error": {"type": "string"},
|
|
112
|
-
},
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
# ---------------------------------------------------------------------------
|
|
116
|
-
# insight_synthesizer.py output (non-skip case)
|
|
117
|
-
# ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
INSIGHT_SYNTHESIZER_SCHEMA: dict = {
|
|
120
|
-
"type": "object",
|
|
121
|
-
"required": ["skip"],
|
|
122
|
-
"properties": {
|
|
123
|
-
"skip": {"type": "boolean"},
|
|
124
|
-
"suggestion": {"type": "string"},
|
|
125
|
-
"insight": {"type": "string"},
|
|
126
|
-
"totalChars": {"type": "integer", "minimum": 0},
|
|
127
|
-
"skipReason": {"type": "string"},
|
|
128
|
-
},
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
# ---------------------------------------------------------------------------
|
|
132
|
-
# module_manager.py extract output
|
|
133
|
-
# ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
MODULE_EXTRACT_SCHEMA: dict = {
|
|
136
|
-
"type": "object",
|
|
137
|
-
"required": ["extracted", "domain", "status"],
|
|
138
|
-
"properties": {
|
|
139
|
-
"extracted": {"type": "string"},
|
|
140
|
-
"domain": {"type": "string"},
|
|
141
|
-
"patternsEstablished": {"type": "integer", "minimum": 0},
|
|
142
|
-
"patternsEmerging": {"type": "integer", "minimum": 0},
|
|
143
|
-
"vocabularyTerms": {"type": "integer", "minimum": 0},
|
|
144
|
-
"modulePath": {"type": "string"},
|
|
145
|
-
"status": {"enum": ["suspended", "active"]},
|
|
146
|
-
"activateWith": {"type": "string"},
|
|
147
|
-
},
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# ---------------------------------------------------------------------------
|
|
152
|
-
# Registry: script name → schema
|
|
153
|
-
# ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
SCHEMA_REGISTRY: dict[str, dict] = {
|
|
156
|
-
"signal_analyzer": SIGNAL_ANALYZER_SCHEMA,
|
|
157
|
-
"feedback_analyzer": FEEDBACK_ANALYZER_SCHEMA,
|
|
158
|
-
"memory_miner": MEMORY_MINER_SCHEMA,
|
|
159
|
-
"playbook_curator": PLAYBOOK_CURATOR_SCHEMA,
|
|
160
|
-
"insight_synthesizer": INSIGHT_SYNTHESIZER_SCHEMA,
|
|
161
|
-
"module_extract": MODULE_EXTRACT_SCHEMA,
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
# ---------------------------------------------------------------------------
|
|
166
|
-
# Lightweight JSON Schema validator (no external dependency)
|
|
167
|
-
# ---------------------------------------------------------------------------
|
|
168
|
-
|
|
169
|
-
def validate(instance: Any, schema: dict) -> list[str]:
|
|
170
|
-
"""Validate *instance* against a JSON Schema subset.
|
|
171
|
-
|
|
172
|
-
Returns a list of error strings (empty = valid). Supports:
|
|
173
|
-
type, required, properties, items, enum, oneOf, minimum, maximum.
|
|
174
|
-
"""
|
|
175
|
-
errors: list[str] = []
|
|
176
|
-
_validate(instance, schema, "", errors)
|
|
177
|
-
return errors
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _validate(instance: Any, schema: dict, path: str, errors: list[str]) -> None:
|
|
181
|
-
# --- oneOf ---
|
|
182
|
-
if "oneOf" in schema:
|
|
183
|
-
matches = 0
|
|
184
|
-
for sub in schema["oneOf"]:
|
|
185
|
-
sub_errors: list[str] = []
|
|
186
|
-
_validate(instance, sub, path, sub_errors)
|
|
187
|
-
if not sub_errors:
|
|
188
|
-
matches += 1
|
|
189
|
-
if matches == 0:
|
|
190
|
-
errors.append(f"{path or '.'}: does not match any oneOf variant")
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
# --- enum ---
|
|
194
|
-
if "enum" in schema:
|
|
195
|
-
if instance not in schema["enum"]:
|
|
196
|
-
errors.append(f"{path or '.'}: {instance!r} not in {schema['enum']}")
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
# --- type ---
|
|
200
|
-
expected_type = schema.get("type")
|
|
201
|
-
if expected_type:
|
|
202
|
-
ok = _type_check(instance, expected_type)
|
|
203
|
-
if not ok:
|
|
204
|
-
errors.append(f"{path or '.'}: expected {expected_type}, got {type(instance).__name__}")
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
# --- required ---
|
|
208
|
-
if "required" in schema and isinstance(instance, dict):
|
|
209
|
-
for key in schema["required"]:
|
|
210
|
-
if key not in instance:
|
|
211
|
-
errors.append(f"{path}.{key}: required field missing")
|
|
212
|
-
|
|
213
|
-
# --- properties ---
|
|
214
|
-
if "properties" in schema and isinstance(instance, dict):
|
|
215
|
-
for key, sub_schema in schema["properties"].items():
|
|
216
|
-
if key in instance:
|
|
217
|
-
_validate(instance[key], sub_schema, f"{path}.{key}", errors)
|
|
218
|
-
|
|
219
|
-
# --- items ---
|
|
220
|
-
if "items" in schema and isinstance(instance, list):
|
|
221
|
-
for i, item in enumerate(instance):
|
|
222
|
-
_validate(item, schema["items"], f"{path}[{i}]", errors)
|
|
223
|
-
|
|
224
|
-
# --- minimum / maximum ---
|
|
225
|
-
if isinstance(instance, (int, float)):
|
|
226
|
-
if "minimum" in schema and instance < schema["minimum"]:
|
|
227
|
-
errors.append(f"{path or '.'}: {instance} < minimum {schema['minimum']}")
|
|
228
|
-
if "maximum" in schema and instance > schema["maximum"]:
|
|
229
|
-
errors.append(f"{path or '.'}: {instance} > maximum {schema['maximum']}")
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _type_check(instance: Any, expected: str) -> bool:
|
|
233
|
-
if expected == "object":
|
|
234
|
-
return isinstance(instance, dict)
|
|
235
|
-
if expected == "array":
|
|
236
|
-
return isinstance(instance, list)
|
|
237
|
-
if expected == "string":
|
|
238
|
-
return isinstance(instance, str)
|
|
239
|
-
if expected == "number":
|
|
240
|
-
return isinstance(instance, (int, float))
|
|
241
|
-
if expected == "integer":
|
|
242
|
-
return isinstance(instance, int) and not isinstance(instance, bool)
|
|
243
|
-
if expected == "boolean":
|
|
244
|
-
return isinstance(instance, bool)
|
|
245
|
-
if expected == "null":
|
|
246
|
-
return instance is None
|
|
247
|
-
return True
|
|
File without changes
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
"""Shared fixtures for sinain-koog pytest test suite."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
# Ensure sinain-koog source is importable
|
|
11
|
-
KOOG_DIR = Path(__file__).resolve().parent.parent
|
|
12
|
-
if str(KOOG_DIR) not in sys.path:
|
|
13
|
-
sys.path.insert(0, str(KOOG_DIR))
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@pytest.fixture
|
|
17
|
-
def tmp_memory_dir(tmp_path):
|
|
18
|
-
"""Create a temporary memory directory with sample data."""
|
|
19
|
-
memory = tmp_path / "memory"
|
|
20
|
-
memory.mkdir()
|
|
21
|
-
(memory / "playbook-logs").mkdir()
|
|
22
|
-
(memory / "playbook-archive").mkdir()
|
|
23
|
-
(memory / "eval-logs").mkdir()
|
|
24
|
-
(memory / "eval-reports").mkdir()
|
|
25
|
-
|
|
26
|
-
# Sample playbook
|
|
27
|
-
playbook = (
|
|
28
|
-
"<!-- mining-index: 2026-02-21,2026-02-20 -->\n"
|
|
29
|
-
"# Sinain Playbook\n\n"
|
|
30
|
-
"## Established Patterns\n"
|
|
31
|
-
"- When OCR pipeline stalls, check camera frame queue depth (score: 0.8)\n"
|
|
32
|
-
"- When user explores new framework, spawn research agent proactively (score: 0.6)\n\n"
|
|
33
|
-
"## Observed\n"
|
|
34
|
-
"- User prefers concise Telegram messages over detailed ones\n"
|
|
35
|
-
"- Late evening sessions tend to be exploratory/research-heavy\n\n"
|
|
36
|
-
"## Stale\n"
|
|
37
|
-
"- Flutter overlay rendering glitch on macOS 15 [since: 2026-02-18]\n\n"
|
|
38
|
-
"<!-- effectiveness: outputs=8,positive=5,negative=1,neutral=2,rate=0.63,updated=2026-02-21 -->\n"
|
|
39
|
-
)
|
|
40
|
-
(memory / "sinain-playbook.md").write_text(playbook, encoding="utf-8")
|
|
41
|
-
|
|
42
|
-
# Sample daily memory files
|
|
43
|
-
for date in ["2026-02-21", "2026-02-20", "2026-02-19"]:
|
|
44
|
-
(memory / f"{date}.md").write_text(
|
|
45
|
-
f"# {date} Session Notes\n\n- Worked on OCR pipeline\n- Explored Flutter overlays\n",
|
|
46
|
-
encoding="utf-8",
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
# Sample playbook-log entries
|
|
50
|
-
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
51
|
-
entries = [
|
|
52
|
-
{
|
|
53
|
-
"ts": "2026-02-28T10:00:00Z",
|
|
54
|
-
"idle": False,
|
|
55
|
-
"sessionSummary": "Debugging OCR pipeline",
|
|
56
|
-
"signals": [{"description": "OCR pipeline backpressure detected", "priority": "high"}],
|
|
57
|
-
"recommendedAction": {"action": "sessions_spawn", "task": "Debug OCR backpressure", "confidence": 0.8},
|
|
58
|
-
"feedbackScores": {"avg": 0.35, "high": ["OCR fix"], "low": []},
|
|
59
|
-
"effectiveness": {"outputs": 8, "positive": 5, "negative": 1, "neutral": 2, "rate": 0.63},
|
|
60
|
-
"curateDirective": "normal",
|
|
61
|
-
"playbookChanges": {
|
|
62
|
-
"changes": {"added": ["new pattern"], "pruned": [], "promoted": []},
|
|
63
|
-
"staleItemActions": [],
|
|
64
|
-
"playbookLines": 12,
|
|
65
|
-
},
|
|
66
|
-
"output": {
|
|
67
|
-
"skip": False,
|
|
68
|
-
"suggestion": "Consider frame batching for OCR pipeline",
|
|
69
|
-
"insight": "Evening sessions correlate with exploratory work patterns",
|
|
70
|
-
"totalChars": 95,
|
|
71
|
-
},
|
|
72
|
-
"skipped": False,
|
|
73
|
-
"actionsConsidered": [
|
|
74
|
-
{"action": "sessions_spawn", "reason": "Debug OCR backpressure", "chosen": True}
|
|
75
|
-
],
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
"ts": "2026-02-28T10:30:00Z",
|
|
79
|
-
"idle": True,
|
|
80
|
-
"sessionSummary": "User idle",
|
|
81
|
-
"signals": [],
|
|
82
|
-
"recommendedAction": None,
|
|
83
|
-
"feedbackScores": {"avg": 0, "high": [], "low": []},
|
|
84
|
-
"effectiveness": {"outputs": 8, "positive": 5, "negative": 1, "neutral": 2, "rate": 0.63},
|
|
85
|
-
"curateDirective": "normal",
|
|
86
|
-
"playbookChanges": {
|
|
87
|
-
"changes": {"added": [], "pruned": [], "promoted": []},
|
|
88
|
-
"staleItemActions": [],
|
|
89
|
-
"playbookLines": 12,
|
|
90
|
-
},
|
|
91
|
-
"output": {
|
|
92
|
-
"skip": True,
|
|
93
|
-
"skipReason": "User is idle and no new patterns detected in playbook since last analysis",
|
|
94
|
-
},
|
|
95
|
-
"skipped": True,
|
|
96
|
-
"miningResult": {
|
|
97
|
-
"findings": "Found cross-day OCR pattern",
|
|
98
|
-
"newPatterns": ["frame dropping improves OCR accuracy"],
|
|
99
|
-
"contradictions": [],
|
|
100
|
-
"preferences": ["user prefers minimal configs"],
|
|
101
|
-
"minedSources": ["2026-02-21.md"],
|
|
102
|
-
},
|
|
103
|
-
"actionsConsidered": [],
|
|
104
|
-
},
|
|
105
|
-
]
|
|
106
|
-
|
|
107
|
-
log_file = memory / "playbook-logs" / f"{today}.jsonl"
|
|
108
|
-
log_file.write_text(
|
|
109
|
-
"\n".join(json.dumps(e) for e in entries) + "\n",
|
|
110
|
-
encoding="utf-8",
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
return memory
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
@pytest.fixture
|
|
117
|
-
def tmp_modules_dir(tmp_path):
|
|
118
|
-
"""Create a temporary modules directory with sample module."""
|
|
119
|
-
modules = tmp_path / "modules"
|
|
120
|
-
modules.mkdir()
|
|
121
|
-
|
|
122
|
-
# Registry
|
|
123
|
-
registry = {
|
|
124
|
-
"version": 1,
|
|
125
|
-
"modules": {
|
|
126
|
-
"react-native-dev": {
|
|
127
|
-
"status": "active",
|
|
128
|
-
"priority": 85,
|
|
129
|
-
"activatedAt": "2026-02-20T10:00:00Z",
|
|
130
|
-
"lastTriggered": None,
|
|
131
|
-
"locked": False,
|
|
132
|
-
},
|
|
133
|
-
"ocr-pipeline": {
|
|
134
|
-
"status": "suspended",
|
|
135
|
-
"priority": 70,
|
|
136
|
-
"activatedAt": None,
|
|
137
|
-
"lastTriggered": None,
|
|
138
|
-
"locked": False,
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
}
|
|
142
|
-
(modules / "module-registry.json").write_text(
|
|
143
|
-
json.dumps(registry, indent=2), encoding="utf-8"
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
# Module directories
|
|
147
|
-
rn_dir = modules / "react-native-dev"
|
|
148
|
-
rn_dir.mkdir()
|
|
149
|
-
(rn_dir / "manifest.json").write_text(json.dumps({
|
|
150
|
-
"id": "react-native-dev",
|
|
151
|
-
"name": "React Native Development",
|
|
152
|
-
"description": "Patterns for RN development",
|
|
153
|
-
"version": "1.0.0",
|
|
154
|
-
"priority": {"default": 85, "range": [50, 100]},
|
|
155
|
-
"triggers": {},
|
|
156
|
-
"locked": False,
|
|
157
|
-
}, indent=2), encoding="utf-8")
|
|
158
|
-
(rn_dir / "patterns.md").write_text(
|
|
159
|
-
"# React Native Development\n\n## Established Patterns\n- Use Hermes engine\n",
|
|
160
|
-
encoding="utf-8",
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
return modules
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@pytest.fixture
|
|
167
|
-
def sample_log_entry():
|
|
168
|
-
"""A sample playbook-log entry for testing."""
|
|
169
|
-
return {
|
|
170
|
-
"ts": "2026-02-28T10:00:00Z",
|
|
171
|
-
"idle": False,
|
|
172
|
-
"signals": [{"description": "OCR pipeline backpressure detected", "priority": "high"}],
|
|
173
|
-
"recommendedAction": {"action": "sessions_spawn", "task": "Debug OCR backpressure", "confidence": 0.8},
|
|
174
|
-
"feedbackScores": {"avg": 0.35, "high": ["OCR fix"], "low": []},
|
|
175
|
-
"effectiveness": {"outputs": 8, "positive": 5, "negative": 1, "neutral": 2, "rate": 0.63},
|
|
176
|
-
"curateDirective": "normal",
|
|
177
|
-
"interpretation": "",
|
|
178
|
-
"playbookChanges": {
|
|
179
|
-
"changes": {"added": ["new pattern"], "pruned": [], "promoted": []},
|
|
180
|
-
"staleItemActions": [],
|
|
181
|
-
"playbookLines": 12,
|
|
182
|
-
},
|
|
183
|
-
"output": {
|
|
184
|
-
"skip": False,
|
|
185
|
-
"suggestion": "Consider frame batching for OCR pipeline",
|
|
186
|
-
"insight": "Evening sessions correlate with exploratory work patterns",
|
|
187
|
-
"totalChars": 95,
|
|
188
|
-
},
|
|
189
|
-
}
|