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/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