ellements 0.2.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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Collaborative editing strategy — LLM generate → user edit → LLM continue.
|
|
2
|
+
|
|
3
|
+
The LLM produces an initial draft, the user edits it through any
|
|
4
|
+
:class:`EditCallback` implementation, and the LLM continues from the
|
|
5
|
+
edited version. The loop ends when the user signals completion
|
|
6
|
+
(via *done_signal*), leaves the content unchanged, returns empty
|
|
7
|
+
content, or ``max_rounds`` is reached.
|
|
8
|
+
|
|
9
|
+
Required prompts:
|
|
10
|
+
- ``generate``: produce the initial draft
|
|
11
|
+
- ``continue``: receives ``{{edited_content}}`` and ``{{original_content}}``
|
|
12
|
+
|
|
13
|
+
Optional prompts:
|
|
14
|
+
- ``system``: persona/context forwarded to every LLM call
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ellements.core import LLMClient
|
|
24
|
+
|
|
25
|
+
from .callbacks import EditCallback
|
|
26
|
+
from .config import CollaborativeEditingConfig, StrategyConfigInput
|
|
27
|
+
from .strategies import BaseStrategy, StepRecord, StrategyResult
|
|
28
|
+
|
|
29
|
+
_logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CollaborativeEditingStrategy(BaseStrategy):
|
|
33
|
+
"""LLM generate → user edit → LLM continue loop.
|
|
34
|
+
|
|
35
|
+
Config keys:
|
|
36
|
+
edit_callback (EditCallback): How to present content and receive edits.
|
|
37
|
+
max_rounds (int): Maximum edit rounds after the initial generation.
|
|
38
|
+
done_signal (str): If the user adds this string on its own line,
|
|
39
|
+
the strategy finishes immediately.
|
|
40
|
+
continue_on_unchanged (bool): If True, the LLM continues even
|
|
41
|
+
when the user returns content identical to the LLM's
|
|
42
|
+
previous output.
|
|
43
|
+
generate_temperature (float): Temperature for initial generation.
|
|
44
|
+
continue_temperature (float): Temperature for continuation.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
REQUIRED_PROMPTS = ("generate", "continue")
|
|
48
|
+
|
|
49
|
+
async def execute(
|
|
50
|
+
self,
|
|
51
|
+
prompts: Mapping[str, str],
|
|
52
|
+
client: LLMClient,
|
|
53
|
+
tools: list[dict[str, Any]] | Mapping[str, Any] | None = None,
|
|
54
|
+
config: StrategyConfigInput = None,
|
|
55
|
+
) -> StrategyResult:
|
|
56
|
+
typed_config = self._normalize_config(config, CollaborativeEditingConfig)
|
|
57
|
+
max_rounds = typed_config.max_rounds
|
|
58
|
+
done_signal = typed_config.done_signal
|
|
59
|
+
continue_on_unchanged = typed_config.continue_on_unchanged
|
|
60
|
+
callback: EditCallback = typed_config.edit_callback
|
|
61
|
+
system = prompts.get("system")
|
|
62
|
+
steps: list[StepRecord] = []
|
|
63
|
+
|
|
64
|
+
generate_temp = self._stage_temperature(
|
|
65
|
+
typed_config, "generate", default=typed_config.generate_temperature
|
|
66
|
+
)
|
|
67
|
+
continue_temp = self._stage_temperature(
|
|
68
|
+
typed_config, "continue", default=typed_config.continue_temperature
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
generate_prompt = self._get_prompt(prompts, "generate")
|
|
72
|
+
current = await self._complete(
|
|
73
|
+
client,
|
|
74
|
+
generate_prompt,
|
|
75
|
+
config=typed_config,
|
|
76
|
+
temperature=generate_temp,
|
|
77
|
+
system=system,
|
|
78
|
+
tools=tools,
|
|
79
|
+
)
|
|
80
|
+
steps.append(
|
|
81
|
+
StepRecord(
|
|
82
|
+
name="generate",
|
|
83
|
+
prompt_key="generate",
|
|
84
|
+
response=current,
|
|
85
|
+
metadata={"round": 0, "source": "llm"},
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
self._notify_step(typed_config, steps[-1])
|
|
89
|
+
|
|
90
|
+
continue_template = self._get_prompt(prompts, "continue")
|
|
91
|
+
|
|
92
|
+
for round_num in range(max_rounds):
|
|
93
|
+
original = current
|
|
94
|
+
context = f"Round {round_num + 1} of {max_rounds}"
|
|
95
|
+
edited = await callback.request_edit(current, context)
|
|
96
|
+
|
|
97
|
+
if not edited.strip():
|
|
98
|
+
_logger.info(
|
|
99
|
+
"Collaborative editing aborted by user (empty content)"
|
|
100
|
+
)
|
|
101
|
+
steps.append(
|
|
102
|
+
StepRecord(
|
|
103
|
+
name=f"abort_{round_num}",
|
|
104
|
+
prompt_key="continue",
|
|
105
|
+
response="",
|
|
106
|
+
metadata={"round": round_num + 1, "reason": "user_abort"},
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
self._notify_step(typed_config, steps[-1])
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
if _has_done_signal(edited, done_signal):
|
|
113
|
+
edited = _strip_done_signal(edited, done_signal)
|
|
114
|
+
steps.append(
|
|
115
|
+
StepRecord(
|
|
116
|
+
name=f"user_edit_{round_num}",
|
|
117
|
+
prompt_key="continue",
|
|
118
|
+
response=edited,
|
|
119
|
+
metadata={
|
|
120
|
+
"round": round_num + 1,
|
|
121
|
+
"source": "user",
|
|
122
|
+
"done_signal": True,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
self._notify_step(typed_config, steps[-1])
|
|
127
|
+
current = edited
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
steps.append(
|
|
131
|
+
StepRecord(
|
|
132
|
+
name=f"user_edit_{round_num}",
|
|
133
|
+
prompt_key="continue",
|
|
134
|
+
response=edited,
|
|
135
|
+
metadata={"round": round_num + 1, "source": "user"},
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
self._notify_step(typed_config, steps[-1])
|
|
139
|
+
|
|
140
|
+
if not continue_on_unchanged and edited.strip() == original.strip():
|
|
141
|
+
_logger.info(
|
|
142
|
+
"Collaborative editing done: user approved without changes"
|
|
143
|
+
)
|
|
144
|
+
current = edited
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
continue_prompt = self._render_template(
|
|
148
|
+
continue_template,
|
|
149
|
+
{"edited_content": edited, "original_content": original},
|
|
150
|
+
)
|
|
151
|
+
current = await self._complete(
|
|
152
|
+
client,
|
|
153
|
+
continue_prompt,
|
|
154
|
+
config=typed_config,
|
|
155
|
+
temperature=continue_temp,
|
|
156
|
+
system=system,
|
|
157
|
+
tools=tools,
|
|
158
|
+
)
|
|
159
|
+
steps.append(
|
|
160
|
+
StepRecord(
|
|
161
|
+
name=f"continue_{round_num}",
|
|
162
|
+
prompt_key="continue",
|
|
163
|
+
response=current,
|
|
164
|
+
metadata={"round": round_num + 1, "source": "llm"},
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
self._notify_step(typed_config, steps[-1])
|
|
168
|
+
|
|
169
|
+
rounds_completed = len(
|
|
170
|
+
[s for s in steps if s.name.startswith("user_edit_")]
|
|
171
|
+
)
|
|
172
|
+
return StrategyResult(
|
|
173
|
+
output=current,
|
|
174
|
+
steps=steps,
|
|
175
|
+
metadata={
|
|
176
|
+
"rounds_completed": rounds_completed,
|
|
177
|
+
"max_rounds": max_rounds,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _has_done_signal(content: str, signal: str) -> bool:
|
|
183
|
+
return any(line.strip() == signal for line in content.splitlines())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _strip_done_signal(content: str, signal: str) -> str:
|
|
187
|
+
lines = [line for line in content.splitlines() if line.strip() != signal]
|
|
188
|
+
return "\n".join(lines).strip()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = ["CollaborativeEditingStrategy"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Typed config models for execution strategies.
|
|
2
|
+
|
|
3
|
+
All built-in strategies accept a :class:`ExecutionStrategyConfig` (or a
|
|
4
|
+
plain dict / pydantic BaseModel that maps onto one). The shared base
|
|
5
|
+
exposes ``model``, ``on_step``, and the ``stage_temperatures`` override
|
|
6
|
+
map; individual strategy configs add their own knobs.
|
|
7
|
+
|
|
8
|
+
Unknown keys are ignored so callers can mix in extra metadata without
|
|
9
|
+
breaking validation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from typing import Any, Literal, TypeVar
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
from .callbacks import EditCallback, PassthroughEditCallback
|
|
20
|
+
|
|
21
|
+
OnStepCallback = Callable[[Any], None]
|
|
22
|
+
StrategyConfigInput = Mapping[str, Any] | BaseModel | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExecutionStrategyConfig(BaseModel):
|
|
26
|
+
"""Shared config fields accepted by every built-in execution strategy."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True)
|
|
29
|
+
|
|
30
|
+
model: str | None = None
|
|
31
|
+
"""Override the LLMClient's default model for every call in this strategy."""
|
|
32
|
+
|
|
33
|
+
on_step: OnStepCallback | None = None
|
|
34
|
+
"""Callback invoked after each StepRecord is produced."""
|
|
35
|
+
|
|
36
|
+
stage_temperatures: dict[str, float] | None = None
|
|
37
|
+
"""Per-stage temperature overrides keyed by stage name.
|
|
38
|
+
|
|
39
|
+
When a stage is listed here, its value wins over the matching
|
|
40
|
+
``{stage}_temperature`` field on the typed config. Unknown stage
|
|
41
|
+
names are ignored.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SingleCallConfig(ExecutionStrategyConfig):
|
|
46
|
+
"""Typed config for :class:`SingleCallStrategy`."""
|
|
47
|
+
|
|
48
|
+
temperature: float | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SelfConsistencyConfig(ExecutionStrategyConfig):
|
|
52
|
+
"""Typed config for :class:`SelfConsistencyStrategy`."""
|
|
53
|
+
|
|
54
|
+
samples: int = 5
|
|
55
|
+
sample_temperature: float = 0.9
|
|
56
|
+
judge_temperature: float = 0.1
|
|
57
|
+
aggregation: Literal["majority_vote", "llm_judge"] = "majority_vote"
|
|
58
|
+
judge_model: str | None = None
|
|
59
|
+
max_concurrent: int = 3
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TreeOfThoughtConfig(ExecutionStrategyConfig):
|
|
63
|
+
"""Typed config for :class:`TreeOfThoughtStrategy`.
|
|
64
|
+
|
|
65
|
+
``mode`` selects the search algorithm:
|
|
66
|
+
|
|
67
|
+
- ``"beam"``: BFS with step-level beam search (canonical Yao et al. 2023).
|
|
68
|
+
- ``"dfs"``: depth-first with backtracking + early exit on max score.
|
|
69
|
+
- ``"simple"``: simplified flat search (generate N → evaluate → synthesize).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
mode: Literal["beam", "dfs", "simple"] = "beam"
|
|
73
|
+
max_depth: int = 3
|
|
74
|
+
branching_factor: int = 3
|
|
75
|
+
beam_width: int = 3
|
|
76
|
+
thought_temperature: float = 0.9
|
|
77
|
+
evaluate_temperature: float = 0.2
|
|
78
|
+
synthesize_temperature: float = 0.3
|
|
79
|
+
max_concurrent: int = 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReflectionConfig(ExecutionStrategyConfig):
|
|
83
|
+
"""Typed config for :class:`ReflectionStrategy`."""
|
|
84
|
+
|
|
85
|
+
max_rounds: int = 3
|
|
86
|
+
generate_temperature: float = 0.7
|
|
87
|
+
critique_temperature: float = 0.3
|
|
88
|
+
revise_temperature: float = 0.4
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CollaborativeEditingConfig(ExecutionStrategyConfig):
|
|
92
|
+
"""Typed config for :class:`CollaborativeEditingStrategy`."""
|
|
93
|
+
|
|
94
|
+
max_rounds: int = 5
|
|
95
|
+
done_signal: str = "DONE"
|
|
96
|
+
continue_on_unchanged: bool = False
|
|
97
|
+
edit_callback: EditCallback = Field(default_factory=PassthroughEditCallback)
|
|
98
|
+
generate_temperature: float = 0.7
|
|
99
|
+
continue_temperature: float = 0.7
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ConfigModelT = TypeVar("ConfigModelT", bound=ExecutionStrategyConfig)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_strategy_config(
|
|
106
|
+
config: StrategyConfigInput,
|
|
107
|
+
config_model: type[ConfigModelT],
|
|
108
|
+
) -> ConfigModelT:
|
|
109
|
+
"""Normalize a mixed config input to the typed *config_model*."""
|
|
110
|
+
if config is None:
|
|
111
|
+
return config_model()
|
|
112
|
+
if isinstance(config, config_model):
|
|
113
|
+
return config
|
|
114
|
+
if isinstance(config, BaseModel):
|
|
115
|
+
raw_config = config.model_dump(mode="python", exclude_none=False)
|
|
116
|
+
elif isinstance(config, Mapping):
|
|
117
|
+
raw_config = dict(config)
|
|
118
|
+
else:
|
|
119
|
+
raise TypeError(
|
|
120
|
+
"Strategy config must be a mapping, pydantic BaseModel, or None."
|
|
121
|
+
)
|
|
122
|
+
return config_model.model_validate(raw_config)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"CollaborativeEditingConfig",
|
|
127
|
+
"ExecutionStrategyConfig",
|
|
128
|
+
"OnStepCallback",
|
|
129
|
+
"ReflectionConfig",
|
|
130
|
+
"SelfConsistencyConfig",
|
|
131
|
+
"SingleCallConfig",
|
|
132
|
+
"StrategyConfigInput",
|
|
133
|
+
"TreeOfThoughtConfig",
|
|
134
|
+
"normalize_strategy_config",
|
|
135
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Reflection strategy — generate → critique → revise loop.
|
|
2
|
+
|
|
3
|
+
Produces an initial response, then asks the model to critique it via a
|
|
4
|
+
structured :class:`CritiqueResult`. When ``is_satisfied`` becomes true
|
|
5
|
+
(or ``max_rounds`` is exhausted), the most recent revision is returned.
|
|
6
|
+
|
|
7
|
+
Required prompts:
|
|
8
|
+
- ``generate``: produce the initial response
|
|
9
|
+
- ``critique``: receives ``response``; must return a CritiqueResult
|
|
10
|
+
- ``revise``: receives ``response`` and ``issues``
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ellements.core import LLMClient
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from .config import ReflectionConfig, StrategyConfigInput
|
|
23
|
+
from .strategies import BaseStrategy, StepRecord, StrategyResult
|
|
24
|
+
|
|
25
|
+
_logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CritiqueResult(BaseModel):
|
|
29
|
+
"""Structured critique output produced by the ``critique`` stage."""
|
|
30
|
+
|
|
31
|
+
is_satisfied: bool = Field(
|
|
32
|
+
description="True if the response is acceptable as-is (no further revision needed)"
|
|
33
|
+
)
|
|
34
|
+
issues: list[str] = Field(
|
|
35
|
+
default_factory=list,
|
|
36
|
+
description="Specific issues the next revision should address",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReflectionStrategy(BaseStrategy):
|
|
41
|
+
"""Generate → critique → revise loop using a structured CritiqueResult."""
|
|
42
|
+
|
|
43
|
+
REQUIRED_PROMPTS = ("generate", "critique", "revise")
|
|
44
|
+
|
|
45
|
+
async def execute(
|
|
46
|
+
self,
|
|
47
|
+
prompts: Mapping[str, str],
|
|
48
|
+
client: LLMClient,
|
|
49
|
+
tools: list[dict[str, Any]] | Mapping[str, Any] | None = None,
|
|
50
|
+
config: StrategyConfigInput = None,
|
|
51
|
+
) -> StrategyResult:
|
|
52
|
+
typed_config = self._normalize_config(config, ReflectionConfig)
|
|
53
|
+
system = prompts.get("system")
|
|
54
|
+
steps: list[StepRecord] = []
|
|
55
|
+
|
|
56
|
+
generate_temp = self._stage_temperature(
|
|
57
|
+
typed_config, "generate", default=typed_config.generate_temperature
|
|
58
|
+
)
|
|
59
|
+
critique_temp = self._stage_temperature(
|
|
60
|
+
typed_config, "critique", default=typed_config.critique_temperature
|
|
61
|
+
)
|
|
62
|
+
revise_temp = self._stage_temperature(
|
|
63
|
+
typed_config, "revise", default=typed_config.revise_temperature
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
generate_prompt = self._get_prompt(prompts, "generate")
|
|
67
|
+
current = await self._complete(
|
|
68
|
+
client,
|
|
69
|
+
generate_prompt,
|
|
70
|
+
config=typed_config,
|
|
71
|
+
temperature=generate_temp,
|
|
72
|
+
system=system,
|
|
73
|
+
tools=tools,
|
|
74
|
+
)
|
|
75
|
+
steps.append(
|
|
76
|
+
StepRecord(name="generate", prompt_key="generate", response=current)
|
|
77
|
+
)
|
|
78
|
+
self._notify_step(typed_config, steps[-1])
|
|
79
|
+
|
|
80
|
+
critique_template = self._get_prompt(prompts, "critique")
|
|
81
|
+
revise_template = self._get_prompt(prompts, "revise")
|
|
82
|
+
|
|
83
|
+
for round_num in range(typed_config.max_rounds):
|
|
84
|
+
critique_prompt = self._render_template(
|
|
85
|
+
critique_template, {"response": current}
|
|
86
|
+
)
|
|
87
|
+
critique = await self._complete_structured(
|
|
88
|
+
client,
|
|
89
|
+
critique_prompt,
|
|
90
|
+
response_model=CritiqueResult,
|
|
91
|
+
config=typed_config,
|
|
92
|
+
temperature=critique_temp,
|
|
93
|
+
system=system,
|
|
94
|
+
)
|
|
95
|
+
steps.append(
|
|
96
|
+
StepRecord(
|
|
97
|
+
name=f"critique_{round_num}",
|
|
98
|
+
prompt_key="critique",
|
|
99
|
+
response=critique.model_dump_json(),
|
|
100
|
+
metadata={
|
|
101
|
+
"round": round_num,
|
|
102
|
+
"is_satisfied": critique.is_satisfied,
|
|
103
|
+
"issue_count": len(critique.issues),
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
self._notify_step(typed_config, steps[-1])
|
|
108
|
+
|
|
109
|
+
if critique.is_satisfied:
|
|
110
|
+
stop_step = StepRecord(
|
|
111
|
+
name="stop",
|
|
112
|
+
prompt_key="critique",
|
|
113
|
+
response=f"Stopped at round {round_num}: critique satisfied.",
|
|
114
|
+
metadata={"round": round_num, "reason": "satisfied"},
|
|
115
|
+
)
|
|
116
|
+
steps.append(stop_step)
|
|
117
|
+
self._notify_step(typed_config, stop_step)
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
revise_prompt = self._render_template(
|
|
121
|
+
revise_template,
|
|
122
|
+
{
|
|
123
|
+
"response": current,
|
|
124
|
+
"issues": "\n".join(f"- {issue}" for issue in critique.issues),
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
current = await self._complete(
|
|
128
|
+
client,
|
|
129
|
+
revise_prompt,
|
|
130
|
+
config=typed_config,
|
|
131
|
+
temperature=revise_temp,
|
|
132
|
+
system=system,
|
|
133
|
+
tools=tools,
|
|
134
|
+
)
|
|
135
|
+
steps.append(
|
|
136
|
+
StepRecord(
|
|
137
|
+
name=f"revise_{round_num}",
|
|
138
|
+
prompt_key="revise",
|
|
139
|
+
response=current,
|
|
140
|
+
metadata={"round": round_num},
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
self._notify_step(typed_config, steps[-1])
|
|
144
|
+
|
|
145
|
+
revise_steps = [s for s in steps if s.name.startswith("revise_")]
|
|
146
|
+
return StrategyResult(
|
|
147
|
+
output=current,
|
|
148
|
+
steps=steps,
|
|
149
|
+
metadata={
|
|
150
|
+
"rounds_used": len(revise_steps) + 1,
|
|
151
|
+
"satisfied": any(s.name == "stop" for s in steps),
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["CritiqueResult", "ReflectionStrategy"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Self-consistency strategy — sample N responses and aggregate.
|
|
2
|
+
|
|
3
|
+
Generates ``samples`` parallel responses to the same prompt (controlled
|
|
4
|
+
concurrency), then either picks the most common answer (majority vote)
|
|
5
|
+
or asks the LLM to synthesize the best one (llm-judge).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ellements.core import LLMClient
|
|
18
|
+
|
|
19
|
+
from .config import SelfConsistencyConfig, StrategyConfigInput
|
|
20
|
+
from .strategies import BaseStrategy, StepRecord, StrategyResult
|
|
21
|
+
|
|
22
|
+
_logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SelfConsistencyStrategy(BaseStrategy):
|
|
26
|
+
"""Run a prompt N times and aggregate the results.
|
|
27
|
+
|
|
28
|
+
Config keys:
|
|
29
|
+
samples (int): Number of samples. Default 5.
|
|
30
|
+
sample_temperature (float): Sampling temperature. Default 0.9.
|
|
31
|
+
aggregation (str): ``"majority_vote"`` or ``"llm_judge"``.
|
|
32
|
+
judge_temperature (float): Temperature for the judge call.
|
|
33
|
+
judge_model (str | None): Model override for the judge call.
|
|
34
|
+
max_concurrent (int): Max in-flight LLM calls.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
REQUIRED_PROMPTS = ("default",)
|
|
38
|
+
|
|
39
|
+
async def execute(
|
|
40
|
+
self,
|
|
41
|
+
prompts: Mapping[str, str],
|
|
42
|
+
client: LLMClient,
|
|
43
|
+
tools: list[dict[str, Any]] | Mapping[str, Any] | None = None,
|
|
44
|
+
config: StrategyConfigInput = None,
|
|
45
|
+
) -> StrategyResult:
|
|
46
|
+
typed_config = self._normalize_config(config, SelfConsistencyConfig)
|
|
47
|
+
prompt = self._get_prompt(prompts, "default")
|
|
48
|
+
system = prompts.get("system")
|
|
49
|
+
|
|
50
|
+
sample_temp = self._stage_temperature(
|
|
51
|
+
typed_config, "sample", default=typed_config.sample_temperature
|
|
52
|
+
)
|
|
53
|
+
judge_temp = self._stage_temperature(
|
|
54
|
+
typed_config, "judge", default=typed_config.judge_temperature
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
sem = asyncio.Semaphore(typed_config.max_concurrent)
|
|
58
|
+
|
|
59
|
+
async def _sample_one() -> str:
|
|
60
|
+
async with sem:
|
|
61
|
+
return await self._complete(
|
|
62
|
+
client,
|
|
63
|
+
prompt,
|
|
64
|
+
config=typed_config,
|
|
65
|
+
temperature=sample_temp,
|
|
66
|
+
system=system,
|
|
67
|
+
tools=tools,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
responses = await asyncio.gather(
|
|
71
|
+
*(_sample_one() for _ in range(typed_config.samples))
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
steps: list[StepRecord] = [
|
|
75
|
+
StepRecord(
|
|
76
|
+
name=f"sample_{i}",
|
|
77
|
+
prompt_key="default",
|
|
78
|
+
response=resp,
|
|
79
|
+
metadata={"temperature": sample_temp},
|
|
80
|
+
)
|
|
81
|
+
for i, resp in enumerate(responses)
|
|
82
|
+
]
|
|
83
|
+
for step in steps:
|
|
84
|
+
self._notify_step(typed_config, step)
|
|
85
|
+
|
|
86
|
+
if typed_config.aggregation == "llm_judge":
|
|
87
|
+
output = await self._llm_judge(
|
|
88
|
+
client,
|
|
89
|
+
prompt,
|
|
90
|
+
list(responses),
|
|
91
|
+
config=typed_config,
|
|
92
|
+
temperature=judge_temp,
|
|
93
|
+
system=system,
|
|
94
|
+
)
|
|
95
|
+
aggregation = "llm_judge"
|
|
96
|
+
agg_step = StepRecord(
|
|
97
|
+
name="judge",
|
|
98
|
+
prompt_key="default",
|
|
99
|
+
response=output,
|
|
100
|
+
metadata={"aggregation": aggregation, "temperature": judge_temp},
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
output = self._majority_vote(list(responses))
|
|
104
|
+
aggregation = "majority_vote"
|
|
105
|
+
agg_step = StepRecord(
|
|
106
|
+
name="vote",
|
|
107
|
+
prompt_key="default",
|
|
108
|
+
response=output,
|
|
109
|
+
metadata={"aggregation": aggregation},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
steps.append(agg_step)
|
|
113
|
+
self._notify_step(typed_config, agg_step)
|
|
114
|
+
|
|
115
|
+
return StrategyResult(
|
|
116
|
+
output=output,
|
|
117
|
+
steps=steps,
|
|
118
|
+
metadata={
|
|
119
|
+
"samples": typed_config.samples,
|
|
120
|
+
"aggregation": aggregation,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _majority_vote(responses: list[str]) -> str:
|
|
126
|
+
"""Pick the most common final answer, falling back to full responses."""
|
|
127
|
+
displays: dict[tuple[str, str], str] = {}
|
|
128
|
+
counter: Counter[tuple[str, str]] = Counter()
|
|
129
|
+
for response in responses:
|
|
130
|
+
answer = SelfConsistencyStrategy._extract_final_answer(response)
|
|
131
|
+
if answer is None:
|
|
132
|
+
display = response.strip()
|
|
133
|
+
key = ("response", display)
|
|
134
|
+
else:
|
|
135
|
+
display = f"ANSWER: {answer}"
|
|
136
|
+
key = ("answer", answer.casefold())
|
|
137
|
+
displays.setdefault(key, display)
|
|
138
|
+
counter[key] += 1
|
|
139
|
+
winner, _ = counter.most_common(1)[0]
|
|
140
|
+
return displays[winner]
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _extract_final_answer(response: str) -> str | None:
|
|
144
|
+
"""Extract a final ``ANSWER: ...`` line when a prompt provides one."""
|
|
145
|
+
matches = re.findall(
|
|
146
|
+
r"(?im)^\s*(?:\*\*)?answer(?:\*\*)?\s*:\s*(.+?)\s*$",
|
|
147
|
+
response,
|
|
148
|
+
)
|
|
149
|
+
if not matches:
|
|
150
|
+
return None
|
|
151
|
+
answer = matches[-1].strip().strip("`")
|
|
152
|
+
answer = re.sub(r"\s+", " ", answer)
|
|
153
|
+
return answer.rstrip(".")
|
|
154
|
+
|
|
155
|
+
async def _llm_judge(
|
|
156
|
+
self,
|
|
157
|
+
client: LLMClient,
|
|
158
|
+
original_prompt: str,
|
|
159
|
+
responses: list[str],
|
|
160
|
+
*,
|
|
161
|
+
config: SelfConsistencyConfig,
|
|
162
|
+
temperature: float,
|
|
163
|
+
system: str | None,
|
|
164
|
+
) -> str:
|
|
165
|
+
numbered = "\n\n".join(
|
|
166
|
+
f"--- Response {i + 1} ---\n{resp}"
|
|
167
|
+
for i, resp in enumerate(responses)
|
|
168
|
+
)
|
|
169
|
+
judge_prompt = (
|
|
170
|
+
"You were asked the following question/task:\n\n"
|
|
171
|
+
f"{original_prompt}\n\n"
|
|
172
|
+
f"Here are {len(responses)} independent responses:\n\n"
|
|
173
|
+
f"{numbered}\n\n"
|
|
174
|
+
"Synthesize the best answer by combining the strongest elements. "
|
|
175
|
+
"If the responses agree, use the consensus answer. If they "
|
|
176
|
+
"disagree, reason about which is most likely correct and explain "
|
|
177
|
+
"why.\n\n"
|
|
178
|
+
"Provide ONLY the final synthesized answer, nothing else."
|
|
179
|
+
)
|
|
180
|
+
judge_config = config.model_copy(
|
|
181
|
+
update={"model": config.judge_model or config.model}
|
|
182
|
+
)
|
|
183
|
+
return await self._complete(
|
|
184
|
+
client,
|
|
185
|
+
judge_prompt,
|
|
186
|
+
config=judge_config,
|
|
187
|
+
temperature=temperature,
|
|
188
|
+
system=system,
|
|
189
|
+
)
|