reasonkit 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.
- reasonkit/__init__.py +20 -0
- reasonkit/__main__.py +6 -0
- reasonkit/_sanitize.py +167 -0
- reasonkit/_utils.py +104 -0
- reasonkit/branching.py +75 -0
- reasonkit/classifier.py +200 -0
- reasonkit/codegen.py +191 -0
- reasonkit/compare.py +67 -0
- reasonkit/core.py +457 -0
- reasonkit/critique.py +79 -0
- reasonkit/errors.py +21 -0
- reasonkit/merge.py +140 -0
- reasonkit/prompts/classify_and_assumptions.txt +44 -0
- reasonkit/prompts/critique_all.txt +50 -0
- reasonkit/prompts/deepen_concrete.txt +29 -0
- reasonkit/prompts/fix_code.txt +9 -0
- reasonkit/prompts/generate_approaches.txt +28 -0
- reasonkit/prompts/generate_code.txt +13 -0
- reasonkit/prompts/merge.txt +43 -0
- reasonkit/prompts/refine_direct.txt +23 -0
- reasonkit/prompts/refine_merge.txt +35 -0
- reasonkit/prompts/verify_code.txt +7 -0
- reasonkit/py.typed +0 -0
- reasonkit/stop_conditions.py +29 -0
- reasonkit/trace.py +56 -0
- reasonkit-0.2.0.dist-info/METADATA +336 -0
- reasonkit-0.2.0.dist-info/RECORD +30 -0
- reasonkit-0.2.0.dist-info/WHEEL +5 -0
- reasonkit-0.2.0.dist-info/licenses/LICENSE +674 -0
- reasonkit-0.2.0.dist-info/top_level.txt +1 -0
reasonkit/core.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Union
|
|
9
|
+
|
|
10
|
+
from ._sanitize import sanitize_final, strip_preamble, strip_post_answer_meta, strip_scaffold_lines
|
|
11
|
+
from ._utils import _looks_like_code, _specificity, _structure_score, _regressed
|
|
12
|
+
from .classifier import classify
|
|
13
|
+
from .branching import generate_approaches
|
|
14
|
+
from .critique import critique_all
|
|
15
|
+
from .merge import merge, deepen, refine_merge
|
|
16
|
+
from .codegen import code_pipeline
|
|
17
|
+
from .stop_conditions import should_stop
|
|
18
|
+
from .trace import Trace
|
|
19
|
+
from .errors import ConfigurationError, ModelCallError
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
LLMCallable = Callable[[str], str]
|
|
24
|
+
AsyncLLMCallable = Callable[[str], Awaitable[str]]
|
|
25
|
+
StreamingLLMCallable = Callable[[str], AsyncIterator[str]]
|
|
26
|
+
|
|
27
|
+
DEFAULT_CONFIG = {
|
|
28
|
+
"max_cycles": 2,
|
|
29
|
+
"branches": 3, # single direct draft -> critique path
|
|
30
|
+
"return_trace": False,
|
|
31
|
+
"verbose": False,
|
|
32
|
+
"test_hook": None, # used by code verify pass
|
|
33
|
+
"timeout": None,
|
|
34
|
+
"retries": 0,
|
|
35
|
+
"stream_chunk_mode": "sentence",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_coroutine_function(fn: Callable) -> bool:
|
|
40
|
+
# partial chains keep the underlying function on .func
|
|
41
|
+
target = fn.func if isinstance(fn, functools.partial) else fn
|
|
42
|
+
return inspect.iscoroutinefunction(target)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _callable_kind(fn: Callable) -> str:
|
|
46
|
+
# Return 'async-gen', 'async', or 'sync'.
|
|
47
|
+
target = fn.func if isinstance(fn, functools.partial) else fn
|
|
48
|
+
if inspect.isasyncgenfunction(target):
|
|
49
|
+
return "async-gen"
|
|
50
|
+
if inspect.iscoroutinefunction(target):
|
|
51
|
+
return "async"
|
|
52
|
+
return "sync"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_config(cfg: dict) -> None:
|
|
56
|
+
branches = cfg.get("branches", DEFAULT_CONFIG["branches"])
|
|
57
|
+
max_cycles = cfg.get("max_cycles", DEFAULT_CONFIG["max_cycles"])
|
|
58
|
+
if not isinstance(branches, int) or branches < 1:
|
|
59
|
+
raise ConfigurationError(f"branches must be an int >= 1, got {branches!r}")
|
|
60
|
+
if not isinstance(max_cycles, int) or max_cycles < 1:
|
|
61
|
+
raise ConfigurationError(f"max_cycles must be an int >= 1, got {max_cycles!r}")
|
|
62
|
+
chunk = cfg.get("stream_chunk_mode", "sentence")
|
|
63
|
+
if chunk not in ("once", "sentence"):
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
f"stream_chunk_mode must be 'once' or 'sentence', got {chunk!r}"
|
|
66
|
+
)
|
|
67
|
+
retries = cfg.get("retries", 0)
|
|
68
|
+
if not isinstance(retries, int) or retries < 0:
|
|
69
|
+
raise ConfigurationError(f"retries must be an int >= 0, got {retries!r}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class EnhanceResult:
|
|
74
|
+
# Returned when return_trace=True. Plain string otherwise.
|
|
75
|
+
|
|
76
|
+
answer: str
|
|
77
|
+
trace: "Trace"
|
|
78
|
+
mode: str
|
|
79
|
+
stopped_reason: str
|
|
80
|
+
|
|
81
|
+
def __str__(self) -> str:
|
|
82
|
+
return self.answer
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enhance(fn: Union[LLMCallable, AsyncLLMCallable, StreamingLLMCallable], **config: Any):
|
|
86
|
+
"""Wrap fn with the ReasonKit reasoning pipeline. The wrapper keeps fn's shape.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
fn : str -> str | async str -> str | async generator
|
|
91
|
+
Your LLM callable. Must accept (prompt: str) and return/produce str.
|
|
92
|
+
**config
|
|
93
|
+
max_cycles (2), branches (3), return_trace (False), verbose (False),
|
|
94
|
+
test_hook ((code)->list[str] for code mode), timeout (None), retries (0),
|
|
95
|
+
stream_chunk_mode ("once"|"sentence").
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
A callable with the same signature as fn. When return_trace=True, each call
|
|
100
|
+
returns an EnhanceResult with .answer, .trace, .mode, .stopped_reason.
|
|
101
|
+
"""
|
|
102
|
+
cfg = {**DEFAULT_CONFIG, **config}
|
|
103
|
+
_validate_config(cfg)
|
|
104
|
+
kind = _callable_kind(fn)
|
|
105
|
+
if kind == "async-gen":
|
|
106
|
+
return _make_streaming_wrapper(fn, cfg)
|
|
107
|
+
if kind == "async":
|
|
108
|
+
return _make_async_wrapper(fn, cfg)
|
|
109
|
+
return _make_sync_wrapper(fn, cfg)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Internal async helpers -- everything runs through the wrapped fn.
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _to_async(fn):
|
|
116
|
+
# Return an awaitable version of fn.
|
|
117
|
+
if _is_coroutine_function(fn):
|
|
118
|
+
return fn
|
|
119
|
+
|
|
120
|
+
async def shim(prompt: str) -> str:
|
|
121
|
+
return fn(prompt)
|
|
122
|
+
|
|
123
|
+
return shim
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _classify_call(afn, prompt, trace):
|
|
127
|
+
return await classify(afn, prompt, trace)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _call(afn, stage, prompt, trace, *, critical=False, timeout=None, retries=0):
|
|
131
|
+
last_exc: BaseException | None = None
|
|
132
|
+
for attempt in range(1 + retries):
|
|
133
|
+
try:
|
|
134
|
+
coro = afn(prompt)
|
|
135
|
+
if timeout is not None:
|
|
136
|
+
coro = asyncio.wait_for(coro, timeout=timeout)
|
|
137
|
+
result = await coro
|
|
138
|
+
if isinstance(result, str) and not result.strip():
|
|
139
|
+
last_exc = ValueError("wrapped fn returned empty response")
|
|
140
|
+
trace.notes.append(f"{stage}: empty response (attempt {attempt})")
|
|
141
|
+
continue
|
|
142
|
+
recorded = result if isinstance(result, str) else str(result)
|
|
143
|
+
trace.add_call(stage, prompt, recorded)
|
|
144
|
+
return recorded
|
|
145
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
146
|
+
last_exc = exc
|
|
147
|
+
trace.notes.append(f"{stage}: call failed (attempt {attempt}): {exc}")
|
|
148
|
+
if critical:
|
|
149
|
+
raise ModelCallError(
|
|
150
|
+
f"wrapped fn failed for critical stage '{stage}' after {1 + retries} attempts",
|
|
151
|
+
stage=stage,
|
|
152
|
+
cause=last_exc,
|
|
153
|
+
)
|
|
154
|
+
trace.notes.append(f"{stage}: degraded after {1 + retries} attempts")
|
|
155
|
+
return ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _goal_and_assumptions(classification: dict, original_prompt: str = "") -> str:
|
|
159
|
+
# Context block for downstream stages. Puts the user's actual prompt first so
|
|
160
|
+
# stages reason from their real words, not the classifier's inferred goal/assumptions.
|
|
161
|
+
lines = []
|
|
162
|
+
if original_prompt:
|
|
163
|
+
p = original_prompt.strip()
|
|
164
|
+
if len(p) > 4000:
|
|
165
|
+
p = p[:4000] + " …(truncated)"
|
|
166
|
+
lines.append("The user's actual message:\n" + p)
|
|
167
|
+
goal = classification.get("goal", "")
|
|
168
|
+
assumptions = classification.get("assumptions", [])
|
|
169
|
+
clarifying = classification.get("clarifying_questions", [])
|
|
170
|
+
lines.append(f"Goal: {goal}" if goal else "Goal: (not specified)")
|
|
171
|
+
if assumptions:
|
|
172
|
+
lines.append("Assumptions to revisit: " + "; ".join(assumptions))
|
|
173
|
+
if clarifying:
|
|
174
|
+
lines.append("Open clarifying questions: " + "; ".join(clarifying))
|
|
175
|
+
return "\n".join(lines)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _decision_pipeline(afn, prompt, classification, cfg, trace):
|
|
179
|
+
# Decision path: classify -> (generate -> critique -> merge) loop, then
|
|
180
|
+
# refine + deepen passes on the final answer.
|
|
181
|
+
goal_ctx = _goal_and_assumptions(classification, original_prompt=prompt)
|
|
182
|
+
branches = int(cfg["branches"])
|
|
183
|
+
max_cycles = int(cfg["max_cycles"])
|
|
184
|
+
verbose = cfg["verbose"]
|
|
185
|
+
|
|
186
|
+
previous_issue_count: int | None = None
|
|
187
|
+
previous_issues_flat: list[str] | None = None # fed back into next generate
|
|
188
|
+
final_answer = ""
|
|
189
|
+
stopped_reason = "max_cycles"
|
|
190
|
+
|
|
191
|
+
for cycle in range(1, max_cycles + 1):
|
|
192
|
+
trace.cycles = cycle
|
|
193
|
+
if verbose:
|
|
194
|
+
logger.info("decision cycle %s/%s", cycle, max_cycles)
|
|
195
|
+
|
|
196
|
+
approaches = await generate_approaches(
|
|
197
|
+
afn, goal_ctx, branches, trace,
|
|
198
|
+
previous_issues=previous_issues_flat,
|
|
199
|
+
)
|
|
200
|
+
# Cap at requested count — some models produce 11+ approaches.
|
|
201
|
+
approaches = approaches[:branches]
|
|
202
|
+
if not approaches:
|
|
203
|
+
trace.notes.append("decision: no approaches generated; skipping cycle")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
issues = await critique_all(afn, approaches, goal_ctx, trace)
|
|
207
|
+
|
|
208
|
+
# No issues found: return best approach + clarifying note (no merge/
|
|
209
|
+
# deepen needed since the approach is already sound). Applies on
|
|
210
|
+
# every cycle: cycle 1 starts fresh, cycle 2+ generates approaches
|
|
211
|
+
# that already avoid the previous cycle's known flaws, so the best
|
|
212
|
+
# individual approach is a sound stopping point.
|
|
213
|
+
if all(len(i) == 0 for i in issues):
|
|
214
|
+
best = max(
|
|
215
|
+
approaches,
|
|
216
|
+
key=lambda a: _specificity(a) + _structure_score(a) * 20,
|
|
217
|
+
)
|
|
218
|
+
note = _clarifier_note(classification)
|
|
219
|
+
final_answer = best.strip() + note
|
|
220
|
+
trace.notes.append(
|
|
221
|
+
"decision: per-approach critique found no issues; "
|
|
222
|
+
"returning best approach + clarifying note"
|
|
223
|
+
)
|
|
224
|
+
stopped_reason = "no_issues"
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# Merge all approaches into a coherent answer (synthesizes best ideas).
|
|
228
|
+
merged = await merge(afn, approaches, issues, goal_ctx, trace)
|
|
229
|
+
final_answer = merged.strip() or approaches[0].strip()
|
|
230
|
+
|
|
231
|
+
stop, reason = should_stop(cycle, max_cycles, issues, previous_issue_count)
|
|
232
|
+
if stop:
|
|
233
|
+
stopped_reason = reason
|
|
234
|
+
break
|
|
235
|
+
previous_issue_count = sum(len(i) for i in issues)
|
|
236
|
+
# Feed critiques back into the next generation cycle.
|
|
237
|
+
previous_issues_flat = [iss for approach_issues in issues for iss in approach_issues]
|
|
238
|
+
|
|
239
|
+
if stopped_reason == "no_issues":
|
|
240
|
+
trace.stopped_reason = stopped_reason
|
|
241
|
+
return sanitize_final(final_answer)
|
|
242
|
+
|
|
243
|
+
# Single refine pass: check merged answer for quality gaps.
|
|
244
|
+
if final_answer:
|
|
245
|
+
refined = await refine_merge(afn, final_answer, goal_ctx, trace)
|
|
246
|
+
if refined.strip():
|
|
247
|
+
final_answer = refined.strip()
|
|
248
|
+
|
|
249
|
+
# Single deepen pass: force concretization on the final answer.
|
|
250
|
+
if final_answer:
|
|
251
|
+
deepened = await deepen(afn, final_answer, goal_ctx, trace)
|
|
252
|
+
if deepened.strip():
|
|
253
|
+
final_answer = deepened.strip()
|
|
254
|
+
|
|
255
|
+
# Final sanitization pass: strip any leaked meta-commentary from the final
|
|
256
|
+
# answer before returning it to the user.
|
|
257
|
+
trace.stopped_reason = stopped_reason
|
|
258
|
+
return sanitize_final(final_answer)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _clarifier_note(classification: dict) -> str:
|
|
262
|
+
# A 'Worth clarifying:' block from the classifier's questions/assumptions,
|
|
263
|
+
# or '' if none.
|
|
264
|
+
clar = [c for c in (classification.get("clarifying_questions") or []) if c.strip()]
|
|
265
|
+
assume = [a for a in (classification.get("assumptions") or []) if a.strip()]
|
|
266
|
+
items = clar or assume # prefer explicit questions; fall back to assumptions
|
|
267
|
+
if not items:
|
|
268
|
+
return ""
|
|
269
|
+
bullets = "\n".join(f"- {it.rstrip('.')}." for it in items[:3])
|
|
270
|
+
return "\n\nWorth clarifying:\n" + bullets
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def _direct_pipeline(afn, prompt, classification, cfg, trace):
|
|
274
|
+
# Answer once, giving fn the classifier's goal/assumptions up front so the
|
|
275
|
+
# single draft already reflects that context. A refine pass then sharpens it.
|
|
276
|
+
# Falls back to the raw draft if refine degrades.
|
|
277
|
+
#
|
|
278
|
+
# Note: the classifier already produced goal/assumptions/clarifying-questions.
|
|
279
|
+
# We feed those into the *first* draft call rather than answering blind and
|
|
280
|
+
# re-feeding context in the refine step -- that avoids a wasted blind draft.
|
|
281
|
+
note = _clarifier_note(classification)
|
|
282
|
+
goal_ctx = _goal_and_assumptions(classification, original_prompt=prompt)
|
|
283
|
+
|
|
284
|
+
raw = await _call(
|
|
285
|
+
afn, "answer", goal_ctx, trace, critical=True,
|
|
286
|
+
timeout=cfg.get("timeout"), retries=cfg.get("retries", 0),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Skip refine when the raw answer is substantive and the classifier
|
|
290
|
+
# didn't flag missing info. The answer prompt already asks for honesty
|
|
291
|
+
# about assumptions and uncertainty, and _clarifier_note handles the
|
|
292
|
+
# "Worth clarifying" section. Refine's unique value -- catching
|
|
293
|
+
# invented specifics -- is already covered by the answer prompt saying
|
|
294
|
+
# "If something is unclear or underspecified, say so honestly. Do not
|
|
295
|
+
# guess." A second look at a substantive, informed answer is unlikely
|
|
296
|
+
# to catch anything the first pass didn't.
|
|
297
|
+
if len(raw.strip()) > 150 and not classification.get("needs_clarification", False):
|
|
298
|
+
trace.notes.append("direct: raw answer is substantive; skipping refine")
|
|
299
|
+
trace.stopped_reason = "no_issues"
|
|
300
|
+
return sanitize_final(raw) + note
|
|
301
|
+
|
|
302
|
+
refined = await _refine(afn, raw, classification, cfg, trace)
|
|
303
|
+
if refined:
|
|
304
|
+
trace.stopped_reason = "no_issues"
|
|
305
|
+
return refined + note
|
|
306
|
+
trace.notes.append("direct mode: refine degraded; returning raw answer")
|
|
307
|
+
trace.stopped_reason = "no_issues"
|
|
308
|
+
return raw.strip() + note
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _refine(afn, raw, classification, cfg, trace) -> str:
|
|
312
|
+
from pathlib import Path
|
|
313
|
+
|
|
314
|
+
prompt_path = Path(__file__).parent / "prompts" / "refine_direct.txt"
|
|
315
|
+
template = prompt_path.read_text(encoding="utf-8")
|
|
316
|
+
goal_ctx = _goal_and_assumptions(classification, original_prompt="")
|
|
317
|
+
rendered = template.format(draft=raw, goal_and_assumptions=goal_ctx)
|
|
318
|
+
refined = await _call(
|
|
319
|
+
afn, "refine", rendered, trace, critical=False,
|
|
320
|
+
timeout=cfg.get("timeout"), retries=cfg.get("retries", 0),
|
|
321
|
+
)
|
|
322
|
+
if not refined:
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
# Apply shared sanitization: preamble, scaffold lines, and post-answer meta.
|
|
326
|
+
refined = strip_preamble(refined)
|
|
327
|
+
refined = strip_scaffold_lines(refined)
|
|
328
|
+
refined = strip_post_answer_meta(refined)
|
|
329
|
+
|
|
330
|
+
return refined.strip()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _run_pipeline(afn, prompt, classification, cfg, trace) -> str:
|
|
334
|
+
# Route to the matching pipeline. CODE with no usable code + a clarification
|
|
335
|
+
# flag reroutes to DECISION so a vague "build me an app" gets a real answer.
|
|
336
|
+
category = classification["category"]
|
|
337
|
+
|
|
338
|
+
if category == "CODE":
|
|
339
|
+
code_input = (classification.get("code_input") or "").strip()
|
|
340
|
+
has_code = bool(code_input) and _looks_like_code(code_input)
|
|
341
|
+
if not has_code and classification.get("needs_clarification", False):
|
|
342
|
+
trace.notes.append(
|
|
343
|
+
"routing: CODE with no usable code + needs_clarification; "
|
|
344
|
+
"rerouting to DECISION"
|
|
345
|
+
)
|
|
346
|
+
classification = {**classification, "category": "DECISION"}
|
|
347
|
+
category = "DECISION"
|
|
348
|
+
trace.mode = "decision"
|
|
349
|
+
|
|
350
|
+
if category == "DECISION":
|
|
351
|
+
return await _decision_pipeline(afn, prompt, classification, cfg, trace)
|
|
352
|
+
if category == "CODE":
|
|
353
|
+
return await code_pipeline(
|
|
354
|
+
afn, prompt, classification,
|
|
355
|
+
int(cfg["max_cycles"]), cfg["test_hook"], trace,
|
|
356
|
+
)
|
|
357
|
+
return await _direct_pipeline(afn, prompt, classification, cfg, trace)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _full_run(afn, prompt, cfg, trace) -> str:
|
|
361
|
+
# Classify, then run the matching pipeline.
|
|
362
|
+
classification = await _classify_call(afn, prompt, trace)
|
|
363
|
+
return await _run_pipeline(afn, prompt, classification, cfg, trace)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# --------------------------------------------------------------------------- #
|
|
367
|
+
# Wrappers
|
|
368
|
+
# --------------------------------------------------------------------------- #
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _make_sync_wrapper(fn, cfg):
|
|
372
|
+
afn = _to_async(fn)
|
|
373
|
+
|
|
374
|
+
@functools.wraps(fn)
|
|
375
|
+
def wrapper(prompt: str, *args, **kwargs):
|
|
376
|
+
trace = Trace()
|
|
377
|
+
loop = asyncio.new_event_loop()
|
|
378
|
+
try:
|
|
379
|
+
answer = loop.run_until_complete(_full_run(afn, prompt, cfg, trace))
|
|
380
|
+
finally:
|
|
381
|
+
loop.close()
|
|
382
|
+
|
|
383
|
+
if cfg["return_trace"]:
|
|
384
|
+
return EnhanceResult(
|
|
385
|
+
answer=answer, trace=trace, mode=trace.mode, stopped_reason=trace.stopped_reason
|
|
386
|
+
)
|
|
387
|
+
return answer
|
|
388
|
+
|
|
389
|
+
return wrapper
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _make_async_wrapper(fn, cfg):
|
|
393
|
+
afn = _to_async(fn)
|
|
394
|
+
|
|
395
|
+
@functools.wraps(fn)
|
|
396
|
+
async def wrapper(prompt: str, *args, **kwargs):
|
|
397
|
+
trace = Trace()
|
|
398
|
+
answer = await _full_run(afn, prompt, cfg, trace)
|
|
399
|
+
if cfg["return_trace"]:
|
|
400
|
+
return EnhanceResult(
|
|
401
|
+
answer=answer, trace=trace, mode=trace.mode, stopped_reason=trace.stopped_reason
|
|
402
|
+
)
|
|
403
|
+
return answer
|
|
404
|
+
|
|
405
|
+
return wrapper
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _split_sentences(text: str) -> list[str]:
|
|
409
|
+
# Split into sentence-ish chunks for progressive streaming.
|
|
410
|
+
import re
|
|
411
|
+
|
|
412
|
+
parts = re.split(r"(?<=[.!?])\s+", text.strip())
|
|
413
|
+
return [p for p in parts if p]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _make_streaming_wrapper(fn, cfg):
|
|
417
|
+
# Buffer the raw stream, run the full pipeline on it, then yield the refined
|
|
418
|
+
# answer ('once' or 'sentence' mode). Degrades to raw if the pipeline fails.
|
|
419
|
+
chunk_mode = cfg.get("stream_chunk_mode", "sentence")
|
|
420
|
+
|
|
421
|
+
async def _collect(stream) -> str:
|
|
422
|
+
chunks = []
|
|
423
|
+
async for piece in stream:
|
|
424
|
+
chunks.append(piece if isinstance(piece, str) else str(piece))
|
|
425
|
+
return "".join(chunks)
|
|
426
|
+
|
|
427
|
+
async def wrapper(prompt: str, *args, **kwargs):
|
|
428
|
+
raw_stream = fn(prompt, *args, **kwargs)
|
|
429
|
+
raw = await _collect(raw_stream)
|
|
430
|
+
|
|
431
|
+
async def _afn(p: str) -> str:
|
|
432
|
+
return await _collect(fn(p))
|
|
433
|
+
|
|
434
|
+
async def afn_call(p: str) -> str:
|
|
435
|
+
return await _call(
|
|
436
|
+
_afn, "pipeline", p, Trace(),
|
|
437
|
+
timeout=cfg.get("timeout"), retries=cfg.get("retries", 0),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
trace = Trace()
|
|
441
|
+
try:
|
|
442
|
+
classification = await _classify_call(afn_call, prompt, trace)
|
|
443
|
+
refined = await _run_pipeline(afn_call, prompt, classification, cfg, trace)
|
|
444
|
+
except Exception as exc: # noqa: BLE001 - degrade to raw on any failure
|
|
445
|
+
trace.notes.append(f"streaming pipeline failed; yielding raw: {exc}")
|
|
446
|
+
refined = raw
|
|
447
|
+
|
|
448
|
+
if not refined:
|
|
449
|
+
refined = raw
|
|
450
|
+
|
|
451
|
+
if chunk_mode == "once":
|
|
452
|
+
yield refined
|
|
453
|
+
else:
|
|
454
|
+
for sentence in _split_sentences(refined):
|
|
455
|
+
yield sentence
|
|
456
|
+
|
|
457
|
+
return wrapper
|
reasonkit/critique.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ._sanitize import strip_post_answer_meta
|
|
7
|
+
from .trace import Trace
|
|
8
|
+
|
|
9
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "critique_all.txt"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_prompt() -> str:
|
|
13
|
+
return _PROMPT_PATH.read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_critique_prompt(approaches: list[str], goal_and_assumptions: str) -> str:
|
|
17
|
+
numbered = "\n\n".join(
|
|
18
|
+
f"APPROACH {i + 1}:\n{a}" for i, a in enumerate(approaches)
|
|
19
|
+
)
|
|
20
|
+
return _load_prompt().format(approaches=numbered, goal_and_assumptions=goal_and_assumptions)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _split_critique(raw: str, n_approaches: int) -> list[list[str]]:
|
|
24
|
+
# Parse critique output into one issue-list per approach (length n_approaches).
|
|
25
|
+
if n_approaches <= 1:
|
|
26
|
+
body = re.sub(r"(?im)^\s*ISSUES\s*:\s*", "", raw, count=1)
|
|
27
|
+
return [_extract_bullets(body)]
|
|
28
|
+
|
|
29
|
+
blocks = re.split(r"(?im)(?:^\s*\d+\s*[\.\)]\s*)?\**\s*APPROACH\s*\d+\s*[:.)-]?\s*\**", raw)
|
|
30
|
+
raw_blocks = blocks[1:] if len(blocks) > 1 else []
|
|
31
|
+
|
|
32
|
+
if not raw_blocks:
|
|
33
|
+
shared = _extract_bullets(re.sub(r"(?im)^\s*ISSUES\s*:\s*", "", raw, count=1))
|
|
34
|
+
return [list(shared) for _ in range(n_approaches)]
|
|
35
|
+
|
|
36
|
+
result: list[list[str]] = []
|
|
37
|
+
for i in range(n_approaches):
|
|
38
|
+
block = raw_blocks[i] if i < len(raw_blocks) else ""
|
|
39
|
+
result.append(_extract_bullets(block))
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_NONE_MARKERS = {"none", "no issues", "no issue", "empty", "n/a", "(none)", "nil"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize(token: str) -> str:
|
|
47
|
+
# Lowercase + strip surrounding punctuation for none-marker comparison.
|
|
48
|
+
return token.strip().lower().strip(".!?;:-")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_bullets(block: str) -> list[str]:
|
|
52
|
+
# Pull issue items out of a block of free-form text.
|
|
53
|
+
block = block.strip()
|
|
54
|
+
if not block:
|
|
55
|
+
return []
|
|
56
|
+
# Strip leading "ISSUES:" header so "(none)" / "none" is recognized below.
|
|
57
|
+
block = re.sub(r"(?im)^\s*ISSUES\s*:\s*", "", block, count=1).strip()
|
|
58
|
+
if _normalize(block) in _NONE_MARKERS:
|
|
59
|
+
return []
|
|
60
|
+
# Strip post-ISSUES meta-commentary that small models sometimes add.
|
|
61
|
+
block = strip_post_answer_meta(block)
|
|
62
|
+
items = []
|
|
63
|
+
for line in block.replace(";", "\n").split("\n"):
|
|
64
|
+
raw_line = line.strip()
|
|
65
|
+
if re.match(r"^\*?\*?APPROACH\s+\d+", raw_line, re.IGNORECASE):
|
|
66
|
+
continue # drop leaked bold approach-title lines
|
|
67
|
+
line = raw_line.lstrip("-*•").strip()
|
|
68
|
+
line = line.lstrip("0123456789").lstrip(".)").strip().strip("*").strip()
|
|
69
|
+
if line and _normalize(line) not in _NONE_MARKERS:
|
|
70
|
+
items.append(line)
|
|
71
|
+
return items
|
|
72
|
+
|
|
73
|
+
async def critique_all(
|
|
74
|
+
fn, approaches: list[str], goal_and_assumptions: str, trace: Trace
|
|
75
|
+
) -> list[list[str]]:
|
|
76
|
+
rendered = build_critique_prompt(approaches, goal_and_assumptions)
|
|
77
|
+
raw = await fn(rendered)
|
|
78
|
+
trace.add_call("critique", rendered, raw, n_approaches=len(approaches))
|
|
79
|
+
return _split_critique(raw, len(approaches))
|
reasonkit/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReasonKitError(Exception):
|
|
5
|
+
# Base class for all ReasonKit errors.
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigurationError(ReasonKitError):
|
|
10
|
+
# Raised at enhance() call time for invalid config -- fails fast.
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelCallError(ReasonKitError):
|
|
15
|
+
# The wrapped fn raised and all retries were exhausted. Critical stages
|
|
16
|
+
# (classify, raw answer) re-raise; non-critical ones degrade (see core._call).
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, stage: str = "", cause: BaseException | None = None):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.stage = stage
|
|
21
|
+
self.__cause__ = cause
|
reasonkit/merge.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ._sanitize import sanitize_final, strip_preamble, strip_post_answer_meta
|
|
7
|
+
from ._utils import _specificity, _structure_score, _regressed
|
|
8
|
+
from .trace import Trace
|
|
9
|
+
|
|
10
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "merge.txt"
|
|
11
|
+
_DEEPEN_PATH = Path(__file__).parent / "prompts" / "deepen_concrete.txt"
|
|
12
|
+
_REFINE_MERGE_PATH = Path(__file__).parent / "prompts" / "refine_merge.txt"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_prompt() -> str:
|
|
16
|
+
return _PROMPT_PATH.read_text(encoding="utf-8")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_deepen_prompt() -> str:
|
|
20
|
+
return _DEEPEN_PATH.read_text(encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_refine_merge_prompt() -> str:
|
|
24
|
+
return _REFINE_MERGE_PATH.read_text(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_merge_prompt(
|
|
28
|
+
approaches: list[str],
|
|
29
|
+
issues_by_approach: list[list[str]],
|
|
30
|
+
goal_and_assumptions: str,
|
|
31
|
+
) -> str:
|
|
32
|
+
single = len(approaches) == 1
|
|
33
|
+
parts = []
|
|
34
|
+
for i, (approach, issues) in enumerate(zip(approaches, issues_by_approach), start=1):
|
|
35
|
+
issue_text = "\n".join(f"- {iss}" for iss in issues) if issues else "(no issues flagged)"
|
|
36
|
+
label = "DRAFT REPLY" if single else f"APPROACH {i}" # don't nudge toward strategy-deck output
|
|
37
|
+
parts.append(f"{label}:\n{approach}\n\nCRITIQUE:\n{issue_text}")
|
|
38
|
+
combined = "\n\n---\n\n".join(parts)
|
|
39
|
+
return _load_prompt().format(
|
|
40
|
+
approaches_with_critiques=combined,
|
|
41
|
+
goal_and_assumptions=goal_and_assumptions,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def merge(
|
|
46
|
+
fn,
|
|
47
|
+
approaches: list[str],
|
|
48
|
+
issues_by_approach: list[list[str]],
|
|
49
|
+
goal_and_assumptions: str,
|
|
50
|
+
trace: Trace,
|
|
51
|
+
) -> str:
|
|
52
|
+
rendered = build_merge_prompt(approaches, issues_by_approach, goal_and_assumptions)
|
|
53
|
+
raw = await fn(rendered)
|
|
54
|
+
trace.add_call("merge", rendered, raw)
|
|
55
|
+
merged = sanitize_final(raw)
|
|
56
|
+
|
|
57
|
+
# Fallback: if the merge regressed vs the best single approach (known
|
|
58
|
+
# small-model failure), ship the best approach instead.
|
|
59
|
+
if approaches:
|
|
60
|
+
best = max(approaches, key=lambda a: _specificity(a) + _structure_score(a) * 20)
|
|
61
|
+
if _regressed(merged, best):
|
|
62
|
+
trace.notes.append(
|
|
63
|
+
"merge: merged output regressed vs best approach "
|
|
64
|
+
f"(spec={_specificity(merged):.0f}/{_specificity(best):.0f}, "
|
|
65
|
+
f"struct={_structure_score(merged):.0f}/{_structure_score(best):.0f}); "
|
|
66
|
+
"falling back to best approach"
|
|
67
|
+
)
|
|
68
|
+
return best
|
|
69
|
+
|
|
70
|
+
return merged
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def deepen(
|
|
74
|
+
fn,
|
|
75
|
+
answer: str,
|
|
76
|
+
goal_and_assumptions: str,
|
|
77
|
+
trace: Trace,
|
|
78
|
+
) -> str:
|
|
79
|
+
# Deepen stage: force concretization of the final answer. Adds specific
|
|
80
|
+
# examples, numbers, named trade-offs where the answer is generic.
|
|
81
|
+
if not answer:
|
|
82
|
+
return answer
|
|
83
|
+
|
|
84
|
+
rendered = _load_deepen_prompt().format(
|
|
85
|
+
answer=answer,
|
|
86
|
+
goal_and_assumptions=goal_and_assumptions,
|
|
87
|
+
)
|
|
88
|
+
raw = await fn(rendered)
|
|
89
|
+
trace.add_call("deepen", rendered, raw)
|
|
90
|
+
result = sanitize_final(raw)
|
|
91
|
+
|
|
92
|
+
# Never return empty — keep the original if deepen hollowed it out.
|
|
93
|
+
if not result.strip():
|
|
94
|
+
trace.notes.append("deepen: output was empty; keeping original answer")
|
|
95
|
+
return answer
|
|
96
|
+
|
|
97
|
+
# Regression guard: if the output is significantly shorter or structurally
|
|
98
|
+
# weaker, the model probably dropped content instead of deepening it.
|
|
99
|
+
if len(result) < len(answer) * 0.6 or _regressed(result, answer):
|
|
100
|
+
trace.notes.append(
|
|
101
|
+
"deepen: output regressed vs input; keeping original answer"
|
|
102
|
+
)
|
|
103
|
+
return answer
|
|
104
|
+
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def refine_merge(
|
|
109
|
+
fn,
|
|
110
|
+
answer: str,
|
|
111
|
+
goal_and_assumptions: str,
|
|
112
|
+
trace: Trace,
|
|
113
|
+
) -> str:
|
|
114
|
+
# Refinement pass: check the merged answer for quality gaps (organization,
|
|
115
|
+
# reasoning depth, assumptions, honesty) and strengthen them without
|
|
116
|
+
# rewriting. Regression guard prevents degradation.
|
|
117
|
+
if not answer:
|
|
118
|
+
return answer
|
|
119
|
+
|
|
120
|
+
rendered = _load_refine_merge_prompt().format(
|
|
121
|
+
answer=answer,
|
|
122
|
+
goal_and_assumptions=goal_and_assumptions,
|
|
123
|
+
)
|
|
124
|
+
raw = await fn(rendered)
|
|
125
|
+
trace.add_call("refine_merge", rendered, raw)
|
|
126
|
+
result = sanitize_final(raw)
|
|
127
|
+
|
|
128
|
+
# Never return empty.
|
|
129
|
+
if not result.strip():
|
|
130
|
+
trace.notes.append("refine_merge: output was empty; keeping original answer")
|
|
131
|
+
return answer
|
|
132
|
+
|
|
133
|
+
# Regression guard: if the output degraded, keep the original.
|
|
134
|
+
if len(result) < len(answer) * 0.6 or _regressed(result, answer):
|
|
135
|
+
trace.notes.append(
|
|
136
|
+
"refine_merge: output regressed vs input; keeping original answer"
|
|
137
|
+
)
|
|
138
|
+
return answer
|
|
139
|
+
|
|
140
|
+
return result
|