local-llm-wrapper 26.4__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.
- local_llm_wrapper/__init__.py +0 -0
- local_llm_wrapper/errors.py +31 -0
- local_llm_wrapper/llm.py +46 -0
- local_llm_wrapper/llm_client.py +82 -0
- local_llm_wrapper/llm_engine.py +293 -0
- local_llm_wrapper/llm_parsers.py +173 -0
- local_llm_wrapper/llm_prompts.py +187 -0
- local_llm_wrapper/llm_utils.py +476 -0
- local_llm_wrapper/transports/__init__.py +0 -0
- local_llm_wrapper/transports/apple.py +88 -0
- local_llm_wrapper/transports/base.py +19 -0
- local_llm_wrapper/transports/ollama.py +146 -0
- local_llm_wrapper-26.4.dist-info/METADATA +816 -0
- local_llm_wrapper-26.4.dist-info/RECORD +17 -0
- local_llm_wrapper-26.4.dist-info/WHEEL +5 -0
- local_llm_wrapper-26.4.dist-info/licenses/LICENSE +674 -0
- local_llm_wrapper-26.4.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standardized exception types for the LLM wrapper.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
#============================================
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LLMError(RuntimeError):
|
|
11
|
+
"""
|
|
12
|
+
Base class for LLM wrapper errors.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransportUnavailableError(LLMError):
|
|
17
|
+
"""
|
|
18
|
+
Raised when a transport cannot be used on this machine.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContextWindowError(LLMError):
|
|
23
|
+
"""
|
|
24
|
+
Raised when the prompt exceeds a model context window.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GuardrailRefusalError(LLMError):
|
|
29
|
+
"""
|
|
30
|
+
Raised when a model refuses a prompt due to safety/guardrails.
|
|
31
|
+
"""
|
local_llm_wrapper/llm.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convenience facade for local_llm_wrapper.
|
|
3
|
+
|
|
4
|
+
External callers can use `import local_llm_wrapper.llm as llm` to access
|
|
5
|
+
the most common names from a single import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from local_llm_wrapper.errors import (
|
|
11
|
+
ContextWindowError,
|
|
12
|
+
GuardrailRefusalError,
|
|
13
|
+
LLMError,
|
|
14
|
+
TransportUnavailableError,
|
|
15
|
+
)
|
|
16
|
+
from local_llm_wrapper.llm_client import LLMClient
|
|
17
|
+
from local_llm_wrapper.llm_parsers import RenameResult, SortResult
|
|
18
|
+
from local_llm_wrapper.llm_utils import (
|
|
19
|
+
apple_models_available,
|
|
20
|
+
choose_model,
|
|
21
|
+
extract_xml_tag_content,
|
|
22
|
+
get_vram_size_in_gb,
|
|
23
|
+
sanitize_filename,
|
|
24
|
+
total_ram_bytes,
|
|
25
|
+
)
|
|
26
|
+
from local_llm_wrapper.transports.apple import AppleTransport
|
|
27
|
+
from local_llm_wrapper.transports.ollama import OllamaTransport
|
|
28
|
+
|
|
29
|
+
# Re-exports are intentional; __all__ suppresses pyflakes unused-import warnings.
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AppleTransport",
|
|
32
|
+
"ContextWindowError",
|
|
33
|
+
"GuardrailRefusalError",
|
|
34
|
+
"LLMClient",
|
|
35
|
+
"LLMError",
|
|
36
|
+
"OllamaTransport",
|
|
37
|
+
"RenameResult",
|
|
38
|
+
"SortResult",
|
|
39
|
+
"TransportUnavailableError",
|
|
40
|
+
"apple_models_available",
|
|
41
|
+
"choose_model",
|
|
42
|
+
"extract_xml_tag_content",
|
|
43
|
+
"get_vram_size_in_gb",
|
|
44
|
+
"sanitize_filename",
|
|
45
|
+
"total_ram_bytes",
|
|
46
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public client wrapper for the local LLM engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
# local repo modules
|
|
8
|
+
from local_llm_wrapper.llm_engine import LLMEngine
|
|
9
|
+
from local_llm_wrapper.llm_parsers import RenameResult, SortResult
|
|
10
|
+
from local_llm_wrapper.llm_prompts import SortItem
|
|
11
|
+
from local_llm_wrapper.transports.base import LLMTransport
|
|
12
|
+
|
|
13
|
+
#============================================
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMClient:
|
|
17
|
+
"""
|
|
18
|
+
Public entry point for local LLM usage.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
transports: list[LLMTransport],
|
|
24
|
+
*,
|
|
25
|
+
context: str | None = None,
|
|
26
|
+
quiet: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._engine = LLMEngine(
|
|
29
|
+
transports=transports,
|
|
30
|
+
context=context,
|
|
31
|
+
quiet=quiet,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#============================================
|
|
35
|
+
def generate(
|
|
36
|
+
self,
|
|
37
|
+
prompt: str | None = None,
|
|
38
|
+
*,
|
|
39
|
+
messages: list[dict[str, str]] | None = None,
|
|
40
|
+
purpose: str | None = None,
|
|
41
|
+
max_tokens: int = 1200,
|
|
42
|
+
) -> str:
|
|
43
|
+
return self._engine.generate(
|
|
44
|
+
prompt,
|
|
45
|
+
messages=messages,
|
|
46
|
+
purpose=purpose,
|
|
47
|
+
max_tokens=max_tokens,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#============================================
|
|
51
|
+
def rename(self, current_name: str, metadata: dict) -> RenameResult:
|
|
52
|
+
return self._engine.rename(current_name, metadata)
|
|
53
|
+
|
|
54
|
+
#============================================
|
|
55
|
+
def sort(self, files: list[SortItem | dict]) -> SortResult:
|
|
56
|
+
items: list[SortItem] = []
|
|
57
|
+
for item in files:
|
|
58
|
+
if isinstance(item, SortItem):
|
|
59
|
+
items.append(item)
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(item, dict):
|
|
62
|
+
required_keys = ("path", "name", "ext", "description")
|
|
63
|
+
for key in required_keys:
|
|
64
|
+
if key not in item:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"Sort items require path, name, ext, and description."
|
|
67
|
+
)
|
|
68
|
+
path = item["path"]
|
|
69
|
+
name = item["name"]
|
|
70
|
+
ext = item["ext"]
|
|
71
|
+
description = item["description"]
|
|
72
|
+
items.append(
|
|
73
|
+
SortItem(
|
|
74
|
+
path=path,
|
|
75
|
+
name=name,
|
|
76
|
+
ext=ext,
|
|
77
|
+
description=description,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
raise TypeError("Sort items must be SortItem or dict.")
|
|
82
|
+
return self._engine.sort(items)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-agnostic LLM engine with fallback and strict parsing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
# Standard Library
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
# local repo modules
|
|
12
|
+
from local_llm_wrapper.errors import TransportUnavailableError
|
|
13
|
+
from local_llm_wrapper.llm_parsers import ParseError, KeepResult, RenameResult, SortResult, parse_keep_response, parse_rename_response, parse_sort_response
|
|
14
|
+
from local_llm_wrapper.llm_prompts import (
|
|
15
|
+
KeepRequest,
|
|
16
|
+
RenameRequest,
|
|
17
|
+
SortItem,
|
|
18
|
+
SortRequest,
|
|
19
|
+
RENAME_EXAMPLE_OUTPUT,
|
|
20
|
+
KEEP_EXAMPLE_OUTPUT,
|
|
21
|
+
SORT_EXAMPLE_OUTPUT,
|
|
22
|
+
build_format_fix_prompt,
|
|
23
|
+
build_keep_prompt,
|
|
24
|
+
build_rename_prompt,
|
|
25
|
+
build_rename_prompt_minimal,
|
|
26
|
+
build_sort_prompt,
|
|
27
|
+
)
|
|
28
|
+
from local_llm_wrapper.llm_utils import (
|
|
29
|
+
compute_stem_features,
|
|
30
|
+
_ensure_text_prompt,
|
|
31
|
+
_ensure_chat_messages,
|
|
32
|
+
format_chat_prompt,
|
|
33
|
+
_is_guardrail_error,
|
|
34
|
+
_is_context_window_error,
|
|
35
|
+
_print_llm,
|
|
36
|
+
log_parse_failure,
|
|
37
|
+
normalize_reason,
|
|
38
|
+
sanitize_filename,
|
|
39
|
+
)
|
|
40
|
+
from local_llm_wrapper.transports.base import LLMTransport
|
|
41
|
+
|
|
42
|
+
#============================================
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class LLMEngine:
|
|
47
|
+
transports: list[LLMTransport]
|
|
48
|
+
context: str | None = None
|
|
49
|
+
quiet: bool = False
|
|
50
|
+
|
|
51
|
+
#============================================
|
|
52
|
+
def generate(
|
|
53
|
+
self,
|
|
54
|
+
prompt: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
messages: list[dict[str, str]] | None = None,
|
|
57
|
+
purpose: str | None = None,
|
|
58
|
+
max_tokens: int = 1200,
|
|
59
|
+
) -> str:
|
|
60
|
+
if prompt is None and messages is None:
|
|
61
|
+
raise ValueError("Prompt or messages are required.")
|
|
62
|
+
if prompt is not None and messages is not None:
|
|
63
|
+
raise ValueError("Provide prompt or messages, not both.")
|
|
64
|
+
text_prompt: str | None = None
|
|
65
|
+
chat_messages: list[dict[str, str]] | None = None
|
|
66
|
+
if messages is not None:
|
|
67
|
+
chat_messages = _ensure_chat_messages(messages)
|
|
68
|
+
else:
|
|
69
|
+
text_prompt = _ensure_text_prompt(prompt)
|
|
70
|
+
return self._generate_with_fallback(
|
|
71
|
+
text_prompt,
|
|
72
|
+
messages=chat_messages,
|
|
73
|
+
purpose=purpose or "general response",
|
|
74
|
+
max_tokens=max_tokens,
|
|
75
|
+
retry_prompt=None,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
#============================================
|
|
79
|
+
def rename(self, current_name: str, metadata: dict) -> RenameResult:
|
|
80
|
+
req = RenameRequest(metadata=metadata, current_name=current_name, context=self.context)
|
|
81
|
+
prompt = build_rename_prompt(req)
|
|
82
|
+
raw = self._generate_with_fallback(
|
|
83
|
+
prompt,
|
|
84
|
+
messages=None,
|
|
85
|
+
purpose="filename based on content",
|
|
86
|
+
max_tokens=200,
|
|
87
|
+
retry_prompt=build_rename_prompt_minimal(req),
|
|
88
|
+
)
|
|
89
|
+
result = self._parse_with_retry(
|
|
90
|
+
lambda text: parse_rename_response(text),
|
|
91
|
+
prompt,
|
|
92
|
+
RENAME_EXAMPLE_OUTPUT,
|
|
93
|
+
raw,
|
|
94
|
+
purpose="filename based on content",
|
|
95
|
+
max_tokens=200,
|
|
96
|
+
)
|
|
97
|
+
result.new_name = sanitize_filename(result.new_name)
|
|
98
|
+
result.reason = normalize_reason(result.reason)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
#============================================
|
|
102
|
+
def stem_action(self, original_stem: str, suggested_name: str, extension: str | None = None) -> KeepResult:
|
|
103
|
+
features = compute_stem_features(original_stem, suggested_name)
|
|
104
|
+
req = KeepRequest(
|
|
105
|
+
original_stem=original_stem,
|
|
106
|
+
suggested_name=suggested_name,
|
|
107
|
+
extension=extension,
|
|
108
|
+
features=features,
|
|
109
|
+
)
|
|
110
|
+
prompt = build_keep_prompt(req)
|
|
111
|
+
raw = self._generate_with_fallback(
|
|
112
|
+
prompt,
|
|
113
|
+
messages=None,
|
|
114
|
+
purpose="how to handle the original filename stem",
|
|
115
|
+
max_tokens=120,
|
|
116
|
+
retry_prompt=None,
|
|
117
|
+
)
|
|
118
|
+
result = self._parse_with_retry(
|
|
119
|
+
lambda text: parse_keep_response(text, original_stem),
|
|
120
|
+
prompt,
|
|
121
|
+
KEEP_EXAMPLE_OUTPUT,
|
|
122
|
+
raw,
|
|
123
|
+
purpose="how to handle the original filename stem",
|
|
124
|
+
max_tokens=120,
|
|
125
|
+
)
|
|
126
|
+
result.reason = normalize_reason(result.reason)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
#============================================
|
|
130
|
+
def sort(self, files: list[SortItem]) -> SortResult:
|
|
131
|
+
if not files:
|
|
132
|
+
return SortResult(assignments={}, raw_text="")
|
|
133
|
+
assignments: dict[str, str] = {}
|
|
134
|
+
reasons: dict[str, str] = {}
|
|
135
|
+
last_raw = ""
|
|
136
|
+
for item in files:
|
|
137
|
+
req = SortRequest(files=[item], context=self.context)
|
|
138
|
+
prompt = build_sort_prompt(req)
|
|
139
|
+
raw = self._generate_with_fallback(
|
|
140
|
+
prompt,
|
|
141
|
+
messages=None,
|
|
142
|
+
purpose="category assignment",
|
|
143
|
+
max_tokens=120,
|
|
144
|
+
retry_prompt=None,
|
|
145
|
+
)
|
|
146
|
+
result = self._parse_with_retry(
|
|
147
|
+
lambda text: parse_sort_response(text, [item.path]),
|
|
148
|
+
prompt,
|
|
149
|
+
SORT_EXAMPLE_OUTPUT,
|
|
150
|
+
raw,
|
|
151
|
+
purpose="category assignment",
|
|
152
|
+
max_tokens=120,
|
|
153
|
+
)
|
|
154
|
+
assignments.update(result.assignments)
|
|
155
|
+
for path, reason in result.reasons.items():
|
|
156
|
+
reasons[path] = normalize_reason(reason)
|
|
157
|
+
last_raw = result.raw_text
|
|
158
|
+
return SortResult(assignments=assignments, reasons=reasons, raw_text=last_raw)
|
|
159
|
+
|
|
160
|
+
#============================================
|
|
161
|
+
def _generate_with_fallback(
|
|
162
|
+
self,
|
|
163
|
+
prompt: str | None,
|
|
164
|
+
*,
|
|
165
|
+
messages: list[dict[str, str]] | None,
|
|
166
|
+
purpose: str,
|
|
167
|
+
max_tokens: int,
|
|
168
|
+
retry_prompt: str | None,
|
|
169
|
+
) -> str:
|
|
170
|
+
last_exc: Exception | None = None
|
|
171
|
+
for idx, transport in enumerate(self.transports):
|
|
172
|
+
try:
|
|
173
|
+
if not self.quiet:
|
|
174
|
+
_print_llm(f"asking {transport.name} for {purpose}")
|
|
175
|
+
return self._generate_on_transport(
|
|
176
|
+
transport,
|
|
177
|
+
prompt,
|
|
178
|
+
messages,
|
|
179
|
+
purpose,
|
|
180
|
+
max_tokens,
|
|
181
|
+
)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
last_exc = exc
|
|
184
|
+
if isinstance(exc, TransportUnavailableError):
|
|
185
|
+
continue
|
|
186
|
+
if _is_guardrail_error(exc) or _is_context_window_error(exc):
|
|
187
|
+
if retry_prompt and idx == 0:
|
|
188
|
+
try:
|
|
189
|
+
if not self.quiet:
|
|
190
|
+
_print_llm(
|
|
191
|
+
f"retrying {transport.name} with minimal prompt for {purpose}"
|
|
192
|
+
)
|
|
193
|
+
return self._generate_on_transport(
|
|
194
|
+
transport,
|
|
195
|
+
retry_prompt,
|
|
196
|
+
None,
|
|
197
|
+
purpose,
|
|
198
|
+
max_tokens,
|
|
199
|
+
)
|
|
200
|
+
except Exception as retry_exc:
|
|
201
|
+
last_exc = retry_exc
|
|
202
|
+
if _is_guardrail_error(retry_exc) or _is_context_window_error(retry_exc):
|
|
203
|
+
continue
|
|
204
|
+
raise
|
|
205
|
+
continue
|
|
206
|
+
raise
|
|
207
|
+
if last_exc:
|
|
208
|
+
raise last_exc
|
|
209
|
+
raise TransportUnavailableError("No LLM transports available.")
|
|
210
|
+
|
|
211
|
+
#============================================
|
|
212
|
+
def _parse_with_retry(
|
|
213
|
+
self,
|
|
214
|
+
parser,
|
|
215
|
+
original_prompt: str,
|
|
216
|
+
example_output: str,
|
|
217
|
+
raw_text: str,
|
|
218
|
+
*,
|
|
219
|
+
purpose: str,
|
|
220
|
+
max_tokens: int,
|
|
221
|
+
):
|
|
222
|
+
try:
|
|
223
|
+
return parser(raw_text)
|
|
224
|
+
except ParseError as exc:
|
|
225
|
+
excerpt = " ".join(raw_text.split())[:160]
|
|
226
|
+
if not self.quiet:
|
|
227
|
+
print(f"[WHY] parse_error: {exc} (excerpt: {excerpt})")
|
|
228
|
+
log_parse_failure(
|
|
229
|
+
purpose=purpose,
|
|
230
|
+
error=exc,
|
|
231
|
+
raw_text=exc.raw_text or raw_text,
|
|
232
|
+
prompt=original_prompt,
|
|
233
|
+
stage="initial",
|
|
234
|
+
)
|
|
235
|
+
fix_prompt = build_format_fix_prompt(example_output)
|
|
236
|
+
last_parse: ParseError | None = None
|
|
237
|
+
last_transport: Exception | None = None
|
|
238
|
+
last_fixed: str | None = None
|
|
239
|
+
for transport in self.transports:
|
|
240
|
+
try:
|
|
241
|
+
if not self.quiet:
|
|
242
|
+
_print_llm(f"asking {transport.name} for {purpose} (format fix)")
|
|
243
|
+
fixed = self._generate_on_transport(
|
|
244
|
+
transport,
|
|
245
|
+
fix_prompt,
|
|
246
|
+
None,
|
|
247
|
+
f"{purpose} (format fix)",
|
|
248
|
+
max_tokens,
|
|
249
|
+
)
|
|
250
|
+
last_fixed = fixed
|
|
251
|
+
except Exception as transport_exc:
|
|
252
|
+
if _is_guardrail_error(transport_exc):
|
|
253
|
+
last_transport = transport_exc
|
|
254
|
+
continue
|
|
255
|
+
last_transport = transport_exc
|
|
256
|
+
continue
|
|
257
|
+
try:
|
|
258
|
+
return parser(fixed)
|
|
259
|
+
except ParseError as parse_exc:
|
|
260
|
+
last_parse = parse_exc
|
|
261
|
+
log_parse_failure(
|
|
262
|
+
purpose=purpose,
|
|
263
|
+
error=parse_exc,
|
|
264
|
+
raw_text=parse_exc.raw_text or fixed,
|
|
265
|
+
prompt=fix_prompt,
|
|
266
|
+
stage=f"format fix ({transport.name})",
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
if last_parse:
|
|
270
|
+
text = last_fixed or raw_text
|
|
271
|
+
raise ParseError(str(last_parse), raw_text=text)
|
|
272
|
+
if last_transport:
|
|
273
|
+
raise last_transport
|
|
274
|
+
raise ParseError("Format-fix retry failed.")
|
|
275
|
+
|
|
276
|
+
#============================================
|
|
277
|
+
def _generate_on_transport(
|
|
278
|
+
self,
|
|
279
|
+
transport: LLMTransport,
|
|
280
|
+
prompt: str | None,
|
|
281
|
+
messages: list[dict[str, str]] | None,
|
|
282
|
+
purpose: str,
|
|
283
|
+
max_tokens: int,
|
|
284
|
+
) -> str:
|
|
285
|
+
if messages is not None:
|
|
286
|
+
generate_chat = getattr(transport, "generate_chat", None)
|
|
287
|
+
if callable(generate_chat):
|
|
288
|
+
result = generate_chat(messages, purpose=purpose, max_tokens=max_tokens)
|
|
289
|
+
return cast(str, result)
|
|
290
|
+
prompt = format_chat_prompt(messages)
|
|
291
|
+
if prompt is None:
|
|
292
|
+
raise ValueError("Prompt or messages are required.")
|
|
293
|
+
return transport.generate(prompt, purpose=purpose, max_tokens=max_tokens)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-agnostic response parsers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
# Standard Library
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# local repo modules
|
|
13
|
+
|
|
14
|
+
#============================================
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParseError(RuntimeError):
|
|
18
|
+
"""
|
|
19
|
+
Raised when a model response does not match required tags.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, raw_text: str = "") -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.raw_text = raw_text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class RenameResult:
|
|
29
|
+
new_name: str
|
|
30
|
+
reason: str
|
|
31
|
+
raw_text: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class KeepResult:
|
|
36
|
+
stem_action: str
|
|
37
|
+
reason: str
|
|
38
|
+
raw_text: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class SortResult:
|
|
43
|
+
assignments: dict[str, str]
|
|
44
|
+
raw_text: str
|
|
45
|
+
reasons: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_CODE_FENCE_RE = re.compile(r"```[a-zA-Z0-9_+-]*\n(.*?)```", re.DOTALL)
|
|
49
|
+
_TAG_NAME_RE = re.compile(r"^[a-zA-Z0-9_:-]+$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _strip_code_fences(text: str) -> str:
|
|
53
|
+
if not text:
|
|
54
|
+
return ""
|
|
55
|
+
cleaned = text.strip()
|
|
56
|
+
if "```" not in cleaned:
|
|
57
|
+
return cleaned
|
|
58
|
+
def _unwrap(match: re.Match) -> str:
|
|
59
|
+
return match.group(1)
|
|
60
|
+
cleaned = _CODE_FENCE_RE.sub(_unwrap, cleaned)
|
|
61
|
+
return cleaned.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _coerce_response_body(text: str) -> str:
|
|
65
|
+
cleaned = _strip_code_fences(text).strip().strip('"').strip("'")
|
|
66
|
+
if "<" in cleaned:
|
|
67
|
+
unescaped = html.unescape(cleaned)
|
|
68
|
+
if unescaped:
|
|
69
|
+
cleaned = unescaped
|
|
70
|
+
return cleaned
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_tag_values(text: str, tag: str) -> list[str]:
|
|
74
|
+
pattern = re.compile(
|
|
75
|
+
rf"<{tag}\b[^>]*>(.*?)</{tag}>",
|
|
76
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
77
|
+
)
|
|
78
|
+
return [match.strip() for match in pattern.findall(text)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_tag_response(text: str, tag: str) -> str:
|
|
82
|
+
response_body = _coerce_response_body(text)
|
|
83
|
+
if not response_body:
|
|
84
|
+
raise ParseError("Missing required tags in response.", text)
|
|
85
|
+
if not isinstance(tag, str):
|
|
86
|
+
raise TypeError("Tag name must be a string.")
|
|
87
|
+
tag_name = tag.strip()
|
|
88
|
+
if not tag_name:
|
|
89
|
+
raise ValueError("Tag name must not be empty.")
|
|
90
|
+
if not _TAG_NAME_RE.match(tag_name):
|
|
91
|
+
raise ValueError("Tag name must use letters, numbers, underscores, dashes, or colons.")
|
|
92
|
+
values = _find_tag_values(response_body, tag_name)
|
|
93
|
+
if not values:
|
|
94
|
+
raise ParseError(f"Missing <{tag_name}> in response.", text)
|
|
95
|
+
if len(values) > 1:
|
|
96
|
+
raise ParseError(f"Duplicate <{tag_name}> tags in response.", text)
|
|
97
|
+
return values[0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_rename_response(text: str) -> RenameResult:
|
|
101
|
+
response_body = _coerce_response_body(text)
|
|
102
|
+
if not response_body:
|
|
103
|
+
raise ParseError("Missing required tags in rename response.", text)
|
|
104
|
+
new_names = _find_tag_values(response_body, "new_name")
|
|
105
|
+
if not new_names:
|
|
106
|
+
raise ParseError("Missing <new_name> in rename response.", text)
|
|
107
|
+
if len(new_names) > 1:
|
|
108
|
+
raise ParseError("Duplicate <new_name> tags in rename response.", text)
|
|
109
|
+
reasons = _find_tag_values(response_body, "reason")
|
|
110
|
+
if len(reasons) > 1:
|
|
111
|
+
raise ParseError("Duplicate <reason> tags in rename response.", text)
|
|
112
|
+
new_name = new_names[0]
|
|
113
|
+
reason = reasons[0] if reasons else ""
|
|
114
|
+
return RenameResult(new_name=new_name, reason=reason, raw_text=text)
|
|
115
|
+
|
|
116
|
+
def parse_keep_response(
|
|
117
|
+
text: str, original_stem: str
|
|
118
|
+
) -> KeepResult:
|
|
119
|
+
response_body = _coerce_response_body(text)
|
|
120
|
+
if not response_body:
|
|
121
|
+
raise ParseError("Missing required tags in keep response.", text)
|
|
122
|
+
stem_actions = _find_tag_values(response_body, "stem_action")
|
|
123
|
+
if len(stem_actions) > 1:
|
|
124
|
+
raise ParseError("Duplicate <stem_action> tags in keep response.", text)
|
|
125
|
+
reason_values = _find_tag_values(response_body, "reason")
|
|
126
|
+
if not reason_values:
|
|
127
|
+
raise ParseError("Missing <reason> in keep response.", text)
|
|
128
|
+
if len(reason_values) > 1:
|
|
129
|
+
raise ParseError("Duplicate <reason> tags in keep response.", text)
|
|
130
|
+
reason = reason_values[0].strip()
|
|
131
|
+
if stem_actions:
|
|
132
|
+
stem_action = stem_actions[0].strip().lower()
|
|
133
|
+
else:
|
|
134
|
+
keep_values = _find_tag_values(response_body, "keep_original")
|
|
135
|
+
if not keep_values:
|
|
136
|
+
raise ParseError("Missing <stem_action> in keep response.", text)
|
|
137
|
+
if len(keep_values) > 1:
|
|
138
|
+
raise ParseError("Duplicate <keep_original> tags in keep response.", text)
|
|
139
|
+
keep_text = keep_values[0].strip().lower()
|
|
140
|
+
stem_action = (
|
|
141
|
+
"keep"
|
|
142
|
+
if (keep_text.startswith("t") or keep_text == "1" or keep_text == "yes")
|
|
143
|
+
else "drop"
|
|
144
|
+
)
|
|
145
|
+
reason = reason.replace('\\"', '"').replace("\\'", "'")
|
|
146
|
+
if stem_action not in {"drop", "keep", "normalize"}:
|
|
147
|
+
raise ParseError("Invalid <stem_action> value in keep response.", text)
|
|
148
|
+
if not reason:
|
|
149
|
+
raise ParseError("Missing <reason> in keep response.", text)
|
|
150
|
+
return KeepResult(stem_action=stem_action, reason=reason, raw_text=text)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_sort_response(text: str, expected_paths: list[str]) -> SortResult:
|
|
154
|
+
response_body = _coerce_response_body(text)
|
|
155
|
+
if not response_body:
|
|
156
|
+
raise ParseError("Missing required tags in sort response.", text)
|
|
157
|
+
if len(expected_paths) != 1:
|
|
158
|
+
raise ParseError("Sort responses only support a single file.", text)
|
|
159
|
+
categories = _find_tag_values(response_body, "category")
|
|
160
|
+
if not categories:
|
|
161
|
+
raise ParseError("Missing <category> in sort response.", text)
|
|
162
|
+
if len(categories) > 1:
|
|
163
|
+
raise ParseError("Duplicate <category> tags in sort response.", text)
|
|
164
|
+
category = categories[0].strip()
|
|
165
|
+
reasons = _find_tag_values(response_body, "reason")
|
|
166
|
+
if len(reasons) > 1:
|
|
167
|
+
raise ParseError("Duplicate <reason> tags in sort response.", text)
|
|
168
|
+
reason = reasons[0].strip() if reasons else ""
|
|
169
|
+
return SortResult(
|
|
170
|
+
assignments={expected_paths[0]: category},
|
|
171
|
+
reasons={expected_paths[0]: reason} if reason else {},
|
|
172
|
+
raw_text=text,
|
|
173
|
+
)
|