Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions apps/api/app/services/freshness.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,19 @@ 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(
r"^\s*(?:(?:라는|이라는)\s*문장(?:의|을)|[을를])\s*"
r"(?:(?:한국어|영어|한글|영문)(?:로)?\s*)?(?:번역|요약|문법)",
re.I,
)
_QUOTED_LOOKUP_AFTER = re.compile(
r"^(?:[은는이가을를]\s*)?(?:누구|누군|얼마|몇|언제|어디|뭔|무엇|"
r"맞는지|현재인지|최신인지|찾아|조회|확인|검색|알려|말해)",
)
_CLAUSE = re.compile(
r"[.!?;\n]|\b(?:and|but|also)\b|그리고|하지만|그런데|(?:와|과)\s+|"
r"(?:고\s+|(?<=고)\s*[,,]\s*)(?=현재|지금|실제|대한민국|한국|국무총리|대통령|총리)",
Expand Down Expand Up @@ -392,14 +396,17 @@ 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)
after_match = _TRANSFORM_AFTER.search(after)
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.lstrip())
parts.append(" " if transforming else text[quote_start:quote_end])
start = quote_end
parts.append(text[start:])
Expand Down Expand Up @@ -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*(?:규정|요건)|"
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -602,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)
Expand All @@ -617,6 +656,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)
Expand Down
95 changes: 95 additions & 0 deletions apps/api/tests/test_freshness_student_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Student task instructions are not requests to retrieve changing facts."""

import subprocess
import sys

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


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)
Loading