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/codegen.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Awaitable, Callable, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ._utils import _line_looks_code, _looks_like_code
|
|
8
|
+
from .critique import _extract_bullets
|
|
9
|
+
from .stop_conditions import should_stop
|
|
10
|
+
from .trace import Trace
|
|
11
|
+
|
|
12
|
+
TestHook = Callable[[str], list[str]]
|
|
13
|
+
LLMCallable = Callable[[str], str]
|
|
14
|
+
|
|
15
|
+
_GEN_PATH = Path(__file__).parent / "prompts" / "generate_code.txt"
|
|
16
|
+
_FIX_PATH = Path(__file__).parent / "prompts" / "fix_code.txt"
|
|
17
|
+
_VERIFY_PATH = Path(__file__).parent / "prompts" / "verify_code.txt"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load(path: Path) -> str:
|
|
21
|
+
return path.read_text(encoding="utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_FENCED_CODE_RE = re.compile(r"```(?:[a-zA-Z0-9_+.#-]+)?\s*\n([\s\S]*?)```", re.MULTILINE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_code_output(text: str) -> str:
|
|
28
|
+
# Extract code when a model wraps it in markdown/prose: largest fenced
|
|
29
|
+
# block, else the original text trimmed.
|
|
30
|
+
t = (text or "").strip()
|
|
31
|
+
if not t:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
blocks = [b.strip() for b in _FENCED_CODE_RE.findall(t) if b.strip()]
|
|
35
|
+
if blocks:
|
|
36
|
+
return max(blocks, key=len)
|
|
37
|
+
|
|
38
|
+
# Some models prepend one explanatory line before unfenced inline code.
|
|
39
|
+
lines = t.splitlines()
|
|
40
|
+
for i, ln in enumerate(lines):
|
|
41
|
+
if _line_looks_code(ln):
|
|
42
|
+
tail = "\n".join(lines[i:]).strip()
|
|
43
|
+
if _looks_like_code(tail):
|
|
44
|
+
return tail
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
return t
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_generate_code_prompt(prompt: str) -> str:
|
|
51
|
+
return _load(_GEN_PATH).format(prompt=prompt)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_fix_prompt(code: str, issues: list[str]) -> str:
|
|
55
|
+
issue_text = "\n".join(f"- {iss}" for iss in issues) if issues else "(none)"
|
|
56
|
+
return _load(_FIX_PATH).format(code=code, issues=issue_text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_verify_prompt(code: str, test_results: Optional[str] = None) -> str:
|
|
60
|
+
test_block = f"Test results: {test_results}\n" if test_results else ""
|
|
61
|
+
return _load(_VERIFY_PATH).format(code=code, test_results=test_block)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def generate_code(fn, prompt: str, trace: Trace) -> str:
|
|
65
|
+
rendered = build_generate_code_prompt(prompt)
|
|
66
|
+
raw = await fn(rendered)
|
|
67
|
+
trace.add_call("generate", rendered, raw)
|
|
68
|
+
normalized = _normalize_code_output(raw)
|
|
69
|
+
return normalized if _looks_like_code(normalized) else raw.strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def verify(
|
|
73
|
+
fn,
|
|
74
|
+
code: str,
|
|
75
|
+
test_hook: Optional[TestHook],
|
|
76
|
+
trace: Trace,
|
|
77
|
+
test_results: Optional[str] = None,
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
# The verify pass: a plain list of concrete issues (never a score). With a
|
|
80
|
+
# test_hook, failures come from real tests; otherwise an LLM self-review call.
|
|
81
|
+
if test_hook is not None:
|
|
82
|
+
issues = list(test_hook(code))
|
|
83
|
+
trace.add_call(
|
|
84
|
+
"verify", f"<test_hook({len(code)} chars)>", str(issues), source="test_hook"
|
|
85
|
+
)
|
|
86
|
+
return issues
|
|
87
|
+
|
|
88
|
+
normalized = _normalize_code_output(code)
|
|
89
|
+
code_to_verify = normalized if _looks_like_code(normalized) else code
|
|
90
|
+
rendered = build_verify_prompt(code_to_verify, test_results)
|
|
91
|
+
raw = await fn(rendered)
|
|
92
|
+
trace.add_call("verify", rendered, raw, source="llm")
|
|
93
|
+
return _extract_bullets(raw)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def fix(fn, code: str, issues: list[str], trace: Trace) -> str:
|
|
97
|
+
rendered = build_fix_prompt(code, issues)
|
|
98
|
+
raw = await fn(rendered)
|
|
99
|
+
trace.add_call("fix", rendered, raw)
|
|
100
|
+
normalized = _normalize_code_output(raw)
|
|
101
|
+
return normalized if _looks_like_code(normalized) else raw.strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_refusal_or_note(text: str) -> bool:
|
|
105
|
+
# True when generation returned a plain 'I need more info' note instead of
|
|
106
|
+
# code. Feeding that into verify->fix would make the model confabulate a
|
|
107
|
+
# program, so we stop and return the honest note instead.
|
|
108
|
+
if not text or _looks_like_code(text):
|
|
109
|
+
return False
|
|
110
|
+
low = text.lower()
|
|
111
|
+
signals = (
|
|
112
|
+
"too vague", "vague", "need more", "need to know", "not specified",
|
|
113
|
+
"unspecified", "missing", "unclear", "can't", "cannot", "unable to",
|
|
114
|
+
"provide", "clarif", "what x", "what does", "no specific",
|
|
115
|
+
"don't know", "do not know", "more detail", "more information",
|
|
116
|
+
)
|
|
117
|
+
return any(s in low for s in signals)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def code_pipeline(
|
|
121
|
+
afn: Union[LLMCallable, Callable[[str], Awaitable[str]]],
|
|
122
|
+
prompt: str,
|
|
123
|
+
classification: dict,
|
|
124
|
+
max_cycles: int,
|
|
125
|
+
test_hook: Optional[TestHook],
|
|
126
|
+
trace: Trace,
|
|
127
|
+
) -> str:
|
|
128
|
+
# Generate (once) -> Verify -> Fix loop. If the request is underspecified,
|
|
129
|
+
# generation returns an honest note and we stop -- no fabricated program.
|
|
130
|
+
code_input = (classification.get("code_input") or "").strip()
|
|
131
|
+
|
|
132
|
+
if code_input and _looks_like_code(code_input):
|
|
133
|
+
trace.notes.append("code mode: user-supplied code; skipping generate")
|
|
134
|
+
code = code_input
|
|
135
|
+
else:
|
|
136
|
+
if code_input:
|
|
137
|
+
# Codegen prompt got prose (not code); ignore it and generate from
|
|
138
|
+
# the prompt rather than guessing from a note.
|
|
139
|
+
trace.notes.append(
|
|
140
|
+
"code mode: CODE_INPUT was prose, not code; generating from prompt"
|
|
141
|
+
)
|
|
142
|
+
code = await generate_code(afn, prompt, trace)
|
|
143
|
+
|
|
144
|
+
trace.code_versions.append(code)
|
|
145
|
+
trace.mode = "code"
|
|
146
|
+
|
|
147
|
+
if _is_refusal_or_note(code):
|
|
148
|
+
trace.notes.append(
|
|
149
|
+
"code mode: generation returned a clarification note, not code; "
|
|
150
|
+
"stopping instead of guessing"
|
|
151
|
+
)
|
|
152
|
+
trace.stopped_reason = "needs_clarification"
|
|
153
|
+
questions = classification.get("clarifying_questions") or []
|
|
154
|
+
if questions:
|
|
155
|
+
note = code.rstrip() + "\n\nWhat I need to proceed:\n" + "\n".join(
|
|
156
|
+
f"- {q.rstrip('.')}." for q in questions[:3] if q.strip()
|
|
157
|
+
)
|
|
158
|
+
return note
|
|
159
|
+
return code
|
|
160
|
+
|
|
161
|
+
previous_issue_count: Optional[int] = None
|
|
162
|
+
stopped_reason = "max_cycles"
|
|
163
|
+
|
|
164
|
+
for cycle in range(1, max_cycles + 1):
|
|
165
|
+
trace.cycles = cycle
|
|
166
|
+
issues = await verify(afn, code, test_hook, trace)
|
|
167
|
+
|
|
168
|
+
stop, reason = should_stop(cycle, max_cycles, [issues], previous_issue_count)
|
|
169
|
+
if stop:
|
|
170
|
+
stopped_reason = reason
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
fixed = await fix(afn, code, issues, trace)
|
|
174
|
+
|
|
175
|
+
# Guard: if fix returned non-code (issue bullets, prose, meta-commentary),
|
|
176
|
+
# keep the original code and stop -- prevents verify->fix cascading where
|
|
177
|
+
# the model keeps outputting issues instead of fixed code each cycle.
|
|
178
|
+
if _looks_like_code(fixed):
|
|
179
|
+
code = fixed
|
|
180
|
+
else:
|
|
181
|
+
trace.notes.append(
|
|
182
|
+
"code mode: fix output was not code; keeping previous version and stopping"
|
|
183
|
+
)
|
|
184
|
+
trace.stopped_reason = "no_improvement"
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
trace.code_versions.append(code)
|
|
188
|
+
previous_issue_count = len(issues)
|
|
189
|
+
|
|
190
|
+
trace.stopped_reason = stopped_reason
|
|
191
|
+
return code
|
reasonkit/compare.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Union
|
|
5
|
+
|
|
6
|
+
from .core import enhance, EnhanceResult, LLMCallable, AsyncLLMCallable, _is_coroutine_function
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Comparison:
|
|
11
|
+
prompt: str
|
|
12
|
+
baseline_answer: str
|
|
13
|
+
reasonkit_answer: str
|
|
14
|
+
trace: Any = None # Trace or None
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return (
|
|
18
|
+
f"PROMPT:\n{self.prompt}\n\n"
|
|
19
|
+
f"BASELINE:\n{self.baseline_answer}\n\n"
|
|
20
|
+
f"REASONKIT:\n{self.reasonkit_answer}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _collect_sync_or_async(result):
|
|
25
|
+
# Resolve str | coroutine | async-gen to a plain string.
|
|
26
|
+
import asyncio
|
|
27
|
+
import inspect
|
|
28
|
+
|
|
29
|
+
if inspect.isasyncgen(result):
|
|
30
|
+
parts = []
|
|
31
|
+
|
|
32
|
+
async def _g():
|
|
33
|
+
async for piece in result:
|
|
34
|
+
parts.append(piece if isinstance(piece, str) else str(piece))
|
|
35
|
+
|
|
36
|
+
asyncio.run(_g())
|
|
37
|
+
return "".join(parts)
|
|
38
|
+
if inspect.iscoroutine(result):
|
|
39
|
+
return asyncio.run(result)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compare(
|
|
44
|
+
fn: Union[LLMCallable, AsyncLLMCallable],
|
|
45
|
+
prompt: str,
|
|
46
|
+
**config: Any,
|
|
47
|
+
) -> Comparison:
|
|
48
|
+
# Run fn directly (baseline) and enhance(fn)(prompt), resolved to strings.
|
|
49
|
+
enhanced = enhance(fn, return_trace=True, **config)
|
|
50
|
+
|
|
51
|
+
baseline = fn(prompt)
|
|
52
|
+
baseline = _collect_sync_or_async(baseline)
|
|
53
|
+
result = _collect_sync_or_async(enhanced(prompt))
|
|
54
|
+
|
|
55
|
+
if isinstance(result, EnhanceResult):
|
|
56
|
+
rk_answer = result.answer
|
|
57
|
+
trace = result.trace
|
|
58
|
+
else:
|
|
59
|
+
rk_answer = result
|
|
60
|
+
trace = None
|
|
61
|
+
|
|
62
|
+
return Comparison(
|
|
63
|
+
prompt=prompt,
|
|
64
|
+
baseline_answer=baseline if isinstance(baseline, str) else str(baseline),
|
|
65
|
+
reasonkit_answer=rk_answer,
|
|
66
|
+
trace=trace,
|
|
67
|
+
)
|