Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/thinking/normalizer.py: 7%
75 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Extraction and normalization of thinking blocks from LLM responses."""
3from __future__ import annotations
5from lexigram.ai.llm.thinking.patterns import THINKING_PATTERNS
8def _extract_gemma_channel(text: str) -> tuple[str, str | None] | None:
9 """Extract thinking from Gemma-4 channel format.
11 Google's canonical format (from official Gemma-4 docs):
12 ``<|channel>thought\\n...thinking...\\n<channel|>\\n...response...``
14 The end token ``<channel|>`` (note: opposite direction from the start token)
15 marks the boundary between thinking and response content.
17 Gemma-4 26B/31B with thinking *disabled* still emits a ghost empty block:
18 ``<|channel>thought\\n<channel|>\\n{json}`` — this is handled correctly since
19 the thinking text between the markers is empty (returned as ``None``).
21 Fallback paths for LM Studio GGUF variants that may use different separators:
22 1. ``\\n<|channel>response\\n`` — seen in some GGUF chat templates
23 2. ``\\n<|channel>output\\n`` — alternative GGUF chat template variant
24 3. Bare format: no end marker, content starts directly at the first ``{``/``[``
26 Args:
27 text: Raw model output potentially containing a Gemma channel block.
29 Returns:
30 ``(clean_content, thinking_text)`` tuple, or ``None`` if no Gemma
31 channel start marker (``<|channel>thought``) is found.
32 """
33 start_marker = "<|channel>thought"
35 if start_marker not in text:
36 return None
38 start_pos = text.find(start_marker)
39 thinking_start = start_pos + len(start_marker)
40 after_start = text[thinking_start:]
42 # --- Primary path: canonical <channel|> end token (Google official format) ---
43 end_token = "<channel|>" # noqa: S105 # marker string, not a credential
44 end_pos = after_start.find(end_token)
45 if end_pos != -1:
46 thinking_text = after_start[:end_pos].strip()
47 clean_content = after_start[end_pos + len(end_token) :].strip()
48 return clean_content, thinking_text or None
50 # --- Secondary paths: GGUF chat-template variants ---
51 for sep in ("\n<|channel>response\n", "\n<|channel>output\n"):
52 sep_pos = after_start.find(sep)
53 if sep_pos != -1:
54 thinking_text = after_start[:sep_pos].strip()
55 clean_content = after_start[sep_pos + len(sep) :].strip()
56 return clean_content, thinking_text or None
58 # --- Tertiary fallback: no separator — look for first { or [ ---
59 # Some LM Studio GGUF variants output thinking directly followed by JSON
60 # with no channel separator between them.
61 for json_char in ("{", "["):
62 json_pos = after_start.find(json_char)
63 if json_pos != -1:
64 thinking_text = after_start[:json_pos].strip()
65 clean_content = after_start[json_pos:].strip()
66 if thinking_text: # Only strip if there actually is thinking text
67 return clean_content, thinking_text
69 return None
72def normalize_thinking_text(text: str) -> tuple[str, str | None]:
73 """Extract thinking text from raw LLM output.
75 Tries each pattern in THINKING_PATTERNS order. Returns (clean_content, thinking_text_or_None).
76 clean_content has thinking block removed and is stripped.
77 thinking_text is the raw thinking content (stripped), or None if not found.
79 Pattern matching is by substring presence of start_marker (and end_marker after it),
80 NOT by model name. The bare-closing-tag pattern (end_marker="</think>", no start)
81 matches only when start_marker is NOT found but end_marker IS found — this covers
82 models that output ...thinking...</think>\\nresponse.
84 Falls back: after removing a thinking block, if clean_content is empty but thinking
85 text was found, tries to extract from the first `{` or `[` in the original text to
86 recover any JSON that may have been embedded.
88 Args:
89 text: Raw LLM response text, possibly containing inline thinking tags.
91 Returns:
92 A tuple of (clean_content, thinking_text_or_None).
93 - clean_content: The response text with thinking stripped out, stripped of whitespace.
94 - thinking_text_or_None: The thinking/reasoning text, or None if no thinking found.
95 """
96 for pattern in THINKING_PATTERNS:
97 # Special handling for Gemma channel format
98 if pattern.name == "gemma_channel":
99 result = _extract_gemma_channel(text)
100 if result is not None:
101 clean_content, thinking_text = result
102 # Fallback: if clean_content is empty but thinking exists, try JSON recovery
103 if not clean_content and thinking_text:
104 # Try to find JSON starting with { or [
105 for json_char in ("{", "["):
106 json_pos = text.find(json_char)
107 if json_pos != -1:
108 clean_content = text[json_pos:].strip()
109 break
110 return clean_content, thinking_text
112 # For bare closing tag pattern, only match if start_marker not found
113 elif pattern.start_marker == "":
114 if pattern.end_marker in text:
115 # Find the position of the end marker
116 end_pos = text.find(pattern.end_marker)
117 thinking_text = text[:end_pos].strip()
118 clean_content = text[end_pos + len(pattern.end_marker) :].strip()
120 # Fallback: if clean_content is empty but thinking exists, try JSON recovery
121 if not clean_content and thinking_text:
122 # Try to find JSON starting with { or [
123 for json_char in ("{", "["):
124 json_pos = text.find(json_char)
125 if json_pos != -1:
126 clean_content = text[json_pos:].strip()
127 break
129 return clean_content, thinking_text or None
131 # For patterns with start markers, check both start and end are present
132 elif pattern.start_marker in text:
133 start_pos = text.find(pattern.start_marker)
134 # Look for end marker after the start marker
135 end_search_start = start_pos + len(pattern.start_marker)
136 end_pos = text.find(pattern.end_marker, end_search_start)
138 if end_pos != -1:
139 # Extract thinking content (between markers)
140 thinking_start = start_pos + len(pattern.start_marker)
141 thinking_text = text[thinking_start:end_pos].strip()
143 # Extract clean content (after end marker)
144 if pattern.strip_end_marker:
145 clean_start = end_pos + len(pattern.end_marker)
146 else:
147 clean_start = end_pos
149 clean_content = text[clean_start:].strip()
151 # Fallback: if clean_content is empty but thinking exists, try JSON recovery
152 if not clean_content and thinking_text:
153 # Try to find JSON starting with { or [
154 after_thinking = text[clean_start:]
155 for json_char in ("{", "["):
156 json_pos = after_thinking.find(json_char)
157 if json_pos != -1:
158 clean_content = after_thinking[json_pos:].strip()
159 break
161 return clean_content, thinking_text or None
163 # No pattern matched
164 return text, None
167__all__ = ["normalize_thinking_text"]