markdown-flow 0.2.10__py3-none-any.whl → 0.2.30__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.
markdown_flow/core.py CHANGED
@@ -13,31 +13,30 @@ from typing import Any
13
13
  from .constants import (
14
14
  BLOCK_INDEX_OUT_OF_RANGE_ERROR,
15
15
  BLOCK_SEPARATOR,
16
- BUTTONS_WITH_TEXT_VALIDATION_TEMPLATE,
17
- COMPILED_BRACKETS_CLEANUP_REGEX,
18
- COMPILED_INTERACTION_CONTENT_RECONSTRUCT_REGEX,
19
- COMPILED_VARIABLE_REFERENCE_CLEANUP_REGEX,
20
- COMPILED_WHITESPACE_CLEANUP_REGEX,
16
+ CONTEXT_BUTTON_OPTIONS_TEMPLATE,
17
+ CONTEXT_QUESTION_TEMPLATE,
18
+ DEFAULT_BASE_SYSTEM_PROMPT,
21
19
  DEFAULT_INTERACTION_ERROR_PROMPT,
22
20
  DEFAULT_INTERACTION_PROMPT,
23
- DEFAULT_VALIDATION_SYSTEM_MESSAGE,
24
21
  INPUT_EMPTY_ERROR,
25
22
  INTERACTION_ERROR_RENDER_INSTRUCTIONS,
26
23
  INTERACTION_PARSE_ERROR,
27
24
  INTERACTION_PATTERN_NON_CAPTURING,
28
25
  INTERACTION_PATTERN_SPLIT,
29
- INTERACTION_RENDER_INSTRUCTIONS,
30
26
  LLM_PROVIDER_REQUIRED_ERROR,
27
+ OUTPUT_INSTRUCTION_EXPLANATION,
31
28
  UNSUPPORTED_PROMPT_TYPE_ERROR,
29
+ VALIDATION_REQUIREMENTS_TEMPLATE,
30
+ VALIDATION_TASK_TEMPLATE,
32
31
  )
33
32
  from .enums import BlockType
34
33
  from .exceptions import BlockIndexError
35
34
  from .llm import LLMProvider, LLMResult, ProcessMode
36
- from .models import Block, InteractionValidationConfig
37
- from .utils import (
35
+ from .models import Block
36
+ from .parser import (
37
+ CodeBlockPreprocessor,
38
38
  InteractionParser,
39
39
  InteractionType,
40
- extract_interaction_question,
41
40
  extract_preserved_content,
42
41
  extract_variables_from_text,
43
42
  is_preserved_content_block,
@@ -56,51 +55,156 @@ class MarkdownFlow:
56
55
 
57
56
  _llm_provider: LLMProvider | None
58
57
  _document: str
58
+ _processed_document: str
59
59
  _document_prompt: str | None
60
60
  _interaction_prompt: str | None
61
61
  _interaction_error_prompt: str | None
62
+ _max_context_length: int
62
63
  _blocks: list[Block] | None
63
- _interaction_configs: dict[int, InteractionValidationConfig]
64
+ _model: str | None
65
+ _temperature: float | None
66
+ _preprocessor: CodeBlockPreprocessor
64
67
 
65
68
  def __init__(
66
69
  self,
67
70
  document: str,
68
71
  llm_provider: LLMProvider | None = None,
72
+ base_system_prompt: str | None = None,
69
73
  document_prompt: str | None = None,
70
74
  interaction_prompt: str | None = None,
71
75
  interaction_error_prompt: str | None = None,
76
+ max_context_length: int = 0,
72
77
  ):
73
78
  """
74
79
  Initialize MarkdownFlow instance.
75
80
 
76
81
  Args:
77
82
  document: Markdown document content
78
- llm_provider: LLM provider, if None only PROMPT_ONLY mode is available
83
+ llm_provider: LLM provider (required for COMPLETE and STREAM modes)
84
+ base_system_prompt: MarkdownFlow base system prompt (framework-level, content blocks only)
79
85
  document_prompt: Document-level system prompt
80
86
  interaction_prompt: Interaction content rendering prompt
81
87
  interaction_error_prompt: Interaction error rendering prompt
88
+ max_context_length: Maximum number of context messages to keep (0 = unlimited)
82
89
  """
83
90
  self._document = document
84
91
  self._llm_provider = llm_provider
92
+ self._base_system_prompt = base_system_prompt or DEFAULT_BASE_SYSTEM_PROMPT
85
93
  self._document_prompt = document_prompt
86
94
  self._interaction_prompt = interaction_prompt or DEFAULT_INTERACTION_PROMPT
87
95
  self._interaction_error_prompt = interaction_error_prompt or DEFAULT_INTERACTION_ERROR_PROMPT
96
+ self._max_context_length = max_context_length
88
97
  self._blocks = None
89
- self._interaction_configs: dict[int, InteractionValidationConfig] = {}
98
+ self._model: str | None = None
99
+ self._temperature: float | None = None
100
+
101
+ # Preprocess document: extract code blocks and replace with placeholders
102
+ # This is done once during initialization, similar to Go implementation
103
+ self._preprocessor = CodeBlockPreprocessor()
104
+ self._processed_document = self._preprocessor.extract_code_blocks(document)
90
105
 
91
106
  def set_llm_provider(self, provider: LLMProvider) -> None:
92
107
  """Set LLM provider."""
93
108
  self._llm_provider = provider
94
109
 
110
+ def get_processed_document(self) -> str:
111
+ """
112
+ Get preprocessed document (for debugging and testing).
113
+
114
+ Returns the document content after code blocks have been replaced with placeholders.
115
+
116
+ Use cases:
117
+ - Verify that code block preprocessing was executed correctly
118
+ - Check placeholder format (__MDFLOW_CODE_BLOCK_N__)
119
+ - Debug preprocessing stage issues
120
+
121
+ Returns:
122
+ Preprocessed document string
123
+ """
124
+ return self._processed_document
125
+
126
+ def get_content_messages(
127
+ self,
128
+ block_index: int,
129
+ variables: dict[str, str | list[str]] | None,
130
+ context: list[dict[str, str]] | None = None,
131
+ ) -> list[dict[str, str]]:
132
+ """
133
+ Get content messages (for debugging and inspection).
134
+
135
+ Builds and returns the complete message list that will be sent to LLM.
136
+
137
+ Use cases:
138
+ - Debug: View actual content sent to LLM
139
+ - Verify: Check if code blocks are correctly restored
140
+ - Inspect: Verify variable replacement and prompt building logic
141
+ - Review: Confirm system/user message assembly results
142
+
143
+ Args:
144
+ block_index: Block index
145
+ variables: Variable mapping
146
+ context: Context message list
147
+
148
+ Returns:
149
+ List of message dictionaries
150
+ """
151
+ return self._build_content_messages(block_index, variables, context)
152
+
153
+ def set_model(self, model: str) -> "MarkdownFlow":
154
+ """
155
+ Set model name for this instance.
156
+
157
+ Args:
158
+ model: Model name to use
159
+
160
+ Returns:
161
+ Self for method chaining
162
+ """
163
+ self._model = model
164
+ return self
165
+
166
+ def set_temperature(self, temperature: float) -> "MarkdownFlow":
167
+ """
168
+ Set temperature for this instance.
169
+
170
+ Args:
171
+ temperature: Temperature value (typically 0.0-2.0)
172
+
173
+ Returns:
174
+ Self for method chaining
175
+ """
176
+ self._temperature = temperature
177
+ return self
178
+
179
+ def get_model(self) -> str | None:
180
+ """
181
+ Get model name for this instance.
182
+
183
+ Returns:
184
+ Model name if set, None otherwise
185
+ """
186
+ return self._model
187
+
188
+ def get_temperature(self) -> float | None:
189
+ """
190
+ Get temperature for this instance.
191
+
192
+ Returns:
193
+ Temperature value if set, None otherwise
194
+ """
195
+ return self._temperature
196
+
95
197
  def set_prompt(self, prompt_type: str, value: str | None) -> None:
96
198
  """
97
199
  Set prompt template.
98
200
 
99
201
  Args:
100
- prompt_type: Prompt type ('document', 'interaction', 'interaction_error')
202
+ prompt_type: Prompt type ('base_system', 'document', 'interaction', 'interaction_error')
101
203
  value: Prompt content
102
204
  """
103
- if prompt_type == "document":
205
+ if prompt_type == "base_system":
206
+ self._base_system_prompt = value or DEFAULT_BASE_SYSTEM_PROMPT
207
+ elif prompt_type == "document":
104
208
  self._document_prompt = value
105
209
  elif prompt_type == "interaction":
106
210
  self._interaction_prompt = value or DEFAULT_INTERACTION_PROMPT
@@ -109,6 +213,44 @@ class MarkdownFlow:
109
213
  else:
110
214
  raise ValueError(UNSUPPORTED_PROMPT_TYPE_ERROR.format(prompt_type=prompt_type))
111
215
 
216
+ def _truncate_context(
217
+ self,
218
+ context: list[dict[str, str]] | None,
219
+ ) -> list[dict[str, str]] | None:
220
+ """
221
+ Filter and truncate context to specified maximum length.
222
+
223
+ Processing steps:
224
+ 1. Filter out messages with empty content (empty string or whitespace only)
225
+ 2. Truncate to max_context_length if configured (0 = unlimited)
226
+
227
+ Args:
228
+ context: Original context list
229
+
230
+ Returns:
231
+ Filtered and truncated context. Returns None if no valid messages remain.
232
+ """
233
+ if not context:
234
+ return None
235
+
236
+ # Step 1: Filter out messages with empty or whitespace-only content
237
+ filtered_context = [msg for msg in context if msg.get("content", "").strip()]
238
+
239
+ # Return None if no valid messages remain after filtering
240
+ if not filtered_context:
241
+ return None
242
+
243
+ # Step 2: Truncate to max_context_length if configured
244
+ if self._max_context_length == 0:
245
+ # No limit, return all filtered messages
246
+ return filtered_context
247
+
248
+ # Keep the most recent N messages
249
+ if len(filtered_context) > self._max_context_length:
250
+ return filtered_context[-self._max_context_length :]
251
+
252
+ return filtered_context
253
+
112
254
  @property
113
255
  def document(self) -> str:
114
256
  """Get document content."""
@@ -124,8 +266,9 @@ class MarkdownFlow:
124
266
  if self._blocks is not None:
125
267
  return self._blocks
126
268
 
127
- content = self._document.strip()
128
- segments = re.split(BLOCK_SEPARATOR, content)
269
+ # Parse the preprocessed document (code blocks already replaced with placeholders)
270
+ # The preprocessing was done once during initialization
271
+ segments = re.split(BLOCK_SEPARATOR, self._processed_document)
129
272
  final_blocks: list[Block] = []
130
273
 
131
274
  for segment in segments:
@@ -166,14 +309,6 @@ class MarkdownFlow:
166
309
  """Extract all variable names from the document."""
167
310
  return extract_variables_from_text(self._document)
168
311
 
169
- def set_interaction_validation_config(self, block_index: int, config: InteractionValidationConfig) -> None:
170
- """Set validation config for specified interaction block."""
171
- self._interaction_configs[block_index] = config
172
-
173
- def get_interaction_validation_config(self, block_index: int) -> InteractionValidationConfig | None:
174
- """Get validation config for specified interaction block."""
175
- return self._interaction_configs.get(block_index)
176
-
177
312
  # Core unified interface
178
313
 
179
314
  def process(
@@ -183,8 +318,7 @@ class MarkdownFlow:
183
318
  context: list[dict[str, str]] | None = None,
184
319
  variables: dict[str, str | list[str]] | None = None,
185
320
  user_input: dict[str, list[str]] | None = None,
186
- dynamic_interaction_format: str | None = None,
187
- ) -> LLMResult | Generator[LLMResult, None, None]:
321
+ ):
188
322
  """
189
323
  Unified block processing interface.
190
324
 
@@ -194,11 +328,14 @@ class MarkdownFlow:
194
328
  context: Context message list
195
329
  variables: Variable mappings
196
330
  user_input: User input (for interaction blocks)
197
- dynamic_interaction_format: Dynamic interaction format for validation
198
331
 
199
332
  Returns:
200
333
  LLMResult or Generator[LLMResult, None, None]
201
334
  """
335
+ # Process base_system_prompt variable replacement
336
+ if self._base_system_prompt:
337
+ self._base_system_prompt = replace_variables_in_text(self._base_system_prompt, variables or {})
338
+
202
339
  # Process document_prompt variable replacement
203
340
  if self._document_prompt:
204
341
  self._document_prompt = replace_variables_in_text(self._document_prompt, variables or {})
@@ -206,16 +343,12 @@ class MarkdownFlow:
206
343
  block = self.get_block(block_index)
207
344
 
208
345
  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
346
  return self._process_content(block_index, mode, context, variables)
214
347
 
215
348
  if block.block_type == BlockType.INTERACTION:
216
349
  if user_input is None:
217
350
  # Render interaction content
218
- return self._process_interaction_render(block_index, mode, variables)
351
+ return self._process_interaction_render(block_index, mode, context, variables)
219
352
  # Process user input
220
353
  return self._process_interaction_input(block_index, user_input, mode, context, variables)
221
354
 
@@ -234,37 +367,27 @@ class MarkdownFlow:
234
367
  mode: ProcessMode,
235
368
  context: list[dict[str, str]] | None,
236
369
  variables: dict[str, str | list[str]] | None,
237
- ) -> LLMResult | Generator[LLMResult, None, None]:
370
+ ):
238
371
  """Process content block."""
372
+ # Truncate context to configured maximum length
373
+ truncated_context = self._truncate_context(context)
239
374
 
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)
375
+ # Build messages with context
376
+ messages = self._build_content_messages(block_index, variables, truncated_context)
254
377
 
255
378
  if mode == ProcessMode.COMPLETE:
256
379
  if not self._llm_provider:
257
380
  raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
258
381
 
259
- result = self._llm_provider.complete(messages)
260
- return LLMResult(content=result.content, prompt=messages[-1]["content"], metadata=result.metadata)
382
+ content = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
383
+ return LLMResult(content=content, prompt=messages[-1]["content"])
261
384
 
262
385
  if mode == ProcessMode.STREAM:
263
386
  if not self._llm_provider:
264
387
  raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
265
388
 
266
389
  def stream_generator():
267
- for chunk in self._llm_provider.stream(messages):
390
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
268
391
  yield LLMResult(content=chunk, prompt=messages[-1]["content"])
269
392
 
270
393
  return stream_generator()
@@ -279,9 +402,18 @@ class MarkdownFlow:
279
402
  # Replace variables
280
403
  content = replace_variables_in_text(content, variables or {})
281
404
 
405
+ # Restore code blocks (replace placeholders with original code blocks)
406
+ content = self._preprocessor.restore_code_blocks(content)
407
+
282
408
  return LLMResult(content=content)
283
409
 
284
- def _process_interaction_render(self, block_index: int, mode: ProcessMode, variables: dict[str, str | list[str]] | None = None) -> LLMResult | Generator[LLMResult, None, None]:
410
+ def _process_interaction_render(
411
+ self,
412
+ block_index: int,
413
+ mode: ProcessMode,
414
+ context: list[dict[str, str]] | None = None,
415
+ variables: dict[str, str | list[str]] | None = None,
416
+ ):
285
417
  """Process interaction content rendering."""
286
418
  block = self.get_block(block_index)
287
419
 
@@ -292,67 +424,92 @@ class MarkdownFlow:
292
424
  processed_block = copy(block)
293
425
  processed_block.content = processed_content
294
426
 
295
- # Extract question text from processed content
296
- question_text = extract_interaction_question(processed_block.content)
297
- if not question_text:
298
- # Unable to extract, return processed content
299
- return LLMResult(content=processed_block.content)
300
-
301
- # Build render messages
302
- messages = self._build_interaction_render_messages(question_text)
427
+ # 提取可翻译内容(JSON 格式)
428
+ translatable_json, interaction_info = self._extract_translatable_content(processed_block.content)
429
+ if not interaction_info:
430
+ # 解析失败,返回原始内容
431
+ return LLMResult(
432
+ content=processed_block.content,
433
+ metadata={
434
+ "block_type": "interaction",
435
+ "block_index": block_index,
436
+ },
437
+ )
303
438
 
304
- if mode == ProcessMode.PROMPT_ONLY:
439
+ # 如果没有可翻译内容,直接返回
440
+ if not translatable_json or translatable_json == "{}":
305
441
  return LLMResult(
306
- prompt=messages[-1]["content"],
442
+ content=processed_block.content,
307
443
  metadata={
308
- "original_content": processed_block.content,
309
- "question_text": question_text,
444
+ "block_type": "interaction",
445
+ "block_index": block_index,
310
446
  },
311
447
  )
312
448
 
449
+ # 构建翻译消息
450
+ messages = self._build_translation_messages(translatable_json)
451
+
313
452
  if mode == ProcessMode.COMPLETE:
314
453
  if not self._llm_provider:
315
- return LLMResult(content=processed_block.content) # Fallback processing
454
+ return LLMResult(
455
+ content=processed_block.content,
456
+ metadata={
457
+ "block_type": "interaction",
458
+ "block_index": block_index,
459
+ },
460
+ )
316
461
 
317
- result = self._llm_provider.complete(messages)
318
- rendered_question = result.content
319
- rendered_content = self._reconstruct_interaction_content(processed_block.content, rendered_question)
462
+ # 调用 LLM 进行翻译
463
+ translated_json = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
464
+
465
+ # 使用翻译结果重构交互内容
466
+ translated_content = self._reconstruct_with_translation(processed_block.content, translatable_json, translated_json, interaction_info)
320
467
 
321
468
  return LLMResult(
322
- content=rendered_content,
469
+ content=translated_content,
323
470
  prompt=messages[-1]["content"],
324
471
  metadata={
325
- "original_question": question_text,
326
- "rendered_question": rendered_question,
472
+ "block_type": "interaction",
473
+ "block_index": block_index,
474
+ "original_content": translatable_json,
475
+ "translated_content": translated_json,
327
476
  },
328
477
  )
329
478
 
330
479
  if mode == ProcessMode.STREAM:
331
480
  if not self._llm_provider:
332
- # For interaction blocks, return reconstructed content (one-time output)
333
- rendered_content = self._reconstruct_interaction_content(processed_block.content, question_text or "")
334
-
481
+ # 降级处理,返回处理后的内容
335
482
  def stream_generator():
336
483
  yield LLMResult(
337
- content=rendered_content,
484
+ content=processed_block.content,
338
485
  prompt=messages[-1]["content"],
486
+ metadata={
487
+ "block_type": "interaction",
488
+ "block_index": block_index,
489
+ },
339
490
  )
340
491
 
341
492
  return stream_generator()
342
493
 
343
- # With LLM provider, collect full response then return once
494
+ # LLM 提供者,收集完整响应后返回一次
344
495
  def stream_generator():
345
496
  full_response = ""
346
- for chunk in self._llm_provider.stream(messages):
497
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
347
498
  full_response += chunk
348
499
 
349
- # Reconstruct final interaction content
350
- rendered_content = self._reconstruct_interaction_content(processed_block.content, full_response)
500
+ # 使用翻译结果重构交互内容
501
+ translated_content = self._reconstruct_with_translation(processed_block.content, translatable_json, full_response, interaction_info)
351
502
 
352
- # Return complete content at once, not incrementally
503
+ # 一次性返回完整内容(不是增量)
353
504
  yield LLMResult(
354
- content=rendered_content,
505
+ content=translated_content,
355
506
  prompt=messages[-1]["content"],
507
+ metadata={
508
+ "block_type": "interaction",
509
+ "block_index": block_index,
510
+ "original_content": translatable_json,
511
+ "translated_content": full_response,
512
+ },
356
513
  )
357
514
 
358
515
  return stream_generator()
@@ -366,14 +523,13 @@ class MarkdownFlow:
366
523
  variables: dict[str, str | list[str]] | None = None,
367
524
  ) -> LLMResult | Generator[LLMResult, None, None]:
368
525
  """Process interaction user input."""
369
- _ = context # Mark as intentionally unused
370
526
  block = self.get_block(block_index)
371
527
  target_variable = block.variables[0] if block.variables else "user_input"
372
528
 
373
529
  # Basic validation
374
530
  if not user_input or not any(values for values in user_input.values()):
375
531
  error_msg = INPUT_EMPTY_ERROR
376
- return self._render_error(error_msg, mode)
532
+ return self._render_error(error_msg, mode, context)
377
533
 
378
534
  # Get the target variable value from user_input
379
535
  target_values = user_input.get(target_variable, [])
@@ -387,24 +543,98 @@ class MarkdownFlow:
387
543
 
388
544
  if "error" in parse_result:
389
545
  error_msg = INTERACTION_PARSE_ERROR.format(error=parse_result["error"])
390
- return self._render_error(error_msg, mode)
546
+ return self._render_error(error_msg, mode, context)
391
547
 
392
548
  interaction_type = parse_result.get("type")
393
549
 
394
550
  # Process user input based on interaction type
395
551
  if interaction_type in [
396
- InteractionType.BUTTONS_ONLY,
397
552
  InteractionType.BUTTONS_WITH_TEXT,
398
- InteractionType.BUTTONS_MULTI_SELECT,
399
553
  InteractionType.BUTTONS_MULTI_WITH_TEXT,
400
554
  ]:
401
- # All button types: validate user input against available buttons
555
+ # Buttons with text input: smart validation (match buttons first, then LLM validate custom text)
556
+ buttons = parse_result.get("buttons", [])
557
+
558
+ # Step 1: Match button values
559
+ matched_values, unmatched_values = self._match_button_values(buttons, target_values)
560
+
561
+ # Step 2: If there are unmatched values (custom text), validate with LLM
562
+ if unmatched_values:
563
+ # Create user_input for LLM validation (only custom text)
564
+ custom_input = {target_variable: unmatched_values}
565
+
566
+ validation_result = self._process_llm_validation(
567
+ block_index=block_index,
568
+ user_input=custom_input,
569
+ target_variable=target_variable,
570
+ mode=mode,
571
+ context=context,
572
+ )
573
+
574
+ # Handle validation result based on mode
575
+ if mode == ProcessMode.COMPLETE:
576
+ # Check if validation passed
577
+ if isinstance(validation_result, LLMResult) and validation_result.variables:
578
+ validated_values = validation_result.variables.get(target_variable, [])
579
+ # Merge matched button values + validated custom text
580
+ all_values = matched_values + validated_values
581
+ return LLMResult(
582
+ content="",
583
+ variables={target_variable: all_values},
584
+ metadata={
585
+ "interaction_type": str(interaction_type),
586
+ "matched_button_values": matched_values,
587
+ "validated_custom_values": validated_values,
588
+ },
589
+ )
590
+ # Validation failed, return error
591
+ return validation_result
592
+
593
+ if mode == ProcessMode.STREAM:
594
+ # For stream mode, collect validation result
595
+ def stream_merge_generator():
596
+ # Consume the validation stream
597
+ for result in validation_result: # type: ignore[attr-defined]
598
+ if isinstance(result, LLMResult) and result.variables:
599
+ validated_values = result.variables.get(target_variable, [])
600
+ all_values = matched_values + validated_values
601
+ yield LLMResult(
602
+ content="",
603
+ variables={target_variable: all_values},
604
+ metadata={
605
+ "interaction_type": str(interaction_type),
606
+ "matched_button_values": matched_values,
607
+ "validated_custom_values": validated_values,
608
+ },
609
+ )
610
+ else:
611
+ # Validation failed
612
+ yield result
613
+
614
+ return stream_merge_generator()
615
+ else:
616
+ # All values matched buttons, return directly
617
+ return LLMResult(
618
+ content="",
619
+ variables={target_variable: matched_values},
620
+ metadata={
621
+ "interaction_type": str(interaction_type),
622
+ "all_matched_buttons": True,
623
+ },
624
+ )
625
+
626
+ if interaction_type in [
627
+ InteractionType.BUTTONS_ONLY,
628
+ InteractionType.BUTTONS_MULTI_SELECT,
629
+ ]:
630
+ # Pure button types: only basic button validation (no LLM)
402
631
  return self._process_button_validation(
403
632
  parse_result,
404
633
  target_values,
405
634
  target_variable,
406
635
  mode,
407
636
  interaction_type,
637
+ context,
408
638
  )
409
639
 
410
640
  if interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
@@ -420,19 +650,50 @@ class MarkdownFlow:
420
650
  )
421
651
 
422
652
  # Text-only input type: ?[%{{sys_user_nickname}}...question]
423
- # For text-only inputs, directly use the target variable values
653
+ # Use LLM validation to check if input is relevant to the question
424
654
  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
- },
655
+ return self._process_llm_validation(
656
+ block_index=block_index,
657
+ user_input=user_input,
658
+ target_variable=target_variable,
659
+ mode=mode,
660
+ context=context,
433
661
  )
434
662
  error_msg = f"No input provided for variable '{target_variable}'"
435
- return self._render_error(error_msg, mode)
663
+ return self._render_error(error_msg, mode, context)
664
+
665
+ def _match_button_values(
666
+ self,
667
+ buttons: list[dict[str, str]],
668
+ target_values: list[str],
669
+ ) -> tuple[list[str], list[str]]:
670
+ """
671
+ Match user input values against button options.
672
+
673
+ Args:
674
+ buttons: List of button dictionaries with 'display' and 'value' keys
675
+ target_values: User input values to match
676
+
677
+ Returns:
678
+ Tuple of (matched_values, unmatched_values)
679
+ - matched_values: Values that match button options (using button value)
680
+ - unmatched_values: Values that don't match any button
681
+ """
682
+ matched_values = []
683
+ unmatched_values = []
684
+
685
+ for value in target_values:
686
+ matched = False
687
+ for button in buttons:
688
+ if value in [button["display"], button["value"]]:
689
+ matched_values.append(button["value"]) # Use button value
690
+ matched = True
691
+ break
692
+
693
+ if not matched:
694
+ unmatched_values.append(value)
695
+
696
+ return matched_values, unmatched_values
436
697
 
437
698
  def _process_button_validation(
438
699
  self,
@@ -441,6 +702,7 @@ class MarkdownFlow:
441
702
  target_variable: str,
442
703
  mode: ProcessMode,
443
704
  interaction_type: InteractionType,
705
+ context: list[dict[str, str]] | None = None,
444
706
  ) -> LLMResult | Generator[LLMResult, None, None]:
445
707
  """
446
708
  Simplified button validation with new input format.
@@ -451,6 +713,7 @@ class MarkdownFlow:
451
713
  target_variable: Target variable name
452
714
  mode: Processing mode
453
715
  interaction_type: Type of interaction
716
+ context: Conversation history context (optional)
454
717
  """
455
718
  buttons = parse_result.get("buttons", [])
456
719
  is_multi_select = interaction_type in [
@@ -476,9 +739,9 @@ class MarkdownFlow:
476
739
  # Pure button mode requires input
477
740
  button_displays = [btn["display"] for btn in buttons]
478
741
  error_msg = f"Please select from: {', '.join(button_displays)}"
479
- return self._render_error(error_msg, mode)
742
+ return self._render_error(error_msg, mode, context)
480
743
 
481
- # First, check if user input matches available buttons
744
+ # Validate input values against available buttons
482
745
  valid_values = []
483
746
  invalid_values = []
484
747
 
@@ -491,30 +754,19 @@ class MarkdownFlow:
491
754
  break
492
755
 
493
756
  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:
757
+ if allow_text_input:
758
+ # Allow custom text in buttons+text mode
759
+ valid_values.append(value)
760
+ else:
761
+ invalid_values.append(value)
762
+
763
+ # Check for validation errors
764
+ if invalid_values and not allow_text_input:
513
765
  button_displays = [btn["display"] for btn in buttons]
514
766
  error_msg = f"Invalid options: {', '.join(invalid_values)}. Please select from: {', '.join(button_displays)}"
515
- return self._render_error(error_msg, mode)
767
+ return self._render_error(error_msg, mode, context)
516
768
 
517
- # Success: return validated button values
769
+ # Success: return validated values
518
770
  return LLMResult(
519
771
  content="",
520
772
  variables={target_variable: valid_values},
@@ -524,7 +776,6 @@ class MarkdownFlow:
524
776
  "valid_values": valid_values,
525
777
  "invalid_values": invalid_values,
526
778
  "total_input_count": len(target_values),
527
- "llm_validated": False,
528
779
  },
529
780
  )
530
781
 
@@ -534,27 +785,18 @@ class MarkdownFlow:
534
785
  user_input: dict[str, list[str]],
535
786
  target_variable: str,
536
787
  mode: ProcessMode,
788
+ context: list[dict[str, str]] | None = None,
537
789
  ) -> LLMResult | Generator[LLMResult, None, None]:
538
790
  """Process LLM validation."""
539
791
  # 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
- )
792
+ messages = self._build_validation_messages(block_index, user_input, target_variable, context)
550
793
 
551
794
  if mode == ProcessMode.COMPLETE:
552
795
  if not self._llm_provider:
553
796
  # Fallback processing, return variables directly
554
797
  return LLMResult(content="", variables=user_input) # type: ignore[arg-type]
555
798
 
556
- result = self._llm_provider.complete(messages)
557
- llm_response = result.content
799
+ llm_response = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
558
800
 
559
801
  # Parse validation response and convert to LLMResult
560
802
  # Use joined target values for fallback; avoids JSON string injection
@@ -568,7 +810,7 @@ class MarkdownFlow:
568
810
 
569
811
  def stream_generator():
570
812
  full_response = ""
571
- for chunk in self._llm_provider.stream(messages):
813
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
572
814
  full_response += chunk
573
815
 
574
816
  # Parse complete response and convert to LLMResult
@@ -592,28 +834,15 @@ class MarkdownFlow:
592
834
  mode: ProcessMode,
593
835
  ) -> LLMResult | Generator[LLMResult, None, None]:
594
836
  """Process LLM validation with button options (third case)."""
595
- _ = block_index # Mark as intentionally unused
596
- # Build special validation messages containing button option information
597
- messages = self._build_validation_messages_with_options(user_input, target_variable, options, question)
598
-
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
- )
837
+ # Use unified validation message builder (button context will be included automatically)
838
+ messages = self._build_validation_messages(block_index, user_input, target_variable, context=None)
609
839
 
610
840
  if mode == ProcessMode.COMPLETE:
611
841
  if not self._llm_provider:
612
842
  # Fallback processing, return variables directly
613
843
  return LLMResult(content="", variables=user_input) # type: ignore[arg-type]
614
844
 
615
- result = self._llm_provider.complete(messages)
616
- llm_response = result.content
845
+ llm_response = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
617
846
 
618
847
  # Parse validation response and convert to LLMResult
619
848
  # Use joined target values for fallback; avoids JSON string injection
@@ -627,7 +856,7 @@ class MarkdownFlow:
627
856
 
628
857
  def stream_generator():
629
858
  full_response = ""
630
- for chunk in self._llm_provider.stream(messages):
859
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
631
860
  full_response += chunk
632
861
  # For validation scenario, don't output chunks in real-time, only final result
633
862
 
@@ -644,22 +873,24 @@ class MarkdownFlow:
644
873
 
645
874
  return stream_generator()
646
875
 
647
- def _render_error(self, error_message: str, mode: ProcessMode) -> LLMResult | Generator[LLMResult, None, None]:
876
+ def _render_error(
877
+ self,
878
+ error_message: str,
879
+ mode: ProcessMode,
880
+ context: list[dict[str, str]] | None = None,
881
+ ) -> LLMResult | Generator[LLMResult, None, None]:
648
882
  """Render user-friendly error message."""
649
- messages = self._build_error_render_messages(error_message)
883
+ # Truncate context to configured maximum length
884
+ truncated_context = self._truncate_context(context)
650
885
 
651
- if mode == ProcessMode.PROMPT_ONLY:
652
- return LLMResult(
653
- prompt=messages[-1]["content"],
654
- metadata={"original_error": error_message},
655
- )
886
+ # Build error messages with context
887
+ messages = self._build_error_render_messages(error_message, truncated_context)
656
888
 
657
889
  if mode == ProcessMode.COMPLETE:
658
890
  if not self._llm_provider:
659
891
  return LLMResult(content=error_message) # Fallback processing
660
892
 
661
- result = self._llm_provider.complete(messages)
662
- friendly_error = result.content
893
+ friendly_error = self._llm_provider.complete(messages, model=self._model, temperature=self._temperature)
663
894
  return LLMResult(content=friendly_error, prompt=messages[-1]["content"])
664
895
 
665
896
  if mode == ProcessMode.STREAM:
@@ -667,7 +898,7 @@ class MarkdownFlow:
667
898
  return LLMResult(content=error_message)
668
899
 
669
900
  def stream_generator():
670
- for chunk in self._llm_provider.stream(messages):
901
+ for chunk in self._llm_provider.stream(messages, model=self._model, temperature=self._temperature): # type: ignore[attr-defined]
671
902
  yield LLMResult(content=chunk, prompt=messages[-1]["content"])
672
903
 
673
904
  return stream_generator()
@@ -678,502 +909,294 @@ class MarkdownFlow:
678
909
  self,
679
910
  block_index: int,
680
911
  variables: dict[str, str | list[str]] | None,
912
+ context: list[dict[str, str]] | None = None,
681
913
  ) -> list[dict[str, str]]:
682
914
  """Build content block messages."""
683
915
  block = self.get_block(block_index)
684
916
  block_content = block.content
685
917
 
686
- # Process output instructions
687
- block_content = process_output_instructions(block_content)
918
+ # Process output instructions and detect if preserved content exists
919
+ # Returns: (processed_content, has_preserved_content)
920
+ block_content, has_preserved_content = process_output_instructions(block_content)
688
921
 
689
922
  # Replace variables
690
923
  block_content = replace_variables_in_text(block_content, variables or {})
691
924
 
925
+ # Restore code blocks (让 LLM 看到真实的代码块内容)
926
+ # Code block preprocessing is to prevent the parser from misinterpreting
927
+ # MarkdownFlow syntax inside code blocks, but the LLM needs to see
928
+ # the real content to correctly understand and generate responses
929
+ block_content = self._preprocessor.restore_code_blocks(block_content)
930
+
692
931
  # Build message array
693
932
  messages = []
694
933
 
695
- # Add document prompt
696
- if self._document_prompt:
697
- messages.append({"role": "system", "content": self._document_prompt})
934
+ # Build system message with XML tags
935
+ # Priority order: preserve_or_translate_instruction > base_system > document_prompt
936
+ system_parts = []
698
937
 
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)
938
+ # 1. Output instruction (highest priority - if preserved content exists)
939
+ # Note: OUTPUT_INSTRUCTION_EXPLANATION already contains <preserve_or_translate_instruction> tags
940
+ if has_preserved_content:
941
+ system_parts.append(OUTPUT_INSTRUCTION_EXPLANATION.strip())
704
942
 
705
- # Add processed content as user message (as instruction to LLM)
706
- messages.append({"role": "user", "content": block_content})
943
+ # 2. Base system prompt (if exists and non-empty)
944
+ if self._base_system_prompt:
945
+ system_parts.append(f"<base_system>\n{self._base_system_prompt}\n</base_system>")
707
946
 
708
- return messages
947
+ # 3. Document prompt (if exists and non-empty)
948
+ if self._document_prompt:
949
+ system_parts.append(f"<document_prompt>\n{self._document_prompt}\n</document_prompt>")
709
950
 
710
- def _build_interaction_render_messages(self, question_text: str) -> list[dict[str, str]]:
711
- """Build interaction rendering messages."""
712
- # Check if using custom interaction prompt
713
- if self._interaction_prompt != DEFAULT_INTERACTION_PROMPT:
714
- # User custom prompt + mandatory direction protection
715
- render_prompt = f"""{self._interaction_prompt}"""
716
- else:
717
- # Use default prompt and instructions
718
- render_prompt = f"""{self._interaction_prompt}
719
- {INTERACTION_RENDER_INSTRUCTIONS}"""
951
+ # Combine all parts and add as system message
952
+ if system_parts:
953
+ system_msg = "\n\n".join(system_parts)
954
+ messages.append({"role": "system", "content": system_msg})
720
955
 
721
- messages = []
956
+ # Add conversation history context if provided
957
+ # Context is inserted after system message and before current user message
958
+ truncated_context = self._truncate_context(context)
959
+ if truncated_context:
960
+ messages.extend(truncated_context)
722
961
 
723
- messages.append({"role": "system", "content": render_prompt})
724
- messages.append({"role": "user", "content": question_text})
962
+ # Add processed content as user message (as instruction to LLM)
963
+ messages.append({"role": "user", "content": block_content})
725
964
 
726
965
  return messages
727
966
 
728
- def _build_validation_messages(self, block_index: int, user_input: dict[str, list[str]], target_variable: str) -> list[dict[str, str]]:
729
- """Build validation messages."""
730
- block = self.get_block(block_index)
731
- config = self.get_interaction_validation_config(block_index)
732
-
733
- if config and config.validation_template:
734
- # Use custom validation template
735
- validation_prompt = config.validation_template
736
- user_input_str = json.dumps(user_input, ensure_ascii=False)
737
- validation_prompt = validation_prompt.replace("{sys_user_input}", user_input_str)
738
- validation_prompt = validation_prompt.replace("{block_content}", block.content)
739
- validation_prompt = validation_prompt.replace("{target_variable}", target_variable)
740
- system_message = DEFAULT_VALIDATION_SYSTEM_MESSAGE
741
- else:
742
- # Use smart default validation template
743
- from .utils import (
744
- extract_interaction_question,
745
- generate_smart_validation_template,
746
- )
967
+ def _extract_translatable_content(self, interaction_content: str) -> tuple[str, dict[str, Any] | None]:
968
+ """提取交互内容中需要翻译的部分为 JSON 格式
747
969
 
748
- # Extract interaction question
749
- interaction_question = extract_interaction_question(block.content)
750
-
751
- # Generate smart validation template
752
- validation_template = generate_smart_validation_template(
753
- target_variable,
754
- context=None, # Could consider passing context here
755
- interaction_question=interaction_question,
756
- )
757
-
758
- # Replace template variables
759
- user_input_str = json.dumps(user_input, ensure_ascii=False)
760
- validation_prompt = validation_template.replace("{sys_user_input}", user_input_str)
761
- validation_prompt = validation_prompt.replace("{block_content}", block.content)
762
- validation_prompt = validation_prompt.replace("{target_variable}", target_variable)
763
- system_message = DEFAULT_VALIDATION_SYSTEM_MESSAGE
970
+ Args:
971
+ interaction_content: 交互内容字符串
764
972
 
765
- messages = []
973
+ Returns:
974
+ tuple: (JSON 字符串, InteractionInfo 字典)
975
+ """
976
+ # 解析交互内容
977
+ interaction_parser = InteractionParser()
978
+ interaction_info = interaction_parser.parse(interaction_content)
979
+ if not interaction_info:
980
+ return "{}", None
766
981
 
767
- messages.append({"role": "system", "content": system_message})
768
- messages.append({"role": "user", "content": validation_prompt})
982
+ translatable = {}
769
983
 
770
- return messages
984
+ # 提取按钮的 Display 文本
985
+ if interaction_info.get("buttons"):
986
+ button_texts = [btn["display"] for btn in interaction_info["buttons"]]
987
+ translatable["buttons"] = button_texts
771
988
 
772
- def _build_validation_messages_with_options(
773
- self,
774
- user_input: dict[str, list[str]],
775
- target_variable: str,
776
- options: list[str],
777
- question: str,
778
- ) -> list[dict[str, str]]:
779
- """Build validation messages with button options (third case)."""
780
- # Use validation template from constants
781
- user_input_str = json.dumps(user_input, ensure_ascii=False)
782
- validation_prompt = BUTTONS_WITH_TEXT_VALIDATION_TEMPLATE.format(
783
- question=question,
784
- options=", ".join(options),
785
- user_input=user_input_str,
786
- target_variable=target_variable,
787
- )
989
+ # 提取问题文本
990
+ if interaction_info.get("question"):
991
+ translatable["question"] = interaction_info["question"]
788
992
 
789
- messages = []
790
- if self._document_prompt:
791
- messages.append({"role": "system", "content": self._document_prompt})
993
+ # 转换为 JSON
994
+ import json
792
995
 
793
- messages.append({"role": "system", "content": DEFAULT_VALIDATION_SYSTEM_MESSAGE})
794
- messages.append({"role": "user", "content": validation_prompt})
996
+ json_str = json.dumps(translatable, ensure_ascii=False)
795
997
 
796
- return messages
998
+ return json_str, interaction_info
797
999
 
798
- def _build_error_render_messages(self, error_message: str) -> list[dict[str, str]]:
799
- """Build error rendering messages."""
800
- render_prompt = f"""{self._interaction_error_prompt}
801
-
802
- Original Error: {error_message}
1000
+ def _build_translation_messages(self, translatable_json: str) -> list[dict[str, str]]:
1001
+ """构建翻译用的消息列表
803
1002
 
804
- {INTERACTION_ERROR_RENDER_INSTRUCTIONS}"""
1003
+ Args:
1004
+ translatable_json: 可翻译内容的 JSON 字符串
805
1005
 
1006
+ Returns:
1007
+ list: 消息列表
1008
+ """
806
1009
  messages = []
807
- if self._document_prompt:
808
- messages.append({"role": "system", "content": self._document_prompt})
809
-
810
- messages.append({"role": "system", "content": render_prompt})
811
- messages.append({"role": "user", "content": error_message})
812
-
813
- return messages
814
-
815
- # Helper methods
816
1010
 
817
- def _reconstruct_interaction_content(self, original_content: str, rendered_question: str) -> str:
818
- """Reconstruct interaction content."""
819
- cleaned_question = rendered_question.strip()
820
- # Use pre-compiled regex for improved performance
821
- cleaned_question = COMPILED_BRACKETS_CLEANUP_REGEX.sub("", cleaned_question)
822
- cleaned_question = COMPILED_VARIABLE_REFERENCE_CLEANUP_REGEX.sub("", cleaned_question)
823
- cleaned_question = COMPILED_WHITESPACE_CLEANUP_REGEX.sub(" ", cleaned_question).strip()
1011
+ # 构建 system message:交互翻译提示词 + 文档提示词(XML 格式)
1012
+ # interaction_prompt: 定义翻译规则和 JSON 格式要求(包含 <interaction_translation_rules> 标签)
1013
+ # document_prompt: 提供语言指令(如"使用英语输出"),包装在 <document_context> 标签中供 LLM 检测
1014
+ system_content = self._interaction_prompt
1015
+ if self._document_prompt:
1016
+ # 将文档提示词包装在 <document_context> 标签中
1017
+ system_content = f"{self._interaction_prompt}\n\n<document_context>\n{self._document_prompt}\n</document_context>"
824
1018
 
825
- match = COMPILED_INTERACTION_CONTENT_RECONSTRUCT_REGEX.search(original_content)
1019
+ messages.append({"role": "system", "content": system_content})
826
1020
 
827
- if match:
828
- prefix = match.group(1)
829
- suffix = match.group(2)
830
- return f"{prefix}{cleaned_question}{suffix}"
831
- return original_content # type: ignore[unreachable]
1021
+ # 添加可翻译内容作为 user message
1022
+ messages.append({"role": "user", "content": translatable_json})
832
1023
 
833
- # Dynamic Interaction Methods
1024
+ return messages
834
1025
 
835
- def _process_with_dynamic_check(
1026
+ def _reconstruct_with_translation(
836
1027
  self,
837
- block_index: int,
838
- mode: ProcessMode,
839
- context: list[dict[str, str]] | None,
840
- variables: dict[str, str | list[str]] | None,
841
- ) -> LLMResult | Generator[LLMResult, None, None]:
842
- """Process content with dynamic interaction detection and conversion."""
843
-
844
- block = self.get_block(block_index)
845
- messages = self._build_dynamic_check_messages(block, context, variables)
846
-
847
- # Define Function Calling tools with structured approach
848
- tools = [
849
- {
850
- "type": "function",
851
- "function": {
852
- "name": "create_interaction_block",
853
- "description": "Convert content to interaction block with structured data when it needs to collect user input",
854
- "parameters": {
855
- "type": "object",
856
- "properties": {
857
- "needs_interaction": {"type": "boolean", "description": "Whether this content needs to be converted to interaction block"},
858
- "variable_name": {"type": "string", "description": "Name of the variable to collect (without {{}} brackets)"},
859
- "interaction_type": {
860
- "type": "string",
861
- "enum": ["single_select", "multi_select", "text_input", "mixed"],
862
- "description": "Type of interaction: single_select (|), multi_select (||), text_input (...), mixed (options + text)",
863
- },
864
- "options": {"type": "array", "items": {"type": "string"}, "description": "List of selectable options (3-4 specific options based on context)"},
865
- "allow_text_input": {"type": "boolean", "description": "Whether to include a text input option for 'Other' cases"},
866
- "text_input_prompt": {"type": "string", "description": "Prompt text for the text input option (e.g., '其他请输入', 'Other, please specify')"},
867
- },
868
- "required": ["needs_interaction"],
869
- },
870
- },
871
- }
872
- ]
873
-
874
- if not self._llm_provider:
875
- raise ValueError(LLM_PROVIDER_REQUIRED_ERROR)
876
-
877
- # Call LLM with tools
878
- result = self._llm_provider.complete(messages, tools)
1028
+ original_content: str,
1029
+ original_json: str,
1030
+ translated_json: str,
1031
+ interaction_info: dict[str, Any],
1032
+ ) -> str:
1033
+ """使用翻译后的内容重构交互块
879
1034
 
880
- # If interaction was generated through Function Calling, construct the MarkdownFlow format
881
- if result.transformed_to_interaction and result.metadata and "tool_args" in result.metadata:
882
- tool_args = result.metadata["tool_args"]
883
- if tool_args.get("needs_interaction"):
884
- # Construct MarkdownFlow format from structured data
885
- interaction_content = self._build_interaction_format(tool_args)
886
- result.content = interaction_content
887
-
888
- # If transformed to interaction, return as is
889
- if result.transformed_to_interaction:
890
- return result
891
-
892
- # If not transformed, continue with normal processing using standard content messages
893
- normal_messages = self._build_content_messages(block_index, variables)
1035
+ Args:
1036
+ original_content: 原始交互内容
1037
+ original_json: 原始的可翻译 JSON(翻译前)
1038
+ translated_json: 翻译后的 JSON 字符串
1039
+ interaction_info: 交互信息字典
894
1040
 
895
- if mode == ProcessMode.STREAM:
1041
+ Returns:
1042
+ str: 重构后的交互内容
1043
+ """
1044
+ import json
896
1045
 
897
- def stream_wrapper():
898
- stream_generator = self._llm_provider.stream(normal_messages)
899
- for chunk in stream_generator:
900
- yield LLMResult(content=chunk)
1046
+ # 解析原始 JSON
1047
+ try:
1048
+ original = json.loads(original_json)
1049
+ except json.JSONDecodeError:
1050
+ return original_content
1051
+
1052
+ # 解析翻译后的 JSON
1053
+ try:
1054
+ translated = json.loads(translated_json)
1055
+ except json.JSONDecodeError:
1056
+ return original_content
1057
+
1058
+ reconstructed = original_content
1059
+
1060
+ # 替换按钮 Display 文本(智能处理 Value)
1061
+ if "buttons" in translated and interaction_info.get("buttons"):
1062
+ for i, button in enumerate(interaction_info["buttons"]):
1063
+ if i < len(translated["buttons"]):
1064
+ old_display = button["display"]
1065
+ new_display = translated["buttons"][i]
1066
+
1067
+ # 检测是否发生了翻译
1068
+ translation_happened = False
1069
+ if "buttons" in original and i < len(original["buttons"]):
1070
+ if original["buttons"][i] != new_display:
1071
+ translation_happened = True
1072
+
1073
+ # 如果有 Value 分离(display//value 格式),保留 value
1074
+ if button["display"] != button["value"]:
1075
+ # 已有 value 分离,按原逻辑处理
1076
+ # 替换格式:oldDisplay//value -> newDisplay//value
1077
+ old_pattern = f"{old_display}//{button['value']}"
1078
+ new_pattern = f"{new_display}//{button['value']}"
1079
+ reconstructed = reconstructed.replace(old_pattern, new_pattern, 1)
1080
+ elif translation_happened:
1081
+ # 没有 value 分离,但发生了翻译
1082
+ # 自动添加 value:翻译后//原始
1083
+ old_pattern = old_display
1084
+ new_pattern = f"{new_display}//{old_display}"
1085
+ reconstructed = reconstructed.replace(old_pattern, new_pattern, 1)
1086
+ else:
1087
+ # 没有翻译,保持原样
1088
+ reconstructed = reconstructed.replace(old_display, new_display, 1)
901
1089
 
902
- return stream_wrapper()
1090
+ # 替换问题文本
1091
+ if "question" in translated and interaction_info.get("question"):
1092
+ old_question = interaction_info["question"]
1093
+ new_question = translated["question"]
1094
+ reconstructed = reconstructed.replace(f"...{old_question}", f"...{new_question}", 1)
903
1095
 
904
- # Complete mode - use normal content processing
905
- normal_result = self._llm_provider.complete(normal_messages)
906
- return LLMResult(content=normal_result.content, prompt=normal_messages[-1]["content"], metadata=normal_result.metadata)
1096
+ return reconstructed
907
1097
 
908
- def _build_dynamic_check_messages(
1098
+ def _build_validation_messages(
909
1099
  self,
910
- block: "Block",
911
- context: list[dict[str, str]] | None,
912
- variables: dict[str, str | list[str]] | None,
1100
+ block_index: int,
1101
+ user_input: dict[str, list[str]],
1102
+ target_variable: str,
1103
+ context: list[dict[str, str]] | None = None,
913
1104
  ) -> list[dict[str, str]]:
914
- """Build messages for dynamic interaction detection."""
915
-
916
- import json
917
-
918
- # System prompt for detection
919
- system_prompt = """You are an intelligent document processing assistant specializing in creating interactive forms.
920
-
921
- Task: Analyze the given content block and determine if it needs to be converted to an interaction block to collect user information.
922
-
923
- Judgment criteria:
924
- 1. Does the content imply the need to ask users for information?
925
- 2. Does it need to collect detailed information based on previous variable values?
926
- 3. Does it mention "recording" or "saving" information to variables?
927
-
928
- If conversion is needed, generate a STANDARD interaction block format with SPECIFIC options based on the document-level instructions and context provided in the user message."""
929
-
930
- # User message with content and context
931
- # Build user prompt with document context
932
- user_prompt_parts = []
933
-
934
- # Add document-level prompt context if exists
935
- if self._document_prompt:
936
- user_prompt_parts.append(f"""Document-level instructions:
937
- {self._document_prompt}
938
-
939
- (Note: The above are the user's document-level instructions that provide context and requirements for processing.)
940
- """)
941
-
942
- # Prepare content analysis with both original and resolved versions
943
- original_content = block.content
944
-
945
- # Create resolved content with variable substitution for better context
946
- resolved_content = original_content
947
- if variables:
948
- from .utils import replace_variables_in_text
949
-
950
- resolved_content = replace_variables_in_text(original_content, variables)
951
-
952
- content_analysis = f"""Current content block to analyze:
953
-
954
- **Original content (shows variable structure):**
955
- {original_content}
956
-
957
- **Resolved content (with current variable values):**
958
- {resolved_content}
959
-
960
- **Existing variable values:**
961
- {json.dumps(variables, ensure_ascii=False) if variables else "None"}"""
962
-
963
- # Add different analysis based on whether content has variables
964
- if "{{" in original_content and "}}" in original_content:
965
- from .utils import extract_variables_from_text
966
-
967
- content_variables = set(extract_variables_from_text(original_content))
968
-
969
- # Find new variables (not yet collected)
970
- new_variables = content_variables - (set(variables.keys()) if variables else set())
971
- existing_used_variables = content_variables & (set(variables.keys()) if variables else set())
972
-
973
- content_analysis += f"""
974
-
975
- **Variable analysis:**
976
- - Variables used from previous steps: {list(existing_used_variables) if existing_used_variables else "None"}
977
- - New variables to collect: {list(new_variables) if new_variables else "None"}
978
-
979
- **Context guidance:**
980
- - Use the resolved content to understand the actual context and requirements
981
- - Generate options based on the real variable values shown in the resolved content
982
- - Collect user input for the new variables identified above"""
983
-
984
- user_prompt_parts.append(content_analysis)
985
-
986
- # Add analysis requirements and structured output guide
987
- user_prompt_parts.append("""## Analysis Task:
988
- 1. Determine if this content needs to be converted to an interaction block
989
- 2. If conversion is needed, provide structured interaction data
1105
+ """
1106
+ Build validation messages with new structure.
990
1107
 
991
- ## Context-based Analysis:
992
- - Use the "Resolved content" to understand actual context (e.g., if it shows "川菜", generate Sichuan dish options)
993
- - Extract the "New variables to collect" identified in the variable analysis above
994
- - Generate 3-4 specific options based on the resolved context and document-level instructions
995
- - Follow ALL document-level instruction requirements (language, domain, terminology)
1108
+ System message contains:
1109
+ - VALIDATION_TASK_TEMPLATE (includes task description and output language rules)
1110
+ - Question context (if exists)
1111
+ - Button options context (if exists)
1112
+ - VALIDATION_REQUIREMENTS_TEMPLATE
1113
+ - document_prompt wrapped in <document_context> tags (if exists)
996
1114
 
997
- ## Selection Type Decision Logic:
998
- Ask: "Can a user realistically want/choose multiple of these options simultaneously?"
1115
+ User message contains:
1116
+ - User input only
1117
+ """
1118
+ from .parser import InteractionParser, extract_interaction_question
999
1119
 
1000
- **Use MULTI_SELECT when:**
1001
- - Food dishes (can order multiple: 宫保鸡丁, 麻婆豆腐)
1002
- - Programming skills (can know multiple: Python, JavaScript)
1003
- - Interests/hobbies (can have multiple: 读书, 运动, 旅游)
1004
- - Product features (can want multiple: 定制颜色, 个性化logo)
1005
- - Exercise types (can do multiple: 跑步, 游泳, 瑜伽)
1120
+ block = self.get_block(block_index)
1006
1121
 
1007
- **Use SINGLE_SELECT when:**
1008
- - Job positions (usually apply for one: 软件工程师 OR 产品经理)
1009
- - Experience levels (have one current level: Beginner OR Advanced)
1010
- - Budget ranges (have one range: 5-10万 OR 10-20万)
1011
- - Education levels (have one highest: Bachelor's OR Master's)
1122
+ # Extract user input values for target variable
1123
+ target_values = user_input.get(target_variable, [])
1124
+ user_input_str = ", ".join(target_values) if target_values else ""
1012
1125
 
1013
- ## Output Instructions:
1014
- If this content needs interaction, use the create_interaction_block function with:
1015
- - `needs_interaction`: true/false
1016
- - `variable_name`: the variable to collect (from "New variables" above)
1017
- - `interaction_type`: "single_select", "multi_select", "text_input", or "mixed"
1018
- - `options`: array of 3-4 specific options based on context
1019
- - `allow_text_input`: true if you want to include "other" option
1020
- - `text_input_prompt`: text for the "other" option (in appropriate language)
1126
+ # Build System Message (contains all validation rules and context)
1127
+ # VALIDATION_TASK_TEMPLATE already includes system message, directly replace variables
1128
+ task_template = VALIDATION_TASK_TEMPLATE.replace("{target_variable}", target_variable)
1129
+ system_parts = [task_template]
1021
1130
 
1022
- Analyze the content and provide the structured interaction data.""")
1131
+ # Extract interaction question
1132
+ interaction_question = extract_interaction_question(block.content)
1023
1133
 
1024
- user_prompt = "\n\n".join(user_prompt_parts)
1134
+ # Add question context (if exists)
1135
+ if interaction_question:
1136
+ question_context = CONTEXT_QUESTION_TEMPLATE.format(question=interaction_question)
1137
+ system_parts.append("")
1138
+ system_parts.append(question_context)
1025
1139
 
1026
- messages = [{"role": "system", "content": system_prompt}]
1140
+ # Parse interaction to extract button information
1141
+ parser = InteractionParser()
1142
+ parse_result = parser.parse(block.content)
1143
+ buttons = parse_result.get("buttons") if "buttons" in parse_result else None
1144
+
1145
+ # Add button options context (if exists)
1146
+ if buttons:
1147
+ button_displays = [btn.get("display", "") for btn in buttons if btn.get("display")]
1148
+ if button_displays:
1149
+ button_options = "、".join(button_displays)
1150
+ button_context = CONTEXT_BUTTON_OPTIONS_TEMPLATE.format(button_options=button_options)
1151
+ system_parts.append("")
1152
+ system_parts.append(button_context)
1153
+
1154
+ # Add extraction requirements (using template)
1155
+ system_parts.append("")
1156
+ system_parts.append(VALIDATION_REQUIREMENTS_TEMPLATE)
1157
+
1158
+ # Add document_prompt (if exists)
1159
+ if self._document_prompt:
1160
+ system_parts.append("")
1161
+ system_parts.append("<document_context>")
1162
+ system_parts.append(self._document_prompt)
1163
+ system_parts.append("</document_context>")
1027
1164
 
1028
- # Add context if provided
1029
- if context:
1030
- messages.extend(context)
1165
+ system_content = "\n".join(system_parts)
1031
1166
 
1032
- messages.append({"role": "user", "content": user_prompt})
1167
+ # Build message list
1168
+ messages = [
1169
+ {"role": "system", "content": system_content},
1170
+ {"role": "user", "content": user_input_str}, # Only user input
1171
+ ]
1033
1172
 
1034
1173
  return messages
1035
1174
 
1036
- def _build_interaction_format(self, tool_args: dict) -> str:
1037
- """Build MarkdownFlow interaction format from structured Function Calling data."""
1038
- variable_name = tool_args.get("variable_name", "")
1039
- interaction_type = tool_args.get("interaction_type", "single_select")
1040
- options = tool_args.get("options", [])
1041
- allow_text_input = tool_args.get("allow_text_input", False)
1042
- text_input_prompt = tool_args.get("text_input_prompt", "...请输入")
1043
-
1044
- if not variable_name:
1045
- return ""
1046
-
1047
- # For text_input type, options can be empty
1048
- if interaction_type != "text_input" and not options:
1049
- return ""
1050
-
1051
- # Choose separator based on interaction type
1052
- if interaction_type in ["multi_select", "mixed"]:
1053
- separator = "||"
1054
- else:
1055
- separator = "|"
1056
-
1057
- # Build options string
1058
- if interaction_type == "text_input":
1059
- # Text input only
1060
- options_str = f"...{text_input_prompt}"
1061
- else:
1062
- # Options with potential text input
1063
- options_str = separator.join(options)
1064
-
1065
- if allow_text_input and text_input_prompt:
1066
- # Ensure text input has ... prefix
1067
- text_option = text_input_prompt if text_input_prompt.startswith("...") else f"...{text_input_prompt}"
1068
- options_str += f"{separator}{text_option}"
1069
-
1070
- return f"?[%{{{{{variable_name}}}}} {options_str}]"
1071
-
1072
- def _process_dynamic_interaction_validation(
1175
+ def _build_error_render_messages(
1073
1176
  self,
1074
- block_index: int,
1075
- interaction_format: str,
1076
- user_input: dict[str, list[str]],
1077
- mode: ProcessMode,
1078
- context: list[dict[str, str]] | None,
1079
- variables: dict[str, str | list[str]] | None,
1080
- ) -> LLMResult:
1081
- """Validate user input for dynamically generated interaction blocks using same logic as normal interactions."""
1082
- _ = block_index # Mark as intentionally unused
1083
- _ = context # Mark as intentionally unused
1084
-
1085
- from .utils import InteractionParser
1086
-
1087
- # Parse the interaction format using the same parser as normal interactions
1088
- parser = InteractionParser()
1089
- parse_result = parser.parse(interaction_format)
1090
-
1091
- if "error" in parse_result:
1092
- error_msg = f"Invalid interaction format: {parse_result['error']}"
1093
- return self._render_error(error_msg, mode)
1177
+ error_message: str,
1178
+ context: list[dict[str, str]] | None = None,
1179
+ ) -> list[dict[str, str]]:
1180
+ """Build error rendering messages."""
1181
+ render_prompt = f"""{self._interaction_error_prompt}
1094
1182
 
1095
- # Extract variable name and interaction type
1096
- variable_name = parse_result.get("variable")
1097
- interaction_type = parse_result.get("type")
1183
+ Original Error: {error_message}
1098
1184
 
1099
- if not variable_name:
1100
- error_msg = f"No variable found in interaction format: {interaction_format}"
1101
- return self._render_error(error_msg, mode)
1185
+ {INTERACTION_ERROR_RENDER_INSTRUCTIONS}"""
1102
1186
 
1103
- # Get user input for the target variable
1104
- target_values = user_input.get(variable_name, [])
1187
+ messages = []
1188
+ if self._document_prompt:
1189
+ messages.append({"role": "system", "content": self._document_prompt})
1105
1190
 
1106
- # Basic validation - check if input is provided when required
1107
- if not target_values:
1108
- # Check if this is a text input or allows empty input
1109
- allow_text_input = interaction_type in [
1110
- InteractionType.BUTTONS_WITH_TEXT,
1111
- InteractionType.BUTTONS_MULTI_WITH_TEXT,
1112
- ]
1191
+ messages.append({"role": "system", "content": render_prompt})
1113
1192
 
1114
- if allow_text_input:
1115
- # Allow empty input for buttons+text mode - merge with existing variables
1116
- merged_variables = dict(variables or {})
1117
- merged_variables[variable_name] = []
1118
- return LLMResult(
1119
- content="",
1120
- variables=merged_variables,
1121
- metadata={
1122
- "interaction_type": "dynamic_interaction",
1123
- "empty_input": True,
1124
- },
1125
- )
1126
- else:
1127
- error_msg = f"No input provided for variable '{variable_name}'"
1128
- return self._render_error(error_msg, mode)
1193
+ # Add conversation history context if provided
1194
+ truncated_context = self._truncate_context(context)
1195
+ if truncated_context:
1196
+ messages.extend(truncated_context)
1129
1197
 
1130
- # Use the same validation logic as normal interactions
1131
- if interaction_type in [
1132
- InteractionType.BUTTONS_ONLY,
1133
- InteractionType.BUTTONS_WITH_TEXT,
1134
- InteractionType.BUTTONS_MULTI_SELECT,
1135
- InteractionType.BUTTONS_MULTI_WITH_TEXT,
1136
- ]:
1137
- # Button validation - reuse the existing button validation logic
1138
- button_result = self._process_button_validation(
1139
- parse_result,
1140
- target_values,
1141
- variable_name,
1142
- mode,
1143
- interaction_type,
1144
- )
1198
+ messages.append({"role": "user", "content": error_message})
1145
1199
 
1146
- # Merge with existing variables for dynamic interactions
1147
- if hasattr(button_result, 'variables') and button_result.variables is not None and variables:
1148
- merged_variables = dict(variables)
1149
- merged_variables.update(button_result.variables)
1150
- return LLMResult(
1151
- content=button_result.content,
1152
- variables=merged_variables,
1153
- metadata=button_result.metadata,
1154
- )
1155
- return button_result
1200
+ return messages
1156
1201
 
1157
- elif interaction_type == InteractionType.NON_ASSIGNMENT_BUTTON:
1158
- # Non-assignment buttons: don't set variables, keep existing ones
1159
- return LLMResult(
1160
- content="",
1161
- variables=dict(variables or {}),
1162
- metadata={
1163
- "interaction_type": "non_assignment_button",
1164
- "user_input": user_input,
1165
- },
1166
- )
1167
- else:
1168
- # Text-only input type - merge with existing variables
1169
- merged_variables = dict(variables or {})
1170
- merged_variables[variable_name] = target_values
1171
- return LLMResult(
1172
- content="",
1173
- variables=merged_variables,
1174
- metadata={
1175
- "interaction_type": "text_only",
1176
- "target_variable": variable_name,
1177
- "values": target_values,
1178
- },
1179
- )
1202
+ # Helper methods