markdown-flow 0.2.10__py3-none-any.whl → 0.2.30__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 +7 -7
- markdown_flow/constants.py +212 -49
- markdown_flow/core.py +614 -591
- markdown_flow/llm.py +10 -12
- 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 +100 -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 +49 -51
- {markdown_flow-0.2.10.dist-info → markdown_flow-0.2.30.dist-info}/METADATA +18 -107
- markdown_flow-0.2.30.dist-info/RECORD +24 -0
- markdown_flow-0.2.10.dist-info/RECORD +0 -13
- {markdown_flow-0.2.10.dist-info → markdown_flow-0.2.30.dist-info}/WHEEL +0 -0
- {markdown_flow-0.2.10.dist-info → markdown_flow-0.2.30.dist-info}/licenses/LICENSE +0 -0
- {markdown_flow-0.2.10.dist-info → markdown_flow-0.2.30.dist-info}/top_level.txt +0 -0
markdown_flow/llm.py
CHANGED
|
@@ -5,7 +5,6 @@ Provides LLM provider interfaces and related data models, supporting multiple pr
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
|
-
from collections.abc import AsyncGenerator, Generator
|
|
9
8
|
from dataclasses import dataclass
|
|
10
9
|
from enum import Enum
|
|
11
10
|
from typing import Any
|
|
@@ -16,7 +15,6 @@ from .constants import NO_LLM_PROVIDER_ERROR
|
|
|
16
15
|
class ProcessMode(Enum):
|
|
17
16
|
"""LLM processing modes."""
|
|
18
17
|
|
|
19
|
-
PROMPT_ONLY = "prompt_only" # Return prompt only, no LLM call
|
|
20
18
|
COMPLETE = "complete" # Complete processing (non-streaming)
|
|
21
19
|
STREAM = "stream" # Streaming processing
|
|
22
20
|
|
|
@@ -29,7 +27,6 @@ class LLMResult:
|
|
|
29
27
|
prompt: str | None = None # Used prompt
|
|
30
28
|
variables: dict[str, str | list[str]] | None = None # Extracted variables
|
|
31
29
|
metadata: dict[str, Any] | None = None # Metadata
|
|
32
|
-
transformed_to_interaction: bool = False # Whether content block was transformed to interaction block
|
|
33
30
|
|
|
34
31
|
def __bool__(self):
|
|
35
32
|
"""Support boolean evaluation."""
|
|
@@ -40,28 +37,29 @@ class LLMProvider(ABC):
|
|
|
40
37
|
"""Abstract LLM provider interface."""
|
|
41
38
|
|
|
42
39
|
@abstractmethod
|
|
43
|
-
def complete(self, messages: list[dict[str, str]]
|
|
40
|
+
def complete(self, messages: list[dict[str, str]]) -> str:
|
|
44
41
|
"""
|
|
45
|
-
Non-streaming LLM call
|
|
42
|
+
Non-streaming LLM call.
|
|
46
43
|
|
|
47
44
|
Args:
|
|
48
|
-
messages: Message list in format [{"role": "system/user/assistant", "content": "..."}]
|
|
49
|
-
|
|
45
|
+
messages: Message list in format [{"role": "system/user/assistant", "content": "..."}].
|
|
46
|
+
This list already includes conversation history context merged by MarkdownFlow.
|
|
50
47
|
|
|
51
48
|
Returns:
|
|
52
|
-
|
|
49
|
+
str: LLM response content
|
|
53
50
|
|
|
54
51
|
Raises:
|
|
55
52
|
ValueError: When LLM call fails
|
|
56
53
|
"""
|
|
57
54
|
|
|
58
55
|
@abstractmethod
|
|
59
|
-
def stream(self, messages: list[dict[str, str]])
|
|
56
|
+
def stream(self, messages: list[dict[str, str]]):
|
|
60
57
|
"""
|
|
61
58
|
Streaming LLM call.
|
|
62
59
|
|
|
63
60
|
Args:
|
|
64
|
-
messages: Message list in format [{"role": "system/user/assistant", "content": "..."}]
|
|
61
|
+
messages: Message list in format [{"role": "system/user/assistant", "content": "..."}].
|
|
62
|
+
This list already includes conversation history context merged by MarkdownFlow.
|
|
65
63
|
|
|
66
64
|
Yields:
|
|
67
65
|
str: Incremental LLM response content
|
|
@@ -74,8 +72,8 @@ class LLMProvider(ABC):
|
|
|
74
72
|
class NoLLMProvider(LLMProvider):
|
|
75
73
|
"""Empty LLM provider for prompt-only scenarios."""
|
|
76
74
|
|
|
77
|
-
def complete(self, messages: list[dict[str, str]]
|
|
75
|
+
def complete(self, messages: list[dict[str, str]]) -> str:
|
|
78
76
|
raise NotImplementedError(NO_LLM_PROVIDER_ERROR)
|
|
79
77
|
|
|
80
|
-
def stream(self, messages: list[dict[str, str]])
|
|
78
|
+
def stream(self, messages: list[dict[str, str]]):
|
|
81
79
|
raise NotImplementedError(NO_LLM_PROVIDER_ERROR)
|
markdown_flow/models.py
CHANGED
|
@@ -7,7 +7,7 @@ Simplified and refactored data models focused on core functionality.
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
|
|
9
9
|
from .enums import BlockType, InputType
|
|
10
|
-
from .
|
|
10
|
+
from .parser import extract_variables_from_text
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@dataclass
|
|
@@ -26,22 +26,6 @@ class UserInput:
|
|
|
26
26
|
is_multi_select: bool = False
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
@dataclass
|
|
30
|
-
class InteractionValidationConfig:
|
|
31
|
-
"""
|
|
32
|
-
Simplified interaction validation configuration.
|
|
33
|
-
|
|
34
|
-
Attributes:
|
|
35
|
-
validation_template (Optional[str]): Validation prompt template
|
|
36
|
-
target_variable (Optional[str]): Target variable name
|
|
37
|
-
enable_custom_validation (bool): Enable custom validation, defaults to True
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
validation_template: str | None = None
|
|
41
|
-
target_variable: str | None = None
|
|
42
|
-
enable_custom_validation: bool = True
|
|
43
|
-
|
|
44
|
-
|
|
45
29
|
@dataclass
|
|
46
30
|
class Block:
|
|
47
31
|
"""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown-Flow Parser Module
|
|
3
|
+
|
|
4
|
+
Provides specialized parsers for different aspects of MarkdownFlow document processing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .interaction import InteractionParser, InteractionType, extract_interaction_question
|
|
8
|
+
from .json_parser import parse_json_response
|
|
9
|
+
from .output import (
|
|
10
|
+
extract_preserved_content,
|
|
11
|
+
is_preserved_content_block,
|
|
12
|
+
process_output_instructions,
|
|
13
|
+
)
|
|
14
|
+
from .preprocessor import CodeBlockPreprocessor
|
|
15
|
+
from .validation import generate_smart_validation_template, parse_validation_response
|
|
16
|
+
from .variable import extract_variables_from_text, replace_variables_in_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Variable parsing
|
|
21
|
+
"extract_variables_from_text",
|
|
22
|
+
"replace_variables_in_text",
|
|
23
|
+
# Interaction parsing
|
|
24
|
+
"InteractionParser",
|
|
25
|
+
"InteractionType",
|
|
26
|
+
"extract_interaction_question",
|
|
27
|
+
# Output and preserved content
|
|
28
|
+
"is_preserved_content_block",
|
|
29
|
+
"extract_preserved_content",
|
|
30
|
+
"process_output_instructions",
|
|
31
|
+
# Code block preprocessing
|
|
32
|
+
"CodeBlockPreprocessor",
|
|
33
|
+
# Validation
|
|
34
|
+
"generate_smart_validation_template",
|
|
35
|
+
"parse_validation_response",
|
|
36
|
+
# JSON parsing
|
|
37
|
+
"parse_json_response",
|
|
38
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code Fence Utilities
|
|
3
|
+
|
|
4
|
+
Provides CommonMark-compliant code fence parsing utility functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ..constants import (
|
|
10
|
+
COMPILED_CODE_FENCE_END_REGEX,
|
|
11
|
+
COMPILED_CODE_FENCE_START_REGEX,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CodeFenceInfo:
|
|
17
|
+
"""
|
|
18
|
+
Code fence information
|
|
19
|
+
|
|
20
|
+
Used to track the opening fence of a code block for proper matching with closing fence.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
char: Fence character ('`' or '~')
|
|
24
|
+
length: Fence length (≥3)
|
|
25
|
+
indent: Number of indent spaces (≤3)
|
|
26
|
+
line: Full opening fence line (including info string, e.g., language identifier)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
char: str
|
|
30
|
+
length: int
|
|
31
|
+
indent: int
|
|
32
|
+
line: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_fence_characters(fence_str: str) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Validate that all characters in the fence string are the same
|
|
38
|
+
|
|
39
|
+
CommonMark specification: fence must consist of the same character (all ` or all ~)
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
fence_str: Fence string (e.g., "```" or "~~~~")
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if all characters are the same, False otherwise
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> validate_fence_characters("```")
|
|
49
|
+
True
|
|
50
|
+
>>> validate_fence_characters("~~~~")
|
|
51
|
+
True
|
|
52
|
+
>>> validate_fence_characters("``~")
|
|
53
|
+
False
|
|
54
|
+
>>> validate_fence_characters("")
|
|
55
|
+
False
|
|
56
|
+
"""
|
|
57
|
+
if not fence_str:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
fence_char = fence_str[0]
|
|
61
|
+
return all(ch == fence_char for ch in fence_str)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_code_fence_start(line: str) -> CodeFenceInfo | None:
|
|
65
|
+
"""
|
|
66
|
+
Parse code block opening fence marker
|
|
67
|
+
|
|
68
|
+
CommonMark specification:
|
|
69
|
+
- 0-3 spaces indent
|
|
70
|
+
- At least 3 consecutive ` or ~ characters
|
|
71
|
+
- All characters must be the same
|
|
72
|
+
- Optional info string (language identifier)
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
line: Line to detect
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
CodeFenceInfo if valid opening fence marker, None otherwise
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> parse_code_fence_start("```")
|
|
82
|
+
CodeFenceInfo(char='`', length=3, ...)
|
|
83
|
+
>>> parse_code_fence_start("```go")
|
|
84
|
+
CodeFenceInfo(char='`', length=3, line="```go", ...)
|
|
85
|
+
>>> parse_code_fence_start(" ~~~python")
|
|
86
|
+
CodeFenceInfo(char='~', length=3, indent=3, ...)
|
|
87
|
+
>>> parse_code_fence_start(" ```")
|
|
88
|
+
None # indent > 3
|
|
89
|
+
>>> parse_code_fence_start("``~")
|
|
90
|
+
None # mixed characters
|
|
91
|
+
"""
|
|
92
|
+
match = COMPILED_CODE_FENCE_START_REGEX.match(line)
|
|
93
|
+
if not match:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# match.group(1) is the fence string (e.g., ```, ~~~~)
|
|
97
|
+
# match.group(2) is the info string (e.g., go, python)
|
|
98
|
+
fence_str = match.group(1)
|
|
99
|
+
|
|
100
|
+
# Validate all characters are the same (backticks or tildes)
|
|
101
|
+
if not validate_fence_characters(fence_str):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Calculate indent
|
|
105
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
106
|
+
|
|
107
|
+
# Validate indent ≤ 3 (CommonMark specification)
|
|
108
|
+
if indent > 3:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Fence length
|
|
112
|
+
fence_length = len(fence_str)
|
|
113
|
+
|
|
114
|
+
# Validate fence length ≥ 3 (regex already ensures this, but check to be safe)
|
|
115
|
+
if fence_length < 3:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
return CodeFenceInfo(
|
|
119
|
+
char=fence_str[0],
|
|
120
|
+
length=fence_length,
|
|
121
|
+
indent=indent,
|
|
122
|
+
line=line,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_code_fence_end(line: str, start_fence: CodeFenceInfo) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Detect if line is a matching code block closing fence marker
|
|
129
|
+
|
|
130
|
+
CommonMark specification:
|
|
131
|
+
- Use same type of fence character (` or ~)
|
|
132
|
+
- Fence length ≥ opening fence
|
|
133
|
+
- 0-3 spaces indent
|
|
134
|
+
- Only contains fence characters and whitespace
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
line: Line to detect
|
|
138
|
+
start_fence: Opening fence information
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Whether line is a matching closing fence
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
>>> start = CodeFenceInfo(char='`', length=3, indent=0, line="```")
|
|
145
|
+
>>> is_code_fence_end("```", start)
|
|
146
|
+
True
|
|
147
|
+
>>> is_code_fence_end("````", start)
|
|
148
|
+
True # length ≥ opening fence
|
|
149
|
+
>>> is_code_fence_end("~~~", start)
|
|
150
|
+
False # character type mismatch
|
|
151
|
+
>>> is_code_fence_end("``", start)
|
|
152
|
+
False # length < opening fence
|
|
153
|
+
>>> is_code_fence_end(" ```", start)
|
|
154
|
+
False # indent > 3
|
|
155
|
+
"""
|
|
156
|
+
match = COMPILED_CODE_FENCE_END_REGEX.match(line)
|
|
157
|
+
if not match:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# Extract indent
|
|
161
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
162
|
+
|
|
163
|
+
# Validate indent ≤ 3
|
|
164
|
+
if indent > 3:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
# Extract fence string (remove indent and trailing whitespace)
|
|
168
|
+
fence_str = line.strip()
|
|
169
|
+
|
|
170
|
+
# Validate non-empty
|
|
171
|
+
if not fence_str:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
first_char = fence_str[0]
|
|
175
|
+
|
|
176
|
+
# Character type must match
|
|
177
|
+
if first_char != start_fence.char:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# Calculate fence length (count consecutive same characters)
|
|
181
|
+
fence_length = 0
|
|
182
|
+
for ch in fence_str:
|
|
183
|
+
if ch == first_char:
|
|
184
|
+
fence_length += 1
|
|
185
|
+
else:
|
|
186
|
+
# Contains other characters, not a valid closing fence
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Length must be ≥ opening fence
|
|
190
|
+
return fence_length >= start_fence.length
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interaction Parser Module
|
|
3
|
+
|
|
4
|
+
Provides three-layer interaction parsing for MarkdownFlow ?[] format validation,
|
|
5
|
+
variable detection, and content parsing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..constants import (
|
|
12
|
+
COMPILED_INTERACTION_REGEX,
|
|
13
|
+
COMPILED_LAYER1_INTERACTION_REGEX,
|
|
14
|
+
COMPILED_LAYER2_VARIABLE_REGEX,
|
|
15
|
+
COMPILED_LAYER3_ELLIPSIS_REGEX,
|
|
16
|
+
COMPILED_SINGLE_PIPE_SPLIT_REGEX,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InteractionType(Enum):
|
|
21
|
+
"""Interaction input type enumeration."""
|
|
22
|
+
|
|
23
|
+
TEXT_ONLY = "text_only" # Text input only: ?[%{{var}}...question]
|
|
24
|
+
BUTTONS_ONLY = "buttons_only" # Button selection only: ?[%{{var}} A|B]
|
|
25
|
+
BUTTONS_WITH_TEXT = "buttons_with_text" # Buttons + text: ?[%{{var}} A|B|...question]
|
|
26
|
+
BUTTONS_MULTI_SELECT = "buttons_multi_select" # Multi-select buttons: ?[%{{var}} A||B]
|
|
27
|
+
BUTTONS_MULTI_WITH_TEXT = "buttons_multi_with_text" # Multi-select + text: ?[%{{var}} A||B||...question]
|
|
28
|
+
NON_ASSIGNMENT_BUTTON = "non_assignment_button" # Display buttons: ?[Continue|Cancel]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_interaction_question(content: str) -> str | None:
|
|
32
|
+
"""
|
|
33
|
+
Extract question text from interaction block content.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
content: Raw interaction block content
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Question text if found, None otherwise
|
|
40
|
+
"""
|
|
41
|
+
# Match interaction format: ?[...] using pre-compiled regex
|
|
42
|
+
match = COMPILED_INTERACTION_REGEX.match(content.strip())
|
|
43
|
+
if not match:
|
|
44
|
+
return None # type: ignore[unreachable]
|
|
45
|
+
|
|
46
|
+
# Extract interaction content (remove ?[ and ])
|
|
47
|
+
interaction_content = match.group(1) if match.groups() else match.group(0)[2:-1]
|
|
48
|
+
|
|
49
|
+
# Find ... separator, question text follows
|
|
50
|
+
if "..." in interaction_content:
|
|
51
|
+
# Split and get question part
|
|
52
|
+
parts = interaction_content.split("...", 1)
|
|
53
|
+
if len(parts) > 1:
|
|
54
|
+
return parts[1].strip()
|
|
55
|
+
|
|
56
|
+
return None # type: ignore[unreachable]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InteractionParser:
|
|
60
|
+
"""
|
|
61
|
+
Three-layer interaction parser for ?[] format validation,
|
|
62
|
+
variable detection, and content parsing.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
"""Initialize parser."""
|
|
67
|
+
|
|
68
|
+
def parse(self, content: str) -> dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Main parsing method.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
content: Raw interaction block content
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Standardized parsing result with type, variable, buttons, and question fields
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
# Layer 1: Validate basic format
|
|
80
|
+
inner_content = self._layer1_validate_format(content)
|
|
81
|
+
if inner_content is None:
|
|
82
|
+
return self._create_error_result(f"Invalid interaction format: {content}")
|
|
83
|
+
|
|
84
|
+
# Layer 2: Variable detection and pattern classification
|
|
85
|
+
has_variable, variable_name, remaining_content = self._layer2_detect_variable(inner_content)
|
|
86
|
+
|
|
87
|
+
# Layer 3: Specific content parsing
|
|
88
|
+
if has_variable:
|
|
89
|
+
assert variable_name is not None, "variable_name should not be None when has_variable is True"
|
|
90
|
+
return self._layer3_parse_variable_interaction(variable_name, remaining_content)
|
|
91
|
+
return self._layer3_parse_display_buttons(inner_content)
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
return self._create_error_result(f"Parsing error: {str(e)}")
|
|
95
|
+
|
|
96
|
+
def _layer1_validate_format(self, content: str) -> str | None:
|
|
97
|
+
"""
|
|
98
|
+
Layer 1: Validate ?[] format and extract content.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
content: Raw content
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Extracted bracket content, None if validation fails
|
|
105
|
+
"""
|
|
106
|
+
content = content.strip()
|
|
107
|
+
match = COMPILED_LAYER1_INTERACTION_REGEX.search(content)
|
|
108
|
+
|
|
109
|
+
if not match:
|
|
110
|
+
return None # type: ignore[unreachable]
|
|
111
|
+
|
|
112
|
+
# Ensure matched content is complete (no other text)
|
|
113
|
+
matched_text = match.group(0)
|
|
114
|
+
if matched_text.strip() != content:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
return match.group(1)
|
|
118
|
+
|
|
119
|
+
def _layer2_detect_variable(self, inner_content: str) -> tuple[bool, str | None, str]:
|
|
120
|
+
"""
|
|
121
|
+
Layer 2: Detect variables and classify patterns.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
inner_content: Content extracted from layer 1
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Tuple of (has_variable, variable_name, remaining_content)
|
|
128
|
+
"""
|
|
129
|
+
match = COMPILED_LAYER2_VARIABLE_REGEX.match(inner_content)
|
|
130
|
+
|
|
131
|
+
if not match:
|
|
132
|
+
# No variable, use entire content for display button parsing
|
|
133
|
+
return False, None, inner_content # type: ignore[unreachable]
|
|
134
|
+
|
|
135
|
+
variable_name = match.group(1).strip()
|
|
136
|
+
remaining_content = match.group(2).strip()
|
|
137
|
+
|
|
138
|
+
return True, variable_name, remaining_content
|
|
139
|
+
|
|
140
|
+
def _layer3_parse_variable_interaction(self, variable_name: str, content: str) -> dict[str, Any]:
|
|
141
|
+
"""
|
|
142
|
+
Layer 3: Parse variable interactions (variable assignment type).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
variable_name: Variable name
|
|
146
|
+
content: Content after variable
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Parsing result dictionary
|
|
150
|
+
"""
|
|
151
|
+
# Detect ... separator
|
|
152
|
+
ellipsis_match = COMPILED_LAYER3_ELLIPSIS_REGEX.match(content)
|
|
153
|
+
|
|
154
|
+
if ellipsis_match:
|
|
155
|
+
# Has ... separator
|
|
156
|
+
before_ellipsis = ellipsis_match.group(1).strip()
|
|
157
|
+
question = ellipsis_match.group(2).strip()
|
|
158
|
+
|
|
159
|
+
if before_ellipsis:
|
|
160
|
+
# Has prefix content (buttons or single option) + text input
|
|
161
|
+
buttons, is_multi_select = self._parse_buttons(before_ellipsis)
|
|
162
|
+
interaction_type = InteractionType.BUTTONS_MULTI_WITH_TEXT if is_multi_select else InteractionType.BUTTONS_WITH_TEXT
|
|
163
|
+
return {
|
|
164
|
+
"type": interaction_type,
|
|
165
|
+
"variable": variable_name,
|
|
166
|
+
"buttons": buttons,
|
|
167
|
+
"question": question,
|
|
168
|
+
"is_multi_select": is_multi_select,
|
|
169
|
+
}
|
|
170
|
+
# Pure text input
|
|
171
|
+
return {
|
|
172
|
+
"type": InteractionType.TEXT_ONLY,
|
|
173
|
+
"variable": variable_name,
|
|
174
|
+
"question": question,
|
|
175
|
+
"is_multi_select": False,
|
|
176
|
+
}
|
|
177
|
+
# No ... separator
|
|
178
|
+
if ("|" in content or "||" in content) and content: # type: ignore[unreachable]
|
|
179
|
+
# Pure button group
|
|
180
|
+
buttons, is_multi_select = self._parse_buttons(content)
|
|
181
|
+
interaction_type = InteractionType.BUTTONS_MULTI_SELECT if is_multi_select else InteractionType.BUTTONS_ONLY
|
|
182
|
+
return {
|
|
183
|
+
"type": interaction_type,
|
|
184
|
+
"variable": variable_name,
|
|
185
|
+
"buttons": buttons,
|
|
186
|
+
"is_multi_select": is_multi_select,
|
|
187
|
+
}
|
|
188
|
+
if content: # type: ignore[unreachable]
|
|
189
|
+
# Single button
|
|
190
|
+
button = self._parse_single_button(content)
|
|
191
|
+
return {
|
|
192
|
+
"type": InteractionType.BUTTONS_ONLY,
|
|
193
|
+
"variable": variable_name,
|
|
194
|
+
"buttons": [button],
|
|
195
|
+
"is_multi_select": False,
|
|
196
|
+
}
|
|
197
|
+
# Pure text input (no hint)
|
|
198
|
+
return {
|
|
199
|
+
"type": InteractionType.TEXT_ONLY,
|
|
200
|
+
"variable": variable_name,
|
|
201
|
+
"question": "",
|
|
202
|
+
"is_multi_select": False,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _layer3_parse_display_buttons(self, content: str) -> dict[str, Any]:
|
|
206
|
+
"""
|
|
207
|
+
Layer 3: Parse display buttons (non-variable assignment type).
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
content: Content to parse
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Parsing result dictionary
|
|
214
|
+
"""
|
|
215
|
+
if not content:
|
|
216
|
+
# Empty content: ?[]
|
|
217
|
+
return {
|
|
218
|
+
"type": InteractionType.NON_ASSIGNMENT_BUTTON,
|
|
219
|
+
"buttons": [{"display": "", "value": ""}],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if "|" in content:
|
|
223
|
+
# Multiple buttons
|
|
224
|
+
buttons, _ = self._parse_buttons(content) # Display buttons don't use multi-select
|
|
225
|
+
return {"type": InteractionType.NON_ASSIGNMENT_BUTTON, "buttons": buttons}
|
|
226
|
+
# Single button
|
|
227
|
+
button = self._parse_single_button(content)
|
|
228
|
+
return {"type": InteractionType.NON_ASSIGNMENT_BUTTON, "buttons": [button]}
|
|
229
|
+
|
|
230
|
+
def _parse_buttons(self, content: str) -> tuple[list[dict[str, str]], bool]:
|
|
231
|
+
"""
|
|
232
|
+
Parse button group with fault tolerance.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
content: Button content separated by | or ||
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Tuple of (button list, is_multi_select)
|
|
239
|
+
"""
|
|
240
|
+
if not content or not isinstance(content, str):
|
|
241
|
+
return [], False
|
|
242
|
+
|
|
243
|
+
_, is_multi_select = self._detect_separator_type(content)
|
|
244
|
+
|
|
245
|
+
buttons = []
|
|
246
|
+
try:
|
|
247
|
+
# Use different splitting logic based on separator type
|
|
248
|
+
if is_multi_select:
|
|
249
|
+
# Multi-select mode: split on ||, preserve single |
|
|
250
|
+
button_parts = content.split("||")
|
|
251
|
+
else:
|
|
252
|
+
# Single-select mode: split on single |, but preserve ||
|
|
253
|
+
# Use pre-compiled regex from constants
|
|
254
|
+
button_parts = COMPILED_SINGLE_PIPE_SPLIT_REGEX.split(content)
|
|
255
|
+
|
|
256
|
+
for button_text in button_parts:
|
|
257
|
+
button_text = button_text.strip()
|
|
258
|
+
if button_text:
|
|
259
|
+
button = self._parse_single_button(button_text)
|
|
260
|
+
buttons.append(button)
|
|
261
|
+
except (TypeError, ValueError):
|
|
262
|
+
# Fallback to treating entire content as single button
|
|
263
|
+
return [{"display": content.strip(), "value": content.strip()}], False
|
|
264
|
+
|
|
265
|
+
# For empty content (like just separators), return empty list
|
|
266
|
+
if not buttons and (content.strip() == "||" or content.strip() == "|"):
|
|
267
|
+
return [], is_multi_select
|
|
268
|
+
|
|
269
|
+
# Ensure at least one button exists (but only if there's actual content)
|
|
270
|
+
if not buttons and content.strip():
|
|
271
|
+
buttons = [{"display": content.strip(), "value": content.strip()}]
|
|
272
|
+
|
|
273
|
+
return buttons, is_multi_select
|
|
274
|
+
|
|
275
|
+
def _parse_single_button(self, button_text: str) -> dict[str, str]:
|
|
276
|
+
"""
|
|
277
|
+
Parse single button with fault tolerance, supports Button//value format.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
button_text: Button text
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Dictionary with display and value keys
|
|
284
|
+
"""
|
|
285
|
+
if not button_text or not isinstance(button_text, str):
|
|
286
|
+
return {"display": "", "value": ""}
|
|
287
|
+
|
|
288
|
+
button_text = button_text.strip()
|
|
289
|
+
if not button_text:
|
|
290
|
+
return {"display": "", "value": ""}
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
# Detect Button//value format - split only on first //
|
|
294
|
+
if "//" in button_text:
|
|
295
|
+
parts = button_text.split("//", 1) # Split only on first //
|
|
296
|
+
display = parts[0].strip()
|
|
297
|
+
value = parts[1] if len(parts) > 1 else ""
|
|
298
|
+
# Don't strip value to preserve intentional spacing/formatting
|
|
299
|
+
return {"display": display, "value": value}
|
|
300
|
+
except (ValueError, IndexError):
|
|
301
|
+
# Fallback: use text as both display and value
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
return {"display": button_text, "value": button_text}
|
|
305
|
+
|
|
306
|
+
def _detect_separator_type(self, content: str) -> tuple[str, bool]:
|
|
307
|
+
"""
|
|
308
|
+
Detect separator type and whether it's multi-select.
|
|
309
|
+
|
|
310
|
+
Implements fault tolerance: first separator type encountered determines the behavior.
|
|
311
|
+
Mixed separators are handled by treating the rest as literal text.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
content: Button content to analyze
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Tuple of (separator, is_multi_select) where separator is '|' or '||'
|
|
318
|
+
"""
|
|
319
|
+
if not content or not isinstance(content, str):
|
|
320
|
+
return "|", False
|
|
321
|
+
|
|
322
|
+
# Find first occurrence of separators
|
|
323
|
+
single_pos = content.find("|")
|
|
324
|
+
double_pos = content.find("||")
|
|
325
|
+
|
|
326
|
+
# If no separators found
|
|
327
|
+
if single_pos == -1 and double_pos == -1:
|
|
328
|
+
return "|", False
|
|
329
|
+
|
|
330
|
+
# If only single separator found
|
|
331
|
+
if double_pos == -1:
|
|
332
|
+
return "|", False
|
|
333
|
+
|
|
334
|
+
# If only double separator found
|
|
335
|
+
if single_pos == -1:
|
|
336
|
+
return "||", True
|
|
337
|
+
|
|
338
|
+
# Both found - fault tolerance: first occurrence wins
|
|
339
|
+
# This handles mixed cases like "A||B|C" (multi-select) and "A|B||C" (single-select)
|
|
340
|
+
if double_pos <= single_pos:
|
|
341
|
+
return "||", True
|
|
342
|
+
return "|", False
|
|
343
|
+
|
|
344
|
+
def _create_error_result(self, error_message: str) -> dict[str, Any]:
|
|
345
|
+
"""
|
|
346
|
+
Create error result.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
error_message: Error message
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Error result dictionary
|
|
353
|
+
"""
|
|
354
|
+
return {"type": None, "error": error_message} # type: ignore[unreachable]
|