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모르면 이렇게{}"]