From dccf06bf6e2a2bacd370259494ebf75dcf625c9a Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 03:06:42 +0900 Subject: [PATCH 1/3] fix: distinguish student transformations from current fact lookups --- apps/api/app/services/freshness.py | 82 ++++++++++++++++++- .../api/tests/test_freshness_student_tasks.py | 78 ++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 apps/api/tests/test_freshness_student_tasks.py diff --git a/apps/api/app/services/freshness.py b/apps/api/app/services/freshness.py index a48c74ad..9214321f 100644 --- a/apps/api/app/services/freshness.py +++ b/apps/api/app/services/freshness.py @@ -283,8 +283,8 @@ def with_answer_policy( r"(?:\btranslate(?:\s+(?:this|the\s+following)(?:\s+(?:sentence|text))?)?" r"(?:\s+(?:into|to)\s+[a-z-]{2,30})?|" r"\bsummarize(?:\s+only)?(?:\s+this\s+supplied\s+text)?|" - r"다음\s*(?:문장|자료|글)(?:만|을|를)?\s*" - r"(?:(?:한국어|영어|한글|영문)로\s*)?(?:번역|요약)해\s*(?:줘|주세요))$", + r"(?:다음\s*(?:문장|자료|글)(?:만|을|를)?\s*)?" + r"(?:(?:한국어|영어|한글|영문)로\s*)?(?:번역|요약)(?:만\s*)?해\s*(?:줘|주세요))$", re.I, ) _TRANSFORM_AFTER = re.compile( @@ -292,6 +292,10 @@ def with_answer_policy( r"(?:(?:한국어|영어|한글|영문)(?:로)?\s*)?(?:번역|요약|문법)", re.I, ) +_QUOTED_LOOKUP_AFTER = re.compile( + r"^\s*[은는이가을를]?\s*(?:누구|누군|얼마|몇|언제|어디|뭔|무엇|" + r"맞는지|현재인지|최신인지|찾아|조회|확인|검색|알려|말해)", +) _CLAUSE = re.compile( r"[.!?;\n]|\b(?:and|but|also)\b|그리고|하지만|그런데|(?:와|과)\s+|" r"(?:고\s+|(?<=고)\s*[,,]\s*)(?=현재|지금|실제|대한민국|한국|국무총리|대통령|총리)", @@ -392,7 +396,9 @@ def without_quoted_transform_sources(text: str) -> str: start = 0 for quote_start, quote_end in _quoted_spans(text): before = text[max(0, quote_start - 160) : quote_start] - before = before.rstrip().removesuffix(":").rstrip() + before = before.rstrip() + if before.endswith((":", ".", "。", "!", "?", "?")): + before = before[:-1].rstrip() after = text[quote_end : quote_end + 160] parts.append(text[start:quote_start]) before_match = _TRANSFORM_BEFORE.search(before) @@ -400,6 +406,7 @@ def without_quoted_transform_sources(text: str) -> str: transforming = ( before_match and not _NEGATED_TRANSFORM_PREFIX.search(before[: before_match.start()]) ) or (after_match and not _NEGATED_TRANSFORM_SUFFIX.search(after[after_match.end() :])) + transforming = transforming and not _QUOTED_LOOKUP_AFTER.search(after) parts.append(" " if transforming else text[quote_start:quote_end]) start = quote_end parts.append(text[start:]) @@ -501,7 +508,7 @@ def abstention_response(request: str) -> str: r"가격|시세|환율|주가|주식\s*시장|금리|기준금리|물가|요금|수수료|" r"날씨|기온|강수|미세먼지|예보|" r"버전|릴리스|출시|업데이트|지원\s*종료|단종|" - r"일정|시간표|영업\s*시간|운영\s*시간|마감|접수|신청\s*기간|" + r"일정(?![하한히])|시간표|영업\s*시간|운영\s*시간|마감|접수|신청\s*기간|" r"순위|순위표|득점|경기\s*결과|우승(?:팀|자)?|대회\s*결과|선거\s*결과|" r"확진자|환자\s*수|인구|실업률|취업률|통계|" r"입국\s*(?:규정|조건|요건)|비자\s*(?:규정|요건)|" @@ -568,6 +575,36 @@ def abstention_response(request: str) -> str: r"(?:요약|정리|번역|추출)(?:해(?:줘|주세요|주)?|하(?:라|세요))$", ) _FACT_VERIFICATION_REQUEST = re.compile(r"확인|검증|검색|조회|비교|찾아") +_FACT_DRAFT_REQUEST = re.compile( + r"(?:메시지|메일|문자|공지|초안|안내문|회신)[^.!?\n]{0,40}(?:써|작성|다듬)|" + r"\b(?:write|draft|compose)\b[^.!?\n]{0,40}\b(?:email|message|notice)\b", re.I, +) +_FACT_SUPPLIED_ASSERTION = re.compile(r"(?:이야|예요|이에요|입니다|이다|이고|임|야)$") +_FACT_LEARNING_LABELS = re.compile( + r"(?:회귀\s*[/·]\s*분류|분류\s*[/·]\s*회귀)(?:로|으로)\s*(?:표시|구분|분류)|" + r"\bclassify\b[^.!?\n]{0,160}\b(?:regression\s+or\s+classification|" + r"classification\s+or\s+regression)\b", re.I, +) +_FACT_PREDICTION_REQUEST = re.compile( + r"(?:예측|추정|추산)(?:해|하|값)|\b(?:predict|estimate|forecast)\b", re.I, +) +_FACT_DOCUMENT_SOURCE = re.compile( + r"^(?:첨부(?:한|된)|업로드한|붙인)\s*|" + r"(?:자료|문서|본문|표)(?:에\s*있으면|\s*(?:근거|기준))", +) +_FACT_OUTSIDE_SOURCE = re.compile( + r"검색|검증|실시간|실제|" + r"(?:현재|지금|최신|오늘|내일|모레|올해|내년|이번|최근|요즘)" + r"[^.!?\n]{0,60}(?:확인|조회|찾아|알려|얼마|누구|몇)", +) +_FACT_SIMULATED_INPUT = re.compile(r"^(?:가상|가정한|예시)\s*[^.!?\n]*\d") +_FACT_TASK_PRIORITY = re.compile( + r"(?:할\s*일|과제|작업)[^.!?\n]{0,20}우선순위[^.!?\n]{0,20}(?:정하|정해|계획|제안)", +) +_FACT_NO_LIVE_CLAIM = re.compile( + r"[,\s]*(?:지금|현재|실시간)[^.!?\n]{0,50}(?:확인|조회|검색)한\s*것은?\s*" + r"아니라는\s*점을?[^.!?\n]*(?:밝혀|명시해|설명해|적어)(?:줘|주세요|라)?$", +) def current_fact_required(request: str, *, as_of: date | None = None) -> bool: @@ -580,6 +617,7 @@ def current_fact_required(request: str, *, as_of: date | None = None) -> bool: return False reference_year = (as_of or datetime.now(UTC).date()).year text = without_quoted_transform_sources(unicodedata.normalize("NFC", request)) + drafting = bool(_FACT_DRAFT_REQUEST.search(text)) # Remove a discarded topic before clause splitting can split its final "고". for switched in reversed(list(_DECLINED_TOPIC.finditer(text))): start = max(text.rfind(mark, 0, switched.start()) for mark in ".!?;\n") + 1 @@ -617,6 +655,42 @@ def current_fact_required(request: str, *, as_of: date | None = None) -> bool: supplied_text = False if not clause or _FACT_TRANSFORM_ONLY.search(clause): continue + clause = _FACT_NO_LIVE_CLAIM.sub( + lambda match: match[0] if ( + _AFFIRMATIVE_REQUEST.search(match[0]) or _IDENTITY.search(match[0]) + or re.search(r"얼마|몇|말하", match[0]) + ) else "", clause, + ).strip() + if not clause: + continue + if ( + _FACT_DOCUMENT_SOURCE.search(clause) + and not _FACT_OUTSIDE_SOURCE.search(clause) + ): + # Reading a supplied source does not assert that it is still current. + continue + if _FACT_TASK_PRIORITY.search(clause) and not _FACT_VALUE_ASK.search(clause): + continue + if ( + _FACT_SIMULATED_INPUT.search(clause) + and _FACT_SUPPLIED_ASSERTION.search(clause) + and not _FACT_VALUE_ASK.search(clause) + ): + continue + if ( + _FACT_LEARNING_LABELS.search(clause) + and not _FACT_VALUE_ASK.search(clause) + and not _FACT_PREDICTION_REQUEST.search(clause) + ): + # Naming a learning task's output type does not request its predicted value. + continue + if ( + drafting and _FACT_SUPPLIED_ASSERTION.search(clause) + and re.search(r"\d", clause) and not re.search(r"[??]", text) + and not _FACT_VALUE_ASK.search(clause) and not _IDENTITY.search(clause) + ): + # A supplied deadline is a drafting input; separate lookup clauses remain guarded. + continue if ( _FACT_CREATIVE.search(clause) and _FACT_CREATE_VERB.search(clause) diff --git a/apps/api/tests/test_freshness_student_tasks.py b/apps/api/tests/test_freshness_student_tasks.py new file mode 100644 index 00000000..3456bb3c --- /dev/null +++ b/apps/api/tests/test_freshness_student_tasks.py @@ -0,0 +1,78 @@ +"""Student task instructions are not requests to retrieve changing facts.""" + +import pytest + +from app.services.freshness import current_fact_required + +STUDENT_TASKS = [ + "회귀와 분류를 구별하는 연습 중이야. 내일 기온 예측, 이메일 스팸 여부 판단, " + "집값 예측을 각각 회귀/분류로 표시하고 이유를 한 구절씩 붙여줘.", + "교환학생 팀원에게 보낼 문장이야. 자연스러운 영어로 번역만 해줘. " + "'오늘 회의에서 정하지 못한 일정은 내일 오전까지 단체 채팅방에서 확정하겠습니다.'", + "팀원에게 자료 업로드를 부탁하는 메시지를 써줘. 마감은 오늘 20시이고 이유는 " + "내일 발표 리허설 준비야. 비난하는 말 없이 두 문장으로 부탁해.", + "A는 혼자 6시간, B는 혼자 3시간이면 같은 작업 하나를 끝내. 둘이 일정한 속도로 " + "처음부터 함께 일하면 몇 시간 걸려? 작업률 식도 한 줄 보여줘.", + "이번 주 할 일 우선순위를 정하는 걸 도와줘. 내일 제출할 보고서 1시간, 사흘 뒤 시험 " + "복습 3시간, 다음 주 동아리 포스터 2시간이야. 오늘 2시간을 어떻게 쓸지 이유와 함께 제안해줘.", + "첨부한 가상 강의계획서만 보고 평가 항목과 비중을 표로 정리해줘. " + "성적 이의신청 기간도 자료에 있으면 적고, 없으면 없다고 말해줘.", + "첨부한 공지 v1과 v2의 달라진 점만 비교해줘. " + "최종 제출 기준은 어느 버전을 따라야 하는지도 자료 근거로 알려줘.", + "실시간 조회가 아니라 제공값으로만 계산해줘. 가상 환율표의 기준일은 2026-09-01이고 " + "1달러=1,300원이야. 20달러는 이 표 기준 몇 원인지 계산하고, " + "지금 환율을 확인한 것은 아니라는 점을 짧게 밝혀줘.", +] + + +@pytest.mark.parametrize("question", STUDENT_TASKS) +def test_classification_translation_drafting_and_constant_rates_are_not_current_lookups(question): + assert not current_fact_required(question) + + +@pytest.mark.parametrize("question", [ + "내일 서울의 기온을 알려줘.", + "오늘 과제 제출 마감은 몇 시야?", + "한국의 현재 기준금리는 얼마야?", + "대통령의 현재 일정은 무엇이야?", + "두 버전의 출시일을 비교하고 최신 버전도 확인해줘.", + "팀원에게 공지를 쓰려고 해. 내일 실제 날씨를 확인해줘.", + "회귀 과제를 하고 있어. 오늘 실제 환율은 얼마야?", + "이 문장을 영어로 번역해줘. 그리고 현재 대한민국 대통령은 누구야?", + "첨부한 공지의 버전을 비교하고 현재 Python 최신 버전도 확인해줘.", + "첨부한 자료 기준 금리가 지금 실제 기준금리와 같은지 검증해줘.", + "자료에 없으면 검색해서 현재 장학금 신청 기간을 찾아줘.", + "가상 환율표로 계산해줘. 그리고 현재 실제 원/달러 환율도 알려줘.", + "이 표 기준 20달러가 몇 원인지 계산하고 지금 환율이 얼마인지 확인해줘.", + "오늘 K리그 순위를 알려줘.", + "첨부한 지난해 날씨표를 참고해서 내일 서울 기온을 알려줘.", + "첨부한 작년 출시표를 참고해서 올해 출시 일정을 알려줘.", + "첨부한 가격표 기준으로 오늘 환율을 반영한 달러 가격을 알려줘.", + "지금 환율을 알려주되 확인한 것은 아니라는 점을 밝혀줘.", + "지금 대통령이 누구인지 말하고 확인한 것은 아니라는 점을 밝혀줘.", + "현재 국가장학금 지원 우선순위를 알려줘.", + "현재 대학 기숙사 배정 우선순위를 알려줘.", +]) +def test_student_context_does_not_exempt_a_separate_current_fact_request(question): + assert current_fact_required(question) + + +@pytest.mark.parametrize("question", [ + '아래 질문에 답한 뒤 영어로 번역해줘. "현재 대한민국 대통령"은 누구야?', + '한국어로 요약해줘. "현재 한국 기준금리"가 몇 퍼센트인지 먼저 확인해.', + '영어로 번역해줘. "현재 원/달러 환율"을 찾아서 알려줘.', + "내일 기온 예측을 회귀/분류로 표시하고 기온을 직접 예측해줘.", + "Classify tomorrow temperature prediction as regression or classification, " + "then estimate Seoul temperature tomorrow.", +]) +def test_a_quoted_lookup_target_and_an_actual_prediction_keep_verification(question): + assert current_fact_required(question) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("question", STUDENT_TASKS) +async def test_student_task_router_does_not_attach_a_current_fact_hold(monkeypatch, question): + from test_plain_chat_tools import _routed_turn + + captured = await _routed_turn(monkeypatch, strict=False, question=question) + assert captured["freshness_request"] is None From 813bc2c2aef2616c434a911be85d78b5991d203a Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 03:09:27 +0900 Subject: [PATCH 2/3] test: retain current lookups beside supplied document tasks --- apps/api/app/services/freshness.py | 5 +++-- apps/api/tests/test_freshness_student_tasks.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/app/services/freshness.py b/apps/api/app/services/freshness.py index 9214321f..17f42f64 100644 --- a/apps/api/app/services/freshness.py +++ b/apps/api/app/services/freshness.py @@ -593,9 +593,9 @@ def abstention_response(request: str) -> str: r"(?:자료|문서|본문|표)(?:에\s*있으면|\s*(?:근거|기준))", ) _FACT_OUTSIDE_SOURCE = re.compile( - r"검색|검증|실시간|실제|" + r"검색|검증|실시간|실제|무시|참고하지|" r"(?:현재|지금|최신|오늘|내일|모레|올해|내년|이번|최근|요즘)" - r"[^.!?\n]{0,60}(?:확인|조회|찾아|알려|얼마|누구|몇)", + r"[^.!?\n]{0,60}(?:확인|조회|찾아|알려|얼마|누구|몇|정리|설명|예측|말해)", ) _FACT_SIMULATED_INPUT = re.compile(r"^(?:가상|가정한|예시)\s*[^.!?\n]*\d") _FACT_TASK_PRIORITY = re.compile( @@ -640,6 +640,7 @@ def current_fact_required(request: str, *, as_of: date | None = None) -> bool: _FACT_ATTACHED_SOURCE.search(clause) and _FACT_ATTACHED_TRANSFORM.search(clause) and not _FACT_VERIFICATION_REQUEST.search(clause) + and not _FACT_OUTSIDE_SOURCE.search(clause) ): continue switched = _DECLINED_TOPIC.search(clause) diff --git a/apps/api/tests/test_freshness_student_tasks.py b/apps/api/tests/test_freshness_student_tasks.py index 3456bb3c..f2056c12 100644 --- a/apps/api/tests/test_freshness_student_tasks.py +++ b/apps/api/tests/test_freshness_student_tasks.py @@ -52,6 +52,8 @@ def test_classification_translation_drafting_and_constant_rates_are_not_current_ "지금 대통령이 누구인지 말하고 확인한 것은 아니라는 점을 밝혀줘.", "현재 국가장학금 지원 우선순위를 알려줘.", "현재 대학 기숙사 배정 우선순위를 알려줘.", + "첨부한 작년 출시표를 무시하고 올해 출시 일정을 정리해줘.", + "첨부한 지난해 날씨표를 참고해서 내일 서울 기온을 예측해줘.", ]) def test_student_context_does_not_exempt_a_separate_current_fact_request(question): assert current_fact_required(question) From a5ba591a0a198d5d4f312253f9e4590b90680c98 Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 03:26:53 +0900 Subject: [PATCH 3/3] fix: remove overlapping whitespace scans in quoted lookup detection --- apps/api/app/services/freshness.py | 4 ++-- apps/api/tests/test_freshness_student_tasks.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/api/app/services/freshness.py b/apps/api/app/services/freshness.py index 17f42f64..ee95b5b1 100644 --- a/apps/api/app/services/freshness.py +++ b/apps/api/app/services/freshness.py @@ -293,7 +293,7 @@ def with_answer_policy( re.I, ) _QUOTED_LOOKUP_AFTER = re.compile( - r"^\s*[은는이가을를]?\s*(?:누구|누군|얼마|몇|언제|어디|뭔|무엇|" + r"^(?:[은는이가을를]\s*)?(?:누구|누군|얼마|몇|언제|어디|뭔|무엇|" r"맞는지|현재인지|최신인지|찾아|조회|확인|검색|알려|말해)", ) _CLAUSE = re.compile( @@ -406,7 +406,7 @@ def without_quoted_transform_sources(text: str) -> str: transforming = ( before_match and not _NEGATED_TRANSFORM_PREFIX.search(before[: before_match.start()]) ) or (after_match and not _NEGATED_TRANSFORM_SUFFIX.search(after[after_match.end() :])) - transforming = transforming and not _QUOTED_LOOKUP_AFTER.search(after) + transforming = transforming and not _QUOTED_LOOKUP_AFTER.search(after.lstrip()) parts.append(" " if transforming else text[quote_start:quote_end]) start = quote_end parts.append(text[start:]) diff --git a/apps/api/tests/test_freshness_student_tasks.py b/apps/api/tests/test_freshness_student_tasks.py index f2056c12..d9894be9 100644 --- a/apps/api/tests/test_freshness_student_tasks.py +++ b/apps/api/tests/test_freshness_student_tasks.py @@ -1,5 +1,8 @@ """Student task instructions are not requests to retrieve changing facts.""" +import subprocess +import sys + import pytest from app.services.freshness import current_fact_required @@ -78,3 +81,15 @@ async def test_student_task_router_does_not_attach_a_current_fact_hold(monkeypat captured = await _routed_turn(monkeypatch, strict=False, question=question) assert captured["freshness_request"] is None + + +def test_many_quoted_transformations_and_spaced_lookup_suffix_finish_promptly(): + script = ''' +from app.services.freshness import without_quoted_transform_sources +text = ('Translate "source"' + ' ' * 160 + 'x. ') * 1000 +result = without_quoted_transform_sources(text) +assert '"source"' not in result +request = '영어로 번역해줘. "현재 환율"' + ' ' * 40 + '을' + ' ' * 40 + '알려줘.' +assert '"현재 환율"' in without_quoted_transform_sources(request) +''' + subprocess.run([sys.executable, "-c", script], check=True, capture_output=True, timeout=5)