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 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
@@ -0,0 +1,6 @@
1
+ """Allow python -m reasonkit."""
2
+
3
+ from . import __version__
4
+
5
+ if __name__ == "__main__":
6
+ print(f"reasonkit {__version__}")
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)
@@ -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