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