markdown-flow 0.3.1__tar.gz → 0.3.2__tar.gz

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.
Files changed (40) hide show
  1. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/PKG-INFO +1 -1
  2. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/__init__.py +1 -1
  3. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/constants.py +12 -3
  4. markdown_flow-0.3.2/markdown_flow/escaping.py +141 -0
  5. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/interaction.py +38 -33
  6. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow.egg-info/PKG-INFO +1 -1
  7. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow.egg-info/SOURCES.txt +1 -0
  8. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/LICENSE +0 -0
  9. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/README.md +0 -0
  10. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/constants_system_prompt.py +0 -0
  11. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/core.py +0 -0
  12. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/enums.py +0 -0
  13. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/exceptions.py +0 -0
  14. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/__init__.py +0 -0
  15. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/classifier.py +0 -0
  16. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/format.py +0 -0
  17. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/patterns.py +0 -0
  18. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/sanitize.py +0 -0
  19. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/stream.py +0 -0
  20. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/formatter/types.py +0 -0
  21. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/higher_priority_prompt.md +0 -0
  22. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/llm.py +0 -0
  23. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/models.py +0 -0
  24. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/__init__.py +0 -0
  25. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/code_fence_utils.py +0 -0
  26. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/html_comment_utils.py +0 -0
  27. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/json_parser.py +0 -0
  28. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/output.py +0 -0
  29. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/preprocessor.py +0 -0
  30. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/validation.py +0 -0
  31. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/parser/variable.py +0 -0
  32. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/providers/__init__.py +0 -0
  33. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/providers/config.py +0 -0
  34. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/providers/openai.py +0 -0
  35. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/system_prompt.md +0 -0
  36. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow/tag_filter.py +0 -0
  37. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow.egg-info/dependency_links.txt +0 -0
  38. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/markdown_flow.egg-info/top_level.txt +0 -0
  39. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/pyproject.toml +0 -0
  40. {markdown_flow-0.3.1 → markdown_flow-0.3.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: An agent library designed to parse and process MarkdownFlow documents
5
5
  Project-URL: Homepage, https://github.com/ai-shifu/markdown-flow-agent-py
6
6
  Project-URL: Bug Tracker, https://github.com/ai-shifu/markdown-flow-agent-py/issues
@@ -106,4 +106,4 @@ __all__ = [
106
106
  "replace_variables_in_text",
107
107
  ]
108
108
 
109
- __version__ = "0.3.1"
109
+ __version__ = "0.3.2"
@@ -13,9 +13,18 @@ COMPILED_PERCENT_VARIABLE_REGEX = re.compile(
13
13
  )
14
14
 
15
15
  # Interaction regex base patterns
16
- INTERACTION_PATTERN = r"(?<!\\)\?\[([^\]]*)\](?!\()" # Base pattern with capturing group for content extraction, excludes escaped \?[]
17
- INTERACTION_PATTERN_NON_CAPTURING = r"(?<!\\)\?\[[^\]]*\](?!\()" # Non-capturing version for block splitting, excludes escaped \?[]
18
- INTERACTION_PATTERN_SPLIT = r"((?<!\\)\?\[[^\]]*\](?!\())" # Pattern for re.split() with outer capturing group, excludes escaped \?[]
16
+ #
17
+ # The bracket content accepts an escaped delimiter as one unit, so a `\]` inside an option does
18
+ # not end the interaction. The three alternatives are mutually exclusive on purpose: a backslash
19
+ # before a delimiter can only be read as an escape, so the pattern cannot backtrack into reading
20
+ # it as plain text and then closing on the very bracket it escapes. Without that, this regex and
21
+ # a left-to-right scanner disagree about `?[a\\]`, and two parsers of one grammar must not.
22
+ # A backslash before anything else -- LaTeX such as `$\pi$`, or `\\` as a line break -- stays
23
+ # ordinary text, exactly as it was before escapes existed. See markdown_flow.escaping.
24
+ _INTERACTION_CONTENT = r"(?:\\[|/.\]]|\\(?![|/.\]])|[^\]\\])*"
25
+ INTERACTION_PATTERN = rf"(?<!\\)\?\[({_INTERACTION_CONTENT})\](?!\()" # Base pattern with capturing group for content extraction, excludes escaped \?[]
26
+ INTERACTION_PATTERN_NON_CAPTURING = rf"(?<!\\)\?\[{_INTERACTION_CONTENT}\](?!\()" # Non-capturing version for block splitting, excludes escaped \?[]
27
+ INTERACTION_PATTERN_SPLIT = rf"((?<!\\)\?\[{_INTERACTION_CONTENT}\](?!\())" # Pattern for re.split() with outer capturing group, excludes escaped \?[]
19
28
 
20
29
  # InteractionParser specific regex patterns
21
30
  COMPILED_INTERACTION_REGEX = re.compile(INTERACTION_PATTERN) # Main interaction pattern matcher
@@ -0,0 +1,141 @@
1
+ """Backslash escapes for the delimiters inside an interaction's brackets.
2
+
3
+ `?[A|B]` gives `|`, `//`, `...` and `]` structural meaning, so option text that contains one of
4
+ them is reshaped: a bar splits one choice into two, a double slash gives half the display away to
5
+ the stored value, an ellipsis turns the rest into a text box, and a bracket ends the interaction
6
+ early. An option carrying a URL or a regular expression could not be written at all.
7
+
8
+ A backslash before one of those characters now makes it ordinary text.
9
+
10
+ The escape is deliberately *selective*: a backslash is an escape only when the character after it
11
+ is a delimiter, and is literal text everywhere else. `\\\\` is not an escape either. That matters
12
+ for content that already exists: of 110,685 interactions in published courses, 97 contain a
13
+ backslash and every one of them is LaTeX -- `$\\pi_\\theta$`, `\\beta`, `\\nabla`, and `\\\\` as a
14
+ line break. A general "backslash escapes the next character" rule would render those as
15
+ `$pi_theta$`. Under this rule, none of them change.
16
+
17
+ The cost is that a literal backslash cannot sit immediately before a literal delimiter: `\\|` is
18
+ an escaped bar, not a backslash and a separator. Nothing in the measured corpus writes that.
19
+ """
20
+
21
+ # The characters a backslash may make ordinary. `/` covers `//`, `.` covers `...`.
22
+ ESCAPABLE = "|/.]"
23
+
24
+
25
+ def is_escape(text: str, index: int) -> bool:
26
+ """Whether the backslash at `index` escapes the character after it."""
27
+ return index + 1 < len(text) and text[index] == "\\" and text[index + 1] in ESCAPABLE
28
+
29
+
30
+ def escape_interaction_text(text: str) -> str:
31
+ """Make `text` safe to write inside `?[...]`, keeping every character it has.
32
+
33
+ Escaping each delimiter rather than the backslashes as well is what keeps existing content
34
+ readable: a script that writes `$\\pi$` in an option still writes `$\\pi$` afterwards.
35
+ """
36
+ out: list[str] = []
37
+ for char in text:
38
+ if char in ESCAPABLE:
39
+ out.append("\\")
40
+ out.append(char)
41
+ return "".join(out)
42
+
43
+
44
+ def unescape_interaction_text(text: str) -> str:
45
+ """Resolve the escapes in one option's text, leaving every other backslash alone."""
46
+ out: list[str] = []
47
+ index = 0
48
+ while index < len(text):
49
+ if is_escape(text, index):
50
+ out.append(text[index + 1])
51
+ index += 2
52
+ continue
53
+ out.append(text[index])
54
+ index += 1
55
+ return "".join(out)
56
+
57
+
58
+ def find_unescaped(text: str, needle: str, start: int = 0) -> int:
59
+ """Index of the first `needle` that is not escaped and does not begin inside an escape.
60
+
61
+ Returns -1 when there is none. `needle` is matched whole: a `//` whose first slash is escaped
62
+ is not a separator even though its second slash is bare, because that second slash is the
63
+ escaped character of the pair.
64
+ """
65
+ index = start
66
+ while index < len(text):
67
+ if is_escape(text, index):
68
+ index += 2
69
+ continue
70
+ if text.startswith(needle, index):
71
+ return index
72
+ index += 1
73
+ return -1
74
+
75
+
76
+ def split_unescaped(text: str, separator: str) -> list[str]:
77
+ """Split on every unescaped `separator`, leaving the escapes themselves in place."""
78
+ parts: list[str] = []
79
+ start = 0
80
+ while True:
81
+ found = find_unescaped(text, separator, start)
82
+ if found < 0:
83
+ parts.append(text[start:])
84
+ return parts
85
+ parts.append(text[start:found])
86
+ start = found + len(separator)
87
+
88
+
89
+ def split_on_single_pipe(text: str) -> list[str]:
90
+ """Split on unescaped single bars, leaving `||` joined as the multi-select separator."""
91
+ parts: list[str] = []
92
+ start = 0
93
+ index = 0
94
+ while index < len(text):
95
+ if is_escape(text, index):
96
+ index += 2
97
+ continue
98
+ if text[index] == "|":
99
+ if text.startswith("||", index):
100
+ index += 2
101
+ continue
102
+ if index > 0 and text[index - 1] == "|" and not is_escape(text, index - 1):
103
+ index += 1
104
+ continue
105
+ parts.append(text[start:index])
106
+ start = index + 1
107
+ index += 1
108
+ parts.append(text[start:])
109
+ return parts
110
+
111
+
112
+ def rfind_unescaped(text: str, needle: str) -> int:
113
+ """Index of the *last* unescaped `needle`, or -1."""
114
+ found = -1
115
+ start = 0
116
+ while True:
117
+ at = find_unescaped(text, needle, start)
118
+ if at < 0:
119
+ return found
120
+ found = at
121
+ start = at + 1
122
+
123
+
124
+ def split_on_ellipsis(content: str) -> tuple[str, str] | None:
125
+ """Split an interaction's content at the `...` that opens its text box, or None.
126
+
127
+ Two details are inherited rather than chosen, because changing either would rewrite questions
128
+ in courses that are already published:
129
+
130
+ * the ellipsis counts only on the first line. The regex this replaces was anchored and its
131
+ `.` did not cross a newline, so `A\n| ...hint` has always been read as two buttons, one of
132
+ them named `...hint`. 122 published interactions depend on that reading.
133
+ * the *last* ellipsis on that line wins, which is what the greedy group matched.
134
+
135
+ What is new is only that an escaped ellipsis no longer counts.
136
+ """
137
+ head = content.split("\n", 1)[0]
138
+ at = rfind_unescaped(head, "...")
139
+ if at < 0:
140
+ return None
141
+ return head[:at], head[at + 3 :]
@@ -12,8 +12,13 @@ from ..constants import (
12
12
  COMPILED_INTERACTION_REGEX,
13
13
  COMPILED_LAYER1_INTERACTION_REGEX,
14
14
  COMPILED_LAYER2_VARIABLE_REGEX,
15
- COMPILED_LAYER3_ELLIPSIS_REGEX,
16
- COMPILED_SINGLE_PIPE_SPLIT_REGEX,
15
+ )
16
+ from ..escaping import (
17
+ find_unescaped,
18
+ split_on_ellipsis,
19
+ split_on_single_pipe,
20
+ split_unescaped,
21
+ unescape_interaction_text,
17
22
  )
18
23
 
19
24
 
@@ -46,12 +51,10 @@ def extract_interaction_question(content: str) -> str | None:
46
51
  # Extract interaction content (remove ?[ and ])
47
52
  interaction_content = match.group(1) if match.groups() else match.group(0)[2:-1]
48
53
 
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()
54
+ # Find ... separator, question text follows. An ellipsis an option escaped is not one.
55
+ at = find_unescaped(interaction_content, "...")
56
+ if at >= 0:
57
+ return unescape_interaction_text(interaction_content[at + 3 :]).strip()
55
58
 
56
59
  return None # type: ignore[unreachable]
57
60
 
@@ -153,13 +156,13 @@ class InteractionParser:
153
156
  Returns:
154
157
  Parsing result dictionary
155
158
  """
156
- # Detect ... separator
157
- ellipsis_match = COMPILED_LAYER3_ELLIPSIS_REGEX.match(content)
159
+ # Detect ... separator, ignoring any an option escaped.
160
+ ellipsis = split_on_ellipsis(content)
158
161
 
159
- if ellipsis_match:
162
+ if ellipsis is not None:
160
163
  # Has ... separator
161
- before_ellipsis = ellipsis_match.group(1).strip()
162
- question = ellipsis_match.group(2).strip()
164
+ before_ellipsis = ellipsis[0].strip()
165
+ question = unescape_interaction_text(ellipsis[1]).strip()
163
166
 
164
167
  if before_ellipsis:
165
168
  # Has prefix content (buttons or single option) + text input
@@ -180,7 +183,7 @@ class InteractionParser:
180
183
  "is_multi_select": False,
181
184
  }
182
185
  # No ... separator
183
- if ("|" in content or "||" in content) and content: # type: ignore[unreachable]
186
+ if content and find_unescaped(content, "|") >= 0: # type: ignore[unreachable]
184
187
  # Pure button group
185
188
  buttons, is_multi_select = self._parse_buttons(content)
186
189
  interaction_type = InteractionType.BUTTONS_MULTI_SELECT if is_multi_select else InteractionType.BUTTONS_ONLY
@@ -229,10 +232,10 @@ class InteractionParser:
229
232
  "buttons": [{"display": "", "value": ""}],
230
233
  }
231
234
 
232
- ellipsis_match = COMPILED_LAYER3_ELLIPSIS_REGEX.match(content)
233
- if ellipsis_match:
234
- before_ellipsis = ellipsis_match.group(1).strip()
235
- question = ellipsis_match.group(2).strip()
235
+ ellipsis = split_on_ellipsis(content)
236
+ if ellipsis is not None:
237
+ before_ellipsis = ellipsis[0].strip()
238
+ question = unescape_interaction_text(ellipsis[1]).strip()
236
239
 
237
240
  if before_ellipsis:
238
241
  # Buttons + text input: ?[A | B | ...question]
@@ -251,7 +254,7 @@ class InteractionParser:
251
254
  "is_multi_select": False,
252
255
  }
253
256
 
254
- if "|" in content: # type: ignore[unreachable]
257
+ if find_unescaped(content, "|") >= 0: # type: ignore[unreachable]
255
258
  # Button group: ?[A | B] or ?[A || B]
256
259
  buttons, is_multi_select = self._parse_buttons(content)
257
260
  return {
@@ -287,11 +290,10 @@ class InteractionParser:
287
290
  # Use different splitting logic based on separator type
288
291
  if is_multi_select:
289
292
  # Multi-select mode: split on ||, preserve single |
290
- button_parts = content.split("||")
293
+ button_parts = split_unescaped(content, "||")
291
294
  else:
292
295
  # Single-select mode: split on single |, but preserve ||
293
- # Use pre-compiled regex from constants
294
- button_parts = COMPILED_SINGLE_PIPE_SPLIT_REGEX.split(content)
296
+ button_parts = split_on_single_pipe(content)
295
297
 
296
298
  for button_text in button_parts:
297
299
  button_text = button_text.strip()
@@ -300,7 +302,8 @@ class InteractionParser:
300
302
  buttons.append(button)
301
303
  except (TypeError, ValueError):
302
304
  # Fallback to treating entire content as single button
303
- return [{"display": content.strip(), "value": content.strip()}], False
305
+ whole = unescape_interaction_text(content).strip()
306
+ return [{"display": whole, "value": whole}], False
304
307
 
305
308
  # For empty content (like just separators), return empty list
306
309
  if not buttons and (content.strip() == "||" or content.strip() == "|"):
@@ -308,7 +311,8 @@ class InteractionParser:
308
311
 
309
312
  # Ensure at least one button exists (but only if there's actual content)
310
313
  if not buttons and content.strip():
311
- buttons = [{"display": content.strip(), "value": content.strip()}]
314
+ whole = unescape_interaction_text(content).strip()
315
+ buttons = [{"display": whole, "value": whole}]
312
316
 
313
317
  return buttons, is_multi_select
314
318
 
@@ -330,18 +334,19 @@ class InteractionParser:
330
334
  return {"display": "", "value": ""}
331
335
 
332
336
  try:
333
- # Detect Button//value format - split only on first //
334
- if "//" in button_text:
335
- parts = button_text.split("//", 1) # Split only on first //
336
- display = parts[0].strip()
337
- value = parts[1] if len(parts) > 1 else ""
337
+ # Detect Button//value format - split only on the first unescaped //
338
+ at = find_unescaped(button_text, "//")
339
+ if at >= 0:
340
+ display = unescape_interaction_text(button_text[:at]).strip()
338
341
  # Don't strip value to preserve intentional spacing/formatting
342
+ value = unescape_interaction_text(button_text[at + 2 :])
339
343
  return {"display": display, "value": value}
340
344
  except (ValueError, IndexError):
341
345
  # Fallback: use text as both display and value
342
346
  pass
343
347
 
344
- return {"display": button_text, "value": button_text}
348
+ resolved = unescape_interaction_text(button_text)
349
+ return {"display": resolved, "value": resolved}
345
350
 
346
351
  def _detect_separator_type(self, content: str) -> tuple[str, bool]:
347
352
  """
@@ -359,9 +364,9 @@ class InteractionParser:
359
364
  if not content or not isinstance(content, str):
360
365
  return "|", False
361
366
 
362
- # Find first occurrence of separators
363
- single_pos = content.find("|")
364
- double_pos = content.find("||")
367
+ # Find first occurrence of separators. A bar an option escaped is not one.
368
+ single_pos = find_unescaped(content, "|")
369
+ double_pos = find_unescaped(content, "||")
365
370
 
366
371
  # If no separators found
367
372
  if single_pos == -1 and double_pos == -1:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: An agent library designed to parse and process MarkdownFlow documents
5
5
  Project-URL: Homepage, https://github.com/ai-shifu/markdown-flow-agent-py
6
6
  Project-URL: Bug Tracker, https://github.com/ai-shifu/markdown-flow-agent-py/issues
@@ -6,6 +6,7 @@ markdown_flow/constants.py
6
6
  markdown_flow/constants_system_prompt.py
7
7
  markdown_flow/core.py
8
8
  markdown_flow/enums.py
9
+ markdown_flow/escaping.py
9
10
  markdown_flow/exceptions.py
10
11
  markdown_flow/higher_priority_prompt.md
11
12
  markdown_flow/llm.py
File without changes
File without changes
File without changes