hyperforge-related 1.0.0.post19__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_related
3
+ Version: 1.0.0.post19
4
+ Summary: Related 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
+ # Related Hyperforge agents
@@ -0,0 +1 @@
1
+ # Related Hyperforge agents
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hyperforge_related"
7
+ version = "1.0.0.post19"
8
+ license = "Apache-2.0"
9
+ description = "Related 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,104 @@
1
+ from time import time
2
+
3
+ from hyperforge.agent import Agent
4
+ from hyperforge.manager import Manager
5
+ from hyperforge.memory import QuestionMemory
6
+ from hyperforge.trace import trace_agent
7
+
8
+ from hyperforge_related.config import RelatedAgentConfig
9
+ from hyperforge import PROMPT_ENVIRONMENT
10
+
11
+ ASK_JSON_SCHEMA = {
12
+ "title": "related_questions",
13
+ "description": "Related questions to main question based on context information",
14
+ "parameters": {
15
+ "type": "object",
16
+ "properties": {
17
+ "related": {
18
+ "type": "array",
19
+ "items": {
20
+ "type": "string",
21
+ "description": "related question to the main question",
22
+ },
23
+ },
24
+ },
25
+ },
26
+ }
27
+
28
+
29
+ RELATED_PROMPT = """
30
+ Provice a list of questions related to the original question that are not answered in the context.
31
+
32
+ {{prompt}}
33
+
34
+ [START OF CONTEXT]
35
+
36
+ {% for con in context -%}
37
+ Text:
38
+ {% for chunk in con.chunks %}
39
+ ## {{chunk.title}}
40
+ Labels: {% for label in chunk.labels %} {{label}} {% endfor -%}
41
+ URL: {% for url in chunk.url%}{{url}}{% endfor -%}
42
+
43
+ {{chunk.text}}
44
+ {% endfor -%}
45
+
46
+ Structured info:
47
+ {% for structured in con.structured %}
48
+ {{structured}}
49
+ {% endfor -%}
50
+
51
+ Answer summary: {{con.summary}}
52
+
53
+ {% endfor -%}
54
+ [END OF CONTEXT]
55
+
56
+ MAIN QUESTION: {{question}}
57
+
58
+ # Notes
59
+ - Use the context provided without being overly selective.
60
+ - If there is no need to add a new question return an empty list
61
+
62
+ """
63
+
64
+
65
+ RELATED_PROMPT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(RELATED_PROMPT)
66
+
67
+
68
+ class RelatedAgent(Agent[RelatedAgentConfig]):
69
+ @trace_agent
70
+ async def __call__(
71
+ self,
72
+ memory: QuestionMemory,
73
+ manager: Manager,
74
+ ):
75
+ # For each context in memory add context query, summary and answer onto a text and the initial question
76
+ t0 = time()
77
+
78
+ related_prompt = RELATED_PROMPT_TEMPLATE.render(
79
+ question=memory.original_question,
80
+ context=memory.contexts,
81
+ prompt=self.config.prompt,
82
+ )
83
+
84
+ related, input, output = await manager.execute_json(
85
+ prompt=related_prompt,
86
+ user_id="related",
87
+ model=self.config.model,
88
+ schema=ASK_JSON_SCHEMA,
89
+ tracking=memory.get_tracking_info(),
90
+ )
91
+
92
+ if related is not None:
93
+ for related_question in related.get("related", []):
94
+ memory.add_future_questions([related_question])
95
+ await memory.add_step(
96
+ step_module=self.config.module,
97
+ step_title=self.step_title("Related questions"),
98
+ step_value=str(related),
99
+ step_reason="",
100
+ step_agent_path=f"/postprocess/{self.config.id if self.config.id else 'default'}",
101
+ timeit=time() - t0,
102
+ input_nuclia_tokens=input,
103
+ output_nuclia_tokens=output,
104
+ )
@@ -0,0 +1,12 @@
1
+ from typing import Literal, Optional
2
+
3
+ from hyperforge.agent import AgentConfig
4
+ from pydantic.config import ConfigDict
5
+
6
+
7
+ class RelatedAgentConfig(AgentConfig):
8
+ model_config = ConfigDict(title="Related")
9
+ module: Literal["related"] = "related"
10
+ prompt: Optional[str] = None
11
+ model: str = "chatgpt-azure-4o-mini"
12
+ images: bool = False
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperforge_related
3
+ Version: 1.0.0.post19
4
+ Summary: Related 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
+ # Related Hyperforge agents
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hyperforge_related/__init__.py
4
+ src/hyperforge_related/agent.py
5
+ src/hyperforge_related/config.py
6
+ src/hyperforge_related.egg-info/PKG-INFO
7
+ src/hyperforge_related.egg-info/SOURCES.txt
8
+ src/hyperforge_related.egg-info/dependency_links.txt
9
+ src/hyperforge_related.egg-info/requires.txt
10
+ src/hyperforge_related.egg-info/top_level.txt
11
+ tests/test_related.py
@@ -0,0 +1,144 @@
1
+ from unittest.mock import AsyncMock, MagicMock
2
+
3
+ import pytest
4
+ from hyperforge.manager import Manager
5
+ from hyperforge.memory.memory import EphemeralSessionMemory
6
+ from hyperforge.models import MemoryConfig, Rules
7
+ from hyperforge_related.agent import RelatedAgent
8
+ from hyperforge_related.config import RelatedAgentConfig
9
+
10
+
11
+ def make_agent(model: str = "chatgpt-azure-4o-mini", prompt: str | None = None) -> RelatedAgent:
12
+ config = RelatedAgentConfig(id="test-related", model=model, prompt=prompt)
13
+ return RelatedAgent(config=config)
14
+
15
+
16
+ def make_memory(question: str = "What is machine learning?"):
17
+ session = EphemeralSessionMemory.from_config(
18
+ config=MemoryConfig(), agent_id="test", workflow_id="test", rules=Rules()
19
+ )
20
+ session.init("test-session")
21
+ return session.start_question(question)
22
+
23
+
24
+ def make_manager(related: list[str] | None = None, return_none: bool = False):
25
+ manager = MagicMock(spec=Manager)
26
+ questions = ["How does ML work?", "What is deep learning?"] if related is None else related
27
+ payload = None if return_none else {"related": questions}
28
+ manager.execute_json = AsyncMock(return_value=(payload, 10, 20))
29
+ return manager
30
+
31
+
32
+ # --- Config tests ---
33
+
34
+ def test_config_defaults():
35
+ config = RelatedAgentConfig()
36
+ assert config.module == "related"
37
+ assert config.model == "chatgpt-azure-4o-mini"
38
+ assert config.prompt is None
39
+ assert config.images is False
40
+
41
+
42
+ def test_step_title():
43
+ agent = make_agent()
44
+ assert agent.step_title("Related questions") == "Related: Related questions"
45
+
46
+
47
+ # --- Behaviour tests ---
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_related_questions_added_to_future_questions():
51
+ """Questions returned by the LLM are stored in memory.future_questions."""
52
+ agent = make_agent()
53
+ memory = make_memory()
54
+ questions = ["What is supervised learning?", "What is a neural network?"]
55
+ manager = make_manager(related=questions)
56
+
57
+ await agent(memory=memory, manager=manager)
58
+
59
+ stored = list(memory.future_questions.values())
60
+ for q in questions:
61
+ assert q in stored
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_step_added_when_related_not_none():
66
+ """A step is recorded when the LLM returns a non-None result."""
67
+ agent = make_agent()
68
+ memory = make_memory()
69
+ manager = make_manager(related=["Some follow-up?"])
70
+
71
+ await agent(memory=memory, manager=manager)
72
+
73
+ assert len(memory.steps) == 1
74
+ step = memory.steps[0]
75
+ assert step.module == "related"
76
+ assert step.title == "Related: Related questions"
77
+ assert step.agent_path == f"/postprocess/{agent.config.id}"
78
+
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_no_step_when_llm_returns_none():
82
+ """When execute_json returns None, no step and no future questions are added."""
83
+ agent = make_agent()
84
+ memory = make_memory()
85
+ manager = make_manager(return_none=True)
86
+
87
+ await agent(memory=memory, manager=manager)
88
+
89
+ assert len(memory.steps) == 0
90
+ assert len(memory.future_questions) == 0
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_empty_related_list_adds_step_but_no_future_questions():
95
+ """When the LLM returns an empty list, a step is still added but future_questions stays empty."""
96
+ agent = make_agent()
97
+ memory = make_memory()
98
+ manager = make_manager(related=[])
99
+
100
+ await agent(memory=memory, manager=manager)
101
+
102
+ assert len(memory.steps) == 1
103
+ assert len(memory.future_questions) == 0
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_execute_json_called_with_correct_model():
108
+ """The configured model name is forwarded to manager.execute_json."""
109
+ agent = make_agent(model="my-custom-model")
110
+ memory = make_memory()
111
+ manager = make_manager()
112
+
113
+ await agent(memory=memory, manager=manager)
114
+
115
+ call_kwargs = manager.execute_json.call_args.kwargs
116
+ assert call_kwargs["model"] == "my-custom-model"
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_custom_prompt_rendered_in_request():
121
+ """A custom prompt from config is included in the rendered prompt sent to the LLM."""
122
+ custom_prompt = "Focus on practical applications only."
123
+ agent = make_agent(prompt=custom_prompt)
124
+ memory = make_memory()
125
+ manager = make_manager()
126
+
127
+ await agent(memory=memory, manager=manager)
128
+
129
+ rendered_prompt = manager.execute_json.call_args.kwargs["prompt"]
130
+ assert custom_prompt in rendered_prompt
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_question_included_in_rendered_prompt():
135
+ """The original question is included in the prompt sent to the LLM."""
136
+ question = "What are transformers in NLP?"
137
+ agent = make_agent()
138
+ memory = make_memory(question=question)
139
+ manager = make_manager()
140
+
141
+ await agent(memory=memory, manager=manager)
142
+
143
+ rendered_prompt = manager.execute_json.call_args.kwargs["prompt"]
144
+ assert question in rendered_prompt