markdown-flow 0.3.3__tar.gz → 0.4.0__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 (43) hide show
  1. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/PKG-INFO +35 -1
  2. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/README.md +34 -0
  3. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/__init__.py +13 -1
  4. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/constants.py +21 -8
  5. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/core.py +124 -27
  6. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/escaping.py +34 -2
  7. markdown_flow-0.4.0/markdown_flow/parser/document_context.py +131 -0
  8. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/interaction.py +15 -17
  9. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/preprocessor.py +71 -2
  10. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/validation.py +2 -1
  11. markdown_flow-0.4.0/markdown_flow/parser/variable.py +132 -0
  12. markdown_flow-0.4.0/markdown_flow/variable_names.py +103 -0
  13. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/PKG-INFO +35 -1
  14. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/SOURCES.txt +2 -0
  15. markdown_flow-0.3.3/markdown_flow/parser/variable.py +0 -157
  16. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/LICENSE +0 -0
  17. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/constants_system_prompt.py +0 -0
  18. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/enums.py +0 -0
  19. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/exceptions.py +0 -0
  20. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/__init__.py +0 -0
  21. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/classifier.py +0 -0
  22. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/format.py +0 -0
  23. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/patterns.py +0 -0
  24. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/sanitize.py +0 -0
  25. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/stream.py +0 -0
  26. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/types.py +0 -0
  27. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/higher_priority_prompt.md +0 -0
  28. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/llm.py +0 -0
  29. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/models.py +0 -0
  30. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/__init__.py +0 -0
  31. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/code_fence_utils.py +0 -0
  32. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/html_comment_utils.py +0 -0
  33. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/json_parser.py +0 -0
  34. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/output.py +0 -0
  35. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/__init__.py +0 -0
  36. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/config.py +0 -0
  37. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/openai.py +0 -0
  38. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/system_prompt.md +0 -0
  39. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/tag_filter.py +0 -0
  40. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/dependency_links.txt +0 -0
  41. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/top_level.txt +0 -0
  42. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/pyproject.toml +0 -0
  43. {markdown_flow-0.3.3 → markdown_flow-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.3.3
3
+ Version: 0.4.0
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
@@ -691,6 +691,40 @@ builder.start_interaction()
691
691
 
692
692
  ### Variable System Deep Dive
693
693
 
694
+ #### Text variable names
695
+
696
+ Names are opaque Unicode text. Python `str.strip()` removes surrounding whitespace;
697
+ the remaining name must be nonempty. Internal spaces, punctuation, emoji, single
698
+ braces, brackets, `|`, `//`, and `...` stay exactly as written. Internal control
699
+ characters (U+0000–001F and U+007F–009F) and U+2028/U+2029 are invalid. Case,
700
+ repeated spaces, and Unicode sequences are never normalized; U+FEFF remains part
701
+ of the name.
702
+
703
+ The first literal `}}` closes a marker. A name cannot contain that pair. Quotes
704
+ and backslashes are literal characters with no decoding rule, and nested `{{`
705
+ inside a name is not another marker. Every complete `{{ ... }}` or `%{{ ... }}`
706
+ is read before interaction delimiters, so internal `]`, `|`, `//`, and `...` stay
707
+ literal. Only a leading assignment marker sets the interaction's variable;
708
+ markers elsewhere in its body remain literal option text. An unterminated
709
+ marker keeps the remaining text opaque, without restarting at an inner `?[`.
710
+
711
+ At document level, complete inline code and HTML are read before markers inside
712
+ them. Complete variable examples there still support collection and replacement,
713
+ but an unclosed example cannot consume text beyond that markup span. Escaped
714
+ document openers stay literal. A true raw marker owns its entire name before any
715
+ code or HTML inside that name is considered; interaction bodies keep the raw
716
+ marker grammar.
717
+
718
+ Use `format_variable_reference(name)` and `format_variable_assignment(name)` from
719
+ `markdown_flow.variable_names` to emit padded markers such as
720
+ `{{ What is your gender? }}` and `%{{ a} }}`. Padding lets a name end in a single
721
+ brace. Extraction and replacement use the same trimmed key; existing stored keys
722
+ are not renamed or migrated.
723
+
724
+ The shared conformance corpus is `scripts/variable-name-contract.json`. Run its
725
+ offline parser, document, replacement, translation, and answer round trips with
726
+ `python -m pytest scripts/test_variable_names.py -q`.
727
+
694
728
  ```python
695
729
  from markdown_flow import extract_variables_from_text, replace_variables_in_text
696
730
 
@@ -673,6 +673,40 @@ builder.start_interaction()
673
673
 
674
674
  ### Variable System Deep Dive
675
675
 
676
+ #### Text variable names
677
+
678
+ Names are opaque Unicode text. Python `str.strip()` removes surrounding whitespace;
679
+ the remaining name must be nonempty. Internal spaces, punctuation, emoji, single
680
+ braces, brackets, `|`, `//`, and `...` stay exactly as written. Internal control
681
+ characters (U+0000–001F and U+007F–009F) and U+2028/U+2029 are invalid. Case,
682
+ repeated spaces, and Unicode sequences are never normalized; U+FEFF remains part
683
+ of the name.
684
+
685
+ The first literal `}}` closes a marker. A name cannot contain that pair. Quotes
686
+ and backslashes are literal characters with no decoding rule, and nested `{{`
687
+ inside a name is not another marker. Every complete `{{ ... }}` or `%{{ ... }}`
688
+ is read before interaction delimiters, so internal `]`, `|`, `//`, and `...` stay
689
+ literal. Only a leading assignment marker sets the interaction's variable;
690
+ markers elsewhere in its body remain literal option text. An unterminated
691
+ marker keeps the remaining text opaque, without restarting at an inner `?[`.
692
+
693
+ At document level, complete inline code and HTML are read before markers inside
694
+ them. Complete variable examples there still support collection and replacement,
695
+ but an unclosed example cannot consume text beyond that markup span. Escaped
696
+ document openers stay literal. A true raw marker owns its entire name before any
697
+ code or HTML inside that name is considered; interaction bodies keep the raw
698
+ marker grammar.
699
+
700
+ Use `format_variable_reference(name)` and `format_variable_assignment(name)` from
701
+ `markdown_flow.variable_names` to emit padded markers such as
702
+ `{{ What is your gender? }}` and `%{{ a} }}`. Padding lets a name end in a single
703
+ brace. Extraction and replacement use the same trimmed key; existing stored keys
704
+ are not renamed or migrated.
705
+
706
+ The shared conformance corpus is `scripts/variable-name-contract.json`. Run its
707
+ offline parser, document, replacement, translation, and answer round trips with
708
+ `python -m pytest scripts/test_variable_names.py -q`.
709
+
676
710
  ```python
677
711
  from markdown_flow import extract_variables_from_text, replace_variables_in_text
678
712
 
@@ -75,6 +75,13 @@ from .parser import (
75
75
  generate_smart_validation_template,
76
76
  replace_variables_in_text,
77
77
  )
78
+ from .variable_names import (
79
+ find_variable_markers,
80
+ format_variable_assignment,
81
+ format_variable_reference,
82
+ normalize_variable_name,
83
+ read_variable_marker,
84
+ )
78
85
 
79
86
 
80
87
  # Public API
@@ -104,6 +111,11 @@ __all__ = [
104
111
  "extract_interaction_question",
105
112
  "extract_variables_from_text",
106
113
  "replace_variables_in_text",
114
+ "normalize_variable_name",
115
+ "read_variable_marker",
116
+ "find_variable_markers",
117
+ "format_variable_reference",
118
+ "format_variable_assignment",
107
119
  ]
108
120
 
109
- __version__ = "0.3.3"
121
+ __version__ = "0.4.0"
@@ -6,11 +6,15 @@ Constants for document parsing, variable matching, validation, and other core fu
6
6
 
7
7
  import re
8
8
 
9
+ from .variable_names import (
10
+ VARIABLE_ASSIGNMENT_SOURCE,
11
+ VARIABLE_REFERENCE_SOURCE,
12
+ VARIABLE_REFERENCE_UNIT_SOURCE,
13
+ )
14
+
9
15
 
10
16
  # Pre-compiled regex patterns
11
- COMPILED_PERCENT_VARIABLE_REGEX = re.compile(
12
- r"%\{\{([^}]+)\}\}" # Match %{{variable}} format for preserved variables
13
- )
17
+ COMPILED_PERCENT_VARIABLE_REGEX = re.compile(VARIABLE_ASSIGNMENT_SOURCE)
14
18
 
15
19
  # Interaction regex base patterns
16
20
  #
@@ -21,7 +25,7 @@ COMPILED_PERCENT_VARIABLE_REGEX = re.compile(
21
25
  # a left-to-right scanner disagree about `?[a\\]`, and two parsers of one grammar must not.
22
26
  # A backslash before anything else -- LaTeX such as `$\pi$`, or `\\` as a line break -- stays
23
27
  # ordinary text, exactly as it was before escapes existed. See markdown_flow.escaping.
24
- _INTERACTION_CONTENT = r"(?:\\[|/.\]]|\\(?![|/.\]])|[^\]\\])*"
28
+ _INTERACTION_CONTENT = rf"(?:{VARIABLE_REFERENCE_UNIT_SOURCE}|\\[|/.\]]|\\(?![|/.\]])|(?!\{{\{{)[^\]\\])*"
25
29
  INTERACTION_PATTERN = rf"(?<!\\)\?\[({_INTERACTION_CONTENT})\](?!\()" # Base pattern with capturing group for content extraction, excludes escaped \?[]
26
30
  INTERACTION_PATTERN_NON_CAPTURING = rf"(?<!\\)\?\[{_INTERACTION_CONTENT}\](?!\()" # Non-capturing version for block splitting, excludes escaped \?[]
27
31
  INTERACTION_PATTERN_SPLIT = rf"((?<!\\)\?\[{_INTERACTION_CONTENT}\](?!\())" # Pattern for re.split() with outer capturing group, excludes escaped \?[]
@@ -29,13 +33,21 @@ INTERACTION_PATTERN_SPLIT = rf"((?<!\\)\?\[{_INTERACTION_CONTENT}\](?!\())" # P
29
33
  # InteractionParser specific regex patterns
30
34
  COMPILED_INTERACTION_REGEX = re.compile(INTERACTION_PATTERN) # Main interaction pattern matcher
31
35
  COMPILED_LAYER1_INTERACTION_REGEX = COMPILED_INTERACTION_REGEX # Layer 1: Basic format validation (alias)
32
- COMPILED_LAYER2_VARIABLE_REGEX = re.compile(r"^%\{\{([^}]+)\}\}(.*)$") # Layer 2: Variable detection
36
+ COMPILED_LAYER2_VARIABLE_REGEX = re.compile(r"^\s*" + VARIABLE_ASSIGNMENT_SOURCE + r"(.*)$")
33
37
  COMPILED_LAYER3_ELLIPSIS_REGEX = re.compile(r"^(.*)\.\.\.(.*)") # Layer 3: Split content around ellipsis
34
38
  COMPILED_LAYER3_BUTTON_VALUE_REGEX = re.compile(r"^(.+)//(.+)$") # Layer 3: Parse Button//value format
35
- COMPILED_BRACE_VARIABLE_REGEX = re.compile(
36
- r"(?<!%)\{\{([^}]+)\}\}" # Match {{variable}} format for replaceable variables
37
- )
39
+ COMPILED_BRACE_VARIABLE_REGEX = re.compile(r"(?<!%)" + VARIABLE_REFERENCE_SOURCE)
38
40
  COMPILED_SINGLE_PIPE_SPLIT_REGEX = re.compile(r"(?<!\|)\|(?!\|)") # Split on single | but not ||
41
+ COMPILED_VARIABLE_NAME_PLACEHOLDER_REGEX = re.compile(r"\x00mdf-name:+[0-9]+\x00")
42
+
43
+ # Inline HTML owns braces in its tag source before document-level MDF scanning.
44
+ _HTML_SPACE = r"[ \t\r\n]"
45
+ _HTML_ATTRIBUTE = r"[A-Za-z_:][A-Za-z0-9_.:-]*(?:" + _HTML_SPACE + r"*=" + _HTML_SPACE + r"*(?:[^ \t\r\n\"'=<>`]+|'[^']*'|\"[^\"]*\"))?"
46
+ COMPILED_INLINE_HTML_REGEX = re.compile(
47
+ r"(?:<[A-Za-z][A-Za-z0-9-]*(?:" + _HTML_SPACE + r"+" + _HTML_ATTRIBUTE + r")*" + _HTML_SPACE + r"*/?>"
48
+ r"|</[A-Za-z][A-Za-z0-9-]*" + _HTML_SPACE + r"*>"
49
+ r"|<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<![A-Z]+" + _HTML_SPACE + r"+[^>]*>|<!\[CDATA\[[\s\S]*?\]\]>)"
50
+ )
39
51
 
40
52
  # Canonical user_input key for non-assignment interactions (?[A|B] without a
41
53
  # variable). Clients should submit {DEFAULT_NON_ASSIGNMENT_INPUT_KEY: values};
@@ -135,6 +147,7 @@ INTERACTION_PROMPT_WITH_TRANSLATION = """
135
147
 
136
148
  - Translate the text in `buttons` and `question` into the target language
137
149
  - Translate only the display text (the Display part); do not change the JSON structure
150
+ - Tokens such as `__MDFLOW_VARIABLE_0__` are opaque placeholders: copy every token occurrence exactly as given in its original order and translate only the surrounding text
138
151
  - If `display//value` separation exists (for example `Yes//1`), translate only the Display part before `//` and keep value unchanged
139
152
  - Use the target language 100%; language mixing is not allowed. Translate all non-target-language words before outputting
140
153
  - If a text segment's original language is already the same as the target language, return that text unchanged (for example, when the target language is English and the original text is also English, do not rewrite it)
@@ -13,6 +13,7 @@ from typing import Any
13
13
  from .constants import (
14
14
  BLOCK_INDEX_OUT_OF_RANGE_ERROR,
15
15
  BLOCK_SEPARATOR,
16
+ COMPILED_LAYER2_VARIABLE_REGEX,
16
17
  CONTEXT_BUTTON_OPTIONS_TEMPLATE,
17
18
  CONTEXT_QUESTION_TEMPLATE,
18
19
  DEFAULT_INTERACTION_ERROR_PROMPT,
@@ -21,8 +22,6 @@ from .constants import (
21
22
  INPUT_EMPTY_ERROR,
22
23
  INTERACTION_ERROR_RENDER_INSTRUCTIONS,
23
24
  INTERACTION_PARSE_ERROR,
24
- INTERACTION_PATTERN_NON_CAPTURING,
25
- INTERACTION_PATTERN_SPLIT,
26
25
  INTERACTION_PROMPT_BASE,
27
26
  INTERACTION_PROMPT_WITH_TRANSLATION,
28
27
  LLM_PROVIDER_REQUIRED_ERROR,
@@ -62,7 +61,76 @@ from .parser import (
62
61
  process_output_instructions,
63
62
  replace_variables_in_text,
64
63
  )
64
+ from .parser.document_context import find_document_interaction_spans, find_document_protected_spans
65
65
  from .tag_filter import StreamTagFilter, strip_preserve_tags
66
+ from .variable_names import find_variable_markers
67
+
68
+
69
+ def _translation_marker_spans(text: str) -> list[tuple[int, int]]:
70
+ """Include the preserved-variable prefix in each complete marker's source span."""
71
+ return [(marker.start - (marker.start > 0 and text[marker.start - 1] == "%"), marker.end) for marker in find_variable_markers(text)]
72
+
73
+
74
+ def _mask_translation_markers(text: str, markers: dict[str, str]) -> str:
75
+ """Replace whole markers with collision-free tokens, without reading inside keys."""
76
+ tokens = {raw: token for token, raw in markers.items()}
77
+ parts: list[str] = []
78
+ at = 0
79
+ for start, end in _translation_marker_spans(text):
80
+ parts.extend((text[at:start], tokens[text[start:end]]))
81
+ at = end
82
+ parts.append(text[at:])
83
+ return "".join(parts)
84
+
85
+
86
+ def _restore_translation_markers(original: str, translated: Any, markers: dict[str, str]) -> str:
87
+ """Accept a translated field only when all its marker tokens survive in order."""
88
+
89
+ def token_sequence(text: str) -> list[str]:
90
+ positions = []
91
+ for token in markers:
92
+ at = 0
93
+ while (at := text.find(token, at)) >= 0:
94
+ positions.append((at, token))
95
+ at += len(token)
96
+ return [token for _, token in sorted(positions)]
97
+
98
+ if not isinstance(translated, str) or token_sequence(original) != token_sequence(translated):
99
+ translated = original
100
+ for token, raw in markers.items():
101
+ translated = translated.replace(token, raw)
102
+ return translated
103
+
104
+
105
+ def _find_translation_text(text: str, old: str) -> int:
106
+ """Locate a display span without matching a substring inside an opaque marker."""
107
+ if not old:
108
+ return -1
109
+ spans = _translation_marker_spans(text)
110
+ at = 0
111
+ while (at := text.find(old, at)) >= 0:
112
+ end = at + len(old)
113
+ if not any(at < marker_end and end > marker_start and (at > marker_start or end < marker_end) for marker_start, marker_end in spans):
114
+ return at
115
+ at += 1
116
+ return -1
117
+
118
+
119
+ def _replace_translation_text(text: str, old: str, new: str) -> str:
120
+ """Replace only a complete display span, leaving marker internals untouched."""
121
+ at = _find_translation_text(text, old)
122
+ return text[:at] + new + text[at + len(old) :] if at >= 0 else text
123
+
124
+
125
+ def _strip_translation_tags(text: str) -> str:
126
+ """Filter generated tags while retaining literal tag text inside variable names."""
127
+ parts: list[str] = []
128
+ at = 0
129
+ for marker in find_variable_markers(text):
130
+ parts.extend((strip_preserve_tags(text[at : marker.start]), text[marker.start : marker.end]))
131
+ at = marker.end
132
+ parts.append(strip_preserve_tags(text[at:]))
133
+ return "".join(parts)
66
134
 
67
135
 
68
136
  class MarkdownFlow:
@@ -468,18 +536,32 @@ class MarkdownFlow:
468
536
 
469
537
  # Parse the preprocessed document (code blocks already replaced with placeholders)
470
538
  # The preprocessing was done once during initialization
471
- segments = re.split(BLOCK_SEPARATOR, self._processed_document)
539
+ source = self._processed_document
540
+ protected = list(find_document_protected_spans(source))
541
+ segments = []
542
+ at = 0
543
+ protected_at = 0
544
+ for separator in re.finditer(BLOCK_SEPARATOR, source):
545
+ while protected_at < len(protected) and protected[protected_at][1] <= separator.start():
546
+ protected_at += 1
547
+ if protected_at < len(protected) and protected[protected_at][0] < separator.end():
548
+ continue
549
+ segments.append(source[at : separator.start()])
550
+ at = separator.end()
551
+ segments.append(source[at:])
472
552
  final_blocks: list[Block] = []
473
553
 
474
554
  for segment in segments:
475
- # Use dedicated split pattern to avoid duplicate blocks from capturing groups
476
- parts = re.split(INTERACTION_PATTERN_SPLIT, segment)
477
-
478
- for part in parts:
555
+ at = 0
556
+ parts: list[tuple[str, bool]] = []
557
+ for start, end in find_document_interaction_spans(segment):
558
+ parts.extend(((segment[at:start], False), (segment[start:end], True)))
559
+ at = end
560
+ parts.append((segment[at:], False))
561
+ for part, interaction in parts:
479
562
  part = part.strip()
480
563
  if part:
481
- # Use non-capturing pattern for matching
482
- if re.match(INTERACTION_PATTERN_NON_CAPTURING, part):
564
+ if interaction:
483
565
  block = Block(
484
566
  content=part,
485
567
  block_type=BlockType.INTERACTION,
@@ -763,7 +845,7 @@ class MarkdownFlow:
763
845
  translated_content = self._reconstruct_with_translation(processed_block.content, translatable_json, translated_json, interaction_info)
764
846
 
765
847
  # 过滤 LLM 输出中可能泄露的 原样输出 标签
766
- translated_content = strip_preserve_tags(translated_content)
848
+ translated_content = _strip_translation_tags(translated_content)
767
849
 
768
850
  return LLMResult(
769
851
  content=translated_content,
@@ -805,7 +887,7 @@ class MarkdownFlow:
805
887
  translated_content = self._reconstruct_with_translation(processed_block.content, translatable_json, full_response, interaction_info)
806
888
 
807
889
  # 过滤 LLM 输出中可能泄露的 原样输出 标签
808
- translated_content = strip_preserve_tags(translated_content)
890
+ translated_content = _strip_translation_tags(translated_content)
809
891
 
810
892
  # Return complete content once (not incremental)
811
893
  yield LLMResult(
@@ -1453,16 +1535,24 @@ class MarkdownFlow:
1453
1535
  if not interaction_info:
1454
1536
  return "{}", None
1455
1537
 
1456
- translatable = {}
1538
+ # Keep marker source out of the translation request. The prefix cannot
1539
+ # collide with author text, and equal raw markers share the same token.
1540
+ prefix = "__MDFLOW_VARIABLE_"
1541
+ while prefix in interaction_content:
1542
+ prefix = "_" + prefix
1543
+ raw_markers = dict.fromkeys(interaction_content[start:end] for start, end in _translation_marker_spans(interaction_content))
1544
+ markers = {f"{prefix}{i}__": raw for i, raw in enumerate(raw_markers)}
1545
+ interaction_info["_translation_markers"] = markers
1546
+ translatable: dict[str, str | list[str]] = {}
1457
1547
 
1458
1548
  # Extract button display text
1459
1549
  if interaction_info.get("buttons"):
1460
- button_texts = [btn["display"] for btn in interaction_info["buttons"]]
1550
+ button_texts = [_mask_translation_markers(btn["display"], markers) for btn in interaction_info["buttons"]]
1461
1551
  translatable["buttons"] = button_texts
1462
1552
 
1463
1553
  # Extract question text
1464
1554
  if interaction_info.get("question"):
1465
- translatable["question"] = interaction_info["question"]
1555
+ translatable["question"] = _mask_translation_markers(interaction_info["question"], markers)
1466
1556
 
1467
1557
  # Convert to JSON
1468
1558
 
@@ -1536,19 +1626,26 @@ class MarkdownFlow:
1536
1626
  except (json.JSONDecodeError, ValueError):
1537
1627
  return original_content
1538
1628
 
1629
+ markers = interaction_info.get("_translation_markers", {})
1630
+ prefix = ""
1539
1631
  reconstructed = original_content
1632
+ marker = COMPILED_LAYER2_VARIABLE_REGEX.match(original_content[2:-1]) if original_content.startswith("?[") else None
1633
+ if marker:
1634
+ body_at = 2 + marker.start(2)
1635
+ prefix, reconstructed = original_content[:body_at], original_content[body_at:]
1540
1636
 
1541
1637
  # Replace button display text (smart value handling)
1542
1638
  if "buttons" in translated and interaction_info.get("buttons"):
1543
1639
  for i, button in enumerate(interaction_info["buttons"]):
1544
1640
  if i < len(translated["buttons"]):
1545
1641
  old_display = button["display"]
1546
- new_display = translated["buttons"][i]
1642
+ original_display = original.get("buttons", [])[i]
1643
+ new_display = _restore_translation_markers(original_display, translated["buttons"][i], markers)
1547
1644
 
1548
1645
  # Detect if translation happened
1549
1646
  translation_happened = False
1550
1647
  if "buttons" in original and i < len(original["buttons"]):
1551
- if original["buttons"][i] != new_display:
1648
+ if old_display != new_display:
1552
1649
  translation_happened = True
1553
1650
 
1554
1651
  # If value separation exists (display//value format), preserve value
@@ -1557,36 +1654,36 @@ class MarkdownFlow:
1557
1654
  # Replace format: oldDisplay//value -> newDisplay//value
1558
1655
  old_pattern = f"{old_display}//{button['value']}"
1559
1656
  new_pattern = f"{new_display}//{button['value']}"
1560
- reconstructed = reconstructed.replace(old_pattern, new_pattern, 1)
1657
+ reconstructed = _replace_translation_text(reconstructed, old_pattern, new_pattern)
1561
1658
  elif translation_happened:
1562
1659
  # Display == Value, but need to check if display//value format exists in original text
1563
1660
  # In this case, original text is like "小兔子//小兔子", both Display and Value are "小兔子"
1564
1661
  old_pattern_with_separator = f"{old_display}//"
1565
- if old_pattern_with_separator in reconstructed:
1662
+ if _find_translation_text(reconstructed, old_pattern_with_separator) >= 0:
1566
1663
  # Original text has display//value format (e.g., "小兔子//小兔子")
1567
1664
  # Only replace display part, preserve original value
1568
1665
  # Example: 小兔子//小兔子 -> 토끼//小兔子
1569
1666
  old_pattern = f"{old_display}//{button['value']}"
1570
1667
  new_pattern = f"{new_display}//{button['value']}"
1571
- reconstructed = reconstructed.replace(old_pattern, new_pattern, 1)
1668
+ reconstructed = _replace_translation_text(reconstructed, old_pattern, new_pattern)
1572
1669
  else:
1573
1670
  # Original text has no // separator (e.g., "小兔子")
1574
1671
  # Auto-add value: translated//original
1575
1672
  # Example: 小兔子 -> 토끼//小兔子
1576
1673
  old_pattern = old_display
1577
1674
  new_pattern = f"{new_display}//{old_display}"
1578
- reconstructed = reconstructed.replace(old_pattern, new_pattern, 1)
1675
+ reconstructed = _replace_translation_text(reconstructed, old_pattern, new_pattern)
1579
1676
  else:
1580
1677
  # No translation, keep as is
1581
- reconstructed = reconstructed.replace(old_display, new_display, 1)
1678
+ reconstructed = _replace_translation_text(reconstructed, old_display, new_display)
1582
1679
 
1583
1680
  # Replace question text
1584
1681
  if "question" in translated and interaction_info.get("question"):
1585
1682
  old_question = interaction_info["question"]
1586
- new_question = translated["question"]
1587
- reconstructed = reconstructed.replace(f"...{old_question}", f"...{new_question}", 1)
1683
+ new_question = _restore_translation_markers(original["question"], translated["question"], markers)
1684
+ reconstructed = _replace_translation_text(reconstructed, f"...{old_question}", f"...{new_question}")
1588
1685
 
1589
- return reconstructed
1686
+ return prefix + reconstructed
1590
1687
 
1591
1688
  def _build_validation_messages(
1592
1689
  self,
@@ -1630,10 +1727,10 @@ class MarkdownFlow:
1630
1727
  # 2. Validation task template (choose based on language requirement)
1631
1728
  if has_language_requirement:
1632
1729
  # Has language requirement: use Base + WithLanguage
1633
- task_template = (VALIDATION_TASK_BASE + VALIDATION_TASK_WITH_LANGUAGE).replace("{target_variable}", target_variable)
1730
+ task_template = (VALIDATION_TASK_BASE + VALIDATION_TASK_WITH_LANGUAGE).replace('"{target_variable}"', json.dumps(target_variable, ensure_ascii=False))
1634
1731
  else:
1635
1732
  # No language requirement: use default (Base + NoLanguage)
1636
- task_template = VALIDATION_TASK_TEMPLATE.replace("{target_variable}", target_variable)
1733
+ task_template = VALIDATION_TASK_TEMPLATE.replace('"{target_variable}"', json.dumps(target_variable, ensure_ascii=False))
1637
1734
 
1638
1735
  system_parts.append(task_template)
1639
1736
 
@@ -1662,7 +1759,7 @@ class MarkdownFlow:
1662
1759
 
1663
1760
  # Add extraction requirements (using template)
1664
1761
  system_parts.append("")
1665
- system_parts.append(VALIDATION_REQUIREMENTS_TEMPLATE)
1762
+ system_parts.append(VALIDATION_REQUIREMENTS_TEMPLATE.replace('"{target_variable}"', json.dumps(target_variable, ensure_ascii=False)))
1666
1763
 
1667
1764
  # 3. Output language anchoring (bottom - if output language is set)
1668
1765
  if has_language_requirement:
@@ -18,6 +18,9 @@ The cost is that a literal backslash cannot sit immediately before a literal del
18
18
  an escaped bar, not a backslash and a separator. Nothing in the measured corpus writes that.
19
19
  """
20
20
 
21
+ from .variable_names import read_variable_marker
22
+
23
+
21
24
  # The characters a backslash may make ordinary. `/` covers `//`, `.` covers `...`.
22
25
  ESCAPABLE = "|/.]"
23
26
 
@@ -45,10 +48,22 @@ def escape_interaction_text(text: str) -> str:
45
48
  out: list[str] = []
46
49
  index = 0
47
50
  while index < len(text):
51
+ if text.startswith("{{", index):
52
+ marker = read_variable_marker(text, index)
53
+ end = marker.end if marker is not None else len(text)
54
+ out.append(text[index:end])
55
+ index = end
56
+ continue
48
57
  char = text[index]
49
58
  if char == ".":
50
59
  run = len(text) - index - len(text[index:].lstrip("."))
51
- if run >= 3:
60
+ # A run of three or more is the `...` delimiter. A shorter one still needs escaping
61
+ # when a literal backslash precedes it, because the parser would read that backslash
62
+ # and the dot as an escape pair and drop the backslash -- `a\\.b` would come back as
63
+ # `a.b`, the round trip would fail, and the question would be refused. Escaping the
64
+ # dot puts a second backslash in front of it, which the parser reads as one literal
65
+ # backslash followed by an escaped dot: exactly the text that went in.
66
+ if run >= 3 or (index > 0 and text[index - 1] == "\\"):
52
67
  out.append("\\." * run)
53
68
  else:
54
69
  out.append("." * run)
@@ -66,6 +81,12 @@ def unescape_interaction_text(text: str) -> str:
66
81
  out: list[str] = []
67
82
  index = 0
68
83
  while index < len(text):
84
+ if text.startswith("{{", index):
85
+ marker = read_variable_marker(text, index)
86
+ end = marker.end if marker is not None else len(text)
87
+ out.append(text[index:end])
88
+ index = end
89
+ continue
69
90
  if is_escape(text, index):
70
91
  out.append(text[index + 1])
71
92
  index += 2
@@ -84,6 +105,12 @@ def find_unescaped(text: str, needle: str, start: int = 0) -> int:
84
105
  """
85
106
  index = start
86
107
  while index < len(text):
108
+ if text.startswith("{{", index):
109
+ marker = read_variable_marker(text, index)
110
+ if marker is None:
111
+ return -1
112
+ index = marker.end
113
+ continue
87
114
  if is_escape(text, index):
88
115
  index += 2
89
116
  continue
@@ -112,6 +139,10 @@ def split_on_single_pipe(text: str) -> list[str]:
112
139
  start = 0
113
140
  index = 0
114
141
  while index < len(text):
142
+ if text.startswith("{{", index):
143
+ marker = read_variable_marker(text, index)
144
+ index = marker.end if marker is not None else len(text)
145
+ continue
115
146
  if is_escape(text, index):
116
147
  index += 2
117
148
  continue
@@ -154,7 +185,8 @@ def split_on_ellipsis(content: str) -> tuple[str, str] | None:
154
185
 
155
186
  What is new is only that an escaped ellipsis no longer counts.
156
187
  """
157
- head = content.split("\n", 1)[0]
188
+ newline = find_unescaped(content, "\n")
189
+ head = content[:newline] if newline >= 0 else content
158
190
  at = rfind_unescaped(head, "...")
159
191
  if at < 0:
160
192
  return None
@@ -0,0 +1,131 @@
1
+ """Document-stage boundaries without changing the raw variable-marker grammar."""
2
+
3
+ from collections.abc import Iterator
4
+ from string import punctuation
5
+
6
+ from ..constants import COMPILED_INLINE_HTML_REGEX
7
+ from ..variable_names import VariableMarker, find_interaction_end, find_variable_markers, read_variable_marker
8
+ from .code_fence_utils import is_code_fence_end, parse_code_fence_start
9
+
10
+
11
+ def _fenced_code_end(text: str, start: int) -> int | None:
12
+ if start and text[start - 1] != "\n":
13
+ return None
14
+ line_end = text.find("\n", start)
15
+ line_end = line_end if line_end >= 0 else len(text)
16
+ fence = parse_code_fence_start(text[start:line_end])
17
+ if fence is None:
18
+ return None
19
+ at = line_end + 1
20
+ while at < len(text):
21
+ end = text.find("\n", at)
22
+ end = end if end >= 0 else len(text)
23
+ if is_code_fence_end(text[at:end], fence):
24
+ return end
25
+ at = end + 1
26
+ return len(text)
27
+
28
+
29
+ def read_document_literal_end(text: str, start: int) -> int | None:
30
+ """Read a Markdown literal region; unmatched inline tick runs stop locally."""
31
+ fenced_end = _fenced_code_end(text, start)
32
+ if fenced_end is not None:
33
+ return fenced_end
34
+ if text[start] == "\\":
35
+ # Markdown escapes ASCII punctuation, never the following line ending.
36
+ return start + 2 if start + 1 < len(text) and text[start + 1] in punctuation else None
37
+ if text[start] == "<":
38
+ html = COMPILED_INLINE_HTML_REGEX.match(text, start)
39
+ return html.end() if html is not None else None
40
+ if text[start] != "`":
41
+ return None
42
+
43
+ opening_end = start + 1
44
+ while opening_end < len(text) and text[opening_end] == "`":
45
+ opening_end += 1
46
+ size = opening_end - start
47
+ at = opening_end
48
+ while at < len(text):
49
+ if text[at] == "\n":
50
+ line_end = text.find("\n", at + 1)
51
+ line = text[at + 1 : line_end if line_end >= 0 else len(text)]
52
+ # Inline code cannot absorb a later fenced block or a new paragraph.
53
+ if not line.strip() or parse_code_fence_start(line) is not None:
54
+ break
55
+ if text[at] == "`":
56
+ end = at + 1
57
+ while end < len(text) and text[end] == "`":
58
+ end += 1
59
+ if end - at == size:
60
+ return end
61
+ at = end
62
+ else:
63
+ at += 1
64
+ # An unmatched run is literal punctuation; its following braces stay active.
65
+ return opening_end
66
+
67
+
68
+ def _document_units(text: str) -> Iterator[tuple[int, int, bool]]:
69
+ """Yield literal markup and raw-marker regions, keeping interaction bodies raw."""
70
+ at = 0
71
+ interaction_until = -1
72
+ while at < len(text):
73
+ if at > interaction_until:
74
+ literal_end = read_document_literal_end(text, at)
75
+ if literal_end is not None:
76
+ yield at, literal_end, True
77
+ at = literal_end
78
+ continue
79
+ if text.startswith("?[", at):
80
+ end = find_interaction_end(text, at + 2)
81
+ interaction_until = end if end >= 0 else len(text)
82
+ if text.startswith("{{", at):
83
+ marker = read_variable_marker(text, at)
84
+ end = marker.end if marker is not None else len(text)
85
+ yield at, end, False
86
+ at = end
87
+ else:
88
+ at += 1
89
+
90
+
91
+ def find_document_variable_markers(text: str) -> Iterator[VariableMarker]:
92
+ """Collect complete examples inside markup, without letting them cross its edge."""
93
+ for start, end, literal in _document_units(text):
94
+ if literal:
95
+ if text[start] == "\\":
96
+ continue
97
+ for marker in find_variable_markers(text[start:end]):
98
+ yield VariableMarker(start + marker.start, start + marker.end, marker.raw_name, marker.name)
99
+ else:
100
+ marker = read_variable_marker(text, start)
101
+ if marker is not None:
102
+ yield marker
103
+
104
+
105
+ def find_document_protected_spans(text: str) -> Iterator[tuple[int, int]]:
106
+ """Keep document separators outside marker names and already-owned markup."""
107
+ for start, end, _ in _document_units(text):
108
+ yield start, end
109
+
110
+
111
+ def find_document_interaction_spans(text: str) -> Iterator[tuple[int, int]]:
112
+ """Find controls outside markup while retaining raw ownership inside controls."""
113
+ at = 0
114
+ while at < len(text):
115
+ literal_end = read_document_literal_end(text, at)
116
+ if literal_end is not None:
117
+ at = literal_end
118
+ elif text.startswith("{{", at):
119
+ marker = read_variable_marker(text, at)
120
+ if marker is None:
121
+ return
122
+ at = marker.end
123
+ elif text.startswith("?[", at):
124
+ end = find_interaction_end(text, at + 2)
125
+ if end < 0:
126
+ return
127
+ if text[end + 1 : end + 2] != "(":
128
+ yield at, end + 1
129
+ at = end + 1
130
+ else:
131
+ at += 1