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.
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/PKG-INFO +35 -1
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/README.md +34 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/__init__.py +13 -1
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/constants.py +21 -8
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/core.py +124 -27
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/escaping.py +34 -2
- markdown_flow-0.4.0/markdown_flow/parser/document_context.py +131 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/interaction.py +15 -17
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/preprocessor.py +71 -2
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/validation.py +2 -1
- markdown_flow-0.4.0/markdown_flow/parser/variable.py +132 -0
- markdown_flow-0.4.0/markdown_flow/variable_names.py +103 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/PKG-INFO +35 -1
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/SOURCES.txt +2 -0
- markdown_flow-0.3.3/markdown_flow/parser/variable.py +0 -157
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/LICENSE +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/constants_system_prompt.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/enums.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/exceptions.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/__init__.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/classifier.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/format.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/patterns.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/sanitize.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/stream.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/formatter/types.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/higher_priority_prompt.md +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/llm.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/models.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/__init__.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/code_fence_utils.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/html_comment_utils.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/json_parser.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/parser/output.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/__init__.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/config.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/providers/openai.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/system_prompt.md +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow/tag_filter.py +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/dependency_links.txt +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/markdown_flow.egg-info/top_level.txt +0 -0
- {markdown_flow-0.3.3 → markdown_flow-0.4.0}/pyproject.toml +0 -0
- {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
|
+
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.
|
|
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 =
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
parts =
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1675
|
+
reconstructed = _replace_translation_text(reconstructed, old_pattern, new_pattern)
|
|
1579
1676
|
else:
|
|
1580
1677
|
# No translation, keep as is
|
|
1581
|
-
reconstructed = reconstructed
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|