| """Parser for ReAct outputs — extracts Thought, Action, and Final Answer.""" |
|
|
| import json |
| import re |
| from dataclasses import dataclass |
| from typing import Optional |
|
|
|
|
| @dataclass |
| class ReActStep: |
| """A single step of the ReAct loop.""" |
| thought: str = "" |
| action: str = "" |
| action_input: dict = None |
| is_final: bool = False |
| final_answer: str = "" |
| raw_text: str = "" |
|
|
| def __post_init__(self): |
| if self.action_input is None: |
| self.action_input = {} |
|
|
|
|
| def parse_react_output(text: str, tool_names: list[str] | None = None) -> ReActStep: |
| """Parse the LLM output into a ReAct step. |
| |
| Handles these patterns: |
| - Thought: ... |
| - Action: tool_name |
| - Action Input: {...} |
| OR |
| - Thought: ... |
| - Final Answer: ... |
| """ |
| |
| |
| text = _trim_repetition(text) |
|
|
| |
| |
| |
| from agentic_rag.services.llm.base import strip_reasoning |
| text = strip_reasoning(text) |
|
|
| result = ReActStep(raw_text=text) |
|
|
| |
| thought_match = re.search(r'Thought:\s*(.+?)(?=\n(?:Action|Final Answer)|$)', text, re.DOTALL) |
| if thought_match: |
| result.thought = thought_match.group(1).strip() |
|
|
| |
| final_match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL) |
| if final_match: |
| result.is_final = True |
| result.final_answer = final_match.group(1).strip() |
| return result |
|
|
| |
| |
| |
| action_match = re.search(r'Action:\s*(.+?)(?:\n|$)', text) |
| if action_match: |
| action_text = action_match.group(1).strip() |
| result.action = _resolve_tool_name(action_text, tool_names or []) |
| |
| if not result.action: |
| action_match2 = re.search(r'Action:\s*(\S+)', text) |
| if action_match2: |
| result.action = _resolve_tool_name(action_match2.group(1).strip(), tool_names or []) |
|
|
| |
| action_input_match = re.search(r'Action Input:\s*(\{.+?\}|.+)', text, re.DOTALL) |
| if action_input_match: |
| input_str = action_input_match.group(1).strip() |
| result.action_input = parse_action_input(input_str) |
|
|
| return result |
|
|
|
|
| def _trim_repetition(text: str) -> str: |
| """Detect and cut repetitive LLM output at the first repetition point. |
| |
| When a model loops — e.g. "I will output the answer... I will output the answer..." |
| — truncate everything after the first occurrence of the repeated line. |
| """ |
| lines = text.split("\n") |
| seen: set[str] = set() |
| clean_lines: list[str] = [] |
|
|
| for line in lines: |
| stripped = line.strip() |
| |
| if not stripped: |
| clean_lines.append(line) |
| continue |
| |
| norm = stripped.lower().rstrip(".。!!??,,") |
| if norm in seen: |
| |
| break |
| if len(norm) > 15: |
| seen.add(norm) |
| clean_lines.append(line) |
|
|
| return "\n".join(clean_lines) |
|
|
|
|
| def _resolve_tool_name(action_text: str, tool_names: list[str]) -> str: |
| """Extract the actual tool name from an Action line that may contain Chinese description. |
| |
| Example inputs → outputs: |
| "rag_search" → "rag_search" |
| "使用 rag_search 搜索" → "rag_search" |
| "调用 mcp__tavily-mcp__tavily_search 查询" → "mcp__tavily-mcp__tavily_search" |
| "搜索文档" → "" (no tool found) |
| """ |
| |
| if re.match(r'^[a-zA-Z_][a-zA-Z0-9_\-/]*$', action_text): |
| if tool_names: |
| if action_text in tool_names: |
| return action_text |
| |
| |
| matches = [t for t in tool_names if t.endswith(action_text)] |
| if len(matches) == 1: |
| return matches[0] |
| return "" |
| return action_text |
|
|
| |
| candidates = re.findall(r'[a-zA-Z_][a-zA-Z0-9_\-/]{2,}', action_text) |
| for c in candidates: |
| |
| if '_' in c: |
| if tool_names: |
| if c in tool_names: |
| return c |
| else: |
| return c |
|
|
| |
| for c in candidates: |
| if tool_names: |
| if c in tool_names: |
| return c |
| else: |
| return c |
|
|
| |
| |
| |
| |
| if tool_names and len(action_text) >= 4 and '_' in action_text: |
| matches = [t for t in tool_names if t.endswith(action_text) or t.split('__')[-1] == action_text] |
| if len(matches) == 1: |
| return matches[0] |
|
|
| return "" |
|
|
|
|
| def parse_action_input(input_str: str) -> dict: |
| """Parse action input string into a dict. Tries JSON first, then key=value.""" |
| |
| try: |
| return json.loads(input_str) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| json_match = re.search(r'\{[^{}]*\}', input_str) |
| if json_match: |
| try: |
| return json.loads(json_match.group(0)) |
| except json.JSONDecodeError: |
| pass |
|
|
| |
| if input_str: |
| return {"query": input_str} |
|
|
| return {} |
|
|
|
|
| def extract_final_answer(text: str) -> Optional[str]: |
| """Extract the final answer from text if present.""" |
| from agentic_rag.services.llm.base import strip_reasoning |
| text = strip_reasoning(text) |
| match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL) |
| if match: |
| return match.group(1).strip() |
| return None |
|
|
|
|
| def is_final_answer(text: str) -> bool: |
| """Check if the text contains a Final Answer marker.""" |
| return "Final Answer:" in text |
|
|
|
|
| def format_observation(tool_name: str, result: str, error: Optional[str] = None) -> str: |
| """Format a tool execution result as an Observation.""" |
| if error: |
| return f"Observation: Error executing '{tool_name}': {error}" |
| |
| |
| if len(result) > 1500: |
| result = result[:1500] + "... (truncated)" |
| return f"Observation: {result}" |
|
|