markdown-flow 0.2.85__tar.gz → 0.3.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 (40) hide show
  1. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/PKG-INFO +32 -2
  2. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/README.md +31 -1
  3. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/__init__.py +13 -2
  4. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/constants.py +9 -0
  5. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/constants_system_prompt.py +1 -0
  6. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/core.py +80 -18
  7. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/format.py +12 -1
  8. markdown_flow-0.3.0/markdown_flow/higher_priority_prompt.md +5 -0
  9. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/interaction.py +41 -6
  10. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow.egg-info/PKG-INFO +32 -2
  11. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow.egg-info/SOURCES.txt +1 -1
  12. markdown_flow-0.2.85/markdown_flow/utils.py +0 -795
  13. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/LICENSE +0 -0
  14. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/enums.py +0 -0
  15. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/exceptions.py +0 -0
  16. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/__init__.py +0 -0
  17. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/classifier.py +0 -0
  18. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/patterns.py +0 -0
  19. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/sanitize.py +0 -0
  20. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/stream.py +0 -0
  21. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/formatter/types.py +0 -0
  22. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/llm.py +0 -0
  23. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/models.py +0 -0
  24. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/__init__.py +0 -0
  25. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/code_fence_utils.py +0 -0
  26. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/html_comment_utils.py +0 -0
  27. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/json_parser.py +0 -0
  28. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/output.py +0 -0
  29. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/preprocessor.py +0 -0
  30. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/validation.py +0 -0
  31. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/parser/variable.py +0 -0
  32. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/providers/__init__.py +0 -0
  33. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/providers/config.py +0 -0
  34. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/providers/openai.py +0 -0
  35. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/system_prompt.md +0 -0
  36. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow/tag_filter.py +0 -0
  37. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow.egg-info/dependency_links.txt +0 -0
  38. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/markdown_flow.egg-info/top_level.txt +0 -0
  39. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/pyproject.toml +0 -0
  40. {markdown_flow-0.2.85 → markdown_flow-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.2.85
3
+ Version: 0.3.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
@@ -313,10 +313,40 @@ class InteractionType(NamedTuple):
313
313
  # BUTTONS_MULTI_WITH_TEXT: Multi-select with text fallback
314
314
  "?[%{{frameworks}} React||Vue||Angular||Please specify others...]"
315
315
 
316
- # NON_ASSIGNMENT_BUTTON: Display buttons without variable assignment
316
+ # NON_ASSIGNMENT_BUTTON: No-variable interaction. Supports the same shapes as
317
+ # variable interactions; the answer is not assigned to a variable but fed back
318
+ # into the conversation context (see "Non-assignment interactions" below).
317
319
  "?[Continue | Cancel | Go Back]"
320
+ "?[JavaScript||TypeScript||Python]"
321
+ "?[...Anything to add?]"
322
+ "?[Option A | Option B | ...Other, please specify]"
318
323
  ```
319
324
 
325
+ ### Non-assignment interactions
326
+
327
+ Interactions without a `%{{variable}}` collect an answer that is not stored in
328
+ any variable — it flows back into the conversation context instead:
329
+
330
+ 1. Clients submit the answer under the canonical key `input`
331
+ (`DEFAULT_NON_ASSIGNMENT_INPUT_KEY`); the library tolerantly merges values
332
+ from any other key.
333
+ 2. `process()` normalizes values matching a button (by display or value) to the
334
+ button value and accepts anything else as free text. The normalized answer
335
+ is returned in `result.metadata["answer"]` (`list[str]`).
336
+ 3. Callers persist that answer and attach it to the interaction's context
337
+ message via the `user_answer` extension field (`USER_ANSWER_CONTEXT_KEY`):
338
+
339
+ ```python
340
+ context = [
341
+ {"role": "assistant", "content": "?[Option A | Option B]", "user_answer": "Option A"},
342
+ ]
343
+ ```
344
+
345
+ When building LLM messages the library expands this into
346
+ `{user: "Option A"}` + `{assistant: "ok"}`. An empty `user_answer` skips the
347
+ turn entirely; omitting the field keeps the legacy `{user: "ok"}` behavior.
348
+ Extension fields are stripped before messages reach the LLM.
349
+
320
350
  ### Utility Functions
321
351
 
322
352
  #### Variable Operations
@@ -295,10 +295,40 @@ class InteractionType(NamedTuple):
295
295
  # BUTTONS_MULTI_WITH_TEXT: Multi-select with text fallback
296
296
  "?[%{{frameworks}} React||Vue||Angular||Please specify others...]"
297
297
 
298
- # NON_ASSIGNMENT_BUTTON: Display buttons without variable assignment
298
+ # NON_ASSIGNMENT_BUTTON: No-variable interaction. Supports the same shapes as
299
+ # variable interactions; the answer is not assigned to a variable but fed back
300
+ # into the conversation context (see "Non-assignment interactions" below).
299
301
  "?[Continue | Cancel | Go Back]"
302
+ "?[JavaScript||TypeScript||Python]"
303
+ "?[...Anything to add?]"
304
+ "?[Option A | Option B | ...Other, please specify]"
300
305
  ```
301
306
 
307
+ ### Non-assignment interactions
308
+
309
+ Interactions without a `%{{variable}}` collect an answer that is not stored in
310
+ any variable — it flows back into the conversation context instead:
311
+
312
+ 1. Clients submit the answer under the canonical key `input`
313
+ (`DEFAULT_NON_ASSIGNMENT_INPUT_KEY`); the library tolerantly merges values
314
+ from any other key.
315
+ 2. `process()` normalizes values matching a button (by display or value) to the
316
+ button value and accepts anything else as free text. The normalized answer
317
+ is returned in `result.metadata["answer"]` (`list[str]`).
318
+ 3. Callers persist that answer and attach it to the interaction's context
319
+ message via the `user_answer` extension field (`USER_ANSWER_CONTEXT_KEY`):
320
+
321
+ ```python
322
+ context = [
323
+ {"role": "assistant", "content": "?[Option A | Option B]", "user_answer": "Option A"},
324
+ ]
325
+ ```
326
+
327
+ When building LLM messages the library expands this into
328
+ `{user: "Option A"}` + `{assistant: "ok"}`. An empty `user_answer` skips the
329
+ turn entirely; omitting the field keeps the legacy `{user: "ok"}` behavior.
330
+ Extension fields are stripped before messages reach the LLM.
331
+
302
332
  ### Utility Functions
303
333
 
304
334
  #### Variable Operations
@@ -17,7 +17,11 @@ Supported Interaction Types:
17
17
  - BUTTONS_WITH_TEXT: ?[%{{var}} A|B|...question] - Buttons + text input
18
18
  - BUTTONS_MULTI_SELECT: ?[%{{var}} A||B||C] - Multi-select buttons
19
19
  - BUTTONS_MULTI_WITH_TEXT: ?[%{{var}} A||B||...question] - Multi-select + text input
20
- - NON_ASSIGNMENT_BUTTON: ?[Continue|Cancel] - Display buttons only
20
+ - NON_ASSIGNMENT_BUTTON: ?[Continue|Cancel] - No-variable interaction.
21
+ Supports the same shapes as variable interactions (?[A|B], ?[A||B],
22
+ ?[...question], ?[A|B|...question]); the answer is not assigned to a
23
+ variable but fed back into the conversation context via the
24
+ "user_answer" context message field.
21
25
 
22
26
  Basic Usage:
23
27
  from markdown_flow import MarkdownFlow, ProcessMode
@@ -49,6 +53,10 @@ Import Guide:
49
53
  """
50
54
 
51
55
  # Import core classes and enums
56
+ from .constants import (
57
+ DEFAULT_NON_ASSIGNMENT_INPUT_KEY,
58
+ USER_ANSWER_CONTEXT_KEY,
59
+ )
52
60
  from .core import MarkdownFlow
53
61
  from .enums import BlockType, InputType
54
62
  from .formatter import (
@@ -88,6 +96,9 @@ __all__ = [
88
96
  "StreamFormatter",
89
97
  "format_content",
90
98
  "strip_untagged_code_fences",
99
+ # Non-assignment interaction contract
100
+ "DEFAULT_NON_ASSIGNMENT_INPUT_KEY",
101
+ "USER_ANSWER_CONTEXT_KEY",
91
102
  # Main utility functions
92
103
  "generate_smart_validation_template",
93
104
  "extract_interaction_question",
@@ -95,4 +106,4 @@ __all__ = [
95
106
  "replace_variables_in_text",
96
107
  ]
97
108
 
98
- __version__ = "0.2.85"
109
+ __version__ = "0.3.0"
@@ -28,6 +28,15 @@ COMPILED_BRACE_VARIABLE_REGEX = re.compile(
28
28
  )
29
29
  COMPILED_SINGLE_PIPE_SPLIT_REGEX = re.compile(r"(?<!\|)\|(?!\|)") # Split on single | but not ||
30
30
 
31
+ # Canonical user_input key for non-assignment interactions (?[A|B] without a
32
+ # variable). Clients should submit {DEFAULT_NON_ASSIGNMENT_INPUT_KEY: values};
33
+ # the library also tolerantly merges values from any other key.
34
+ DEFAULT_NON_ASSIGNMENT_INPUT_KEY = "input"
35
+
36
+ # Context message extension field carrying the user's answer to a
37
+ # non-assignment interaction. Stripped before messages reach the LLM.
38
+ USER_ANSWER_CONTEXT_KEY = "user_answer"
39
+
31
40
  # Document parsing constants (using shared INTERACTION_PATTERN defined above)
32
41
 
33
42
  # Separators
@@ -12,3 +12,4 @@ from pathlib import Path
12
12
 
13
13
 
14
14
  DEFAULT_MDF_SYSTEM_PROMPT = (Path(__file__).parent / "system_prompt.md").read_text(encoding="utf-8")
15
+ HIGHER_PRIORITY_INSTRUCTIONS_PROMPT = (Path(__file__).parent / "higher_priority_prompt.md").read_text(encoding="utf-8").strip()
@@ -17,6 +17,7 @@ from .constants import (
17
17
  CONTEXT_QUESTION_TEMPLATE,
18
18
  DEFAULT_INTERACTION_ERROR_PROMPT,
19
19
  DEFAULT_INTERACTION_PROMPT,
20
+ DEFAULT_NON_ASSIGNMENT_INPUT_KEY,
20
21
  INPUT_EMPTY_ERROR,
21
22
  INTERACTION_ERROR_RENDER_INSTRUCTIONS,
22
23
  INTERACTION_PARSE_ERROR,
@@ -36,12 +37,13 @@ from .constants import (
36
37
  OUTPUT_LANGUAGE_INSTRUCTION_BOTTOM,
37
38
  OUTPUT_LANGUAGE_INSTRUCTION_TOP,
38
39
  UNSUPPORTED_PROMPT_TYPE_ERROR,
40
+ USER_ANSWER_CONTEXT_KEY,
39
41
  VALIDATION_REQUIREMENTS_TEMPLATE,
40
42
  VALIDATION_TASK_BASE,
41
43
  VALIDATION_TASK_TEMPLATE,
42
44
  VALIDATION_TASK_WITH_LANGUAGE,
43
45
  )
44
- from .constants_system_prompt import DEFAULT_MDF_SYSTEM_PROMPT
46
+ from .constants_system_prompt import DEFAULT_MDF_SYSTEM_PROMPT, HIGHER_PRIORITY_INSTRUCTIONS_PROMPT
45
47
  from .enums import BlockType
46
48
  from .exceptions import BlockIndexError
47
49
  from .formatter import ElementType, StreamFormatter, format_content
@@ -363,10 +365,18 @@ class MarkdownFlow:
363
365
  Transform context messages containing interaction syntax into LLM-friendly format.
364
366
 
365
367
  For assistant messages containing ?[...] syntax:
366
- - No variable (e.g. ?[Continue]): replace with {user: "ok"} + {assistant: "ok"}
368
+ - No variable + "user_answer" field present and non-empty: replace with
369
+ {user: answer} + {assistant: "ok"}
370
+ - No variable + "user_answer" field present but empty: skip the message
371
+ (the interaction was never answered; do not fabricate an "ok")
372
+ - No variable + no "user_answer" field: replace with {user: "ok"} +
373
+ {assistant: "ok"} (legacy behavior for callers unaware of the field)
367
374
  - Has variable + value found: replace with {user: value} + {assistant: "ok"}
368
375
  - Has variable + value not found: skip the message
369
376
 
377
+ Non-standard fields such as "user_answer" are stripped from the
378
+ returned messages; the output only ever contains {role, content}.
379
+
370
380
  Args:
371
381
  context: Context message list
372
382
  variables: Variable mappings
@@ -382,7 +392,9 @@ class MarkdownFlow:
382
392
 
383
393
  for msg in context:
384
394
  if msg.get("role") != "assistant" or not has_interaction(msg.get("content", "")):
385
- result.append(msg)
395
+ # Whitelist {role, content}: callers may attach extension
396
+ # fields (e.g. user_answer) that must not reach the LLM.
397
+ result.append({"role": msg.get("role", ""), "content": msg.get("content", "")})
386
398
  continue
387
399
 
388
400
  # Parse interaction syntax
@@ -395,8 +407,23 @@ class MarkdownFlow:
395
407
 
396
408
  variable_name = parse_result.get("variable")
397
409
 
398
- # No variable interaction (e.g. ?[Continue])
410
+ # No variable interaction (e.g. ?[Continue] or ?[A | B])
399
411
  if not variable_name:
412
+ if USER_ANSWER_CONTEXT_KEY in msg:
413
+ # Accept both a flattened string and a list of values
414
+ # (callers may pass metadata["answer"] through directly,
415
+ # beyond the declared str value type).
416
+ raw_answer: Any = msg.get(USER_ANSWER_CONTEXT_KEY)
417
+ if isinstance(raw_answer, list):
418
+ user_answer = ", ".join(str(value) for value in raw_answer if value is not None).strip()
419
+ else:
420
+ user_answer = str(raw_answer or "").strip()
421
+ if user_answer:
422
+ result.append({"role": "user", "content": user_answer})
423
+ result.append({"role": "assistant", "content": "ok"})
424
+ # Empty answer: the interaction was never answered, skip it
425
+ continue
426
+ # Legacy behavior for callers that do not supply user_answer
400
427
  result.append({"role": "user", "content": "ok"})
401
428
  result.append({"role": "assistant", "content": "ok"})
402
429
  continue
@@ -806,16 +833,12 @@ class MarkdownFlow:
806
833
  ) -> LLMResult | Generator[LLMResult, None, None]:
807
834
  """Process interaction user input."""
808
835
  block = self.get_block(block_index)
809
- target_variable = block.variables[0] if block.variables else "user_input"
810
836
 
811
837
  # Basic validation
812
838
  if not user_input or not any(values for values in user_input.values()):
813
839
  error_msg = INPUT_EMPTY_ERROR
814
840
  return self._render_error(error_msg, mode, context, variables)
815
841
 
816
- # Get the target variable value from user_input
817
- target_values = user_input.get(target_variable, [])
818
-
819
842
  # Apply variable replacement to interaction content
820
843
  processed_content = replace_variables_in_text(block.content, variables or {})
821
844
 
@@ -829,6 +852,20 @@ class MarkdownFlow:
829
852
 
830
853
  interaction_type = parse_result.get("type")
831
854
 
855
+ # Derive the target variable from the parsed interaction, not from
856
+ # block.variables: text interpolation (e.g. ?[{{userName}} Continue])
857
+ # must not be mistaken for the assignment target.
858
+ target_variable = parse_result.get("variable") or DEFAULT_NON_ASSIGNMENT_INPUT_KEY
859
+
860
+ # Get the target variable value from user_input
861
+ if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
862
+ # Non-assignment interactions have no variable name for clients to
863
+ # key on; accept the answer from any key ("", "input", legacy
864
+ # "user_input", ...) by merging all submitted values.
865
+ target_values = [value for values in user_input.values() for value in values]
866
+ else:
867
+ target_values = user_input.get(target_variable, [])
868
+
832
869
  # Process user input based on interaction type
833
870
  if interaction_type in [
834
871
  InteractionType.BUTTONS_WITH_TEXT,
@@ -954,14 +991,22 @@ class MarkdownFlow:
954
991
  )
955
992
 
956
993
  if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
957
- # Non-assignment buttons: ?[Continue] or ?[Continue|Cancel]
958
- # These buttons don't assign variables, any input completes the interaction
994
+ # Non-assignment interactions: ?[Continue], ?[A|B], ?[A||B],
995
+ # ?[...question], ?[A|B|...question]. The answer is not assigned
996
+ # to a variable; values matching a button (by display or value)
997
+ # are normalized to the button value, anything else is accepted
998
+ # as free text. Callers should persist metadata["answer"] and
999
+ # feed it back through the "user_answer" context field so the
1000
+ # choice reaches the next LLM request.
1001
+ buttons = parse_result.get("buttons", [])
1002
+ matched_values, unmatched_values = self._match_button_values(buttons, target_values)
959
1003
  result = LLMResult(
960
1004
  content="", # Empty content indicates interaction complete
961
- variables={}, # Non-assignment buttons don't set variables
1005
+ variables={}, # Non-assignment interactions don't set variables
962
1006
  metadata={
963
1007
  "interaction_type": "non_assignment_button",
964
1008
  "user_input": user_input,
1009
+ "answer": matched_values + unmatched_values,
965
1010
  },
966
1011
  )
967
1012
  # Return generator for STREAM mode, direct result for COMPLETE mode
@@ -1247,8 +1292,7 @@ class MarkdownFlow:
1247
1292
  # Build message array
1248
1293
  messages = []
1249
1294
 
1250
- # Build system message with Markdown headings (no XML tags)
1251
- # Two parts: MDF internal prompt + Document prompt
1295
+ # Build system message from the MDF prompt and optional higher-priority instructions.
1252
1296
  system_parts = []
1253
1297
 
1254
1298
  # Part 1: MDF internal prompt (content rules + visual rules)
@@ -1259,9 +1303,10 @@ class MarkdownFlow:
1259
1303
  if self._viewing_mode_prompt:
1260
1304
  system_parts.append(self._viewing_mode_prompt)
1261
1305
 
1262
- # Part 2: Document prompt
1263
- if self._document_prompt:
1264
- system_parts.append(f"# Document Prompt\n\n{self._document_prompt}")
1306
+ # Part 2: Document prompt, wrapped as higher-priority instructions. This
1307
+ # part remains last so every supplied Markdown heading stays in scope.
1308
+ if self._document_prompt and self._document_prompt.strip():
1309
+ system_parts.append(f"{HIGHER_PRIORITY_INSTRUCTIONS_PROMPT}\n\n{self._document_prompt}")
1265
1310
 
1266
1311
  # Combine all parts and add as system message
1267
1312
  if system_parts:
@@ -1327,7 +1372,12 @@ class MarkdownFlow:
1327
1372
  buttons = parse_result.get("buttons") or []
1328
1373
  option_displays = [button.get("display", "").strip() for button in buttons if button.get("display", "").strip()]
1329
1374
 
1330
- detail = self._format_next_interaction_detail(parse_result.get("type"), question, option_displays)
1375
+ detail = self._format_next_interaction_detail(
1376
+ parse_result.get("type"),
1377
+ question,
1378
+ option_displays,
1379
+ parse_result.get("is_multi_select", False),
1380
+ )
1331
1381
  if not detail:
1332
1382
  return ""
1333
1383
 
@@ -1338,17 +1388,29 @@ class MarkdownFlow:
1338
1388
  interaction_type: InteractionType | None,
1339
1389
  question: str,
1340
1390
  option_displays: list[str],
1391
+ is_multi_select: bool = False,
1341
1392
  ) -> str:
1342
1393
  """Format the type-specific part of the next interaction prompt."""
1343
1394
  options = json.dumps(option_displays, ensure_ascii=False) if option_displays else ""
1344
1395
  question_text = json.dumps(question, ensure_ascii=False) if question else ""
1345
1396
 
1397
+ # Non-assignment interactions share the same shapes as variable ones
1398
+ # (buttons / multi-select / text input / buttons+text); map to the
1399
+ # equivalent variable type so the prompt wording matches.
1400
+ if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
1401
+ if option_displays and question:
1402
+ interaction_type = InteractionType.BUTTONS_MULTI_WITH_TEXT if is_multi_select else InteractionType.BUTTONS_WITH_TEXT
1403
+ elif question:
1404
+ interaction_type = InteractionType.TEXT_ONLY
1405
+ else:
1406
+ interaction_type = InteractionType.BUTTONS_MULTI_SELECT if is_multi_select else InteractionType.BUTTONS_ONLY
1407
+
1346
1408
  if interaction_type == InteractionType.TEXT_ONLY:
1347
1409
  if not question:
1348
1410
  return ""
1349
1411
  return NEXT_INTERACTION_TEXT_INPUT_TEMPLATE.format(question=question_text)
1350
1412
 
1351
- if interaction_type in [InteractionType.BUTTONS_ONLY, InteractionType.NON_ASSIGNMENT_BUTTON]:
1413
+ if interaction_type == InteractionType.BUTTONS_ONLY:
1352
1414
  if not options:
1353
1415
  return ""
1354
1416
  return NEXT_INTERACTION_SINGLE_CHOICE_TEMPLATE.format(options=options)
@@ -6,14 +6,20 @@ Mirrors Go formatter/format.go.
6
6
  """
7
7
 
8
8
  from .classifier import Classifier
9
+ from .sanitize import strip_untagged_code_fences
9
10
  from .types import ClassifyResult, ElementType, FormattedElement, is_text_family
10
11
 
11
12
 
12
- def format_content(content: str) -> list[FormattedElement]:
13
+ def format_content(content: str, *, strip_untagged_fences: bool = True) -> list[FormattedElement]:
13
14
  """Format complete content into structured elements.
14
15
 
15
16
  Args:
16
17
  content: The full content string to format.
18
+ strip_untagged_fences: When True (default), untagged (language-less)
19
+ code-fence markers are removed before classification, so HTML/SVG
20
+ an LLM wrapped in a bare ``` renders as its real type instead of a
21
+ code block. Language-tagged fences (``` ```html ```, ``` ```mermaid ```,
22
+ ...) are always preserved. Set False to keep raw CommonMark behaviour.
17
23
 
18
24
  Returns:
19
25
  A list of FormattedElement instances.
@@ -21,6 +27,11 @@ def format_content(content: str) -> list[FormattedElement]:
21
27
  if not content:
22
28
  return []
23
29
 
30
+ if strip_untagged_fences:
31
+ content = strip_untagged_code_fences(content)
32
+ if not content:
33
+ return []
34
+
24
35
  c = Classifier()
25
36
  elements: list[FormattedElement] = []
26
37
  current_number = 0
@@ -0,0 +1,5 @@
1
+ The remainder of this system message contains higher-priority instructions.
2
+
3
+ Treat everything after the marker below—including every Markdown heading and section—as one instruction block. Within this system message, instructions in that block take precedence over all instructions above it. If there is a conflict, follow the instruction in the block. If there is no conflict, follow all applicable instructions. Do not reproduce the marker.
4
+
5
+ [BEGIN HIGHER-PRIORITY INSTRUCTIONS]
@@ -209,7 +209,12 @@ class InteractionParser:
209
209
 
210
210
  def _layer3_parse_display_buttons(self, content: str) -> dict[str, Any]:
211
211
  """
212
- Layer 3: Parse display buttons (non-variable assignment type).
212
+ Layer 3: Parse non-assignment interactions (no variable).
213
+
214
+ Mirrors the variable branch: supports button groups (single/multi
215
+ select) and the `...` text-input suffix. The collected answer is not
216
+ assigned to a variable; callers feed it back into the conversation
217
+ context instead.
213
218
 
214
219
  Args:
215
220
  content: Content to parse
@@ -224,13 +229,43 @@ class InteractionParser:
224
229
  "buttons": [{"display": "", "value": ""}],
225
230
  }
226
231
 
227
- if "|" in content:
228
- # Multiple buttons
229
- buttons, _ = self._parse_buttons(content) # Display buttons don't use multi-select
230
- return {"type": InteractionType.NON_ASSIGNMENT_BUTTON, "buttons": buttons}
232
+ ellipsis_match = COMPILED_LAYER3_ELLIPSIS_REGEX.match(content)
233
+ if ellipsis_match:
234
+ before_ellipsis = ellipsis_match.group(1).strip()
235
+ question = ellipsis_match.group(2).strip()
236
+
237
+ if before_ellipsis:
238
+ # Buttons + text input: ?[A | B | ...question]
239
+ buttons, is_multi_select = self._parse_buttons(before_ellipsis)
240
+ return {
241
+ "type": InteractionType.NON_ASSIGNMENT_BUTTON,
242
+ "buttons": buttons,
243
+ "question": question,
244
+ "is_multi_select": is_multi_select,
245
+ }
246
+ # Pure text input: ?[...question]
247
+ return {
248
+ "type": InteractionType.NON_ASSIGNMENT_BUTTON,
249
+ "buttons": [],
250
+ "question": question,
251
+ "is_multi_select": False,
252
+ }
253
+
254
+ if "|" in content: # type: ignore[unreachable]
255
+ # Button group: ?[A | B] or ?[A || B]
256
+ buttons, is_multi_select = self._parse_buttons(content)
257
+ return {
258
+ "type": InteractionType.NON_ASSIGNMENT_BUTTON,
259
+ "buttons": buttons,
260
+ "is_multi_select": is_multi_select,
261
+ }
231
262
  # Single button
232
263
  button = self._parse_single_button(content)
233
- return {"type": InteractionType.NON_ASSIGNMENT_BUTTON, "buttons": [button]}
264
+ return {
265
+ "type": InteractionType.NON_ASSIGNMENT_BUTTON,
266
+ "buttons": [button],
267
+ "is_multi_select": False,
268
+ }
234
269
 
235
270
  def _parse_buttons(self, content: str) -> tuple[list[dict[str, str]], bool]:
236
271
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: markdown-flow
3
- Version: 0.2.85
3
+ Version: 0.3.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
@@ -313,10 +313,40 @@ class InteractionType(NamedTuple):
313
313
  # BUTTONS_MULTI_WITH_TEXT: Multi-select with text fallback
314
314
  "?[%{{frameworks}} React||Vue||Angular||Please specify others...]"
315
315
 
316
- # NON_ASSIGNMENT_BUTTON: Display buttons without variable assignment
316
+ # NON_ASSIGNMENT_BUTTON: No-variable interaction. Supports the same shapes as
317
+ # variable interactions; the answer is not assigned to a variable but fed back
318
+ # into the conversation context (see "Non-assignment interactions" below).
317
319
  "?[Continue | Cancel | Go Back]"
320
+ "?[JavaScript||TypeScript||Python]"
321
+ "?[...Anything to add?]"
322
+ "?[Option A | Option B | ...Other, please specify]"
318
323
  ```
319
324
 
325
+ ### Non-assignment interactions
326
+
327
+ Interactions without a `%{{variable}}` collect an answer that is not stored in
328
+ any variable — it flows back into the conversation context instead:
329
+
330
+ 1. Clients submit the answer under the canonical key `input`
331
+ (`DEFAULT_NON_ASSIGNMENT_INPUT_KEY`); the library tolerantly merges values
332
+ from any other key.
333
+ 2. `process()` normalizes values matching a button (by display or value) to the
334
+ button value and accepts anything else as free text. The normalized answer
335
+ is returned in `result.metadata["answer"]` (`list[str]`).
336
+ 3. Callers persist that answer and attach it to the interaction's context
337
+ message via the `user_answer` extension field (`USER_ANSWER_CONTEXT_KEY`):
338
+
339
+ ```python
340
+ context = [
341
+ {"role": "assistant", "content": "?[Option A | Option B]", "user_answer": "Option A"},
342
+ ]
343
+ ```
344
+
345
+ When building LLM messages the library expands this into
346
+ `{user: "Option A"}` + `{assistant: "ok"}`. An empty `user_answer` skips the
347
+ turn entirely; omitting the field keeps the legacy `{user: "ok"}` behavior.
348
+ Extension fields are stripped before messages reach the LLM.
349
+
320
350
  ### Utility Functions
321
351
 
322
352
  #### Variable Operations
@@ -7,11 +7,11 @@ markdown_flow/constants_system_prompt.py
7
7
  markdown_flow/core.py
8
8
  markdown_flow/enums.py
9
9
  markdown_flow/exceptions.py
10
+ markdown_flow/higher_priority_prompt.md
10
11
  markdown_flow/llm.py
11
12
  markdown_flow/models.py
12
13
  markdown_flow/system_prompt.md
13
14
  markdown_flow/tag_filter.py
14
- markdown_flow/utils.py
15
15
  markdown_flow.egg-info/PKG-INFO
16
16
  markdown_flow.egg-info/SOURCES.txt
17
17
  markdown_flow.egg-info/dependency_links.txt