@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,81 +0,0 @@
|
|
|
1
|
-
"""LLM-as-Judge: QA answer quality evaluator (LongMemEval-compatible, 1-5 scale).
|
|
2
|
-
|
|
3
|
-
Uses GPT-4o via OpenRouter for comparability with published results.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
# Add sinain-memory to path for common imports
|
|
12
|
-
_koog_dir = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
13
|
-
if _koog_dir not in sys.path:
|
|
14
|
-
sys.path.insert(0, _koog_dir)
|
|
15
|
-
|
|
16
|
-
from common import LLMError, call_llm, extract_json # noqa: E402
|
|
17
|
-
|
|
18
|
-
SYSTEM_PROMPT = """\
|
|
19
|
-
You are evaluating whether a predicted answer correctly answers a question.
|
|
20
|
-
The gold (reference) answer is provided.
|
|
21
|
-
|
|
22
|
-
Score on a scale of 1-5:
|
|
23
|
-
5: Perfect — captures all key information from the gold answer, no errors
|
|
24
|
-
4: Mostly correct — minor omissions or imprecision, main point is right
|
|
25
|
-
3: Partially correct — captures some key points but misses important details
|
|
26
|
-
2: Related but mostly wrong — touches the topic but answer is largely incorrect
|
|
27
|
-
1: Completely wrong, contradicts the gold answer, or says "I don't know" when the answer exists
|
|
28
|
-
|
|
29
|
-
Special cases:
|
|
30
|
-
- If the gold answer indicates abstention is correct (e.g. "I don't know" or "not mentioned"),
|
|
31
|
-
then a predicted "I don't know" scores 5.
|
|
32
|
-
- Numeric answers within 10% of gold = full credit.
|
|
33
|
-
- Getting the gist right but missing specifics = 3-4 depending on importance.
|
|
34
|
-
|
|
35
|
-
Respond with ONLY a JSON object: {"score": <1-5>, "reasoning": "brief explanation"}"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def judge_qa(
|
|
39
|
-
question: str,
|
|
40
|
-
gold_answer: str,
|
|
41
|
-
predicted_answer: str,
|
|
42
|
-
*,
|
|
43
|
-
condition: str = "",
|
|
44
|
-
model: str | None = None,
|
|
45
|
-
) -> dict | None:
|
|
46
|
-
"""Score a QA answer. Returns {"score": 1-5, "reasoning": str} or None on failure."""
|
|
47
|
-
user_parts = [
|
|
48
|
-
f"## Question\n{question}",
|
|
49
|
-
f"\n## Gold Answer\n{gold_answer}",
|
|
50
|
-
f"\n## Predicted Answer\n{predicted_answer}",
|
|
51
|
-
]
|
|
52
|
-
if condition:
|
|
53
|
-
user_parts.append(f"\n## Context Condition: {condition}")
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
kwargs: dict = {
|
|
57
|
-
"system_prompt": SYSTEM_PROMPT,
|
|
58
|
-
"user_prompt": "\n".join(user_parts),
|
|
59
|
-
"max_tokens": 200,
|
|
60
|
-
"json_mode": True,
|
|
61
|
-
}
|
|
62
|
-
if model:
|
|
63
|
-
kwargs["model"] = model
|
|
64
|
-
else:
|
|
65
|
-
kwargs["script"] = "meeting_benchmark"
|
|
66
|
-
|
|
67
|
-
raw = call_llm(**kwargs)
|
|
68
|
-
result = extract_json(raw)
|
|
69
|
-
|
|
70
|
-
score = result.get("score")
|
|
71
|
-
reasoning = result.get("reasoning", "")
|
|
72
|
-
|
|
73
|
-
if not isinstance(score, (int, float)) or not (1 <= score <= 5):
|
|
74
|
-
print(f"[warn] qa_judge returned invalid score: {score}", file=sys.stderr)
|
|
75
|
-
return None
|
|
76
|
-
|
|
77
|
-
return {"score": int(score), "reasoning": str(reasoning)[:300]}
|
|
78
|
-
|
|
79
|
-
except (ValueError, LLMError, KeyError) as e:
|
|
80
|
-
print(f"[warn] qa_judge call failed: {e}", file=sys.stderr)
|
|
81
|
-
return None
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
"""LongMemEval (ICLR 2025) adapter — download + parse into sinain format.
|
|
2
|
-
|
|
3
|
-
Dataset: https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned
|
|
4
|
-
Paper: https://arxiv.org/abs/2410.10813
|
|
5
|
-
|
|
6
|
-
Fields per item:
|
|
7
|
-
question_id, question_type, question, answer, question_date,
|
|
8
|
-
haystack_session_ids, haystack_dates, haystack_sessions, answer_session_ids
|
|
9
|
-
|
|
10
|
-
haystack_sessions entries: {"role": "user"/"assistant", "content": "...", "has_answer": bool}
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import hashlib
|
|
16
|
-
import json
|
|
17
|
-
from datetime import datetime, timedelta, timezone
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
from .base_adapter import BenchmarkAdapter, BenchmarkInstance, BenchmarkQuestion
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _download_dataset(data_dir: Path) -> Path:
|
|
24
|
-
"""Download LongMemEval from HuggingFace if not cached."""
|
|
25
|
-
cache_path = data_dir / "longmemeval" / "longmemeval_s_cleaned.json"
|
|
26
|
-
if cache_path.exists():
|
|
27
|
-
return cache_path
|
|
28
|
-
|
|
29
|
-
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
-
|
|
31
|
-
url = "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json"
|
|
32
|
-
print(f"[longmemeval] downloading from {url} ...")
|
|
33
|
-
|
|
34
|
-
# Use curl to avoid macOS Python SSL cert issues
|
|
35
|
-
import subprocess
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
["curl", "-fSL", "-o", str(cache_path), url],
|
|
38
|
-
capture_output=True, text=True, timeout=120,
|
|
39
|
-
)
|
|
40
|
-
if result.returncode != 0:
|
|
41
|
-
raise RuntimeError(f"Download failed: {result.stderr[:200]}")
|
|
42
|
-
print(f"[longmemeval] saved to {cache_path} ({cache_path.stat().st_size} bytes)")
|
|
43
|
-
return cache_path
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _session_hash(sessions: list[dict]) -> str:
|
|
47
|
-
"""Content hash for a haystack (for grouping questions with shared context)."""
|
|
48
|
-
raw = json.dumps(sessions, sort_keys=True, ensure_ascii=False)
|
|
49
|
-
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _sessions_to_feed_items(
|
|
53
|
-
haystack_sessions: list[list[dict]],
|
|
54
|
-
haystack_session_ids: list[str],
|
|
55
|
-
haystack_dates: list[str],
|
|
56
|
-
) -> list[list[dict]]:
|
|
57
|
-
"""Convert LongMemEval haystack into sinain feed item sessions.
|
|
58
|
-
|
|
59
|
-
haystack_sessions is a list of sessions, each a list of turn dicts:
|
|
60
|
-
sessions[i][j] = {"role": "user"/"assistant", "content": "..."}
|
|
61
|
-
|
|
62
|
-
Each session becomes a list of feed items with synthesized timestamps.
|
|
63
|
-
User turns → source: "audio", assistant turns → source: "agent".
|
|
64
|
-
"""
|
|
65
|
-
result: list[list[dict]] = []
|
|
66
|
-
|
|
67
|
-
for i, session_turns in enumerate(haystack_sessions):
|
|
68
|
-
if not session_turns:
|
|
69
|
-
continue
|
|
70
|
-
|
|
71
|
-
base_ts = haystack_dates[i] if i < len(haystack_dates) else "2025-01-01T10:00:00Z"
|
|
72
|
-
base_dt = _parse_date(base_ts)
|
|
73
|
-
|
|
74
|
-
items = []
|
|
75
|
-
for j, turn in enumerate(session_turns):
|
|
76
|
-
ts = (base_dt + timedelta(seconds=30 * j)).isoformat()
|
|
77
|
-
source = "audio" if turn.get("role") == "user" else "agent"
|
|
78
|
-
items.append({
|
|
79
|
-
"source": source,
|
|
80
|
-
"text": turn.get("content", ""),
|
|
81
|
-
"ts": ts,
|
|
82
|
-
"channel": "benchmark",
|
|
83
|
-
})
|
|
84
|
-
if items:
|
|
85
|
-
result.append(items)
|
|
86
|
-
|
|
87
|
-
return result
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _parse_date(s: str) -> datetime:
|
|
91
|
-
"""Best-effort date parsing."""
|
|
92
|
-
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%m/%d/%Y"):
|
|
93
|
-
try:
|
|
94
|
-
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
|
95
|
-
except (ValueError, TypeError):
|
|
96
|
-
continue
|
|
97
|
-
return datetime(2025, 1, 1, 10, 0, tzinfo=timezone.utc)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
class LongMemEvalAdapter(BenchmarkAdapter):
|
|
101
|
-
"""Adapter for LongMemEval (ICLR 2025) benchmark."""
|
|
102
|
-
|
|
103
|
-
@property
|
|
104
|
-
def name(self) -> str:
|
|
105
|
-
return "longmemeval"
|
|
106
|
-
|
|
107
|
-
def load_dataset(self, data_dir: str) -> list[BenchmarkInstance]:
|
|
108
|
-
"""Download and parse LongMemEval, grouping questions by shared haystack."""
|
|
109
|
-
path = _download_dataset(Path(data_dir))
|
|
110
|
-
with open(path) as f:
|
|
111
|
-
raw_items = json.load(f)
|
|
112
|
-
|
|
113
|
-
# Group questions by haystack content hash
|
|
114
|
-
groups: dict[str, dict] = {}
|
|
115
|
-
for item in raw_items:
|
|
116
|
-
h = _session_hash(item.get("haystack_sessions", []))
|
|
117
|
-
if h not in groups:
|
|
118
|
-
groups[h] = {
|
|
119
|
-
"haystack_sessions": item["haystack_sessions"],
|
|
120
|
-
"haystack_session_ids": item.get("haystack_session_ids", []),
|
|
121
|
-
"haystack_dates": item.get("haystack_dates", []),
|
|
122
|
-
"questions": [],
|
|
123
|
-
}
|
|
124
|
-
groups[h]["questions"].append(item)
|
|
125
|
-
|
|
126
|
-
instances = []
|
|
127
|
-
for h, group in groups.items():
|
|
128
|
-
feed_sessions = _sessions_to_feed_items(
|
|
129
|
-
group["haystack_sessions"],
|
|
130
|
-
group["haystack_session_ids"],
|
|
131
|
-
group["haystack_dates"],
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
questions = []
|
|
135
|
-
for item in group["questions"]:
|
|
136
|
-
questions.append(BenchmarkQuestion(
|
|
137
|
-
id=item["question_id"],
|
|
138
|
-
text=item["question"],
|
|
139
|
-
gold_answer=str(item["answer"]),
|
|
140
|
-
category=item.get("question_type", "unknown"),
|
|
141
|
-
evidence_session_ids=item.get("answer_session_ids", []),
|
|
142
|
-
metadata={
|
|
143
|
-
"question_date": item.get("question_date", ""),
|
|
144
|
-
},
|
|
145
|
-
))
|
|
146
|
-
|
|
147
|
-
instances.append(BenchmarkInstance(
|
|
148
|
-
id=f"lme-{h}",
|
|
149
|
-
sessions=feed_sessions,
|
|
150
|
-
questions=questions,
|
|
151
|
-
raw_sessions=group["haystack_sessions"],
|
|
152
|
-
metadata={
|
|
153
|
-
"haystack_hash": h,
|
|
154
|
-
"num_sessions": len(feed_sessions),
|
|
155
|
-
"num_turns": len(group["haystack_sessions"]),
|
|
156
|
-
},
|
|
157
|
-
))
|
|
158
|
-
|
|
159
|
-
print(f"[longmemeval] loaded {sum(len(i.questions) for i in instances)} questions "
|
|
160
|
-
f"across {len(instances)} unique haystacks")
|
|
161
|
-
return instances
|
|
162
|
-
|
|
163
|
-
def format_full_context(self, instance: BenchmarkInstance) -> str:
|
|
164
|
-
"""Render the full conversation history for the baseline condition."""
|
|
165
|
-
lines = []
|
|
166
|
-
for session in instance.raw_sessions:
|
|
167
|
-
if isinstance(session, list):
|
|
168
|
-
for turn in session:
|
|
169
|
-
role = turn.get("role", "unknown").capitalize()
|
|
170
|
-
content = turn.get("content", "")
|
|
171
|
-
lines.append(f"{role}: {content}")
|
|
172
|
-
lines.append("---") # session separator
|
|
173
|
-
elif isinstance(session, dict):
|
|
174
|
-
role = session.get("role", "unknown").capitalize()
|
|
175
|
-
content = session.get("content", "")
|
|
176
|
-
lines.append(f"{role}: {content}")
|
|
177
|
-
return "\n\n".join(lines)
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Meeting Memory adapter — loads QA pairs + ground-truth transcript.
|
|
2
|
-
|
|
3
|
-
Unlike LongMemEval, this adapter does NOT ingest. The real sinain pipeline
|
|
4
|
-
(start-local.sh) produces the knowledge-graph.db during a live capture run.
|
|
5
|
-
This adapter only loads QA pairs and provides the full-context baseline.
|
|
6
|
-
|
|
7
|
-
Data layout:
|
|
8
|
-
eval/benchmarks/data/meeting/
|
|
9
|
-
<name>.txt — ground-truth transcript
|
|
10
|
-
<name>_qa.json — gold QA pairs
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import json
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
|
|
18
|
-
from .base_adapter import BenchmarkAdapter, BenchmarkInstance, BenchmarkQuestion
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class MeetingMemoryAdapter(BenchmarkAdapter):
|
|
22
|
-
"""Adapter for meeting memory benchmarks (real pipeline capture)."""
|
|
23
|
-
|
|
24
|
-
@property
|
|
25
|
-
def name(self) -> str:
|
|
26
|
-
return "meeting"
|
|
27
|
-
|
|
28
|
-
def load_dataset(self, data_dir: str) -> list[BenchmarkInstance]:
|
|
29
|
-
"""Load meeting QA pairs and transcripts from data/meeting/."""
|
|
30
|
-
meeting_dir = Path(data_dir) / "meeting"
|
|
31
|
-
if not meeting_dir.exists():
|
|
32
|
-
raise FileNotFoundError(f"Meeting data not found: {meeting_dir}")
|
|
33
|
-
|
|
34
|
-
instances = []
|
|
35
|
-
for qa_path in sorted(meeting_dir.glob("*_qa.json")):
|
|
36
|
-
# Derive transcript path: foo_qa.json → foo.txt
|
|
37
|
-
stem = qa_path.stem.replace("_qa", "")
|
|
38
|
-
transcript_path = qa_path.parent / f"{stem}.txt"
|
|
39
|
-
if not transcript_path.exists():
|
|
40
|
-
print(f"[meeting] warning: no transcript for {qa_path.name}, skipping")
|
|
41
|
-
continue
|
|
42
|
-
|
|
43
|
-
# Load QA pairs
|
|
44
|
-
with open(qa_path) as f:
|
|
45
|
-
raw_questions = json.load(f)
|
|
46
|
-
|
|
47
|
-
questions = []
|
|
48
|
-
for item in raw_questions:
|
|
49
|
-
questions.append(BenchmarkQuestion(
|
|
50
|
-
id=item["id"],
|
|
51
|
-
text=item["question"],
|
|
52
|
-
gold_answer=item["gold_answer"],
|
|
53
|
-
category=item.get("category", "unknown"),
|
|
54
|
-
evidence_session_ids=item.get("evidence_timestamps", []),
|
|
55
|
-
metadata={
|
|
56
|
-
"evidence_timestamps": item.get("evidence_timestamps", []),
|
|
57
|
-
},
|
|
58
|
-
))
|
|
59
|
-
|
|
60
|
-
# Load transcript
|
|
61
|
-
transcript_text = transcript_path.read_text(encoding="utf-8")
|
|
62
|
-
|
|
63
|
-
instances.append(BenchmarkInstance(
|
|
64
|
-
id=f"meeting-{stem}",
|
|
65
|
-
sessions=[], # Not used — real pipeline does ingestion
|
|
66
|
-
questions=questions,
|
|
67
|
-
raw_sessions=[],
|
|
68
|
-
metadata={
|
|
69
|
-
"transcript_path": str(transcript_path),
|
|
70
|
-
"raw_transcript": transcript_text,
|
|
71
|
-
"stem": stem,
|
|
72
|
-
},
|
|
73
|
-
))
|
|
74
|
-
|
|
75
|
-
total_q = sum(len(i.questions) for i in instances)
|
|
76
|
-
print(f"[meeting] loaded {total_q} questions across {len(instances)} meetings")
|
|
77
|
-
return instances
|
|
78
|
-
|
|
79
|
-
def format_full_context(self, instance: BenchmarkInstance) -> str:
|
|
80
|
-
"""Return the raw transcript verbatim for the full-context baseline."""
|
|
81
|
-
return instance.metadata.get("raw_transcript", "")
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Meeting Memory Benchmark runner — evaluates a captured knowledge-graph.db.
|
|
3
|
-
|
|
4
|
-
Standalone script: does NOT modify or import runner.py.
|
|
5
|
-
Reuses shared infrastructure: query, judge, evaluate, report.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
python3 eval/benchmarks/meeting_runner.py \
|
|
9
|
-
--db /tmp/sinain-bench-XXXX/knowledge-graph.db \
|
|
10
|
-
--conditions sinain-memory,full-context
|
|
11
|
-
|
|
12
|
-
# Quick test (3 questions):
|
|
13
|
-
python3 eval/benchmarks/meeting_runner.py \
|
|
14
|
-
--db /tmp/sinain-bench-XXXX/knowledge-graph.db \
|
|
15
|
-
--subset 3
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import argparse
|
|
21
|
-
import json
|
|
22
|
-
import sys
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
# Add sinain-memory to path
|
|
26
|
-
_koog_dir = str(Path(__file__).resolve().parent.parent.parent)
|
|
27
|
-
if _koog_dir not in sys.path:
|
|
28
|
-
sys.path.insert(0, _koog_dir)
|
|
29
|
-
|
|
30
|
-
from eval.benchmarks.config import DATA_DIR, RESULTS_DIR, QA_MODEL, JUDGE_MODEL
|
|
31
|
-
from eval.benchmarks.meeting_adapter import MeetingMemoryAdapter
|
|
32
|
-
from eval.benchmarks.query import answer_question, _get_retrieved_facts, compute_content_recall
|
|
33
|
-
from eval.benchmarks.evaluate import token_f1, aggregate_results
|
|
34
|
-
from eval.benchmarks.judges.qa_judge import judge_qa
|
|
35
|
-
from eval.benchmarks.report import generate_markdown, generate_json
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def run_meeting_benchmark(
|
|
39
|
-
db_path: str,
|
|
40
|
-
conditions: list[str],
|
|
41
|
-
*,
|
|
42
|
-
subset: int | None = None,
|
|
43
|
-
meeting_filter: str | None = None,
|
|
44
|
-
qa_model: str = QA_MODEL,
|
|
45
|
-
judge_model: str = JUDGE_MODEL,
|
|
46
|
-
output_dir: Path = RESULTS_DIR,
|
|
47
|
-
data_dir: Path = DATA_DIR,
|
|
48
|
-
resume: bool = False,
|
|
49
|
-
) -> tuple[dict, list[dict]]:
|
|
50
|
-
"""Run meeting benchmark against a captured knowledge-graph.db."""
|
|
51
|
-
|
|
52
|
-
adapter = MeetingMemoryAdapter()
|
|
53
|
-
instances = adapter.load_dataset(str(data_dir))
|
|
54
|
-
|
|
55
|
-
if meeting_filter:
|
|
56
|
-
instances = [i for i in instances if meeting_filter in i.id]
|
|
57
|
-
print(f"[meeting] filtered to {len(instances)} meeting(s) matching '{meeting_filter}'")
|
|
58
|
-
|
|
59
|
-
if not instances:
|
|
60
|
-
print("[meeting] no meeting data found — check data/meeting/ directory")
|
|
61
|
-
return {"error": "no data"}, []
|
|
62
|
-
|
|
63
|
-
# Validate DB exists for sinain-memory condition
|
|
64
|
-
if "sinain-memory" in conditions:
|
|
65
|
-
if not db_path or not Path(db_path).exists():
|
|
66
|
-
print(f"[meeting] ERROR: --db path does not exist: {db_path}")
|
|
67
|
-
print(" Run the capture pipeline first (see plan Part 1)")
|
|
68
|
-
sys.exit(1)
|
|
69
|
-
|
|
70
|
-
print(f"\n{'='*60}")
|
|
71
|
-
print(f" Meeting Memory Benchmark")
|
|
72
|
-
print(f" Conditions: {', '.join(conditions)}")
|
|
73
|
-
print(f" DB: {db_path or '(none)'}")
|
|
74
|
-
print(f" QA model: {qa_model}")
|
|
75
|
-
print(f" Judge model: {judge_model}")
|
|
76
|
-
print(f"{'='*60}\n")
|
|
77
|
-
|
|
78
|
-
# Flatten questions
|
|
79
|
-
all_questions = []
|
|
80
|
-
for inst in instances:
|
|
81
|
-
for q in inst.questions:
|
|
82
|
-
all_questions.append((inst, q))
|
|
83
|
-
|
|
84
|
-
if subset:
|
|
85
|
-
all_questions = all_questions[:subset]
|
|
86
|
-
|
|
87
|
-
total = len(all_questions)
|
|
88
|
-
print(f"[meeting] evaluating {total} questions\n")
|
|
89
|
-
|
|
90
|
-
# Resume support
|
|
91
|
-
resume_path = output_dir / "meeting_progress.jsonl"
|
|
92
|
-
completed: dict[str, dict] = {}
|
|
93
|
-
if resume and resume_path.exists():
|
|
94
|
-
for line in resume_path.read_text().strip().split("\n"):
|
|
95
|
-
if line:
|
|
96
|
-
entry = json.loads(line)
|
|
97
|
-
completed[entry["id"]] = entry
|
|
98
|
-
|
|
99
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
-
details: list[dict] = []
|
|
101
|
-
|
|
102
|
-
for idx, (inst, question) in enumerate(all_questions):
|
|
103
|
-
qid = question.id
|
|
104
|
-
|
|
105
|
-
if qid in completed:
|
|
106
|
-
details.append(completed[qid])
|
|
107
|
-
continue
|
|
108
|
-
|
|
109
|
-
print(f"[{idx+1}/{total}] {qid} [{question.category}]")
|
|
110
|
-
print(f" Q: {question.text[:80]}...")
|
|
111
|
-
|
|
112
|
-
full_context = adapter.format_full_context(inst)
|
|
113
|
-
|
|
114
|
-
# Retrieval metrics
|
|
115
|
-
retrieval = {}
|
|
116
|
-
if db_path and "sinain-memory" in conditions:
|
|
117
|
-
retrieved_facts = _get_retrieved_facts(db_path, question.text)
|
|
118
|
-
retrieval = compute_content_recall(retrieved_facts, question.gold_answer)
|
|
119
|
-
|
|
120
|
-
# Generate answers per condition
|
|
121
|
-
answers = {}
|
|
122
|
-
for cond in conditions:
|
|
123
|
-
if cond == "sinain-memory" and not db_path:
|
|
124
|
-
answers[cond] = {"text": "(no DB)", "score": 1, "f1": 0.0}
|
|
125
|
-
continue
|
|
126
|
-
|
|
127
|
-
print(f" [{cond}] generating answer...")
|
|
128
|
-
answer_text = answer_question(
|
|
129
|
-
question, cond,
|
|
130
|
-
db_path=db_path,
|
|
131
|
-
full_context=full_context,
|
|
132
|
-
model=qa_model,
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
f1 = token_f1(answer_text, question.gold_answer)
|
|
136
|
-
|
|
137
|
-
judge_result = judge_qa(
|
|
138
|
-
question.text, question.gold_answer, answer_text,
|
|
139
|
-
condition=cond, model=judge_model,
|
|
140
|
-
)
|
|
141
|
-
score = judge_result["score"] if judge_result else None
|
|
142
|
-
reasoning = judge_result["reasoning"] if judge_result else None
|
|
143
|
-
|
|
144
|
-
answers[cond] = {
|
|
145
|
-
"text": answer_text[:500],
|
|
146
|
-
"score": score,
|
|
147
|
-
"f1": round(f1, 4),
|
|
148
|
-
"reasoning": reasoning,
|
|
149
|
-
}
|
|
150
|
-
print(f" score={score}/5 f1={f1:.2f}")
|
|
151
|
-
|
|
152
|
-
entry = {
|
|
153
|
-
"id": qid,
|
|
154
|
-
"question": question.text,
|
|
155
|
-
"gold_answer": question.gold_answer,
|
|
156
|
-
"category": question.category,
|
|
157
|
-
"retrieval": retrieval,
|
|
158
|
-
"answers": answers,
|
|
159
|
-
}
|
|
160
|
-
details.append(entry)
|
|
161
|
-
|
|
162
|
-
# Save progress
|
|
163
|
-
with open(resume_path, "a") as f:
|
|
164
|
-
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
165
|
-
|
|
166
|
-
summary = aggregate_results(details)
|
|
167
|
-
return summary, details
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def main() -> None:
|
|
171
|
-
parser = argparse.ArgumentParser(description="Meeting Memory Benchmark")
|
|
172
|
-
parser.add_argument("--db", required=False, default=None,
|
|
173
|
-
help="Path to knowledge-graph.db from capture run")
|
|
174
|
-
parser.add_argument("--conditions", default="sinain-memory,full-context",
|
|
175
|
-
help="Comma-separated conditions (sinain-memory, full-context)")
|
|
176
|
-
parser.add_argument("--subset", type=int, default=None,
|
|
177
|
-
help="Run only first N questions")
|
|
178
|
-
parser.add_argument("--meeting", default=None,
|
|
179
|
-
help="Filter to specific meeting by stem (e.g. al-futaim-prep-5min)")
|
|
180
|
-
parser.add_argument("--qa-model", default=QA_MODEL)
|
|
181
|
-
parser.add_argument("--judge-model", default=JUDGE_MODEL)
|
|
182
|
-
parser.add_argument("--output-dir", type=Path, default=RESULTS_DIR)
|
|
183
|
-
parser.add_argument("--data-dir", type=Path, default=DATA_DIR)
|
|
184
|
-
parser.add_argument("--format", default="json,markdown")
|
|
185
|
-
parser.add_argument("--resume", action="store_true")
|
|
186
|
-
args = parser.parse_args()
|
|
187
|
-
|
|
188
|
-
conditions = [c.strip() for c in args.conditions.split(",")]
|
|
189
|
-
formats = [f.strip() for f in args.format.split(",")]
|
|
190
|
-
|
|
191
|
-
summary, details = run_meeting_benchmark(
|
|
192
|
-
args.db, conditions,
|
|
193
|
-
subset=args.subset,
|
|
194
|
-
meeting_filter=args.meeting,
|
|
195
|
-
qa_model=args.qa_model,
|
|
196
|
-
judge_model=args.judge_model,
|
|
197
|
-
output_dir=args.output_dir,
|
|
198
|
-
data_dir=args.data_dir,
|
|
199
|
-
resume=args.resume,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
# Write outputs
|
|
203
|
-
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
-
|
|
205
|
-
if "json" in formats:
|
|
206
|
-
json_path = args.output_dir / "meeting_results.json"
|
|
207
|
-
json_path.write_text(generate_json("meeting", summary, details))
|
|
208
|
-
print(f"\n[output] JSON: {json_path}")
|
|
209
|
-
|
|
210
|
-
if "markdown" in formats:
|
|
211
|
-
md_path = args.output_dir / "meeting_results.md"
|
|
212
|
-
md_path.write_text(generate_markdown("meeting", summary, details))
|
|
213
|
-
print(f"[output] Markdown: {md_path}")
|
|
214
|
-
|
|
215
|
-
# Print summary
|
|
216
|
-
print(f"\n{'='*60}")
|
|
217
|
-
print(f" Meeting Memory Benchmark — Summary")
|
|
218
|
-
print(f"{'='*60}")
|
|
219
|
-
ipr = summary.get("ipr")
|
|
220
|
-
if ipr:
|
|
221
|
-
print(f" IPR: {ipr:.1%}")
|
|
222
|
-
for cond, data in summary.get("conditions", {}).items():
|
|
223
|
-
print(f" {cond}: {data['mean_score']:.2f}/5 (n={data['n']})")
|
|
224
|
-
for k, v in summary.get("retrieval", {}).items():
|
|
225
|
-
print(f" {k}: {v:.1%}")
|
|
226
|
-
print()
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if __name__ == "__main__":
|
|
230
|
-
main()
|