markdown-flow 0.2.16__py3-none-any.whl → 0.2.26__py3-none-any.whl

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.

Potentially problematic release.


This version of markdown-flow might be problematic. Click here for more details.

markdown_flow/core.py CHANGED
@@ -18,6 +18,7 @@ from .constants import (
18
18
  COMPILED_INTERACTION_CONTENT_RECONSTRUCT_REGEX,
19
19
  COMPILED_VARIABLE_REFERENCE_CLEANUP_REGEX,
20
20
  COMPILED_WHITESPACE_CLEANUP_REGEX,
21
+ DEFAULT_BASE_SYSTEM_PROMPT,
21
22
  DEFAULT_INTERACTION_ERROR_PROMPT,
22
23
  DEFAULT_INTERACTION_PROMPT,
23
24
  DEFAULT_VALIDATION_SYSTEM_MESSAGE,
@@ -28,13 +29,14 @@ from .constants import (
28
29
  INTERACTION_PATTERN_SPLIT,
29
30
  INTERACTION_RENDER_INSTRUCTIONS,
30
31
  LLM_PROVIDER_REQUIRED_ERROR,
32
+ OUTPUT_INSTRUCTION_EXPLANATION,
31
33
  UNSUPPORTED_PROMPT_TYPE_ERROR,
32
34
  )
33
35
  from .enums import BlockType
34
36
  from .exceptions import BlockIndexError
35
37
  from .llm import LLMProvider, LLMResult, ProcessMode
36
38
  from .models import Block, InteractionValidationConfig
37
- from .utils import (
39
+ from .parser import (
38
40
  InteractionParser,
39
41
  InteractionType,
40
42
  extract_interaction_question,
@@ -59,48 +61,105 @@ class MarkdownFlow:
59
61
  _document_prompt: str | None
60
62
  _interaction_prompt: str | None
61
63
  _interaction_error_prompt: str | None
64
+ _max_context_length: int
62
65
  _blocks: list[Block] | None
63
66
  _interaction_configs: dict[int, InteractionValidationConfig]
67
+ _model: str | None
68
+ _temperature: float | None
64
69
 
65
70
  def __init__(
66
71
  self,
67
72
  document: str,
68
73
  llm_provider: LLMProvider | None = None,
74
+ base_system_prompt: str | None = None,
69
75
  document_prompt: str | None = None,
70
76
  interaction_prompt: str | None = None,
71
77
  interaction_error_prompt: str | None = None,
78
+ max_context_length: int = 0,
72
79
  ):
73
80
  """
74
81
  Initialize MarkdownFlow instance.
75
82
 
76
83
  Args:
77
84
  document: Markdown document content
78
- llm_provider: LLM provider, if None only PROMPT_ONLY mode is available
85
+ llm_provider: LLM provider (required for COMPLETE and STREAM modes)
86
+ base_system_prompt: MarkdownFlow base system prompt (framework-level, content blocks only)
79
87
  document_prompt: Document-level system prompt
80
88
  interaction_prompt: Interaction content rendering prompt
81
89
  interaction_error_prompt: Interaction error rendering prompt
90
+ max_context_length: Maximum number of context messages to keep (0 = unlimited)
82
91
  """
83
92
  self._document = document
84
93
  self._llm_provider = llm_provider
94
+ self._base_system_prompt = base_system_prompt or DEFAULT_BASE_SYSTEM_PROMPT
85
95
  self._document_prompt = document_prompt
86
96
  self._interaction_prompt = interaction_prompt or DEFAULT_INTERACTION_PROMPT
87
97
  self._interaction_error_prompt = interaction_error_prompt or DEFAULT_INTERACTION_ERROR_PROMPT
98
+ self._max_context_length = max_context_length
88
99
  self._blocks = None
89
100
  self._interaction_configs: dict[int, InteractionValidationConfig] = {}
101
+ self._model: str | None = None
102
+ self._temperature: float | None = None
90
103
 
91
104
  def set_llm_provider(self, provider: LLMProvider) -> None:
92
105
  """Set LLM provider."""
93
106
  self._llm_provider = provider
94
107
 
108
+ def set_model(self, model: str) -> "MarkdownFlow":
109
+ """
110
+ Set model name for this instance.
111
+
112
+ Args:
113
+ model: Model name to use
114
+
115
+ Returns:
116
+ Self for method chaining
117
+ """
118
+ self._model = model
119
+ return self
120
+
121
+ def set_temperature(self, temperature: float) -> "MarkdownFlow":
122
+ """
123
+ Set temperature for this instance.
124
+
125
+ Args:
126
+ temperature: Temperature value (typically 0.0-2.0)
127
+
128
+ Returns:
129
+ Self for method chaining
130
+ """
131
+ self._temperature = temperature
132
+ return self
133
+
134
+ def get_model(self) -> str | None:
135
+ """
136
+ Get model name for this instance.
137
+
138
+ Returns:
139
+ Model name if set, None otherwise
140
+ """
141
+ return self._model
142
+
143
+ def get_temperature(self) -> float | None:
144
+ """
145
+ Get temperature for this instance.
146
+
147
+ Returns:
148
+ Temperature value if set, None otherwise
149
+ """
150
+ return self._temperature
151
+
95
152
  def set_prompt(self, prompt_type: str, value: str | None) -> None:
96
153
  """
97
154
  Set prompt template.
98
155
 
99
156
  Args:
100
- prompt_type: Prompt type ('document', 'interaction', 'interaction_error')
157
+ prompt_type: Prompt type ('base_system', 'document', 'interaction', 'interaction_error')
101
158
  value: Prompt content
102
159
  """
103
- if prompt_type == "document":
160
+ if prompt_type == "base_system":
161
+ self._base_system_prompt = value or DEFAULT_BASE_SYSTEM_PROMPT
162
+ elif prompt_type == "document":
104
163
  self._document_prompt = value
105
164
  elif prompt_type == "interaction":
106
165
  self._interaction_prompt = value or DEFAULT_INTERACTION_PROMPT
@@ -109,6 +168,44 @@ class MarkdownFlow:
109
168
  else:
110
169
  raise ValueError(UNSUPPORTED_PROMPT_TYPE_ERROR.format(prompt_type=prompt_type))
111
170
 
171
+ def _truncate_context(
172
+ self,
173
+ context: list[dict[str, str]] | None,
174
+ ) -> list[dict[str, str]] | None:
175
+ """
176
+ Filter and truncate context to specified maximum length.
177
+
178
+ Processing steps:
179
+ 1. Filter out messages with empty content (empty string or whitespace only)
180
+ 2. Truncate to max_context_length if configured (0 = unlimited)
181
+
182
+ Args:
183
+ context: Original context list
184
+
185
+ Returns:
186
+ Filtered and truncated context. Returns None if no valid messages remain.
187
+ """
188
+ if not context:
189
+ return None
190
+
191
+ # Step 1: Filter out messages with empty or whitespace-only content
192
+ filtered_context = [msg for msg in context if msg.get("content", "").strip()]
193
+
194
+ # Return None if no valid messages remain after filtering
195
+ if not filtered_context:
196
+ return None
197
+
198
+ # Step 2: Truncate to max_context_length if configured
199
+ if self._max_context_length == 0:
200
+ # No limit, return all filtered messages
201
+ return filtered_context
202
+
203
+ # Keep the most recent N messages
204
+ if len(filtered_context) > self._max_context_length:
205
+ return filtered_context[-self._max_context_length :]
206
+
207
+ return filtered_context
208
+
112
209
  @property
113
210
  def document(self) -> str:
114
211
  """Get document content."""
@@ -183,8 +280,7 @@ class MarkdownFlow:
183
280
  context: list[dict[str, str]] | None = None,
184
281
  variables: dict[str, str | list[str]] | None = None,
185
282
  user_input: dict[str, list[str]] | None = None,
186
- dynamic_interaction_format: str | None = None,
187
- ) -> LLMResult | Generator[LLMResult, None, None]:
283
+ ):
188
284
  """
189
285
  Unified block processing interface.
190
286
 
@@ -194,11 +290,14 @@ class MarkdownFlow:
194
290
  context: Context message list
195
291
  variables: Variable mappings
196
292
  user_input: User input (for interaction blocks)
197
- dynamic_interaction_format: Dynamic interaction format for validation
198
293
 
199
294
  Returns:
200
295
  LLMResult or Generator[LLMResult, None, None]
201
296
  """
297
+ # Process base_system_prompt variable replacement
298
+ if self._base_system_prompt:
299
+ self._base_system_prompt = replace_variables_in_text(self._base_system_prompt, variables or {})
300
+
202
301
  # Process document_prompt variable replacement
203
302
  if self._document_prompt:
204
303
  self._document_prompt = replace_variables_in_text(self._document_prompt, variables or {})
@@ -206,16 +305,12 @@ class MarkdownFlow:
206
305
  block = self.get_block(block_index)
207
306
 
208
307
  if block.block_type == BlockType.CONTENT:
209
- # Check if this is dynamic interaction validation
210
- if dynamic_interaction_format and user_input:
211
- return self._process_dynamic_interaction_validation(block_index, dynamic_interaction_format, user_input, mode, context, variables)
212
- # Normal content processing (possibly with dynamic conversion)
213
308
  return self._process_content(block_index, mode, context, variables)
214
309
 
215
310
  if block.block_type == BlockType.INTERACTION:
216
311
  if user_input is None:
217
312
  # Render interaction content
218
- return self._process_interaction_render(block_index, mode, variables)
313
+ return self._process_interaction_render(block_index, mode, context, variables)
219
314
  # Process user input
220
315
  return self._process_interaction_input(block_index, user_input, mode, context, variables)
221
316
 
@@ -234,37 +329,27 @@ class MarkdownFlow:
234
329
  mode: ProcessMode,
235
330
  context: list[dict[str, str]] | None,
236
331
  variables: dict[str, str | list[str]] | None,
237
- ) -> LLMResult | Generator[LLMResult, None, None]:
332
+ ):
238
333
  """Process content block."""
334
+ # Truncate context to configured maximum length
335
+ truncated_context = self._truncate_context(context)
239
336
 
240
- # For PROMPT_ONLY mode, use standard content processing
241
- if mode == ProcessMode.PROMPT_ONLY:
242
- messages = self._build_content_messages(block_index, variables)
243
- return LLMResult(prompt=messages[-1]["content"], metadata={"messages": messages})
244
-
245
- # For COMPLETE and STREAM modes with LLM provider, use dynamic interaction check
246
- # LLM will decide whether content needs to be converted to interaction block
247
- if self._llm_provider:
248
- block = self.get_block(block_index)
249
- if block.block_type == BlockType.CONTENT:
250
- return self._process_with_dynamic_check(block_index, mode, context, variables)
251
-
252
- # Fallback: Build messages using standard content processing
253
- messages = self._build_content_messages(block_index, variables)
337
+ # Build messages with context
338
+ messages = self._build_content_messages(block_index, variables, truncated_context)
254
339
 
255
340
  if mode == ProcessMode.COMPLETE:
256
341
  if not self._llm_provider:
257
342
  raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
258
343
 
259
- result = self._llm_provider.complete(messages)
260
- return LLMResult(content=result.content, prompt=messages[-1]["content"], metadata=result.metadata)
344
+ content = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
345
+ return LLMResult(content=content, prompt=messages[-1]["content"])
261
346
 
262
347
  if mode == ProcessMode.STREAM:
263
348
  if not self._llm_provider:
264
349
  raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
265
350
 
266
351
  def stream_generator():
267
- for chunk in self._llm_provider.stream(messages):
352
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
268
353
  yield LLMResult(content=chunk, prompt=messages[-1]["content"])
269
354
 
270
355
  return stream_generator()
@@ -281,7 +366,13 @@ class MarkdownFlow:
281
366
 
282
367
  return LLMResult(content=content)
283
368
 
284
- def _process_interaction_render(self, block_index: int, mode: ProcessMode, variables: dict[str, str | list[str]] | None = None) -> LLMResult | Generator[LLMResult, None, None]:
369
+ def _process_interaction_render(
370
+ self,
371
+ block_index: int,
372
+ mode: ProcessMode,
373
+ context: list[dict[str, str]] | None = None,
374
+ variables: dict[str, str | list[str]] | None = None,
375
+ ):
285
376
  """Process interaction content rendering."""
286
377
  block = self.get_block(block_index)
287
378
 
@@ -298,24 +389,17 @@ class MarkdownFlow:
298
389
  # Unable to extract, return processed content
299
390
  return LLMResult(content=processed_block.content)
300
391
 
301
- # Build render messages
302
- messages = self._build_interaction_render_messages(question_text)
392
+ # Truncate context to configured maximum length
393
+ truncated_context = self._truncate_context(context)
303
394
 
304
- if mode == ProcessMode.PROMPT_ONLY:
305
- return LLMResult(
306
- prompt=messages[-1]["content"],
307
- metadata={
308
- "original_content": processed_block.content,
309
- "question_text": question_text,
310
- },
311
- )
395
+ # Build render messages with context
396
+ messages = self._build_interaction_render_messages(question_text, truncated_context)
312
397
 
313
398
  if mode == ProcessMode.COMPLETE:
314
399
  if not self._llm_provider:
315
400
  return LLMResult(content=processed_block.content) # Fallback processing
316
401
 
317
- result = self._llm_provider.complete(messages)
318
- rendered_question = result.content
402
+ rendered_question = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
319
403
  rendered_content = self._reconstruct_interaction_content(processed_block.content, rendered_question)
320
404
 
321
405
  return LLMResult(
@@ -343,7 +427,7 @@ class MarkdownFlow:
343
427
  # With LLM provider, collect full response then return once
344
428
  def stream_generator():
345
429
  full_response = ""
346
- for chunk in self._llm_provider.stream(messages):
430
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
347
431
  full_response += chunk
348
432
 
349
433
  # Reconstruct final interaction content
@@ -366,14 +450,13 @@ class MarkdownFlow:
366
450
  variables: dict[str, str | list[str]] | None = None,
367
451
  ) -> LLMResult | Generator[LLMResult, None, None]:
368
452
  """Process interaction user input."""
369
- _ = context # Mark as intentionally unused
370
453
  block = self.get_block(block_index)
371
454
  target_variable = block.variables[0] if block.variables else "user_input"
372
455
 
373
456
  # Basic validation
374
457
  if not user_input or not any(values for values in user_input.values()):
375
458
  error_msg = INPUT_EMPTY_ERROR
376
- return self._render_error(error_msg, mode)
459
+ return self._render_error(error_msg, mode, context)
377
460
 
378
461
  # Get the target variable value from user_input
379
462
  target_values = user_input.get(target_variable, [])
@@ -387,24 +470,99 @@ class MarkdownFlow:
387
470
 
388
471
  if "error" in parse_result:
389
472
  error_msg = INTERACTION_PARSE_ERROR.format(error=parse_result["error"])
390
- return self._render_error(error_msg, mode)
473
+ return self._render_error(error_msg, mode, context)
391
474
 
392
475
  interaction_type = parse_result.get("type")
393
476
 
394
477
  # Process user input based on interaction type
395
478
  if interaction_type in [
396
- InteractionType.BUTTONS_ONLY,
397
479
  InteractionType.BUTTONS_WITH_TEXT,
398
- InteractionType.BUTTONS_MULTI_SELECT,
399
480
  InteractionType.BUTTONS_MULTI_WITH_TEXT,
400
481
  ]:
401
- # All button types: validate user input against available buttons
482
+ # Buttons with text input: smart validation (match buttons first, then LLM validate custom text)
483
+ buttons = parse_result.get("buttons", [])
484
+
485
+ # Step 1: Match button values
486
+ matched_values, unmatched_values = self._match_button_values(buttons, target_values)
487
+
488
+ # Step 2: If there are unmatched values (custom text), validate with LLM
489
+ if unmatched_values:
490
+ # Create user_input for LLM validation (only custom text)
491
+ custom_input = {target_variable: unmatched_values}
492
+
493
+ validation_result = self._process_llm_validation(
494
+ block_index=block_index,
495
+ user_input=custom_input,
496
+ target_variable=target_variable,
497
+ mode=mode,
498
+ context=context,
499
+ )
500
+
501
+ # Handle validation result based on mode
502
+ if mode == ProcessMode.COMPLETE:
503
+ # Check if validation passed
504
+ if isinstance(validation_result, LLMResult) and validation_result.variables:
505
+ validated_values = validation_result.variables.get(target_variable, [])
506
+ # Merge matched button values + validated custom text
507
+ all_values = matched_values + validated_values
508
+ return LLMResult(
509
+ content="",
510
+ variables={target_variable: all_values},
511
+ metadata={
512
+ "interaction_type": str(interaction_type),
513
+ "matched_button_values": matched_values,
514
+ "validated_custom_values": validated_values,
515
+ },
516
+ )
517
+ else:
518
+ # Validation failed, return error
519
+ return validation_result
520
+
521
+ if mode == ProcessMode.STREAM:
522
+ # For stream mode, collect validation result
523
+ def stream_merge_generator():
524
+ # Consume the validation stream
525
+ for result in validation_result: # type: ignore[attr-defined]
526
+ if isinstance(result, LLMResult) and result.variables:
527
+ validated_values = result.variables.get(target_variable, [])
528
+ all_values = matched_values + validated_values
529
+ yield LLMResult(
530
+ content="",
531
+ variables={target_variable: all_values},
532
+ metadata={
533
+ "interaction_type": str(interaction_type),
534
+ "matched_button_values": matched_values,
535
+ "validated_custom_values": validated_values,
536
+ },
537
+ )
538
+ else:
539
+ # Validation failed
540
+ yield result
541
+
542
+ return stream_merge_generator()
543
+ else:
544
+ # All values matched buttons, return directly
545
+ return LLMResult(
546
+ content="",
547
+ variables={target_variable: matched_values},
548
+ metadata={
549
+ "interaction_type": str(interaction_type),
550
+ "all_matched_buttons": True,
551
+ },
552
+ )
553
+
554
+ if interaction_type in [
555
+ InteractionType.BUTTONS_ONLY,
556
+ InteractionType.BUTTONS_MULTI_SELECT,
557
+ ]:
558
+ # Pure button types: only basic button validation (no LLM)
402
559
  return self._process_button_validation(
403
560
  parse_result,
404
561
  target_values,
405
562
  target_variable,
406
563
  mode,
407
564
  interaction_type,
565
+ context,
408
566
  )
409
567
 
410
568
  if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
@@ -420,19 +578,50 @@ class MarkdownFlow:
420
578
  )
421
579
 
422
580
  # Text-only input type: ?[%{{sys_user_nickname}}...question]
423
- # For text-only inputs, directly use the target variable values
581
+ # Use LLM validation to check if input is relevant to the question
424
582
  if target_values:
425
- return LLMResult(
426
- content="",
427
- variables={target_variable: target_values},
428
- metadata={
429
- "interaction_type": "text_only",
430
- "target_variable": target_variable,
431
- "values": target_values,
432
- },
583
+ return self._process_llm_validation(
584
+ block_index=block_index,
585
+ user_input=user_input,
586
+ target_variable=target_variable,
587
+ mode=mode,
588
+ context=context,
433
589
  )
434
590
  error_msg = f"No input provided for variable '{target_variable}'"
435
- return self._render_error(error_msg, mode)
591
+ return self._render_error(error_msg, mode, context)
592
+
593
+ def _match_button_values(
594
+ self,
595
+ buttons: list[dict[str, str]],
596
+ target_values: list[str],
597
+ ) -> tuple[list[str], list[str]]:
598
+ """
599
+ Match user input values against button options.
600
+
601
+ Args:
602
+ buttons: List of button dictionaries with 'display' and 'value' keys
603
+ target_values: User input values to match
604
+
605
+ Returns:
606
+ Tuple of (matched_values, unmatched_values)
607
+ - matched_values: Values that match button options (using button value)
608
+ - unmatched_values: Values that don't match any button
609
+ """
610
+ matched_values = []
611
+ unmatched_values = []
612
+
613
+ for value in target_values:
614
+ matched = False
615
+ for button in buttons:
616
+ if value in [button["display"], button["value"]]:
617
+ matched_values.append(button["value"]) # Use button value
618
+ matched = True
619
+ break
620
+
621
+ if not matched:
622
+ unmatched_values.append(value)
623
+
624
+ return matched_values, unmatched_values
436
625
 
437
626
  def _process_button_validation(
438
627
  self,
@@ -441,6 +630,7 @@ class MarkdownFlow:
441
630
  target_variable: str,
442
631
  mode: ProcessMode,
443
632
  interaction_type: InteractionType,
633
+ context: list[dict[str, str]] | None = None,
444
634
  ) -> LLMResult | Generator[LLMResult, None, None]:
445
635
  """
446
636
  Simplified button validation with new input format.
@@ -451,6 +641,7 @@ class MarkdownFlow:
451
641
  target_variable: Target variable name
452
642
  mode: Processing mode
453
643
  interaction_type: Type of interaction
644
+ context: Conversation history context (optional)
454
645
  """
455
646
  buttons = parse_result.get("buttons", [])
456
647
  is_multi_select = interaction_type in [
@@ -476,9 +667,9 @@ class MarkdownFlow:
476
667
  # Pure button mode requires input
477
668
  button_displays = [btn["display"] for btn in buttons]
478
669
  error_msg = f"Please select from: {', '.join(button_displays)}"
479
- return self._render_error(error_msg, mode)
670
+ return self._render_error(error_msg, mode, context)
480
671
 
481
- # First, check if user input matches available buttons
672
+ # Validate input values against available buttons
482
673
  valid_values = []
483
674
  invalid_values = []
484
675
 
@@ -491,30 +682,19 @@ class MarkdownFlow:
491
682
  break
492
683
 
493
684
  if not matched:
494
- invalid_values.append(value)
495
-
496
- # If there are invalid values and this interaction allows text input, use LLM validation
497
- if invalid_values and allow_text_input:
498
- # Use LLM validation for text input interactions
499
- button_displays = [btn["display"] for btn in buttons]
500
- question = parse_result.get("question", "")
501
-
502
- return self._process_llm_validation_with_options(
503
- block_index=0, # Not used in the method
504
- user_input={target_variable: target_values},
505
- target_variable=target_variable,
506
- options=button_displays,
507
- question=question,
508
- mode=mode,
509
- )
510
-
511
- # Check for validation errors in pure button mode or when text input not allowed
512
- if invalid_values:
685
+ if allow_text_input:
686
+ # Allow custom text in buttons+text mode
687
+ valid_values.append(value)
688
+ else:
689
+ invalid_values.append(value)
690
+
691
+ # Check for validation errors
692
+ if invalid_values and not allow_text_input:
513
693
  button_displays = [btn["display"] for btn in buttons]
514
694
  error_msg = f"Invalid options: {', '.join(invalid_values)}. Please select from: {', '.join(button_displays)}"
515
- return self._render_error(error_msg, mode)
695
+ return self._render_error(error_msg, mode, context)
516
696
 
517
- # Success: return validated button values
697
+ # Success: return validated values
518
698
  return LLMResult(
519
699
  content="",
520
700
  variables={target_variable: valid_values},
@@ -524,7 +704,6 @@ class MarkdownFlow:
524
704
  "valid_values": valid_values,
525
705
  "invalid_values": invalid_values,
526
706
  "total_input_count": len(target_values),
527
- "llm_validated": False,
528
707
  },
529
708
  )
530
709
 
@@ -534,27 +713,18 @@ class MarkdownFlow:
534
713
  user_input: dict[str, list[str]],
535
714
  target_variable: str,
536
715
  mode: ProcessMode,
716
+ context: list[dict[str, str]] | None = None,
537
717
  ) -> LLMResult | Generator[LLMResult, None, None]:
538
718
  """Process LLM validation."""
539
719
  # Build validation messages
540
- messages = self._build_validation_messages(block_index, user_input, target_variable)
541
-
542
- if mode == ProcessMode.PROMPT_ONLY:
543
- return LLMResult(
544
- prompt=messages[-1]["content"],
545
- metadata={
546
- "validation_target": user_input,
547
- "target_variable": target_variable,
548
- },
549
- )
720
+ messages = self._build_validation_messages(block_index, user_input, target_variable, context)
550
721
 
551
722
  if mode == ProcessMode.COMPLETE:
552
723
  if not self._llm_provider:
553
724
  # Fallback processing, return variables directly
554
725
  return LLMResult(content="", variables=user_input) # type: ignore[arg-type]
555
726
 
556
- result = self._llm_provider.complete(messages)
557
- llm_response = result.content
727
+ llm_response = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
558
728
 
559
729
  # Parse validation response and convert to LLMResult
560
730
  # Use joined target values for fallback; avoids JSON string injection
@@ -568,7 +738,7 @@ class MarkdownFlow:
568
738
 
569
739
  def stream_generator():
570
740
  full_response = ""
571
- for chunk in self._llm_provider.stream(messages):
741
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
572
742
  full_response += chunk
573
743
 
574
744
  # Parse complete response and convert to LLMResult
@@ -592,28 +762,15 @@ class MarkdownFlow:
592
762
  mode: ProcessMode,
593
763
  ) -> LLMResult | Generator[LLMResult, None, None]:
594
764
  """Process LLM validation with button options (third case)."""
595
- _ = block_index # Mark as intentionally unused
596
765
  # Build special validation messages containing button option information
597
766
  messages = self._build_validation_messages_with_options(user_input, target_variable, options, question)
598
767
 
599
- if mode == ProcessMode.PROMPT_ONLY:
600
- return LLMResult(
601
- prompt=messages[-1]["content"],
602
- metadata={
603
- "validation_target": user_input,
604
- "target_variable": target_variable,
605
- "options": options,
606
- "question": question,
607
- },
608
- )
609
-
610
768
  if mode == ProcessMode.COMPLETE:
611
769
  if not self._llm_provider:
612
770
  # Fallback processing, return variables directly
613
771
  return LLMResult(content="", variables=user_input) # type: ignore[arg-type]
614
772
 
615
- result = self._llm_provider.complete(messages)
616
- llm_response = result.content
773
+ llm_response = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
617
774
 
618
775
  # Parse validation response and convert to LLMResult
619
776
  # Use joined target values for fallback; avoids JSON string injection
@@ -627,7 +784,7 @@ class MarkdownFlow:
627
784
 
628
785
  def stream_generator():
629
786
  full_response = ""
630
- for chunk in self._llm_provider.stream(messages):
787
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
631
788
  full_response += chunk
632
789
  # For validation scenario, don't output chunks in real-time, only final result
633
790
 
@@ -644,22 +801,24 @@ class MarkdownFlow:
644
801
 
645
802
  return stream_generator()
646
803
 
647
- def _render_error(self, error_message: str, mode: ProcessMode) -> LLMResult | Generator[LLMResult, None, None]:
804
+ def _render_error(
805
+ self,
806
+ error_message: str,
807
+ mode: ProcessMode,
808
+ context: list[dict[str, str]] | None = None,
809
+ ) -> LLMResult | Generator[LLMResult, None, None]:
648
810
  """Render user-friendly error message."""
649
- messages = self._build_error_render_messages(error_message)
811
+ # Truncate context to configured maximum length
812
+ truncated_context = self._truncate_context(context)
650
813
 
651
- if mode == ProcessMode.PROMPT_ONLY:
652
- return LLMResult(
653
- prompt=messages[-1]["content"],
654
- metadata={"original_error": error_message},
655
- )
814
+ # Build error messages with context
815
+ messages = self._build_error_render_messages(error_message, truncated_context)
656
816
 
657
817
  if mode == ProcessMode.COMPLETE:
658
818
  if not self._llm_provider:
659
819
  return LLMResult(content=error_message) # Fallback processing
660
820
 
661
- result = self._llm_provider.complete(messages)
662
- friendly_error = result.content
821
+ friendly_error = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
663
822
  return LLMResult(content=friendly_error, prompt=messages[-1]["content"])
664
823
 
665
824
  if mode == ProcessMode.STREAM:
@@ -667,7 +826,7 @@ class MarkdownFlow:
667
826
  return LLMResult(content=error_message)
668
827
 
669
828
  def stream_generator():
670
- for chunk in self._llm_provider.stream(messages):
829
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
671
830
  yield LLMResult(content=chunk, prompt=messages[-1]["content"])
672
831
 
673
832
  return stream_generator()
@@ -678,13 +837,15 @@ class MarkdownFlow:
678
837
  self,
679
838
  block_index: int,
680
839
  variables: dict[str, str | list[str]] | None,
840
+ context: list[dict[str, str]] | None = None,
681
841
  ) -> list[dict[str, str]]:
682
842
  """Build content block messages."""
683
843
  block = self.get_block(block_index)
684
844
  block_content = block.content
685
845
 
686
- # Process output instructions
687
- block_content = process_output_instructions(block_content)
846
+ # Process output instructions and detect if preserved content exists
847
+ # Returns: (processed_content, has_preserved_content)
848
+ block_content, has_preserved_content = process_output_instructions(block_content)
688
849
 
689
850
  # Replace variables
690
851
  block_content = replace_variables_in_text(block_content, variables or {})
@@ -692,22 +853,43 @@ class MarkdownFlow:
692
853
  # Build message array
693
854
  messages = []
694
855
 
695
- # Add document prompt
856
+ # Build system message with XML tags
857
+ system_parts = []
858
+
859
+ # 1. Base system prompt (if exists and non-empty)
860
+ if self._base_system_prompt:
861
+ system_parts.append(f"<base_system>\n{self._base_system_prompt}\n</base_system>")
862
+
863
+ # 2. Document prompt (if exists and non-empty)
696
864
  if self._document_prompt:
697
- messages.append({"role": "system", "content": self._document_prompt})
865
+ system_parts.append(f"<document_prompt>\n{self._document_prompt}\n</document_prompt>")
866
+
867
+ # 3. Output instruction (if preserved content exists)
868
+ # Note: OUTPUT_INSTRUCTION_EXPLANATION already contains <preserve_or_translate_instruction> tags
869
+ if has_preserved_content:
870
+ system_parts.append(OUTPUT_INSTRUCTION_EXPLANATION.strip())
698
871
 
699
- # For most content blocks, historical conversation context is not needed
700
- # because each document block is an independent instruction
701
- # If future specific scenarios need context, logic can be added here
702
- # if context:
703
- # messages.extend(context)
872
+ # Combine all parts and add as system message
873
+ if system_parts:
874
+ system_msg = "\n\n".join(system_parts)
875
+ messages.append({"role": "system", "content": system_msg})
876
+
877
+ # Add conversation history context if provided
878
+ # Context is inserted after system message and before current user message
879
+ truncated_context = self._truncate_context(context)
880
+ if truncated_context:
881
+ messages.extend(truncated_context)
704
882
 
705
883
  # Add processed content as user message (as instruction to LLM)
706
884
  messages.append({"role": "user", "content": block_content})
707
885
 
708
886
  return messages
709
887
 
710
- def _build_interaction_render_messages(self, question_text: str) -> list[dict[str, str]]:
888
+ def _build_interaction_render_messages(
889
+ self,
890
+ question_text: str,
891
+ context: list[dict[str, str]] | None = None,
892
+ ) -> list[dict[str, str]]:
711
893
  """Build interaction rendering messages."""
712
894
  # Check if using custom interaction prompt
713
895
  if self._interaction_prompt != DEFAULT_INTERACTION_PROMPT:
@@ -721,15 +903,32 @@ class MarkdownFlow:
721
903
  messages = []
722
904
 
723
905
  messages.append({"role": "system", "content": render_prompt})
906
+
907
+ # NOTE: Context is temporarily disabled for interaction rendering
908
+ # Mixing conversation history with interaction content rewriting can cause issues
909
+ # The context parameter is kept in the signature for future use
910
+ # truncated_context = self._truncate_context(context)
911
+ # if truncated_context:
912
+ # messages.extend(truncated_context)
913
+
724
914
  messages.append({"role": "user", "content": question_text})
725
915
 
726
916
  return messages
727
917
 
728
- def _build_validation_messages(self, block_index: int, user_input: dict[str, list[str]], target_variable: str) -> list[dict[str, str]]:
918
+ def _build_validation_messages(
919
+ self,
920
+ block_index: int,
921
+ user_input: dict[str, list[str]],
922
+ target_variable: str,
923
+ context: list[dict[str, str]] | None = None,
924
+ ) -> list[dict[str, str]]:
729
925
  """Build validation messages."""
730
926
  block = self.get_block(block_index)
731
927
  config = self.get_interaction_validation_config(block_index)
732
928
 
929
+ # Truncate context to configured maximum length
930
+ truncated_context = self._truncate_context(context)
931
+
733
932
  if config and config.validation_template:
734
933
  # Use custom validation template
735
934
  validation_prompt = config.validation_template
@@ -740,7 +939,8 @@ class MarkdownFlow:
740
939
  system_message = DEFAULT_VALIDATION_SYSTEM_MESSAGE
741
940
  else:
742
941
  # Use smart default validation template
743
- from .utils import (
942
+ from .parser import (
943
+ InteractionParser,
744
944
  extract_interaction_question,
745
945
  generate_smart_validation_template,
746
946
  )
@@ -748,11 +948,17 @@ class MarkdownFlow:
748
948
  # Extract interaction question
749
949
  interaction_question = extract_interaction_question(block.content)
750
950
 
751
- # Generate smart validation template
951
+ # Parse interaction to extract button information
952
+ parser = InteractionParser()
953
+ parse_result = parser.parse(block.content)
954
+ buttons = parse_result.get("buttons") if "buttons" in parse_result else None
955
+
956
+ # Generate smart validation template with context and buttons
752
957
  validation_template = generate_smart_validation_template(
753
958
  target_variable,
754
- context=None, # Could consider passing context here
959
+ context=truncated_context,
755
960
  interaction_question=interaction_question,
961
+ buttons=buttons,
756
962
  )
757
963
 
758
964
  # Replace template variables
@@ -765,6 +971,11 @@ class MarkdownFlow:
765
971
  messages = []
766
972
 
767
973
  messages.append({"role": "system", "content": system_message})
974
+
975
+ # Add conversation history context if provided (only if not using custom template)
976
+ if truncated_context and not (config and config.validation_template):
977
+ messages.extend(truncated_context)
978
+
768
979
  messages.append({"role": "user", "content": validation_prompt})
769
980
 
770
981
  return messages
@@ -795,7 +1006,11 @@ class MarkdownFlow:
795
1006
 
796
1007
  return messages
797
1008
 
798
- def _build_error_render_messages(self, error_message: str) -> list[dict[str, str]]:
1009
+ def _build_error_render_messages(
1010
+ self,
1011
+ error_message: str,
1012
+ context: list[dict[str, str]] | None = None,
1013
+ ) -> list[dict[str, str]]:
799
1014
  """Build error rendering messages."""
800
1015
  render_prompt = f"""{self._interaction_error_prompt}
801
1016
 
@@ -808,6 +1023,12 @@ Original Error: {error_message}
808
1023
  messages.append({"role": "system", "content": self._document_prompt})
809
1024
 
810
1025
  messages.append({"role": "system", "content": render_prompt})
1026
+
1027
+ # Add conversation history context if provided
1028
+ truncated_context = self._truncate_context(context)
1029
+ if truncated_context:
1030
+ messages.extend(truncated_context)
1031
+
811
1032
  messages.append({"role": "user", "content": error_message})
812
1033
 
813
1034
  return messages
@@ -827,411 +1048,5 @@ Original Error: {error_message}
827
1048
  if match:
828
1049
  prefix = match.group(1)
829
1050
  suffix = match.group(2)
830
- # Extract only the closing bracket from suffix, remove original question
831
- # suffix format is "original_question]", we only want "]"
832
- if suffix.endswith("]"):
833
- clean_suffix = "]"
834
- else:
835
- clean_suffix = suffix
836
-
837
- return f"{prefix}{cleaned_question}{clean_suffix}"
1051
+ return f"{prefix}{cleaned_question}{suffix}"
838
1052
  return original_content # type: ignore[unreachable]
839
-
840
- # Dynamic Interaction Methods
841
-
842
- def _process_with_dynamic_check(
843
- self,
844
- block_index: int,
845
- mode: ProcessMode,
846
- context: list[dict[str, str]] | None,
847
- variables: dict[str, str | list[str]] | None,
848
- ) -> LLMResult | Generator[LLMResult, None, None]:
849
- """Process content with dynamic interaction detection and conversion."""
850
-
851
- block = self.get_block(block_index)
852
- messages = self._build_dynamic_check_messages(block, context, variables)
853
-
854
- # Define Function Calling tools with structured approach
855
- tools = [
856
- {
857
- "type": "function",
858
- "function": {
859
- "name": "create_interaction_block",
860
- "description": "Convert content to interaction block with structured data when it needs to collect user input",
861
- "parameters": {
862
- "type": "object",
863
- "properties": {
864
- "needs_interaction": {"type": "boolean", "description": "Whether this content needs to be converted to interaction block"},
865
- "variable_name": {"type": "string", "description": "Name of the variable to collect (without {{}} brackets)"},
866
- "interaction_type": {
867
- "type": "string",
868
- "enum": ["single_select", "multi_select", "text_input", "mixed"],
869
- "description": "Type of interaction: single_select (|), multi_select (||), text_input (...), mixed (options + text)",
870
- },
871
- "options": {"type": "array", "items": {"type": "string"}, "description": "List of selectable options (3-4 specific options based on context)"},
872
- "allow_text_input": {"type": "boolean", "description": "Whether to include a text input option for 'Other' cases"},
873
- "text_input_prompt": {"type": "string", "description": "Prompt text for the text input option (e.g., '其他请输入', 'Other, please specify')"},
874
- },
875
- "required": ["needs_interaction"],
876
- },
877
- },
878
- }
879
- ]
880
-
881
- if not self._llm_provider:
882
- raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
883
-
884
- # Call LLM with tools
885
- result = self._llm_provider.complete(messages, tools)
886
-
887
- # If interaction was generated through Function Calling, construct the MarkdownFlow format
888
- if result.transformed_to_interaction and result.metadata and "tool_args" in result.metadata:
889
- tool_args = result.metadata["tool_args"]
890
- if tool_args.get("needs_interaction"):
891
- # Construct MarkdownFlow format from structured data
892
- interaction_content = self._build_interaction_format(tool_args)
893
- result.content = interaction_content
894
-
895
- # If transformed to interaction, return as is
896
- if result.transformed_to_interaction:
897
- return result
898
-
899
- # If not transformed, continue with normal processing using standard content messages
900
- normal_messages = self._build_content_messages(block_index, variables)
901
-
902
- if mode == ProcessMode.STREAM:
903
-
904
- def stream_wrapper():
905
- stream_generator = self._llm_provider.stream(normal_messages)
906
- for chunk in stream_generator:
907
- yield LLMResult(content=chunk)
908
-
909
- return stream_wrapper()
910
-
911
- # Complete mode - use normal content processing
912
- normal_result = self._llm_provider.complete(normal_messages)
913
- return LLMResult(content=normal_result.content, prompt=normal_messages[-1]["content"], metadata=normal_result.metadata)
914
-
915
- def _build_dynamic_check_messages(
916
- self,
917
- block: "Block",
918
- context: list[dict[str, str]] | None,
919
- variables: dict[str, str | list[str]] | None,
920
- ) -> list[dict[str, str]]:
921
- """Build messages for dynamic interaction detection."""
922
-
923
- import json
924
-
925
- # System prompt for detection
926
- system_prompt = """You are an intelligent document processing assistant specializing in creating interactive forms.
927
-
928
- Task: Analyze the given content block and determine if it needs to be converted to an interaction block to collect user information.
929
-
930
- **ABSOLUTE RULE**: Convert ONLY when ALL THREE mandatory elements are explicitly present:
931
- 1. Storage action word + target connector + variable
932
- 2. No exceptions, no implications, no assumptions
933
-
934
- **MANDATORY TRIPLE PATTERN (ALL REQUIRED):**
935
-
936
- **Element 1: Storage Action Words**
937
- - Chinese: "记录", "保存", "存储", "收集", "采集"
938
- - English: "save", "store", "record", "collect", "gather"
939
-
940
- **Element 2: Target Connection Words**
941
- - Chinese: "到", "为", "在", "至"
942
- - English: "to", "as", "in", "into"
943
-
944
- **Element 3: Target Variable**
945
- - Must contain {{variable_name}} syntax for NEW data storage
946
- - Variable must be for collecting NEW information, not using existing data
947
-
948
- **VALID CONVERSION FORMULA:**
949
- [Storage Word] + [Connector] + {{new_variable}}
950
-
951
- Examples of VALID patterns:
952
- - "...记录到{{姓名}}"
953
- - "...保存为{{偏好}}"
954
- - "...存储在{{选择}}"
955
- - "...save to {{preference}}"
956
- - "...collect as {{user_input}}"
957
-
958
- **STRICT EXCLUSION RULES:**
959
-
960
- ❌ NEVER convert if missing ANY element:
961
- - No storage action word = NO conversion
962
- - No target connector = NO conversion
963
- - No {{variable}} = NO conversion
964
- - Using existing {{variable}} instead of collecting new = NO conversion
965
-
966
- ❌ NEVER convert casual conversation:
967
- - Simple questions without storage intent
968
- - Introduction requests without persistence
969
- - General inquiries without data collection
970
- - Educational or exploratory content
971
-
972
- ❌ NEVER infer or assume storage intent:
973
- - Don't assume "询问姓名" means "保存姓名"
974
- - Don't assume "了解偏好" means "记录偏好"
975
- - Don't assume data collection without explicit storage words
976
-
977
- **PATTERN ANALYSIS METHOD:**
978
- 1. **Exact Pattern Match**: Search for [Storage Word] + [Connector] + {{variable}}
979
- 2. **No Pattern = No Conversion**: If exact pattern not found, return needs_interaction: false
980
- 3. **Zero Tolerance**: No partial matches, no similar meanings, no interpretations
981
-
982
- **ULTRA-CONSERVATIVE APPROACH:**
983
- - If there's ANY doubt about storage intent = DON'T convert
984
- - If storage pattern is not 100% explicit = DON'T convert
985
- - If you need to "interpret" or "infer" storage intent = DON'T convert
986
- - Prefer false negatives over false positives
987
-
988
- When exact pattern is found, generate structured interaction data. Otherwise, always return needs_interaction: false."""
989
-
990
- # User message with content and context
991
- # Build user prompt with document context
992
- user_prompt_parts = []
993
-
994
- # Add document-level prompt context if exists
995
- if self._document_prompt:
996
- user_prompt_parts.append(f"""Document-level instructions:
997
- {self._document_prompt}
998
-
999
- (Note: The above are the user's document-level instructions that provide context and requirements for processing.)
1000
- """)
1001
-
1002
- # Prepare content analysis with both original and resolved versions
1003
- original_content = block.content
1004
-
1005
- # Create resolved content with variable substitution for better context
1006
- resolved_content = original_content
1007
- if variables:
1008
- from .utils import replace_variables_in_text
1009
-
1010
- resolved_content = replace_variables_in_text(original_content, variables)
1011
-
1012
- content_analysis = f"""Current content block to analyze:
1013
-
1014
- **Original content (shows variable structure):**
1015
- {original_content}
1016
-
1017
- **Resolved content (with current variable values):**
1018
- {resolved_content}
1019
-
1020
- **Existing variable values:**
1021
- {json.dumps(variables, ensure_ascii=False) if variables else "None"}"""
1022
-
1023
- # Add different analysis based on whether content has variables
1024
- if "{{" in original_content and "}}" in original_content:
1025
- from .utils import extract_variables_from_text
1026
-
1027
- content_variables = set(extract_variables_from_text(original_content))
1028
-
1029
- # Find new variables (not yet collected)
1030
- new_variables = content_variables - (set(variables.keys()) if variables else set())
1031
- existing_used_variables = content_variables & (set(variables.keys()) if variables else set())
1032
-
1033
- content_analysis += f"""
1034
-
1035
- **Variable analysis:**
1036
- - Variables used from previous steps: {list(existing_used_variables) if existing_used_variables else "None"}
1037
- - New variables to collect: {list(new_variables) if new_variables else "None"}
1038
-
1039
- **Context guidance:**
1040
- - Use the resolved content to understand the actual context and requirements
1041
- - Generate options based on the real variable values shown in the resolved content
1042
- - Collect user input for the new variables identified above"""
1043
-
1044
- user_prompt_parts.append(content_analysis)
1045
-
1046
- # Add analysis requirements and structured output guide
1047
- user_prompt_parts.append("""## Analysis Task:
1048
- 1. Determine if this content needs to be converted to an interaction block
1049
- 2. If conversion is needed, provide structured interaction data
1050
-
1051
- ## Context-based Analysis:
1052
- - Use the "Resolved content" to understand actual context (e.g., if it shows "川菜", generate Sichuan dish options)
1053
- - Extract the "New variables to collect" identified in the variable analysis above
1054
- - Generate 3-4 specific options based on the resolved context and document-level instructions
1055
- - Follow ALL document-level instruction requirements (language, domain, terminology)
1056
-
1057
- ## Selection Type Decision Logic:
1058
- Ask: "Can a user realistically want/choose multiple of these options simultaneously?"
1059
-
1060
- **Use MULTI_SELECT when:**
1061
- - Food dishes (can order multiple: 宫保鸡丁, 麻婆豆腐)
1062
- - Programming skills (can know multiple: Python, JavaScript)
1063
- - Interests/hobbies (can have multiple: 读书, 运动, 旅游)
1064
- - Product features (can want multiple: 定制颜色, 个性化logo)
1065
- - Exercise types (can do multiple: 跑步, 游泳, 瑜伽)
1066
-
1067
- **Use SINGLE_SELECT when:**
1068
- - Job positions (usually apply for one: 软件工程师 OR 产品经理)
1069
- - Experience levels (have one current level: Beginner OR Advanced)
1070
- - Budget ranges (have one range: 5-10万 OR 10-20万)
1071
- - Education levels (have one highest: Bachelor's OR Master's)
1072
-
1073
- ## Output Instructions:
1074
- If this content needs interaction, use the create_interaction_block function with:
1075
- - `needs_interaction`: true/false
1076
- - `variable_name`: the variable to collect (from "New variables" above)
1077
- - `interaction_type`: "single_select", "multi_select", "text_input", or "mixed"
1078
- - `options`: array of 3-4 specific options based on context
1079
- - `allow_text_input`: true if you want to include "other" option
1080
- - `text_input_prompt`: text for the "other" option (in appropriate language)
1081
-
1082
- Analyze the content and provide the structured interaction data.""")
1083
-
1084
- user_prompt = "\n\n".join(user_prompt_parts)
1085
-
1086
- messages = [{"role": "system", "content": system_prompt}]
1087
-
1088
- # Add context if provided
1089
- if context:
1090
- messages.extend(context)
1091
-
1092
- messages.append({"role": "user", "content": user_prompt})
1093
-
1094
- return messages
1095
-
1096
- def _build_interaction_format(self, tool_args: dict) -> str:
1097
- """Build MarkdownFlow interaction format from structured Function Calling data."""
1098
- variable_name = tool_args.get("variable_name", "")
1099
- interaction_type = tool_args.get("interaction_type", "single_select")
1100
- options = tool_args.get("options", [])
1101
- allow_text_input = tool_args.get("allow_text_input", False)
1102
- text_input_prompt = tool_args.get("text_input_prompt", "...请输入")
1103
-
1104
- if not variable_name:
1105
- return ""
1106
-
1107
- # For text_input type, options can be empty
1108
- if interaction_type != "text_input" and not options:
1109
- return ""
1110
-
1111
- # Choose separator based on interaction type
1112
- if interaction_type in ["multi_select", "mixed"]:
1113
- separator = "||"
1114
- else:
1115
- separator = "|"
1116
-
1117
- # Build options string
1118
- if interaction_type == "text_input":
1119
- # Text input only
1120
- options_str = f"...{text_input_prompt}"
1121
- else:
1122
- # Options with potential text input
1123
- options_str = separator.join(options)
1124
-
1125
- if allow_text_input and text_input_prompt:
1126
- # Ensure text input has ... prefix
1127
- text_option = text_input_prompt if text_input_prompt.startswith("...") else f"...{text_input_prompt}"
1128
- options_str += f"{separator}{text_option}"
1129
-
1130
- return f"?[%{{{{{variable_name}}}}} {options_str}]"
1131
-
1132
- def _process_dynamic_interaction_validation(
1133
- self,
1134
- block_index: int,
1135
- interaction_format: str,
1136
- user_input: dict[str, list[str]],
1137
- mode: ProcessMode,
1138
- context: list[dict[str, str]] | None,
1139
- variables: dict[str, str | list[str]] | None,
1140
- ) -> LLMResult:
1141
- """Validate user input for dynamically generated interaction blocks using same logic as normal interactions."""
1142
- _ = block_index # Mark as intentionally unused
1143
- _ = context # Mark as intentionally unused
1144
-
1145
- from .utils import InteractionParser
1146
-
1147
- # Parse the interaction format using the same parser as normal interactions
1148
- parser = InteractionParser()
1149
- parse_result = parser.parse(interaction_format)
1150
-
1151
- if "error" in parse_result:
1152
- error_msg = f"Invalid interaction format: {parse_result['error']}"
1153
- return self._render_error(error_msg, mode)
1154
-
1155
- # Extract variable name and interaction type
1156
- variable_name = parse_result.get("variable")
1157
- interaction_type = parse_result.get("type")
1158
-
1159
- if not variable_name:
1160
- error_msg = f"No variable found in interaction format: {interaction_format}"
1161
- return self._render_error(error_msg, mode)
1162
-
1163
- # Get user input for the target variable
1164
- target_values = user_input.get(variable_name, [])
1165
-
1166
- # Basic validation - check if input is provided when required
1167
- if not target_values:
1168
- # Check if this is a text input or allows empty input
1169
- allow_text_input = interaction_type in [
1170
- InteractionType.BUTTONS_WITH_TEXT,
1171
- InteractionType.BUTTONS_MULTI_WITH_TEXT,
1172
- ]
1173
-
1174
- if allow_text_input:
1175
- # Allow empty input for buttons+text mode - merge with existing variables
1176
- merged_variables = dict(variables or {})
1177
- merged_variables[variable_name] = []
1178
- return LLMResult(
1179
- content="",
1180
- variables=merged_variables,
1181
- metadata={
1182
- "interaction_type": "dynamic_interaction",
1183
- "empty_input": True,
1184
- },
1185
- )
1186
- error_msg = f"No input provided for variable '{variable_name}'"
1187
- return self._render_error(error_msg, mode)
1188
-
1189
- # Use the same validation logic as normal interactions
1190
- if interaction_type in [
1191
- InteractionType.BUTTONS_ONLY,
1192
- InteractionType.BUTTONS_WITH_TEXT,
1193
- InteractionType.BUTTONS_MULTI_SELECT,
1194
- InteractionType.BUTTONS_MULTI_WITH_TEXT,
1195
- ]:
1196
- # Button validation - reuse the existing button validation logic
1197
- button_result = self._process_button_validation(
1198
- parse_result,
1199
- target_values,
1200
- variable_name,
1201
- mode,
1202
- interaction_type,
1203
- )
1204
-
1205
- # Merge with existing variables for dynamic interactions
1206
- if hasattr(button_result, "variables") and button_result.variables is not None and variables:
1207
- merged_variables = dict(variables)
1208
- merged_variables.update(button_result.variables)
1209
- return LLMResult(
1210
- content=button_result.content,
1211
- variables=merged_variables,
1212
- metadata=button_result.metadata,
1213
- )
1214
- return button_result
1215
-
1216
- if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
1217
- # Non-assignment buttons: don't set variables, keep existing ones
1218
- return LLMResult(
1219
- content="",
1220
- variables=dict(variables or {}),
1221
- metadata={
1222
- "interaction_type": "non_assignment_button",
1223
- "user_input": user_input,
1224
- },
1225
- )
1226
- # Text-only input type - merge with existing variables
1227
- merged_variables = dict(variables or {})
1228
- merged_variables[variable_name] = target_values
1229
- return LLMResult(
1230
- content="",
1231
- variables=merged_variables,
1232
- metadata={
1233
- "interaction_type": "text_only",
1234
- "target_variable": variable_name,
1235
- "values": target_values,
1236
- },
1237
- )