hyperforge-remi 1.0.0.post20__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.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperforge_remi
3
+ Version: 1.0.0.post20
4
+ Summary: Remi Hyperforge agent
5
+ Author-email: Nuclia <nucliadb@nuclia.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://progress.com
8
+ Project-URL: Repository, https://github.com/nuclia/forge
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: <4,>=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: hyperforge
18
+
19
+ # Remi Hyperforge agents
@@ -0,0 +1 @@
1
+ # Remi Hyperforge agents
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hyperforge_remi"
7
+ version = "1.0.0.post20"
8
+ license = "Apache-2.0"
9
+ description = "Remi Hyperforge agent"
10
+ authors = [{ name = "Nuclia", email = "nucliadb@nuclia.com" }]
11
+ readme = "README.md"
12
+ classifiers = [
13
+ "Programming Language :: Python",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ requires-python = ">=3.10, <4"
21
+ dependencies = ["hyperforge"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest",
26
+ "pytest-benchmark",
27
+ "pytest-docker-fixtures>=1.4.2",
28
+ "pytest-lazy-fixtures",
29
+ "pytest-recording",
30
+ "pytest-asyncio",
31
+ "pytest-cov",
32
+ "pytest-mock",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://progress.com"
37
+ Repository = "https://github.com/nuclia/forge"
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .agent import RemiAgent
2
+
3
+ __all__ = ["RemiAgent"]
@@ -0,0 +1,177 @@
1
+ import asyncio
2
+
3
+ from hyperforge.agent import Agent
4
+ from hyperforge.configure import agent
5
+ from hyperforge.manager import Manager
6
+ from hyperforge.memory import Chunk, Context, QuestionMemory
7
+ from hyperforge.trace import trace_agent
8
+ from nuclia_models.predict.remi import RemiResponse
9
+
10
+ from hyperforge import logger
11
+ from hyperforge_remi.config import (
12
+ ContextGranularity,
13
+ RemiAgentConfig,
14
+ )
15
+
16
+ MAX_CONTEXTS_REMI = 60
17
+
18
+
19
+ @agent(
20
+ id="remi",
21
+ agent_type="postprocess",
22
+ title="REMI Evaluation",
23
+ description="Agent that performs REMI evaluation.",
24
+ config_schema=RemiAgentConfig,
25
+ )
26
+ class RemiAgent(Agent[RemiAgentConfig]):
27
+ @trace_agent
28
+ async def __call__(
29
+ self,
30
+ memory: QuestionMemory,
31
+ manager: Manager,
32
+ ):
33
+ error = None
34
+ # For each context in memory add context query, summary and answer onto a text and the initial question
35
+ if memory.final_answer is None:
36
+ raise Exception("No final answer")
37
+ if memory.original_question is None:
38
+ raise Exception("No original question")
39
+
40
+ # Only answer relevance and groundedness for now
41
+ # If we also did context relevance, we would combine into a single remi call
42
+ tasks = [
43
+ manager.remi(
44
+ question=memory.original_question,
45
+ answer=memory.final_answer,
46
+ contexts=None,
47
+ )
48
+ ]
49
+ contexts = (
50
+ memory.list_contexts_minimal()
51
+ if self.config.context_granularity == ContextGranularity.PARTIAL_ANSWERS
52
+ else memory.list_chunks_markdown()
53
+ )
54
+ if len(contexts) > MAX_CONTEXTS_REMI:
55
+ contexts = contexts[:MAX_CONTEXTS_REMI]
56
+ error = f"Too many contexts for groundedness evaluation, truncated to {MAX_CONTEXTS_REMI}. "
57
+
58
+ if memory.is_answered is True:
59
+ tasks.append(
60
+ manager.remi(
61
+ question=None,
62
+ answer=memory.final_answer,
63
+ contexts=contexts,
64
+ )
65
+ )
66
+ results = await asyncio.gather(*tasks, return_exceptions=True)
67
+ ev_answer_rel: RemiResponse | BaseException = results[0]
68
+ if memory.is_answered is True:
69
+ ev_groundedness: RemiResponse | BaseException | None = results[1]
70
+ else:
71
+ logger.info(
72
+ "Forcing REMi groundedness to 0 on question flagged as not answered"
73
+ )
74
+ ev_groundedness = RemiResponse(
75
+ groundedness=[0] * max(len(contexts), 1), time=0
76
+ )
77
+
78
+ response_str = ""
79
+ if (
80
+ isinstance(ev_answer_rel, BaseException)
81
+ or not ev_answer_rel
82
+ or not ev_answer_rel.answer_relevance
83
+ ):
84
+ msg = "Error evaluating answer relevance. "
85
+ logger.warning(
86
+ msg + str(ev_answer_rel)
87
+ if isinstance(ev_answer_rel, BaseException)
88
+ else ""
89
+ )
90
+ error = error + msg if error else msg
91
+ else:
92
+ response_str += (
93
+ f"Answer relevance: {ev_answer_rel.answer_relevance.score}/5. "
94
+ )
95
+
96
+ # Max aggregation for groundedness, could be configurable
97
+ if (
98
+ isinstance(ev_groundedness, BaseException)
99
+ or not ev_groundedness
100
+ or not ev_groundedness.groundedness
101
+ ):
102
+ msg = "Error evaluating answer groundedness. "
103
+ logger.warning(
104
+ msg + str(ev_groundedness)
105
+ if isinstance(ev_groundedness, BaseException)
106
+ else ""
107
+ )
108
+ error = error + msg if error else msg
109
+ else:
110
+ groundedness = max(
111
+ [g if g is not None else 0 for g in ev_groundedness.groundedness]
112
+ )
113
+ response_str += f"Answer groundedness: {groundedness}/5."
114
+
115
+ remi_chunks = []
116
+
117
+ # Use chunks directly if context granularity is chunk (markdown)
118
+ if self.config.context_granularity != ContextGranularity.PARTIAL_ANSWERS:
119
+ chunk_idx = 0
120
+ for context in memory.contexts:
121
+ agent_name = context.agent_id if context.agent_id else context.agent
122
+ if context.chunks:
123
+ for chunk in context.chunks:
124
+ if chunk_idx >= min(
125
+ MAX_CONTEXTS_REMI, len(ev_groundedness.groundedness)
126
+ ):
127
+ break
128
+ score = ev_groundedness.groundedness[chunk_idx]
129
+ g_score = score if score is not None else 0
130
+
131
+ c = Chunk(
132
+ chunk_id=chunk.chunk_id or f"remi_chunk_{chunk_idx}",
133
+ title=f"Groundedness {g_score}/5 - [{agent_name}] {chunk.title or 'Untitled'}",
134
+ text=f"**Groundedness: {g_score}/5**\n\n{chunk.text}",
135
+ origin_agent=self.config.module,
136
+ )
137
+ remi_chunks.append(c)
138
+ chunk_idx += 1
139
+ else:
140
+ for i, context in enumerate(memory.contexts[:MAX_CONTEXTS_REMI]):
141
+ if i < len(ev_groundedness.groundedness):
142
+ score = ev_groundedness.groundedness[i]
143
+ g_score = score if score is not None else 0
144
+ agent_name = (
145
+ context.agent_id if context.agent_id else context.agent
146
+ )
147
+
148
+ evaluated_text = (
149
+ context.answer_summary_markdown()
150
+ if context.summary.strip()
151
+ else context.context_markdown()
152
+ )
153
+ c = Chunk(
154
+ chunk_id=context.id or f"remi_{i}",
155
+ title=f"Groundedness {g_score}/5 - [{agent_name}] {context.title or 'Untitled'}",
156
+ text=f"**Groundedness: {g_score}/5**\n\n{evaluated_text}",
157
+ origin_agent=self.config.module,
158
+ )
159
+ remi_chunks.append(c)
160
+
161
+ if remi_chunks:
162
+ remi_context = Context(
163
+ original_question_uuid=memory.original_question_uuid,
164
+ actual_question_uuid=memory.actual_question_uuid,
165
+ agent="remi",
166
+ agent_id="remi",
167
+ title="REMi Evaluation Breakdown",
168
+ summary=response_str.strip()
169
+ if response_str.strip()
170
+ else f"Evaluated {len(remi_chunks)} contexts for groundedness.",
171
+ question=memory.original_question or "",
172
+ source="remi",
173
+ chunks=remi_chunks,
174
+ )
175
+ if error:
176
+ remi_context.summary += f"\nErrors: {error}"
177
+ await memory.save_context("postprocess", remi_context)
@@ -0,0 +1,23 @@
1
+ from enum import Enum
2
+ from typing import Literal
3
+
4
+ from hyperforge.agent import AgentConfig
5
+ from pydantic import Field
6
+ from pydantic.config import ConfigDict
7
+
8
+
9
+ class ContextGranularity(str, Enum):
10
+ FULL = "full"
11
+ PARTIAL_ANSWERS = "partial_answers"
12
+
13
+
14
+ class RemiAgentConfig(AgentConfig):
15
+ model_config = ConfigDict(title="REMi evaluation")
16
+ module: Literal["remi"] = "remi"
17
+ context_granularity: ContextGranularity = Field(
18
+ default=ContextGranularity.FULL,
19
+ title="Granularity of the contexts pieces",
20
+ description="Granularity of the context pieces sent to REMi for groundedness evaluation. "
21
+ "If 'partial_answers', the evaluation will use agent-level answer attempts to the question when available for a speedier analysis. "
22
+ "If 'full', the evaluation will use all individual text chunks that each agent generated for a more detailed analysis.",
23
+ )
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperforge_remi
3
+ Version: 1.0.0.post20
4
+ Summary: Remi Hyperforge agent
5
+ Author-email: Nuclia <nucliadb@nuclia.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://progress.com
8
+ Project-URL: Repository, https://github.com/nuclia/forge
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: <4,>=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: hyperforge
18
+
19
+ # Remi Hyperforge agents
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hyperforge_remi/__init__.py
4
+ src/hyperforge_remi/agent.py
5
+ src/hyperforge_remi/config.py
6
+ src/hyperforge_remi.egg-info/PKG-INFO
7
+ src/hyperforge_remi.egg-info/SOURCES.txt
8
+ src/hyperforge_remi.egg-info/dependency_links.txt
9
+ src/hyperforge_remi.egg-info/requires.txt
10
+ src/hyperforge_remi.egg-info/top_level.txt
11
+ tests/test_remi.py
@@ -0,0 +1,236 @@
1
+ import os
2
+ import re
3
+
4
+ import pytest
5
+ from hyperforge.engine import main as arag_main
6
+ from hyperforge.minimal_fixtures import cassette_nua_key
7
+ from hyperforge_remi.config import ContextGranularity
8
+
9
+ NUA_KEY = os.environ.get(
10
+ "NUA_KEY",
11
+ ) or cassette_nua_key("https://europe-1.nuclia.cloud/")
12
+
13
+
14
+ pytestmark = [
15
+ pytest.mark.vcr(ignore_localhost=True, ignore_hosts=["europe-1.nuclia.cloud"]),
16
+ pytest.mark.asyncio,
17
+ ]
18
+
19
+ ANSWERED_QUESTION = "What is Progress Agentic RAG?"
20
+ UNANSWERED_QUESTION = (
21
+ "Como usar max_magic y dime como cambiará este parametro en el futuro"
22
+ )
23
+
24
+ CONFIG = {
25
+ "drivers": [
26
+ {
27
+ "provider": "nucliadb",
28
+ "identifier": "nuclia-docs",
29
+ "name": "nuclia-docs",
30
+ "config": {
31
+ "url": "https://europe-1.nuclia.cloud/api",
32
+ "manager": "https://europe-1.nuclia.cloud/api",
33
+ "kbid": "df8b4c24-2807-4888-ad6c-ae97357a638b",
34
+ "key": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNhIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2V1cm9wZS0xLm51Y2xpYS5jbG91ZC8iLCJpYXQiOjE3NTkyNTQ3NjQsInN1YiI6ImUwNGUwMzcyLTYwNDgtNDY5ZC04NWExLWI1ZTM1MjBmMzdlZiIsImp0aSI6IjgzMzRhY2NlLTIwMTUtNGY0MS05M2U5LTczNzk1MTg2NDdiZiIsImV4cCI6MTc5MDc5MDc2MCwia2V5IjoiMmYxMzYyNTItNjNiMy00NzA1LTg1MjQtMDhmYWJjOWUzMjUyIiwia2lkIjoiN2RmMzY2NDctOTdiOC00NzU0LWExNjUtZWZkY2ZlMDRkMzI2In0.kwHAfx9RRTI-G3S64X0iisr0iAyXRKNRhnN4C67MkLSxeu1AOAnVV8EIQuu4jpXW7O4FkSsthFXEv9ZxlRRh_CaS0z_TjPzIzDPeE6eIKskZ70Q7c-pDe949WE9DZiDyy9_dwKsdX5cnvYpKorp0ROm-GvRXrdHaTZKDSYWht3gvEtm6-0j9C1gx2BzKr2coizUAIde_qjSpLOojO4S-k8P8I9dsQFagdcrjxgGWgrAzjhAs_qkqlRmP0QP6S7ToN0nrbHmtKKb0lWmcpVvlAfH95CM20YUs7IAqU_t7-_V6mm43FstRgGeiHkoapo8nPVJtXMBSlaM7GSz0Kxf2TWQwi94mTEQLdA8CblX0skMCfIHFwbcbm1Vf-2C6LywAsSmTYAwsVPpqeQcVZdrfLMhddCjZKUFCNLSurCSb4TuN79GZicPCJDT-VEBMlNH8ayHOyRib5RyqvgXUwGN9zyM-ma7RrVk4eEwSk7923bn_9GTk-s5tYw_exbYsQ1Qa84GA6NzgJ_kNQmgJwb2zW1V5ddCpYd5k6lNEdPRk0JQKlCC2zTmSvnRcLxfDPi4SZFdLLdtG0j2hIl_QNTEC_3VtqJds4FMofy7TkmUObdbEmXjdAsOxkqj2ntGOsaBNiCI_w47BbPvG_V1LsBHDrrIo0Wo1fgAhUbtWV7Dd5J4",
35
+ "filters": [],
36
+ "description": "Documentation of the Nuclia API, recipies, reference",
37
+ },
38
+ },
39
+ ],
40
+ "rules": {
41
+ "rules": [
42
+ {
43
+ "prompt": "Be polite",
44
+ },
45
+ {
46
+ "prompt": "The documentation of Nuclia is hosted at https://docs.nuclia.dev",
47
+ },
48
+ ]
49
+ },
50
+ "memory": {},
51
+ "workflow": {
52
+ "id": "default",
53
+ "name": "Default workflow",
54
+ "description": "Default workflow for testing",
55
+ "parameters": {},
56
+ },
57
+ "preprocess": [],
58
+ "context": [
59
+ {
60
+ "module": "basic_ask",
61
+ "title": "Nuclia Docs Retrieval Agent",
62
+ "sources": ["nuclia-docs"],
63
+ "prune_context": False,
64
+ },
65
+ ],
66
+ "generation": [
67
+ {"module": "summarize", "title": "Summarize agent"},
68
+ ],
69
+ "postprocess": [{"module": "remi"}],
70
+ }
71
+
72
+
73
+ # Match on body since we send parallel requests and otherwise they get played back in a different order
74
+ @pytest.mark.vcr(match_on=["method", "scheme", "host", "port", "path", "query", "body"])
75
+ @pytest.mark.parametrize(
76
+ "granularity", (ContextGranularity.PARTIAL_ANSWERS, ContextGranularity.FULL)
77
+ )
78
+ async def test_remi(granularity: ContextGranularity):
79
+ CONFIG["postprocess"][0] = {"module": "remi", "context_granularity": granularity} # type: ignore
80
+ question_memory = await arag_main(
81
+ agent_id="default",
82
+ internal_nua=False,
83
+ external_nua_api_key=NUA_KEY,
84
+ question=ANSWERED_QUESTION,
85
+ config=CONFIG,
86
+ loaded_modules=[
87
+ "hyperforge_remi",
88
+ "hyperforge_summarize",
89
+ "hyperforge_nucliadb",
90
+ ],
91
+ )
92
+
93
+ assert question_memory.final_answer is not None
94
+ # verify the step logic is actually validating the context summary instead
95
+ remi_contexts = [ctx for ctx in question_memory.contexts if ctx.agent == "remi"]
96
+ assert len(remi_contexts) > 0, "Missing REMi structured context"
97
+ remi_ctx = remi_contexts[-1]
98
+ assert remi_ctx.title == "REMi Evaluation Breakdown"
99
+
100
+ assert remi_ctx.summary is not None
101
+ relevance_match = re.search(r"Answer relevance: (\d+)/5\.", remi_ctx.summary)
102
+ assert relevance_match is not None, remi_ctx.summary
103
+ relevance_score = int(relevance_match.group(1))
104
+ assert relevance_score > 1, f"Relevance score {relevance_score} should be > 1"
105
+
106
+ groundedness_match = re.search(r"Answer groundedness: (\d+)/5\.", remi_ctx.summary)
107
+ assert groundedness_match is not None, remi_ctx.summary
108
+ groundedness_score = int(groundedness_match.group(1))
109
+ assert groundedness_score > 1, (
110
+ f"Groundedness score {groundedness_score} should be > 1"
111
+ )
112
+
113
+ assert len(remi_ctx.chunks) > 0, "Missing evaluated chunks in REMi context"
114
+ assert remi_ctx.chunks[0].title is not None
115
+ assert "Groundedness" in remi_ctx.chunks[0].title, (
116
+ f"Missing score in title: {remi_ctx.chunks[0].title}"
117
+ )
118
+ assert "**Groundedness:" in remi_ctx.chunks[0].text, (
119
+ "Missing score text inside chunk text"
120
+ )
121
+
122
+ if granularity == ContextGranularity.FULL:
123
+ # original text chunks check
124
+ original_chunks = sum(
125
+ len(ctx.chunks) for ctx in question_memory.contexts if ctx.agent != "remi"
126
+ )
127
+ assert len(remi_ctx.chunks) == original_chunks
128
+
129
+ orig_ids = {
130
+ c.chunk_id
131
+ for ctx in question_memory.contexts
132
+ if ctx.agent != "remi"
133
+ for c in ctx.chunks
134
+ if c.chunk_id
135
+ }
136
+ for chunk in remi_ctx.chunks:
137
+ assert chunk.chunk_id in orig_ids or "remi_chunk_" in (chunk.chunk_id or "")
138
+ else:
139
+ # One chunk per context evaluated
140
+ ctx_list = [ctx for ctx in question_memory.contexts if ctx.agent != "remi"]
141
+ original_contexts = min(len(ctx_list), 60)
142
+ assert len(remi_ctx.chunks) == original_contexts, (
143
+ f"remi_ctx: {len(remi_ctx.chunks)}, orig: {original_contexts}. ctx_list: {[c.agent for c in ctx_list]}"
144
+ )
145
+ orig_ids = {c.id for c in ctx_list if c.id}
146
+ for chunk in remi_ctx.chunks:
147
+ assert chunk.chunk_id in orig_ids or (chunk.chunk_id or "").startswith(
148
+ "remi_"
149
+ )
150
+
151
+ assert "Errors:" not in remi_ctx.summary
152
+
153
+
154
+ @pytest.mark.vcr(match_on=["method", "scheme", "host", "port", "path", "query", "body"])
155
+ @pytest.mark.parametrize(
156
+ "granularity", [ContextGranularity.PARTIAL_ANSWERS, ContextGranularity.FULL]
157
+ )
158
+ async def test_remi_not_enough_data(granularity: ContextGranularity):
159
+ CONFIG["postprocess"][0] = {"module": "remi", "context_granularity": granularity} # type: ignore
160
+ question_memory = await arag_main(
161
+ agent_id="default",
162
+ internal_nua=False,
163
+ external_nua_api_key=NUA_KEY,
164
+ question=UNANSWERED_QUESTION,
165
+ config=CONFIG,
166
+ loaded_modules=[
167
+ "hyperforge_remi",
168
+ "hyperforge_summarize",
169
+ "hyperforge_nucliadb",
170
+ ],
171
+ )
172
+ assert question_memory.final_answer is not None
173
+ assert "not enough data to answer this" in question_memory.final_answer.lower()
174
+ assert question_memory.is_answered is False
175
+
176
+ remi_contexts = [ctx for ctx in question_memory.contexts if ctx.agent == "remi"]
177
+ # If there are context chunks, REMi generates a structured context
178
+ original_chunks = sum(
179
+ len(ctx.chunks) for ctx in question_memory.contexts if ctx.agent != "remi"
180
+ )
181
+ if original_chunks > 0:
182
+ assert len(remi_contexts) > 0, "Missing REMi structured context"
183
+ remi_ctx = remi_contexts[-1]
184
+ assert remi_ctx.title == "REMi Evaluation Breakdown"
185
+
186
+ assert remi_ctx.summary is not None
187
+ relevance_match = re.search(r"Answer relevance: (\d+)/5\.", remi_ctx.summary)
188
+ assert relevance_match is not None, remi_ctx.summary
189
+ relevance_score = int(relevance_match.group(1))
190
+ assert relevance_score < 1, f"Relevance score {relevance_score} should be < 1"
191
+
192
+ groundedness_match = re.search(
193
+ r"Answer groundedness: (\d+)/5\.", remi_ctx.summary
194
+ )
195
+ assert groundedness_match is not None, remi_ctx.summary
196
+ groundedness_score = int(groundedness_match.group(1))
197
+ assert groundedness_score == 0, (
198
+ f"Groundedness score {groundedness_score} should be 0 when the question is not answered"
199
+ )
200
+
201
+ assert len(remi_ctx.chunks) > 0, "Missing evaluated chunks in REMi context"
202
+ assert remi_ctx.chunks[0].title is not None
203
+ assert "Groundedness" in remi_ctx.chunks[0].title, (
204
+ f"Missing score in title: {remi_ctx.chunks[0].title}"
205
+ )
206
+ assert "**Groundedness:" in remi_ctx.chunks[0].text, (
207
+ "Missing score text inside chunk text"
208
+ )
209
+
210
+ if granularity == ContextGranularity.FULL:
211
+ assert len(remi_ctx.chunks) == original_chunks
212
+ orig_ids = {
213
+ c.chunk_id
214
+ for ctx in question_memory.contexts
215
+ if ctx.agent != "remi"
216
+ for c in ctx.chunks
217
+ if c.chunk_id
218
+ }
219
+ for chunk in remi_ctx.chunks:
220
+ assert chunk.chunk_id in orig_ids or "remi_chunk_" in (
221
+ chunk.chunk_id or ""
222
+ )
223
+ else:
224
+ # One chunk per context evaluated
225
+ ctx_list = [ctx for ctx in question_memory.contexts if ctx.agent != "remi"]
226
+ original_contexts = min(len(ctx_list), 60)
227
+ assert len(remi_ctx.chunks) == original_contexts, (
228
+ f"remi_ctx: {len(remi_ctx.chunks)}, orig: {original_contexts}. ctx_list: {[c.agent for c in ctx_list]}"
229
+ )
230
+ orig_ids = {c.id for c in ctx_list if c.id}
231
+ for chunk in remi_ctx.chunks:
232
+ assert chunk.chunk_id in orig_ids or (chunk.chunk_id or "").startswith(
233
+ "remi_"
234
+ )
235
+
236
+ assert "Errors:" not in remi_ctx.summary