Skip to content

Commit 8d6793b

Browse files
authored
Merge pull request #114 from tarosky/fix/ai-overview-literal-newline
Fix: AI Overview の回答でリテラル改行 `\n` がそのまま表示される問題を修正
2 parents a34405c + 7213b88 commit 8d6793b

2 files changed

Lines changed: 122 additions & 2 deletions

File tree

app/Hametuha/Hamelp/Services/FaqSearchService.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public function generate_overview( string $query, array $history = [] ) {
122122
if ( null === $data ) {
123123
// JSON parse failed: treat response as plain text.
124124
return [
125-
'answer' => $response,
125+
'answer' => self::normalize_answer_text( $response ),
126126
'sources' => [],
127127
'cited_ids' => [],
128128
];
@@ -143,7 +143,7 @@ public function generate_overview( string $query, array $history = [] ) {
143143
}
144144

145145
return [
146-
'answer' => $data['answer'],
146+
'answer' => self::normalize_answer_text( (string) $data['answer'] ),
147147
'sources' => $sources,
148148
'cited_ids' => wp_list_pluck( $sources, 'id' ),
149149
];
@@ -212,6 +212,34 @@ public static function decode_json_response( string $response ): ?array {
212212
return $data;
213213
}
214214

215+
/**
216+
* Normalize literal escape sequences in answer text into real characters.
217+
*
218+
* Some models double-escape newlines in their structured JSON output, so a
219+
* single `json_decode()` leaves the two-character sequence `\n` (backslash +
220+
* n) in the answer instead of a real line break. The frontend renderer only
221+
* understands real newline characters, so those literals leak into the
222+
* rendered answer as raw text. This converts the common literal sequences
223+
* back into the real characters they represent.
224+
*
225+
* A `strtr()` map is used so replacement is single-pass and order-independent
226+
* (the longer `\r\n` sequence is matched before the shorter `\r`/`\n`).
227+
*
228+
* @param string $text Answer text possibly containing literal escape sequences.
229+
* @return string Text with literal `\r\n`, `\n`, `\r` and `\t` converted.
230+
*/
231+
public static function normalize_answer_text( string $text ): string {
232+
return strtr(
233+
$text,
234+
[
235+
'\\r\\n' => "\n",
236+
'\\n' => "\n",
237+
'\\r' => "\n",
238+
'\\t' => "\t",
239+
]
240+
);
241+
}
242+
215243
/**
216244
* Resolve cited FAQ IDs into source link data.
217245
*

tests/test-ai-overview.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,98 @@ public function test_empty_history() {
130130
$this->assertSame( [], $this->service->prepare_history( [] ) );
131131
}
132132

133+
/**
134+
* Literal newline escape sequences are converted to real characters.
135+
*/
136+
public function test_normalize_answer_text_literal_newline() {
137+
$this->assertSame(
138+
"line1\nline2",
139+
FaqSearchService::normalize_answer_text( 'line1\nline2' )
140+
);
141+
}
142+
143+
/**
144+
* Literal double newlines (paragraph breaks) are converted.
145+
*/
146+
public function test_normalize_answer_text_literal_double_newline() {
147+
$this->assertSame(
148+
"para1\n\npara2",
149+
FaqSearchService::normalize_answer_text( 'para1\n\npara2' )
150+
);
151+
}
152+
153+
/**
154+
* A literal CRLF sequence collapses to a single real newline.
155+
*/
156+
public function test_normalize_answer_text_literal_crlf() {
157+
$this->assertSame(
158+
"line1\nline2",
159+
FaqSearchService::normalize_answer_text( 'line1\r\nline2' )
160+
);
161+
}
162+
163+
/**
164+
* A literal tab sequence is converted to a real tab.
165+
*/
166+
public function test_normalize_answer_text_literal_tab() {
167+
$this->assertSame(
168+
"a\tb",
169+
FaqSearchService::normalize_answer_text( 'a\tb' )
170+
);
171+
}
172+
173+
/**
174+
* Real newline characters are left intact.
175+
*/
176+
public function test_normalize_answer_text_real_newline_untouched() {
177+
$this->assertSame(
178+
"line1\nline2",
179+
FaqSearchService::normalize_answer_text( "line1\nline2" )
180+
);
181+
}
182+
183+
/**
184+
* Plain text without escape sequences is returned unchanged.
185+
*/
186+
public function test_normalize_answer_text_plain_unchanged() {
187+
$this->assertSame(
188+
'Just a plain answer.',
189+
FaqSearchService::normalize_answer_text( 'Just a plain answer.' )
190+
);
191+
}
192+
193+
/**
194+
* A standalone literal carriage return (no following n) becomes one real newline.
195+
*/
196+
public function test_normalize_answer_text_literal_lone_cr() {
197+
$this->assertSame(
198+
"line1\nline2",
199+
FaqSearchService::normalize_answer_text( 'line1\rline2' )
200+
);
201+
}
202+
203+
/**
204+
* A real newline and a literal escape sequence in the same string both
205+
* end up as real newlines, with the pre-existing real newline untouched.
206+
*/
207+
public function test_normalize_answer_text_mixed_real_and_literal_newline() {
208+
$this->assertSame(
209+
"line1\nline2\nline3",
210+
FaqSearchService::normalize_answer_text( "line1\nline2" . '\n' . 'line3' )
211+
);
212+
}
213+
214+
/**
215+
* Normalization is idempotent: running it on already-normalized output
216+
* produces the same result as a single pass.
217+
*/
218+
public function test_normalize_answer_text_idempotent() {
219+
$input = 'a\r\nb\tc' . "\n" . 'd';
220+
$once = FaqSearchService::normalize_answer_text( $input );
221+
$twice = FaqSearchService::normalize_answer_text( $once );
222+
$this->assertSame( $once, $twice );
223+
}
224+
133225
/**
134226
* The AI Overview mode defaults to conversation and honors option/filter.
135227
*/

0 commit comments

Comments
 (0)