File size: 5,301 Bytes
8191051
 
 
 
 
f02dea5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8191051
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71f07fc
8191051
 
 
 
 
 
 
71f07fc
8191051
 
 
 
 
 
 
 
71f07fc
8191051
 
71f07fc
 
8191051
 
 
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
import re
import logging

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Patterns for output cleanup
# ---------------------------------------------------------------------------

# Strips raw [THINK]...[/THINK] blocks from visible output
_THINK_PATTERN = re.compile(r'\[THINK\](.*?)\[/THINK\]', re.DOTALL)

# Strips the internal [RAG_EMPTY] signal tag if it leaks into output
_RAG_EMPTY_PATTERN = re.compile(r'\[RAG_EMPTY\].*?\[/RAG_EMPTY\]', re.DOTALL)

# Matches "N/A", "Unknown", "Missing", "None" (case-insensitive) when used as values
_NA_VALUE_PATTERN = re.compile(
    r'(?:rating|price|cost|cuisine|review|stars?|score)\s*[::]\s*(?:N/A|Unknown|Missing|None|-)\b',
    re.IGNORECASE
)

# Matches standalone known POI markers that are NOT yet wrapped in [[...]]
# We rely on the model to wrap them; this is a safety net for bare uppercase-starting words
# preceded by known location/attraction indicators.
_POI_INDICATOR = re.compile(
    r'(?<!\[)\[(?!\[)([A-ZÁÀẢÃẠĂẮẰẲẴẶÂẤẦẨẪẬÉÈẺẼẸÊẾỀỂỄỆÍÌỈĨỊÓÒỎÕỌÔỐỒỔỖỘƠỚỜỞỠỢÚÙỦŨỤƯỨỪỬỮỰÝỲỶỸỴĐ][^\[\]]{2,60})\](?!\])',
    re.UNICODE
)

# "Based on general travel information..." prefix for empty-RAG responses
_GENERAL_INFO_PREFIX = {
    "vi": "Dựa trên thông tin du lịch chung",
    "en": "Based on general travel information",
    "ja": "一般的な旅行情報に基づくと",
    "ko": "일반적인 여행 정보에 따르면",
}


def parse_llm_response(raw_output: str, language: str = "en", rag_was_empty: bool = False) -> dict:
    """Parse and post-process LLM output.

    Steps:
    1. Extract [THINK] reasoning blocks (hidden from user).
    2. Remove internal control tags.
    3. Strip N/A / Unknown / Missing value strings.
    4. Enforce [[POI]] bracket format on any single-bracketed references.
    5. Prepend "Based on general travel information..." when RAG was empty.
    6. Clean up whitespace.

    Args:
        raw_output: Raw text from the LLM.
        language: Response language code (vi/en/ja/ko).
        rag_was_empty: Whether the RAG context was empty for this query.

    Returns:
        dict with keys:
            - text: User-facing response text
            - reasoning: Chain-of-thought reasoning (or None)
            - rag_was_empty: Passed-through flag for downstream use
    """
    if not raw_output or not raw_output.strip():
        return {"text": "", "reasoning": None, "rag_was_empty": rag_was_empty}

    # 1. Extract [THINK] blocks
    think_matches = _THINK_PATTERN.findall(raw_output)
    reasoning = "\n".join(m.strip() for m in think_matches) if think_matches else None

    # 2. Remove [THINK] and [RAG_EMPTY] tags
    visible_text = _THINK_PATTERN.sub('', raw_output)
    visible_text = _RAG_EMPTY_PATTERN.sub('', visible_text)

    # 3. Remove N/A value patterns
    visible_text = _NA_VALUE_PATTERN.sub('', visible_text)

    # 4. Fix single-bracket POI references → [[POI]]
    #    e.g. [Hội An Ancient Town] → [[Hội An Ancient Town]]
    visible_text = _POI_INDICATOR.sub(r'[[\1]]', visible_text)

    # 5. Prepend general-info prefix when RAG was empty and model hasn't already added it
    if rag_was_empty:
        prefix = _GENERAL_INFO_PREFIX.get(language, _GENERAL_INFO_PREFIX["en"])
        if prefix.lower() not in visible_text.lower():
            visible_text = f"{prefix} —\n\n{visible_text}"

    # 6. Normalise whitespace
    visible_text = re.sub(r'\n{3,}', '\n\n', visible_text)
    visible_text = visible_text.strip()

    return {
        "text": visible_text,
        "reasoning": reasoning,
        "rag_was_empty": rag_was_empty,
    }


def extract_quick_replies_from_text(text: str, language: str = "vi") -> list[str]:
    """Attempt to extract suggested follow-up questions from LLM output.

    If the LLM includes numbered suggestions or bullet points at the end,
    extract them as quick replies.
    """
    lines = text.strip().split('\n')
    suggestions = []

    # Look for patterns like "1. ...", "- ...", "• ..." at the end of text
    for line in reversed(lines):
        line = line.strip()
        match = re.match(r'^(?:\d+[.)]\s*|[-•]\s*)(.+)$', line)
        if match and len(match.group(1)) < 60:
            suggestions.insert(0, match.group(1).strip())
        elif suggestions:
            break

    # Only return if we found a reasonable number
    if 2 <= len(suggestions) <= 6:
        return suggestions

    return []


def validate_response(text: str, intent: str, min_length: int = 10) -> bool:
    """Basic validation of LLM response quality.

    Args:
        text: Generated response text
        intent: Expected intent
        min_length: Minimum response length

    Returns:
        True if response passes validation
    """
    if not text or len(text.strip()) < min_length:
        return False

    # Check for obvious garbage (repeated characters, mostly special chars)
    if len(set(text)) < 5:
        return False

    # Check repetition ratio
    words = text.split()
    if len(words) > 10:
        unique_ratio = len(set(words)) / len(words)
        if unique_ratio < 0.2:
            return False

    return True