markdown-flow 0.2.19__py3-none-any.whl → 0.2.31__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.
- markdown_flow/__init__.py +4 -4
- markdown_flow/constants.py +245 -124
- markdown_flow/core.py +701 -212
- markdown_flow/llm.py +4 -3
- markdown_flow/models.py +1 -17
- markdown_flow/parser/__init__.py +38 -0
- markdown_flow/parser/code_fence_utils.py +190 -0
- markdown_flow/parser/interaction.py +354 -0
- markdown_flow/parser/json_parser.py +50 -0
- markdown_flow/parser/output.py +215 -0
- markdown_flow/parser/preprocessor.py +151 -0
- markdown_flow/parser/validation.py +96 -0
- markdown_flow/parser/variable.py +95 -0
- markdown_flow/providers/__init__.py +16 -0
- markdown_flow/providers/config.py +46 -0
- markdown_flow/providers/openai.py +369 -0
- markdown_flow/utils.py +43 -43
- {markdown_flow-0.2.19.dist-info → markdown_flow-0.2.31.dist-info}/METADATA +45 -52
- markdown_flow-0.2.31.dist-info/RECORD +24 -0
- markdown_flow-0.2.19.dist-info/RECORD +0 -13
- {markdown_flow-0.2.19.dist-info → markdown_flow-0.2.31.dist-info}/WHEEL +0 -0
- {markdown_flow-0.2.19.dist-info → markdown_flow-0.2.31.dist-info}/licenses/LICENSE +0 -0
- {markdown_flow-0.2.19.dist-info → markdown_flow-0.2.31.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI-Compatible Provider Implementation
|
|
3
|
+
|
|
4
|
+
Provides a production-ready OpenAI-compatible LLM provider with debug mode,
|
|
5
|
+
token tracking, and comprehensive metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..llm import LLMProvider
|
|
13
|
+
from .config import ProviderConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from openai import OpenAI
|
|
18
|
+
except ImportError:
|
|
19
|
+
OpenAI = None # type: ignore[misc, assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAIProvider(LLMProvider):
|
|
23
|
+
"""
|
|
24
|
+
OpenAI-compatible LLM provider implementation.
|
|
25
|
+
|
|
26
|
+
Features:
|
|
27
|
+
- Debug mode with colorized console output
|
|
28
|
+
- Automatic token usage tracking
|
|
29
|
+
- Comprehensive metadata (model, temperature, processing time, tokens, timestamp)
|
|
30
|
+
- Instance-level model/temperature override support
|
|
31
|
+
- Streaming and non-streaming modes
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: ProviderConfig):
|
|
35
|
+
"""
|
|
36
|
+
Initialize OpenAI provider.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
config: Provider configuration
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ImportError: If openai package is not installed
|
|
43
|
+
ValueError: If configuration is invalid
|
|
44
|
+
"""
|
|
45
|
+
if OpenAI is None:
|
|
46
|
+
raise ImportError("The 'openai' package is required for OpenAIProvider. Install it with: pip install openai")
|
|
47
|
+
|
|
48
|
+
self.config = config
|
|
49
|
+
self.client = OpenAI(
|
|
50
|
+
api_key=config.api_key,
|
|
51
|
+
base_url=config.base_url,
|
|
52
|
+
timeout=config.timeout,
|
|
53
|
+
)
|
|
54
|
+
self._last_metadata: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
def complete(
|
|
57
|
+
self,
|
|
58
|
+
messages: list[dict[str, str]],
|
|
59
|
+
model: str | None = None,
|
|
60
|
+
temperature: float | None = None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Non-streaming LLM call.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
messages: Message list
|
|
67
|
+
model: Optional model override
|
|
68
|
+
temperature: Optional temperature override
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
LLM response content
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Exception: If API call fails
|
|
75
|
+
"""
|
|
76
|
+
# Determine actual model and temperature (instance override > provider default)
|
|
77
|
+
actual_model = model if model is not None else self.config.model
|
|
78
|
+
actual_temperature = temperature if temperature is not None else self.config.temperature
|
|
79
|
+
|
|
80
|
+
# Debug output: Request info
|
|
81
|
+
if self.config.debug:
|
|
82
|
+
self._print_request_info(messages, actual_model, actual_temperature)
|
|
83
|
+
|
|
84
|
+
# Format messages
|
|
85
|
+
formatted_messages = self._format_messages(messages)
|
|
86
|
+
|
|
87
|
+
# Record start time
|
|
88
|
+
start_time = time.time()
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Make API call
|
|
92
|
+
response = self.client.chat.completions.create(
|
|
93
|
+
model=actual_model,
|
|
94
|
+
messages=formatted_messages,
|
|
95
|
+
temperature=actual_temperature,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Calculate processing time
|
|
99
|
+
processing_time_ms = int((time.time() - start_time) * 1000)
|
|
100
|
+
|
|
101
|
+
# Extract content
|
|
102
|
+
if not response.choices or len(response.choices) == 0:
|
|
103
|
+
raise Exception("API response error: no choices returned")
|
|
104
|
+
|
|
105
|
+
choice = response.choices[0]
|
|
106
|
+
if not choice.message:
|
|
107
|
+
raise Exception("Response has no message field")
|
|
108
|
+
|
|
109
|
+
content = choice.message.content or ""
|
|
110
|
+
|
|
111
|
+
# Extract token usage
|
|
112
|
+
usage = response.usage
|
|
113
|
+
metadata = {
|
|
114
|
+
"model": actual_model,
|
|
115
|
+
"temperature": actual_temperature,
|
|
116
|
+
"provider": "openai-compatible",
|
|
117
|
+
"processing_time": processing_time_ms,
|
|
118
|
+
"timestamp": int(time.time()),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if usage:
|
|
122
|
+
metadata.update(
|
|
123
|
+
{
|
|
124
|
+
"prompt_tokens": usage.prompt_tokens,
|
|
125
|
+
"output_tokens": usage.completion_tokens,
|
|
126
|
+
"total_tokens": usage.total_tokens,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Save metadata for retrieval by MarkdownFlow
|
|
131
|
+
self._last_metadata = metadata
|
|
132
|
+
|
|
133
|
+
# Debug output: Response metadata
|
|
134
|
+
if self.config.debug:
|
|
135
|
+
self._print_response_metadata(metadata)
|
|
136
|
+
|
|
137
|
+
return content
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise Exception(f"API request failed: {str(e)}") from e
|
|
141
|
+
|
|
142
|
+
def stream(
|
|
143
|
+
self,
|
|
144
|
+
messages: list[dict[str, str]],
|
|
145
|
+
model: str | None = None,
|
|
146
|
+
temperature: float | None = None,
|
|
147
|
+
) -> Generator[str, None, None]:
|
|
148
|
+
"""
|
|
149
|
+
Streaming LLM call.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
messages: Message list
|
|
153
|
+
model: Optional model override
|
|
154
|
+
temperature: Optional temperature override
|
|
155
|
+
|
|
156
|
+
Yields:
|
|
157
|
+
Incremental LLM response content
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
Exception: If API call fails
|
|
161
|
+
"""
|
|
162
|
+
# Determine actual model and temperature
|
|
163
|
+
actual_model = model if model is not None else self.config.model
|
|
164
|
+
actual_temperature = temperature if temperature is not None else self.config.temperature
|
|
165
|
+
|
|
166
|
+
# Debug output: Request info
|
|
167
|
+
if self.config.debug:
|
|
168
|
+
self._print_request_info(messages, actual_model, actual_temperature)
|
|
169
|
+
|
|
170
|
+
# Format messages
|
|
171
|
+
formatted_messages = self._format_messages(messages)
|
|
172
|
+
|
|
173
|
+
# Record start time
|
|
174
|
+
start_time = time.time()
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
# Create streaming response
|
|
178
|
+
stream = self.client.chat.completions.create(
|
|
179
|
+
model=actual_model,
|
|
180
|
+
messages=formatted_messages,
|
|
181
|
+
temperature=actual_temperature,
|
|
182
|
+
stream=True,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
for chunk in stream:
|
|
186
|
+
if chunk.choices and chunk.choices[0].delta.content:
|
|
187
|
+
yield chunk.choices[0].delta.content
|
|
188
|
+
|
|
189
|
+
# Calculate processing time after stream completes
|
|
190
|
+
processing_time_ms = int((time.time() - start_time) * 1000)
|
|
191
|
+
|
|
192
|
+
# Save metadata for retrieval by MarkdownFlow
|
|
193
|
+
metadata = {
|
|
194
|
+
"model": actual_model,
|
|
195
|
+
"temperature": actual_temperature,
|
|
196
|
+
"provider": "openai-compatible",
|
|
197
|
+
"processing_time": processing_time_ms,
|
|
198
|
+
"timestamp": int(time.time()),
|
|
199
|
+
"stream_done": True,
|
|
200
|
+
}
|
|
201
|
+
self._last_metadata = metadata
|
|
202
|
+
|
|
203
|
+
# Debug output: Stream completion info
|
|
204
|
+
if self.config.debug:
|
|
205
|
+
self._print_response_metadata(metadata)
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
raise ValueError(f"Streaming request failed: {str(e)}") from e
|
|
209
|
+
|
|
210
|
+
def get_last_metadata(self) -> dict[str, Any]:
|
|
211
|
+
"""
|
|
212
|
+
Get metadata from the last LLM call.
|
|
213
|
+
|
|
214
|
+
This method allows MarkdownFlow to retrieve comprehensive metadata including
|
|
215
|
+
token usage, processing time, and other information from the most recent
|
|
216
|
+
complete() or stream() call.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dictionary containing metadata:
|
|
220
|
+
- model: Model name used
|
|
221
|
+
- temperature: Temperature value used
|
|
222
|
+
- provider: Provider identifier
|
|
223
|
+
- processing_time: Processing time in milliseconds
|
|
224
|
+
- timestamp: Unix timestamp
|
|
225
|
+
- prompt_tokens: Number of input tokens (if available)
|
|
226
|
+
- output_tokens: Number of output tokens (if available)
|
|
227
|
+
- total_tokens: Total tokens (if available)
|
|
228
|
+
- stream_done: True if this was a completed stream (stream mode only)
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> provider = create_default_provider()
|
|
232
|
+
>>> content = provider.complete(messages)
|
|
233
|
+
>>> metadata = provider.get_last_metadata()
|
|
234
|
+
>>> print(f"Used {metadata['total_tokens']} tokens")
|
|
235
|
+
"""
|
|
236
|
+
return self._last_metadata.copy()
|
|
237
|
+
|
|
238
|
+
def _format_messages(self, messages: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
239
|
+
"""
|
|
240
|
+
Format messages for API call.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
messages: Raw message list
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Formatted message list
|
|
247
|
+
"""
|
|
248
|
+
formatted = []
|
|
249
|
+
for msg in messages:
|
|
250
|
+
if isinstance(msg, dict) and "role" in msg and "content" in msg:
|
|
251
|
+
formatted.append(
|
|
252
|
+
{
|
|
253
|
+
"role": msg["role"],
|
|
254
|
+
"content": str(msg["content"]),
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
# Fallback for non-standard format
|
|
259
|
+
formatted.append(
|
|
260
|
+
{
|
|
261
|
+
"role": "user",
|
|
262
|
+
"content": str(msg),
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
return formatted
|
|
266
|
+
|
|
267
|
+
def _print_request_info(self, messages: list[dict[str, str]], model: str, temperature: float) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Print colorized request information to console (debug mode).
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
messages: Message list
|
|
273
|
+
model: Model name
|
|
274
|
+
temperature: Temperature value
|
|
275
|
+
"""
|
|
276
|
+
print("\033[97m\033[44m[ ====== LLM Request Start ====== ]\033[0m")
|
|
277
|
+
print(f"\033[30m\033[42mmodel\033[0m: {model}")
|
|
278
|
+
print(f"\033[30m\033[42mtemperature\033[0m: {temperature}")
|
|
279
|
+
|
|
280
|
+
for message in messages:
|
|
281
|
+
role = message.get("role", "user")
|
|
282
|
+
content = message.get("content", "")
|
|
283
|
+
# Truncate long content for readability
|
|
284
|
+
display_content = content
|
|
285
|
+
print(f"\033[30m\033[43m{role}\033[0m: {display_content}")
|
|
286
|
+
|
|
287
|
+
print("\033[97m\033[44m[ ====== LLM Request End ====== ]\033[0m")
|
|
288
|
+
|
|
289
|
+
def _print_response_metadata(self, metadata: dict[str, Any]) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Print colorized response metadata to console (debug mode).
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
metadata: Response metadata dictionary
|
|
295
|
+
"""
|
|
296
|
+
print("\033[97m\033[42m[ ====== LLM Response Metadata ====== ]\033[0m")
|
|
297
|
+
|
|
298
|
+
# Essential fields
|
|
299
|
+
print(f"\033[36mmodel:\033[0m {metadata.get('model', 'N/A')}")
|
|
300
|
+
print(f"\033[36mtemperature:\033[0m {metadata.get('temperature', 'N/A')}")
|
|
301
|
+
print(f"\033[36mprovider:\033[0m {metadata.get('provider', 'N/A')}")
|
|
302
|
+
print(f"\033[36mprocessing_time:\033[0m {metadata.get('processing_time', 'N/A')} ms")
|
|
303
|
+
|
|
304
|
+
# Token usage (if available)
|
|
305
|
+
if "prompt_tokens" in metadata:
|
|
306
|
+
print(
|
|
307
|
+
f"\033[36mprompt_tokens:\033[0m \033[33m{metadata['prompt_tokens']}\033[0m "
|
|
308
|
+
f"\033[36moutput_tokens:\033[0m \033[33m{metadata['output_tokens']}\033[0m "
|
|
309
|
+
f"\033[36mtotal_tokens:\033[0m \033[32m{metadata['total_tokens']}\033[0m"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
print(f"\033[36mtimestamp:\033[0m {metadata.get('timestamp', 'N/A')}")
|
|
313
|
+
|
|
314
|
+
if metadata.get("stream_done"):
|
|
315
|
+
print("\033[36mstream:\033[0m completed")
|
|
316
|
+
|
|
317
|
+
print("\033[97m\033[42m[ ====== ======================= ====== ]\033[0m")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def create_provider(config: ProviderConfig | None = None) -> OpenAIProvider:
|
|
321
|
+
"""
|
|
322
|
+
Create an OpenAI provider instance.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
config: Optional provider configuration. If None, uses default config
|
|
326
|
+
(reads from environment variables).
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
OpenAIProvider instance
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
ValueError: If configuration is invalid
|
|
333
|
+
ImportError: If openai package is not installed
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
>>> config = ProviderConfig(api_key="sk-...", model="gpt-4")
|
|
337
|
+
>>> provider = create_provider(config)
|
|
338
|
+
"""
|
|
339
|
+
if config is None:
|
|
340
|
+
config = ProviderConfig()
|
|
341
|
+
return OpenAIProvider(config)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def create_default_provider() -> OpenAIProvider:
|
|
345
|
+
"""
|
|
346
|
+
Create an OpenAI provider with default configuration.
|
|
347
|
+
|
|
348
|
+
Reads configuration from environment variables:
|
|
349
|
+
- LLM_API_KEY: API key (required)
|
|
350
|
+
- LLM_BASE_URL: Base URL (default: https://api.openai.com/v1)
|
|
351
|
+
- LLM_MODEL: Model name (default: gpt-3.5-turbo)
|
|
352
|
+
- LLM_TEMPERATURE: Temperature (default: 0.7)
|
|
353
|
+
- LLM_DEBUG: Debug mode (default: false)
|
|
354
|
+
- LLM_TIMEOUT: Request timeout in seconds (default: None, no timeout)
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
OpenAIProvider instance with default config
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
ValueError: If LLM_API_KEY is not set
|
|
361
|
+
ImportError: If openai package is not installed
|
|
362
|
+
|
|
363
|
+
Example:
|
|
364
|
+
>>> # Set environment variable first
|
|
365
|
+
>>> import os
|
|
366
|
+
>>> os.environ["LLM_API_KEY"] = "sk-..."
|
|
367
|
+
>>> provider = create_default_provider()
|
|
368
|
+
"""
|
|
369
|
+
return create_provider()
|
markdown_flow/utils.py
CHANGED
|
@@ -19,6 +19,7 @@ from .constants import (
|
|
|
19
19
|
COMPILED_PERCENT_VARIABLE_REGEX,
|
|
20
20
|
COMPILED_PRESERVE_FENCE_REGEX,
|
|
21
21
|
COMPILED_SINGLE_PIPE_SPLIT_REGEX,
|
|
22
|
+
CONTEXT_BUTTON_OPTIONS_TEMPLATE,
|
|
22
23
|
CONTEXT_CONVERSATION_TEMPLATE,
|
|
23
24
|
CONTEXT_QUESTION_MARKER,
|
|
24
25
|
CONTEXT_QUESTION_TEMPLATE,
|
|
@@ -67,7 +68,7 @@ def is_preserved_content_block(content: str) -> bool:
|
|
|
67
68
|
Check if content is completely preserved content block.
|
|
68
69
|
|
|
69
70
|
Preserved blocks are entirely wrapped by markers with no external content.
|
|
70
|
-
Supports inline (===content===)
|
|
71
|
+
Supports inline (===content===), multiline (!=== ... !===) formats, and mixed formats.
|
|
71
72
|
|
|
72
73
|
Args:
|
|
73
74
|
content: Content to check
|
|
@@ -81,61 +82,50 @@ def is_preserved_content_block(content: str) -> bool:
|
|
|
81
82
|
|
|
82
83
|
lines = content.split("\n")
|
|
83
84
|
|
|
84
|
-
#
|
|
85
|
-
all_inline_format = True
|
|
86
|
-
has_any_content = False
|
|
87
|
-
|
|
88
|
-
for line in lines:
|
|
89
|
-
stripped_line = line.strip()
|
|
90
|
-
if stripped_line: # Non-empty line
|
|
91
|
-
has_any_content = True
|
|
92
|
-
# Check if inline format: ===content===
|
|
93
|
-
match = COMPILED_INLINE_PRESERVE_REGEX.match(stripped_line)
|
|
94
|
-
if match:
|
|
95
|
-
# Ensure inner content exists and contains no ===
|
|
96
|
-
inner_content = match.group(1).strip()
|
|
97
|
-
if not inner_content or "===" in inner_content:
|
|
98
|
-
all_inline_format = False
|
|
99
|
-
break
|
|
100
|
-
else:
|
|
101
|
-
all_inline_format = False # type: ignore[unreachable]
|
|
102
|
-
break
|
|
103
|
-
|
|
104
|
-
# If all lines are inline format, return directly
|
|
105
|
-
if has_any_content and all_inline_format:
|
|
106
|
-
return True
|
|
107
|
-
|
|
108
|
-
# Check multiline format using state machine
|
|
85
|
+
# Use state machine to validate that all non-empty content is preserved
|
|
109
86
|
state = "OUTSIDE" # States: OUTSIDE, INSIDE
|
|
110
|
-
|
|
111
|
-
has_preserve_blocks = False # Has preserve blocks
|
|
87
|
+
has_preserve_content = False
|
|
112
88
|
|
|
113
89
|
for line in lines:
|
|
114
90
|
stripped_line = line.strip()
|
|
115
91
|
|
|
92
|
+
# Check if this line is a fence marker (!===)
|
|
116
93
|
if COMPILED_PRESERVE_FENCE_REGEX.match(stripped_line):
|
|
117
94
|
if state == "OUTSIDE":
|
|
118
95
|
# Enter preserve block
|
|
119
96
|
state = "INSIDE"
|
|
120
|
-
|
|
97
|
+
has_preserve_content = True
|
|
121
98
|
elif state == "INSIDE":
|
|
122
99
|
# Exit preserve block
|
|
123
100
|
state = "OUTSIDE"
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
101
|
+
# Fence markers themselves are valid preserved content
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Non-fence lines
|
|
105
|
+
if stripped_line: # Non-empty line
|
|
106
|
+
if state == "INSIDE":
|
|
107
|
+
# Inside fence block, this is valid preserved content
|
|
108
|
+
has_preserve_content = True
|
|
109
|
+
else:
|
|
110
|
+
# Outside fence block, check if it's inline format
|
|
111
|
+
match = COMPILED_INLINE_PRESERVE_REGEX.match(stripped_line)
|
|
112
|
+
if match:
|
|
113
|
+
# Ensure inner content exists and contains no ===
|
|
114
|
+
inner_content = match.group(1).strip()
|
|
115
|
+
if inner_content and "===" not in inner_content:
|
|
116
|
+
# Valid inline format
|
|
117
|
+
has_preserve_content = True
|
|
118
|
+
else:
|
|
119
|
+
# Invalid inline format
|
|
120
|
+
return False
|
|
121
|
+
else:
|
|
122
|
+
# Not fence, not inline format -> external content
|
|
123
|
+
return False
|
|
133
124
|
|
|
134
125
|
# Judgment conditions:
|
|
135
|
-
# 1. Must have
|
|
136
|
-
# 2.
|
|
137
|
-
|
|
138
|
-
return has_preserve_blocks and not has_content_outside and state == "OUTSIDE"
|
|
126
|
+
# 1. Must have preserved content
|
|
127
|
+
# 2. Final state must be OUTSIDE (all fence blocks closed)
|
|
128
|
+
return has_preserve_content and state == "OUTSIDE"
|
|
139
129
|
|
|
140
130
|
|
|
141
131
|
def extract_interaction_question(content: str) -> str | None:
|
|
@@ -479,6 +469,7 @@ def generate_smart_validation_template(
|
|
|
479
469
|
target_variable: str,
|
|
480
470
|
context: list[dict[str, Any]] | None = None,
|
|
481
471
|
interaction_question: str | None = None,
|
|
472
|
+
buttons: list[dict[str, str]] | None = None,
|
|
482
473
|
) -> str:
|
|
483
474
|
"""
|
|
484
475
|
Generate smart validation template based on context and question.
|
|
@@ -487,19 +478,28 @@ def generate_smart_validation_template(
|
|
|
487
478
|
target_variable: Target variable name
|
|
488
479
|
context: Context message list with role and content fields
|
|
489
480
|
interaction_question: Question text from interaction block
|
|
481
|
+
buttons: Button options list with display and value fields
|
|
490
482
|
|
|
491
483
|
Returns:
|
|
492
484
|
Generated validation template
|
|
493
485
|
"""
|
|
494
486
|
# Build context information
|
|
495
487
|
context_info = ""
|
|
496
|
-
if interaction_question or context:
|
|
488
|
+
if interaction_question or context or buttons:
|
|
497
489
|
context_parts = []
|
|
498
490
|
|
|
499
491
|
# Add question information (most important, put first)
|
|
500
492
|
if interaction_question:
|
|
501
493
|
context_parts.append(CONTEXT_QUESTION_TEMPLATE.format(question=interaction_question))
|
|
502
494
|
|
|
495
|
+
# Add button options information
|
|
496
|
+
if buttons:
|
|
497
|
+
button_displays = [btn.get("display", "") for btn in buttons if btn.get("display")]
|
|
498
|
+
if button_displays:
|
|
499
|
+
button_options_str = ", ".join(button_displays)
|
|
500
|
+
button_info = CONTEXT_BUTTON_OPTIONS_TEMPLATE.format(button_options=button_options_str)
|
|
501
|
+
context_parts.append(button_info)
|
|
502
|
+
|
|
503
503
|
# Add conversation context
|
|
504
504
|
if context:
|
|
505
505
|
for msg in context:
|