File size: 7,304 Bytes
6b62834
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""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: ...
    """
    # Truncate repetitive output — if the model loops on the same sentence,
    # cut at the first repetition to keep only useful content.
    text = _trim_repetition(text)

    # Strip any residual reasoning (<think> blocks) — reasoning text may
    # contain rehearsed "Thought:/Action:" lines that must NOT be parsed as
    # real actions. Providers normally strip this already; this is a safety net.
    from agentic_rag.services.llm.base import strip_reasoning
    text = strip_reasoning(text)

    result = ReActStep(raw_text=text)

    # Extract Thought
    thought_match = re.search(r'Thought:\s*(.+?)(?=\n(?:Action|Final Answer)|$)', text, re.DOTALL)
    if thought_match:
        result.thought = thought_match.group(1).strip()

    # Check for Final Answer first
    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

    # Extract Action — handle Chinese-descriptive Action lines like:
    #   Action: 使用 rag_search 搜索...
    #   Action: 调用 mcp__tavily-mcp__tavily_search 查询...
    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 [])
        # Re-parse with stricter matching if failed
        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 [])

    # Extract Action Input (try JSON first, then key=value)
    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()
        # Skip empty lines in repetition check
        if not stripped:
            clean_lines.append(line)
            continue
        # Normalize for comparison
        norm = stripped.lower().rstrip(".。!!??,,")
        if norm in seen:
            # Found repetition — stop here
            break
        if len(norm) > 15:  # only track meaningful lines
            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)
    """
    # Already a clean single identifier
    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
            # Fuzzy: unique tool ending with the model's text
            # ("tavily_search" → "mcp__tavily-mcp__tavily_search")
            matches = [t for t in tool_names if t.endswith(action_text)]
            if len(matches) == 1:
                return matches[0]
            return ""
        return action_text

    # Find tool-like patterns in the text: lowercase_with_underscores, possibly with __ or /
    candidates = re.findall(r'[a-zA-Z_][a-zA-Z0-9_\-/]{2,}', action_text)
    for c in candidates:
        # Must contain underscore (real tools look like rag_search, mcp__xxx__yyy)
        if '_' in c:
            if tool_names:
                if c in tool_names:
                    return c
            else:
                return c

    # Last resort: pick the first ascii word
    for c in candidates:
        if tool_names:
            if c in tool_names:
                return c
        else:
            return c

    # Fuzzy match: unique tool whose name contains the model's text.
    # Handles cases like "tavily_search" → "mcp__tavily-mcp__tavily_search".
    # The model's text must be a real suffix/substring, and only ONE tool may
    # match (otherwise it's ambiguous and we refuse to guess).
    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 JSON
    try:
        return json.loads(input_str)
    except json.JSONDecodeError:
        pass

    # Try to extract JSON from within the string
    json_match = re.search(r'\{[^{}]*\}', input_str)
    if json_match:
        try:
            return json.loads(json_match.group(0))
        except json.JSONDecodeError:
            pass

    # Fallback: treat as raw string
    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}"
    # Truncate very long results — keeps the ReAct prompt compact so the
    # model has less material to over-analyze on the next turn.
    if len(result) > 1500:
        result = result[:1500] + "... (truncated)"
    return f"Observation: {result}"