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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .core import enhance, EnhanceResult, LLMCallable, AsyncLLMCallable, StreamingLLMCallable
|
|
2
|
+
from .compare import compare, Comparison
|
|
3
|
+
from .trace import Trace
|
|
4
|
+
from .errors import ReasonKitError, ConfigurationError, ModelCallError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"enhance",
|
|
8
|
+
"EnhanceResult",
|
|
9
|
+
"compare",
|
|
10
|
+
"Comparison",
|
|
11
|
+
"Trace",
|
|
12
|
+
"ReasonKitError",
|
|
13
|
+
"ConfigurationError",
|
|
14
|
+
"ModelCallError",
|
|
15
|
+
"LLMCallable",
|
|
16
|
+
"AsyncLLMCallable",
|
|
17
|
+
"StreamingLLMCallable",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.2.0"
|
reasonkit/__main__.py
ADDED
reasonkit/_sanitize.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# --------------------------------------------------------------------------- #
|
|
7
|
+
# Preamble patterns -- meta-preambles small models prepend despite the prompt
|
|
8
|
+
# telling them not to.
|
|
9
|
+
# --------------------------------------------------------------------------- #
|
|
10
|
+
|
|
11
|
+
_PREAMBLE_RE = re.compile(
|
|
12
|
+
r"^\s*(?:"
|
|
13
|
+
r"here(?:'s| is)|below is|this is|"
|
|
14
|
+
r"i'?ve\s+(?:revised|improved|updated|corrected|rewritten|reworked)|"
|
|
15
|
+
r"after\s+(?:considering|reviewing|reading|analyzing)|"
|
|
16
|
+
r"based\s+on\s+(?:the\s+)?(?:critique|above|draft|issues|feedback)|"
|
|
17
|
+
r"here'?s\s+my\s+(?:revised|improved|refined|better|updated)"
|
|
18
|
+
r")\b[^\n:]{0,80}:\s*",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# Scaffold labels from _goal_and_assumptions that leak into the final answer.
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
|
|
27
|
+
_SCAFFOLD_LINE_RE = re.compile(
|
|
28
|
+
r"^\s*(?:the user's actual message|assumptions to revisit|"
|
|
29
|
+
r"open clarifying questions?|draft reply(?: \+ critique)?|"
|
|
30
|
+
r"goal / things to keep in mind)\s*:.*$",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
# Post-answer meta-commentary: after the actual answer, some models continue
|
|
37
|
+
# with reasoning about their own writing process ("We need to...", "We must...",
|
|
38
|
+
# "Let's produce..."). These phrases would never be in a real answer (they
|
|
39
|
+
# address the writer, not the user), so we can safely strip everything from the
|
|
40
|
+
# first such marker onwards.
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
|
|
43
|
+
# "we (need to|must|should|can|can now) [not] <action verb>"
|
|
44
|
+
# Handles "we should not claim", "we can provide", "we can say", etc.
|
|
45
|
+
_META_WE_ACTION_RE = re.compile(
|
|
46
|
+
r"\n{2,}we\s+(?:need\s+to|must|should|can(?:\s+now)?)\s+"
|
|
47
|
+
r"(?:not\s+)?(?:produce|create|write|generate|output|"
|
|
48
|
+
r"craft|do|make|put|try|go\s+ahead|wrap|start|give|summarize|"
|
|
49
|
+
r"fix|repair|correct|revise|rewrite|rework|begin|improve|keep|"
|
|
50
|
+
r"add|include|ask|ensure|mention|consider|note|be|talk|"
|
|
51
|
+
r"say|provide|claim)\b.*",
|
|
52
|
+
re.IGNORECASE | re.DOTALL,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# "we must preserve|keep|maintain|..." -- a different class of instruction
|
|
56
|
+
_META_WE_MUST_RE = re.compile(
|
|
57
|
+
r"\n{2,}we\s+must\s+(?:preserve|keep|maintain|include|add|use|follow|adhere|"
|
|
58
|
+
r"comply|obey|respect|retain|not|avoid|never)\b.*",
|
|
59
|
+
re.IGNORECASE | re.DOTALL,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# "let's <action verb>" (with optional interjection prefix like "ok. let's")
|
|
63
|
+
_META_LET_RE = re.compile(
|
|
64
|
+
r"\n{2,}(?:ok[a-z]*[.!?]+\s+)?let['´`]?s\s+(?:produce|output|write|craft|do|make|put|try|"
|
|
65
|
+
r"go\s+ahead|wrap|start|give|summarize|begin|get|work|fix|create|"
|
|
66
|
+
r"repair|correct|revise|rewrite|rework|answer)\b.*",
|
|
67
|
+
re.IGNORECASE | re.DOTALL,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# "i/we (will|shall|'ll) [not|also|...] <action verb>"
|
|
71
|
+
_META_PERSONAL_WILL_RE = re.compile(
|
|
72
|
+
r"\n{2,}(?:i|we)(?:['´`]?\s+(?:will|shall)|['´`]ll)\s+"
|
|
73
|
+
r"(?:\w+\s+)*?(?:"
|
|
74
|
+
r"output|produce|write|provide|give|craft|fix|revise|rewrite|"
|
|
75
|
+
r"present|offer|explain|keep|mention|make|comply|answer|try|do|"
|
|
76
|
+
r"add|include|ensure|note|consider|ask"
|
|
77
|
+
r")\b.*",
|
|
78
|
+
re.IGNORECASE | re.DOTALL,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# "however|but,? the instruction|the prompt|the rules|..."
|
|
82
|
+
_META_INSTRUCTION_RE = re.compile(
|
|
83
|
+
r"\n{2,}(?:however|but)\s*,?\s*(?:the instruction|the prompt|the rules|"
|
|
84
|
+
r"the guidelines|our task|i(?:'m|\s+am)\s+(?:supposed|asked|told|instructed)"
|
|
85
|
+
r"|as\s+(?:an?\s+)?(?:AI|language\s+model|assistant)).*",
|
|
86
|
+
re.IGNORECASE | re.DOTALL,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Revision / process notes: "i've revised...", "after considering...", etc.
|
|
90
|
+
_META_REVISION_RE = re.compile(
|
|
91
|
+
r"\n{2,}"
|
|
92
|
+
r"(?:"
|
|
93
|
+
r"i'?ve\s+(?:revised|improved|updated|corrected|rewritten|reworked)\s+"
|
|
94
|
+
r"(?:the|this|it|above|answer|draft)"
|
|
95
|
+
r"|after\s+(?:considering|reviewing|reading)\s+(?:the\s+)?"
|
|
96
|
+
r"(?:critique|above|draft|issues|feedback|analysis)"
|
|
97
|
+
r"|here'?s\s+my\s+(?:revised|improved|refined|better|updated|answer)"
|
|
98
|
+
r"|the\s+(?:user|prompt|request|question)\s+(?:asks|wants|says|needs|requests)\s+(?:(?:me\s+)?to\b)?"
|
|
99
|
+
r"|User\s+wants\s+(?:a|an|the|to|me)\b"
|
|
100
|
+
r"|User's\s+(?:request|question|prompt)\s+"
|
|
101
|
+
r"|(?:now|so)\s+(?:let\s+me|i['´`]?ll)\s+(?:provide|give|present|offer|"
|
|
102
|
+
r"produce|write|craft|explain)"
|
|
103
|
+
r"|i['´`]?ll\s+(?:provide|give|present|offer|produce|write|craft|explain|revise)"
|
|
104
|
+
r"|my\s+(?:revised|improved|refined|final)\s+(?:answer|draft|version)"
|
|
105
|
+
r").*",
|
|
106
|
+
re.IGNORECASE | re.DOTALL,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# All post-answer meta patterns, applied in order.
|
|
110
|
+
_POST_ANSWER_PATTERNS = (
|
|
111
|
+
_META_WE_ACTION_RE,
|
|
112
|
+
_META_WE_MUST_RE,
|
|
113
|
+
_META_LET_RE,
|
|
114
|
+
_META_INSTRUCTION_RE,
|
|
115
|
+
_META_REVISION_RE,
|
|
116
|
+
_META_PERSONAL_WILL_RE,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
# Public helpers
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
|
|
124
|
+
def strip_preamble(text: str) -> str:
|
|
125
|
+
"""Strip leading meta-preambles like 'Here is my revised answer:'."""
|
|
126
|
+
if not text:
|
|
127
|
+
return text
|
|
128
|
+
return _PREAMBLE_RE.sub("", text, count=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def strip_scaffold_lines(text: str) -> str:
|
|
132
|
+
"""Strip lines that look like internal pipeline context labels (Goal:,
|
|
133
|
+
Assumptions to revisit:, etc.)."""
|
|
134
|
+
if not text:
|
|
135
|
+
return text
|
|
136
|
+
kept = [ln for ln in text.splitlines() if not _SCAFFOLD_LINE_RE.match(ln)]
|
|
137
|
+
return "\n".join(kept).strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def strip_post_answer_meta(text: str) -> str:
|
|
141
|
+
"""Strip post-answer meta-commentary about the writing process.
|
|
142
|
+
|
|
143
|
+
Applies all known meta-commentary patterns in order and returns the
|
|
144
|
+
clean text. Each pattern strips from its match to the end of the string,
|
|
145
|
+
so the first hit removes everything after it.
|
|
146
|
+
"""
|
|
147
|
+
if not text:
|
|
148
|
+
return text
|
|
149
|
+
for pattern in _POST_ANSWER_PATTERNS:
|
|
150
|
+
text = pattern.sub("", text)
|
|
151
|
+
return text.strip()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def sanitize_final(text: str) -> str:
|
|
155
|
+
"""Full sanitization: preambles, scaffold lines, and post-answer meta.
|
|
156
|
+
|
|
157
|
+
This is the main entry point for final-answer sanitization. Strips:
|
|
158
|
+
1. Leading meta-preambles ("Here is my revised answer:")
|
|
159
|
+
2. Internal scaffold labels ("Goal:", "Assumptions to revisit:")
|
|
160
|
+
3. Post-answer commentary ("We need to fix...", "Let's rewrite...")
|
|
161
|
+
"""
|
|
162
|
+
if not text:
|
|
163
|
+
return text
|
|
164
|
+
text = strip_preamble(text)
|
|
165
|
+
text = strip_scaffold_lines(text)
|
|
166
|
+
text = strip_post_answer_meta(text)
|
|
167
|
+
return text.strip()
|
reasonkit/_utils.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# --------------------------------------------------------------------------- #
|
|
7
|
+
# Code-detection helpers (extracted from codegen.py to break circular imports)
|
|
8
|
+
# --------------------------------------------------------------------------- #
|
|
9
|
+
|
|
10
|
+
def _line_looks_code(line: str) -> bool:
|
|
11
|
+
"""Heuristic: does a single line look like source code?"""
|
|
12
|
+
low = line.strip().lower()
|
|
13
|
+
if not low:
|
|
14
|
+
return False
|
|
15
|
+
starters = (
|
|
16
|
+
"def ", "class ", "if ", "for ", "while ", "try:", "except", "return ",
|
|
17
|
+
"import ", "from ", "#include", "public ", "private ", "fn ", "let ",
|
|
18
|
+
"const ", "var ", "function ", "package ", "interface ", "struct ",
|
|
19
|
+
)
|
|
20
|
+
if any(low.startswith(s) for s in starters):
|
|
21
|
+
return True
|
|
22
|
+
return any(ch in line for ch in ("{", "}", "(", ")", "=>", "::", ";", "[", "]"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _looks_like_code(text: str) -> bool:
|
|
26
|
+
"""Heuristic: is this source code, or prose the classifier dumped into
|
|
27
|
+
CODE_INPUT? A "the user did not provide code" sentence must not be treated
|
|
28
|
+
as code to verify/fix -- that's what makes the pipeline hallucinate."""
|
|
29
|
+
if not text:
|
|
30
|
+
return False
|
|
31
|
+
if "```" in text or text.lstrip().startswith("#!"):
|
|
32
|
+
return True
|
|
33
|
+
low = text.lower()
|
|
34
|
+
seen = sum(
|
|
35
|
+
1
|
|
36
|
+
for kw in (
|
|
37
|
+
"def ", "class ", "func ", "function ", "public ", "private ",
|
|
38
|
+
"void ", "int ", "return ", "import ", "include", "package ",
|
|
39
|
+
"fn ", "let ", "var ", "const ", "struct ", "end", "begin",
|
|
40
|
+
)
|
|
41
|
+
if kw in low
|
|
42
|
+
)
|
|
43
|
+
prose_markers = sum(
|
|
44
|
+
1 for m in ("note:", "since the user", "please provide", "it's essential")
|
|
45
|
+
if m in low
|
|
46
|
+
)
|
|
47
|
+
# Need >= 2 code-ish keywords and no prose markers, so a plain sentence isn't
|
|
48
|
+
# misread as code but short snippets (def add(a,b): return a+b) still pass.
|
|
49
|
+
return seen >= 2 and prose_markers == 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
# Heuristic scoring helpers (extracted from merge.py)
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
|
|
56
|
+
def _specificity(text: str) -> float:
|
|
57
|
+
"""Cheap proxy for how concrete an answer is: length + developed points +
|
|
58
|
+
a bottom-line marker. Used only to catch clear merge regressions, not to
|
|
59
|
+
judge quality in general."""
|
|
60
|
+
if not text:
|
|
61
|
+
return 0.0
|
|
62
|
+
low = text.lower()
|
|
63
|
+
sentences = text.count(".") + text.count("!") + text.count("?")
|
|
64
|
+
bottom_line = 1.0 if any(
|
|
65
|
+
k in low for k in ("bottom line", "recommend", "in short", "overall,")
|
|
66
|
+
) else 0.0
|
|
67
|
+
return float(len(text)) + sentences * 8.0 + bottom_line * 40.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Markers a well-organized answer carries (headings, bullets, ordinals, bold).
|
|
71
|
+
# A merge that collapses a 40-section draft into 4 items has lost structure even
|
|
72
|
+
# if still "long enough"; _structure_score catches that where length alone won't.
|
|
73
|
+
_STRUCTURE_RE = re.compile(
|
|
74
|
+
r"^\s*(?:"
|
|
75
|
+
r"#{1,6}\s+"
|
|
76
|
+
r"|[-*]\s+\S"
|
|
77
|
+
r"|\d+[.)]\s+\S"
|
|
78
|
+
r"|\*\*[^*]+\*\*"
|
|
79
|
+
r"|(?i:first|second|third|fourth|fifth|next|then|finally|lastly)\b[,:.\s]"
|
|
80
|
+
r")",
|
|
81
|
+
re.MULTILINE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _structure_score(text: str) -> float:
|
|
86
|
+
"""Count of structural markers per line. 0 for a flat prose blob."""
|
|
87
|
+
if not text:
|
|
88
|
+
return 0.0
|
|
89
|
+
return float(len(_STRUCTURE_RE.findall(text)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Flag only clear regressions: merged below 0.5x on either axis, or below 0.7x
|
|
93
|
+
# on both at once. A merge that tightens a few sentences but keeps structure
|
|
94
|
+
# stays strictly better and is not flagged.
|
|
95
|
+
def _regressed(merged: str, best: str) -> bool:
|
|
96
|
+
if not merged:
|
|
97
|
+
return True
|
|
98
|
+
spec_ratio = _specificity(merged) / max(_specificity(best), 1.0)
|
|
99
|
+
struct_ratio = _structure_score(merged) / max(_structure_score(best), 1.0)
|
|
100
|
+
if spec_ratio < 0.5 or struct_ratio < 0.5:
|
|
101
|
+
return True
|
|
102
|
+
if spec_ratio < 0.7 and struct_ratio < 0.7:
|
|
103
|
+
return True
|
|
104
|
+
return False
|
reasonkit/branching.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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" / "generate_approaches.txt"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_prompt() -> str:
|
|
13
|
+
return _PROMPT_PATH.read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_generate_prompt(
|
|
17
|
+
goal_and_assumptions: str,
|
|
18
|
+
n: int,
|
|
19
|
+
previous_issues: list[str] | None = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
if n > 1:
|
|
22
|
+
instruction = (
|
|
23
|
+
f"Generate {n} GENUINELY DISTINCT approaches to address it -- real "
|
|
24
|
+
"alternative strategies, not rephrasings of each other. Each must "
|
|
25
|
+
"take a different angle (e.g. different audience, different risk "
|
|
26
|
+
"posture, different first move)."
|
|
27
|
+
)
|
|
28
|
+
fmt = (
|
|
29
|
+
"Label each approach exactly 'APPROACH 1:', 'APPROACH 2:', etc., in "
|
|
30
|
+
"order, with a blank line between them. Do not wrap them in extra "
|
|
31
|
+
"sections or a summary."
|
|
32
|
+
)
|
|
33
|
+
# Feed previous-cycle critique back into generation so new approaches
|
|
34
|
+
# avoid known flaws.
|
|
35
|
+
if previous_issues:
|
|
36
|
+
bullets = "\n".join(f"- {iss}" for iss in previous_issues[:8])
|
|
37
|
+
instruction += (
|
|
38
|
+
"\n\n**Avoid previous mistakes.** The previous cycle's approaches "
|
|
39
|
+
"had these issues. Your NEW approaches must NOT repeat them:\n"
|
|
40
|
+
f"{bullets}"
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
instruction = (
|
|
44
|
+
"Write ONE direct, human reply to that message in plain language "
|
|
45
|
+
"(the way a knowledgeable friend would answer)."
|
|
46
|
+
)
|
|
47
|
+
fmt = "Write the reply as a single continuous answer. No 'APPROACH' labels."
|
|
48
|
+
return _load_prompt().format(
|
|
49
|
+
goal_and_assumptions=goal_and_assumptions,
|
|
50
|
+
n_instruction=instruction,
|
|
51
|
+
n_format=fmt,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _split_approaches(raw: str) -> list[str]:
|
|
56
|
+
# Split the numbered output into distinct approach blocks.
|
|
57
|
+
parts = re.split(r"(?im)^\s*APPROACH\s*\d+\s*[:.)-]?\s*", raw)
|
|
58
|
+
approaches = [p.strip() for p in parts[1:] if p.strip()]
|
|
59
|
+
if not approaches:
|
|
60
|
+
return [raw.strip()] if raw.strip() else [] # no markers: one approach
|
|
61
|
+
# Strip meta-commentary that leaks into any approach.
|
|
62
|
+
return [strip_post_answer_meta(a) for a in approaches]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def generate_approaches(
|
|
66
|
+
fn,
|
|
67
|
+
goal_and_assumptions: str,
|
|
68
|
+
n: int,
|
|
69
|
+
trace: Trace,
|
|
70
|
+
previous_issues: list[str] | None = None,
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
rendered = build_generate_prompt(goal_and_assumptions, n, previous_issues=previous_issues)
|
|
73
|
+
raw = await fn(rendered)
|
|
74
|
+
trace.add_call("generate", rendered, raw)
|
|
75
|
+
return _split_approaches(raw)
|
reasonkit/classifier.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .trace import Trace
|
|
6
|
+
|
|
7
|
+
_PROMPT_PATH = Path(__file__).parent / "prompts" / "classify_and_assumptions.txt"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_prompt() -> str:
|
|
11
|
+
return _PROMPT_PATH.read_text(encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_classify_prompt(prompt: str) -> str:
|
|
15
|
+
return _load_prompt().format(prompt=prompt)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Structured-output field headers the classify prompt asks for. DIRECT_ANSWER is
|
|
19
|
+
# intentionally absent -- the classifier routes only.
|
|
20
|
+
_FIELD_NAMES = (
|
|
21
|
+
"CATEGORY",
|
|
22
|
+
"COMPLEXITY",
|
|
23
|
+
"NEEDS_CLARIFICATION",
|
|
24
|
+
"GOAL",
|
|
25
|
+
"ASSUMPTIONS",
|
|
26
|
+
"CLARIFYING_QUESTIONS",
|
|
27
|
+
"CODE_INPUT",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_field(text: str, field_name: str) -> str:
|
|
32
|
+
# Extract FIELD_NAME: up to the next known field header. '' if absent.
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
others = [f for f in _FIELD_NAMES if f != field_name]
|
|
36
|
+
stop = r"|".join(re.escape(o) for o in others)
|
|
37
|
+
pattern = (
|
|
38
|
+
rf"^\s*{re.escape(field_name)}\s*:\s*(.*?)"
|
|
39
|
+
rf"(?=^\s*(?:{stop})\s*:|\Z)"
|
|
40
|
+
)
|
|
41
|
+
m = re.search(pattern, text, re.IGNORECASE | re.MULTILINE | re.DOTALL)
|
|
42
|
+
return m.group(1).strip() if m else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_category(value: str) -> str:
|
|
46
|
+
v = value.strip().upper()
|
|
47
|
+
if v in ("DECISION", "CODE", "DIRECT"):
|
|
48
|
+
return v
|
|
49
|
+
if "CODE" in v:
|
|
50
|
+
return "CODE"
|
|
51
|
+
if "DECIS" in v:
|
|
52
|
+
return "DECISION"
|
|
53
|
+
return "DIRECT" # safest fallback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _normalize_complexity(value: str) -> str:
|
|
57
|
+
return "COMPLEX" if "COMPLEX" in value.upper() else "SIMPLE"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_needs_clarification(value: str) -> bool:
|
|
61
|
+
v = value.strip().upper()
|
|
62
|
+
if v.startswith("Y"):
|
|
63
|
+
return True
|
|
64
|
+
if v.startswith("N"):
|
|
65
|
+
return False
|
|
66
|
+
return "YES" in v and "NO" not in v # tolerate free text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _try_parse_json(text: str) -> dict | None:
|
|
70
|
+
# Try to parse text as JSON. Handles ```json fences and bare objects.
|
|
71
|
+
import json
|
|
72
|
+
import re
|
|
73
|
+
|
|
74
|
+
t = text.strip()
|
|
75
|
+
|
|
76
|
+
# Strip ```json ... ``` fences if present.
|
|
77
|
+
m = re.search(r"```(?:json)?\s*\n?([\s\S]*?)```", t)
|
|
78
|
+
if m:
|
|
79
|
+
t = m.group(1).strip()
|
|
80
|
+
|
|
81
|
+
# Strip outer code fences without language tag.
|
|
82
|
+
m = re.search(r"```\s*\n?([\s\S]*?)```", t)
|
|
83
|
+
if m:
|
|
84
|
+
t = m.group(1).strip()
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
obj = json.loads(t)
|
|
88
|
+
if isinstance(obj, dict):
|
|
89
|
+
return obj
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Also try to find a top-level JSON object anywhere in the text (some models
|
|
94
|
+
# wrap JSON in prose).
|
|
95
|
+
m = re.search(r"\{[\s\S]*\}", t)
|
|
96
|
+
if m:
|
|
97
|
+
try:
|
|
98
|
+
obj = json.loads(m.group(0))
|
|
99
|
+
if isinstance(obj, dict):
|
|
100
|
+
return obj
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_classification(raw: str) -> dict:
|
|
108
|
+
# Parse structured classify output into a dict.
|
|
109
|
+
#
|
|
110
|
+
# Handles BOTH formats models actually output:
|
|
111
|
+
# 1. Field-per-line (CATEGORY: DECISION\\nGOAL: ...)
|
|
112
|
+
# 2. JSON ({"CATEGORY": "DECISION", "goal": "..."})
|
|
113
|
+
# The classify prompt asks for format 1, but many models (codestral, deepseek)
|
|
114
|
+
# emit JSON, sometimes inside a ```json code block. JSON keys can be uppercase
|
|
115
|
+
# (CATEGORY) or lowercase (category); we accept both.
|
|
116
|
+
import re
|
|
117
|
+
|
|
118
|
+
# Try JSON parsing first (handles ```json blocks, bare JSON, prose-wrapped).
|
|
119
|
+
json_obj = _try_parse_json(raw)
|
|
120
|
+
|
|
121
|
+
if json_obj is not None:
|
|
122
|
+
# Normalise: accept both "CATEGORY" and "category" as keys.
|
|
123
|
+
def _jget(*keys: str) -> str:
|
|
124
|
+
for k in keys:
|
|
125
|
+
v = json_obj.get(k)
|
|
126
|
+
if v is not None and v != "":
|
|
127
|
+
return str(v).strip()
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
def _jget_list(*keys: str) -> list[str]:
|
|
131
|
+
for k in keys:
|
|
132
|
+
v = json_obj.get(k)
|
|
133
|
+
if isinstance(v, list):
|
|
134
|
+
return [str(x).strip() for x in v if x]
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
category = _normalize_category(_jget("CATEGORY", "category"))
|
|
138
|
+
complexity = _normalize_complexity(_jget("COMPLEXITY", "complexity"))
|
|
139
|
+
needs_clar = _jget("NEEDS_CLARIFICATION", "needs_clarification")
|
|
140
|
+
assumptions = _jget_list("ASSUMPTIONS", "assumptions")
|
|
141
|
+
clarifying = _jget_list("CLARIFYING_QUESTIONS", "clarifying_questions")
|
|
142
|
+
goal = _jget("GOAL", "goal")
|
|
143
|
+
code_input = _jget("CODE_INPUT", "code_input")
|
|
144
|
+
|
|
145
|
+
if not code_input:
|
|
146
|
+
code_input = _read_field(raw, "CODE_INPUT") # fallback: regex
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"category": category,
|
|
150
|
+
"complexity": complexity,
|
|
151
|
+
"needs_clarification": _normalize_needs_clarification(needs_clar),
|
|
152
|
+
"goal": goal,
|
|
153
|
+
"assumptions": assumptions if assumptions else _split_list(_read_field(raw, "ASSUMPTIONS")),
|
|
154
|
+
"clarifying_questions": clarifying if clarifying else _split_list(_read_field(raw, "CLARIFYING_QUESTIONS")),
|
|
155
|
+
"code_input": code_input,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Fall back to line-based field parsing (format 1 in the prompt).
|
|
159
|
+
category = _normalize_category(_read_field(raw, "CATEGORY"))
|
|
160
|
+
complexity = _normalize_complexity(_read_field(raw, "COMPLEXITY"))
|
|
161
|
+
assumptions = _split_list(_read_field(raw, "ASSUMPTIONS"))
|
|
162
|
+
clarifying = _split_list(_read_field(raw, "CLARIFYING_QUESTIONS"))
|
|
163
|
+
code_input = _read_field(raw, "CODE_INPUT")
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"category": category,
|
|
167
|
+
"complexity": complexity,
|
|
168
|
+
"needs_clarification": _normalize_needs_clarification(
|
|
169
|
+
_read_field(raw, "NEEDS_CLARIFICATION")
|
|
170
|
+
),
|
|
171
|
+
"goal": _read_field(raw, "GOAL"),
|
|
172
|
+
"assumptions": assumptions,
|
|
173
|
+
"clarifying_questions": clarifying,
|
|
174
|
+
"code_input": code_input,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _split_list(value: str) -> list[str]:
|
|
179
|
+
# Split a free-form field into list items on newlines or ';' or '-' bullets.
|
|
180
|
+
if not value:
|
|
181
|
+
return []
|
|
182
|
+
items = []
|
|
183
|
+
for part in value.replace(";", "\n").split("\n"):
|
|
184
|
+
part = part.strip().lstrip("-").strip()
|
|
185
|
+
part = part.lstrip("0123456789").lstrip(".)").strip()
|
|
186
|
+
if part:
|
|
187
|
+
items.append(part)
|
|
188
|
+
return items
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def classify(fn, prompt: str, trace: Trace) -> dict:
|
|
192
|
+
rendered = build_classify_prompt(prompt)
|
|
193
|
+
raw = await fn(rendered)
|
|
194
|
+
trace.add_call("classify", rendered, raw)
|
|
195
|
+
result = parse_classification(raw)
|
|
196
|
+
trace.classification = result
|
|
197
|
+
trace.mode = result["category"].lower()
|
|
198
|
+
if result["category"] not in ("DECISION", "CODE", "DIRECT"):
|
|
199
|
+
trace.notes.append("classify: unrecognized category, fell back to DIRECT")
|
|
200
|
+
return result
|