markdown-flow 0.3.4__tar.gz → 0.4.1__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 (44) hide show
  1. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/PKG-INFO +39 -1
  2. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/README.md +38 -0
  3. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/__init__.py +13 -1
  4. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/constants.py +34 -8
  5. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/core.py +124 -27
  6. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/escaping.py +27 -1
  7. markdown_flow-0.4.1/markdown_flow/parser/document_blocks.py +357 -0
  8. markdown_flow-0.4.1/markdown_flow/parser/document_context.py +447 -0
  9. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/interaction.py +15 -17
  10. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/preprocessor.py +95 -19
  11. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/validation.py +2 -1
  12. markdown_flow-0.4.1/markdown_flow/parser/variable.py +132 -0
  13. markdown_flow-0.4.1/markdown_flow/variable_names.py +103 -0
  14. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow.egg-info/PKG-INFO +39 -1
  15. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow.egg-info/SOURCES.txt +3 -0
  16. markdown_flow-0.3.4/markdown_flow/parser/variable.py +0 -157
  17. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/LICENSE +0 -0
  18. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/constants_system_prompt.py +0 -0
  19. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/enums.py +0 -0
  20. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/exceptions.py +0 -0
  21. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/__init__.py +0 -0
  22. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/classifier.py +0 -0
  23. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/format.py +0 -0
  24. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/patterns.py +0 -0
  25. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/sanitize.py +0 -0
  26. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/stream.py +0 -0
  27. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/formatter/types.py +0 -0
  28. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/higher_priority_prompt.md +0 -0
  29. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/llm.py +0 -0
  30. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/models.py +0 -0
  31. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/__init__.py +0 -0
  32. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/code_fence_utils.py +0 -0
  33. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/html_comment_utils.py +0 -0
  34. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/json_parser.py +0 -0
  35. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/parser/output.py +0 -0
  36. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/providers/__init__.py +0 -0
  37. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/providers/config.py +0 -0
  38. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/providers/openai.py +0 -0
  39. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/system_prompt.md +0 -0
  40. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow/tag_filter.py +0 -0
  41. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow.egg-info/dependency_links.txt +0 -0
  42. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/markdown_flow.egg-info/top_level.txt +0 -0
  43. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/pyproject.toml +0 -0
  44. {markdown_flow-0.3.4 → markdown_flow-0.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.3.4
3
+ Version: 0.4.1
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,44 @@ 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, inline math, link resources, indented
712
+ code, and raw-text HTML bodies (`script`, `style`, `pre`, `textarea`) bound local
713
+ variable examples. Quote/list-contained fences and ordinary HTML blocks also keep
714
+ unfinished examples local. Reference-definition destinations and titles retain
715
+ their complete variable examples. Physical CRLF, LF and CR boundaries preserve
716
+ original offsets and marker text. Complete variable examples there still support collection and replacement,
717
+ but an unclosed example cannot consume text beyond that markup span. Escaped
718
+ document openers stay literal. A true raw marker owns its entire name before any
719
+ code or HTML inside that name is considered; interaction bodies keep the raw
720
+ marker grammar.
721
+
722
+ Use `format_variable_reference(name)` and `format_variable_assignment(name)` from
723
+ `markdown_flow.variable_names` to emit padded markers such as
724
+ `{{ What is your gender? }}` and `%{{ a} }}`. Padding lets a name end in a single
725
+ brace. Extraction and replacement use the same trimmed key; existing stored keys
726
+ are not renamed or migrated.
727
+
728
+ The shared conformance corpus is `scripts/variable-name-contract.json`. Run its
729
+ offline parser, document, replacement, translation, and answer round trips with
730
+ `python -m pytest scripts/test_variable_names.py -q`.
731
+
694
732
  ```python
695
733
  from markdown_flow import extract_variables_from_text, replace_variables_in_text
696
734
 
@@ -673,6 +673,44 @@ 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, inline math, link resources, indented
694
+ code, and raw-text HTML bodies (`script`, `style`, `pre`, `textarea`) bound local
695
+ variable examples. Quote/list-contained fences and ordinary HTML blocks also keep
696
+ unfinished examples local. Reference-definition destinations and titles retain
697
+ their complete variable examples. Physical CRLF, LF and CR boundaries preserve
698
+ original offsets and marker text. Complete variable examples there still support collection and replacement,
699
+ but an unclosed example cannot consume text beyond that markup span. Escaped
700
+ document openers stay literal. A true raw marker owns its entire name before any
701
+ code or HTML inside that name is considered; interaction bodies keep the raw
702
+ marker grammar.
703
+
704
+ Use `format_variable_reference(name)` and `format_variable_assignment(name)` from
705
+ `markdown_flow.variable_names` to emit padded markers such as
706
+ `{{ What is your gender? }}` and `%{{ a} }}`. Padding lets a name end in a single
707
+ brace. Extraction and replacement use the same trimmed key; existing stored keys
708
+ are not renamed or migrated.
709
+
710
+ The shared conformance corpus is `scripts/variable-name-contract.json`. Run its
711
+ offline parser, document, replacement, translation, and answer round trips with
712
+ `python -m pytest scripts/test_variable_names.py -q`.
713
+
676
714
  ```python
677
715
  from markdown_flow import extract_variables_from_text, replace_variables_in_text
678
716
 
@@ -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.4"
121
+ __version__ = "0.4.1"
@@ -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,34 @@ 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
+ )
51
+ COMPILED_INDENTED_CODE_REGEX = re.compile(r"(?: {4}| {0,3}\t)")
52
+ COMPILED_RAW_HTML_START_REGEX = re.compile(r" {0,3}<(script|style|pre|textarea)(?=[ \t\r\n\f>])", re.IGNORECASE)
53
+ COMPILED_RAW_HTML_END_REGEX = {name: re.compile(r"</" + name + r"[ \t\r\n]*>", re.IGNORECASE) for name in ("script", "style", "pre", "textarea")}
54
+ COMPILED_DOLLAR_RUN_REGEX = re.compile(r"\$+")
55
+ COMPILED_BLANK_LINE_REGEX = re.compile(r"(?:\r\n|\r(?!\n)|(?<!\r)\n)[ \t]*(?:\r\n|\r(?!\n)|(?<!\r)\n)")
56
+ COMPILED_ATX_START_REGEX = re.compile(r"^ {0,3}#{1,6}(?=[ \t\r\n]|$)", re.MULTILINE)
57
+ COMPILED_PHYSICAL_LINE_END_REGEX = re.compile(r"\r\n|\r|\n")
58
+ COMPILED_CONTAINER_LIST_REGEX = re.compile(r"(?:[-+*]|[0-9]{1,9}[.)])(?=[ \t])")
59
+ COMPILED_DOCUMENT_FENCE_REGEX = re.compile(r"(`{3,})([^`]*$)|(~{3,})(.*$)")
60
+ COMPILED_DOCUMENT_TAG_REGEX = re.compile(r" {0,3}</?([A-Za-z][A-Za-z0-9-]*)(?=[ \t/>]|$)")
61
+ COMPILED_REFERENCE_PREFIX_REGEX = re.compile(r" {0,3}\[((?:\\[^\r\n]|[^\]\\\r\n])+)]\:[ \t]*")
62
+ COMPILED_DOCUMENT_BREAK_REGEX = re.compile(r" {0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$")
63
+ COMPILED_SETEXT_REGEX = re.compile(r" {0,3}(?:=+|-+)[ \t]*$")
39
64
 
40
65
  # Canonical user_input key for non-assignment interactions (?[A|B] without a
41
66
  # variable). Clients should submit {DEFAULT_NON_ASSIGNMENT_INPUT_KEY: values};
@@ -135,6 +160,7 @@ INTERACTION_PROMPT_WITH_TRANSLATION = """
135
160
 
136
161
  - Translate the text in `buttons` and `question` into the target language
137
162
  - Translate only the display text (the Display part); do not change the JSON structure
163
+ - 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
164
  - If `display//value` separation exists (for example `Yes//1`), translate only the Display part before `//` and keep value unchanged
139
165
  - Use the target language 100%; language mixing is not allowed. Translate all non-target-language words before outputting
140
166
  - 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,6 +48,12 @@ 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("."))
@@ -72,6 +81,12 @@ def unescape_interaction_text(text: str) -> str:
72
81
  out: list[str] = []
73
82
  index = 0
74
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
75
90
  if is_escape(text, index):
76
91
  out.append(text[index + 1])
77
92
  index += 2
@@ -90,6 +105,12 @@ def find_unescaped(text: str, needle: str, start: int = 0) -> int:
90
105
  """
91
106
  index = start
92
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
93
114
  if is_escape(text, index):
94
115
  index += 2
95
116
  continue
@@ -118,6 +139,10 @@ def split_on_single_pipe(text: str) -> list[str]:
118
139
  start = 0
119
140
  index = 0
120
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
121
146
  if is_escape(text, index):
122
147
  index += 2
123
148
  continue
@@ -160,7 +185,8 @@ def split_on_ellipsis(content: str) -> tuple[str, str] | None:
160
185
 
161
186
  What is new is only that an escaped ellipsis no longer counts.
162
187
  """
163
- head = content.split("\n", 1)[0]
188
+ newline = find_unescaped(content, "\n")
189
+ head = content[:newline] if newline >= 0 else content
164
190
  at = rfind_unescaped(head, "...")
165
191
  if at < 0:
166
192
  return None