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.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. 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
+ )