From 64d23c219daaebdb5a25778483fe7d7701df69a7 Mon Sep 17 00:00:00 2001 From: jmj Date: Sun, 14 Jun 2026 14:32:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=EA=BC=AC=EB=A6=AC=EC=A7=88=EB=AC=B8?= =?UTF-8?q?=EC=97=90=20'<'=20=ED=8F=AC=ED=95=A8=20=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=A4=91=20=EC=9E=98=EB=A6=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_question_span 의 '미완성 태그 꼬리 제거' 휴리스틱이 질문 본문의 부등호·제네릭('a < b', 'List')까지 잘라내, 스트리밍 중 질문이 '<' 지점에서 멈춰 잘린 채로 표시되던 문제(닫는 태그 도착 전까지 지속). - 마지막 '<' 꼬리가 또는 시작과 일치할 때만 제거하도록 보수화 → 본문의 '<' 는 보존 - 닫는 태그 누락 시 최종 저장값 잘림도 함께 방지 - 회귀 테스트: '<' 보존, partial 태그 제거, 청크 분할 스트리밍 델타 온전성 재현/검증: extract_question_span('…a < b 인지 확인하세요') 이전 'a' → 이후 'a < b 인지 확인하세요'. 전체 245 테스트 통과. Co-Authored-By: Claude Opus 4.8 --- .../chain/followup_generation_chain.py | 9 +++-- ai/tests/test_followup_parse.py | 33 +++++++++++++++++++ ai/tests/test_followup_streaming.py | 29 ++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py index ae191101..79bfdaf1 100644 --- a/ai/src/ai_server/chain/followup_generation_chain.py +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -39,11 +39,14 @@ def extract_question_span(text: str) -> str: return "" tail = text[open_idx + len("") :] tail = tail.split("", 1)[0] - # 닫는/메타 태그가 스트림 청크 경계로 잘려 들어와도(예: "질문?')까지 잘라내지 + # 않도록, 마지막 '<' 꼬리가 태그 시작과 일치할 때만 자른다. lt = tail.rfind("<") if lt != -1 and ">" not in tail[lt:]: - tail = tail[:lt] + partial = tail[lt:] + if "".startswith(partial) or "".startswith(partial): + tail = tail[:lt] return tail.strip() diff --git a/ai/tests/test_followup_parse.py b/ai/tests/test_followup_parse.py index e67b82e2..6c3bac96 100644 --- a/ai/tests/test_followup_parse.py +++ b/ai/tests/test_followup_parse.py @@ -29,3 +29,36 @@ def test_extract_question_only_helper(): extract_question_span("NORMAL안녕{}") == "안녕" ) + + +def test_streaming_keeps_lt_in_body(): + # 스트리밍 중(닫는 태그 전) 질문 본문의 부등호·제네릭 '<' 를 잘라내면 안 된다. + from ai_server.chain.followup_generation_chain import extract_question_span + + assert ( + extract_question_span("NORMALa < b 인지 확인하세요") + == "a < b 인지 확인하세요" + ) + assert ( + extract_question_span("NORMAL제네릭 List 를 설명하세요") + == "제네릭 List 를 설명하세요" + ) + + +def test_streaming_strips_partial_closing_or_meta_tag(): + # 청크 경계로 잘려 들어온 닫는/메타 태그 partial 만 제거한다. + from ai_server.chain.followup_generation_chain import extract_question_span + + assert extract_question_span("NORMAL질문입니다?NORMAL끝났나요?NORMAL" + "리스트에서 a < b 를 만족하는 쌍을 어떻게 셀까요?" + "{}" + ) + r = parse_followup_result(text) + assert r.followup_question == "리스트에서 a < b 를 만족하는 쌍을 어떻게 셀까요?" diff --git a/ai/tests/test_followup_streaming.py b/ai/tests/test_followup_streaming.py index ee5b977e..fc72c90b 100644 --- a/ai/tests/test_followup_streaming.py +++ b/ai/tests/test_followup_streaming.py @@ -48,6 +48,35 @@ async def test_stream_emits_question_tokens_and_final_result(): assert result.followup_question == "그 설계에서 트레이드오프는?" +@pytest.mark.asyncio +async def test_stream_emits_full_question_with_lt_operator(): + # 부등호 '<' 가 든 질문이 스트리밍 중 '<' 에서 잘리지 않고 끝까지 흘러야 한다. + pieces = [ + "NORMAL리스트에서 a ", + "< b 를 ", + "만족하는 쌍을 세는 법은?", + "{}", + ] + prompt = ChatPromptTemplate.from_messages([("human", "{answer_text}")]) + gen = StreamingFollowupGenerator(prompt=prompt, llm=_FakeStreamLLM(pieces)) + + seen = [] + result = await gen.stream( + on_question_token=lambda piece: seen.append(piece), + job_category="BACKEND", + mode="TECHNICAL", + previous_question="q", + answer_text="a", + context="(none)", + parent_category="X", + expected_signal="(none)", + history="(none)", + ) + joined = "".join(seen) + assert joined == "리스트에서 a < b 를 만족하는 쌍을 세는 법은?" + assert result.followup_question == "리스트에서 a < b 를 만족하는 쌍을 세는 법은?" + + @pytest.mark.asyncio async def test_stream_dont_know_emits_no_question_tokens(): pieces = ["DONT_KNOW모르면 이렇게{}"]