From 6abaa64c3ebf408c51c9ee476644881acebdd892 Mon Sep 17 00:00:00 2001 From: kimsungmin1011 Date: Mon, 14 Sep 2026 03:28:53 +0900 Subject: [PATCH] fix(search): plan long queries and preserve source scope --- apps/api/app/routers/sessions.py | 19 +- apps/api/app/services/context.py | 84 ++++++- apps/api/app/services/tools/builtin.py | 21 +- apps/api/tests/test_search_query_planning.py | 235 +++++++++++++++++++ 4 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 apps/api/tests/test_search_query_planning.py diff --git a/apps/api/app/routers/sessions.py b/apps/api/app/routers/sessions.py index a3ea5845..78c85a1c 100644 --- a/apps/api/app/routers/sessions.py +++ b/apps/api/app/routers/sessions.py @@ -116,6 +116,7 @@ declines_web_search, requests_web_search, search_hints, + search_needs_planning, search_plan, search_query, weather_location, @@ -3063,12 +3064,13 @@ async def send_message( outbound_history[fresh_followup_index] if fresh_followup_index is not None else content ) - preset_call = ( - "web_search", { - "query": search_query(lookup_content, prefer_primary=fresh_fact), - **search_hints(lookup_content), - }, - ) + if not search_needs_planning(lookup_content): + preset_call = ( + "web_search", { + "query": search_query(lookup_content, prefer_primary=fresh_fact), + **search_hints(lookup_content), + }, + ) elif forced_tool == "weather" and "weather" in tool_names: place = weather_location(content) if place: @@ -3113,9 +3115,8 @@ async def send_message( protect_enrichment=policy.pii_masking or policy.external_data_guard, privacy_audit_id=privacy_audit_id, routing_audit_id=routing_audit_id, - # The toggle's search is the server's own first call (a named - # `tool_choice` is not reliably obeyed); a weather question whose - # place the words do not name is left to the model, forced. + # Short lookups are server presets. Long requests and weather + # without a named place use the allowed tool's forced planning hop. preset_call=preset_call, freshness_request=content if fresh_fact else None, force_tool=( diff --git a/apps/api/app/services/context.py b/apps/api/app/services/context.py index 80bd4368..4cacdfd8 100644 --- a/apps/api/app/services/context.py +++ b/apps/api/app/services/context.py @@ -17,7 +17,11 @@ from app.core.config import settings from app.models.chat import SessionKind -from app.services.freshness import FRESHNESS_INSTRUCTION, without_quoted_transform_sources +from app.services.freshness import ( + FRESHNESS_INSTRUCTION, + _quoted_spans, + without_quoted_transform_sources, +) # Models leak Chinese Hanja into Korean prose; parenthesised glosses are allowed. _KOREAN_ONLY = ( @@ -444,9 +448,22 @@ def asks_weather(request: str) -> bool: ) +SEARCH_QUERY_LIMIT = 120 + + +def search_needs_planning(request: str) -> bool: + """Do not cut a long request's subject, period or constraints into a preset.""" + return len(re.sub(r"\s+", " ", (request or "").strip())) > SEARCH_QUERY_LIMIT + + def search_query(request: str, *, prefer_primary: bool = False) -> str: """The user's sentence as a search query: request phrasing trimmed, capped.""" - text = re.sub(r"\s+", " ", _HINT_PHRASES.sub(" ", request or "").strip()) + try: + text, _site = search_site_scope(request or "") + except ValueError: + # Leave conflicting operators for the search tool to reject before lookup. + text = request or "" + text = re.sub(r"\s+", " ", _HINT_PHRASES.sub(" ", text).strip()) for _ in range(4): text = text.rstrip(" ??!.。~,") peeled = _FILLER.sub( @@ -456,13 +473,17 @@ def search_query(request: str, *, prefer_primary: bool = False) -> str: break text = peeled text = _DANGLING_UNIT.sub("", _DANGLING_PARTICLE.sub("", text.strip(" ??!.。~,"))) - query = (text or (request or "").strip())[:120] - if prefer_primary and not _HINT_SITE.search(request or "") and not _HINT_OFFICIAL.search(query): + query = (text or (request or "").strip())[:SEARCH_QUERY_LIMIT] + if ( + prefer_primary + and not _search_site_operators(request or "") + and not _HINT_OFFICIAL.search(query) + ): # Improve retrieval without assuming a country, authority domain or answer. # Mixing an English cue into a Korean query can select the wrong search lane. hint = "공식" if _HANGUL.search(request or "") else "official" if hint not in query: - query = query[:120 - len(hint) - 1].rstrip() + " " + hint + query = query[:SEARCH_QUERY_LIMIT - len(hint) - 1].rstrip() + " " + hint return query @@ -498,7 +519,7 @@ def is_small_talk(request: str) -> bool: #: back, in what language. Honoured on the server's own first search. _HINT_SITE = re.compile(r"\bsite:([A-Za-z0-9.-]+\.[A-Za-z]{2,})") _HINT_OFFICIAL = re.compile( - r"공식\s*(?:사이트|홈페이지|자료|발표|문서|출처|기준)|정부\s*(?:자료|발표|사이트)|" + r"공식\s*(?:사이트|홈페이지|자료|발표|문서|출처|기준|공지|공고)|정부\s*(?:자료|발표|사이트)|" r"기관\s*(?:자료|홈페이지)|공공기관|\bofficial\b", re.I, ) @@ -523,7 +544,7 @@ def is_small_talk(request: str) -> bool: _HINT_NEWS = re.compile(r"뉴스\s*(?:로|에서|기사|위주로)|기사\s*(?:로|에서|위주로)") #: The hint phrases, so `search_query` can leave them out of the query itself. _HINT_PHRASES = re.compile( - r"\bsite:[A-Za-z0-9.-]+|(?:공식|정부|기관)\s*(?:사이트|홈페이지|자료|발표|문서|출처)\s*" + r"(?:공식|정부|기관)\s*(?:사이트|홈페이지|자료|발표|문서|출처|공지|공고)\s*" r"(?:기준으로|기준|에서|으로|로|만)?|공공기관\s*(?:자료)?\s*(?:기준으로|에서|로)?|" r"(?:영어|영문|해외)\s*(?:자료|문서|기사)\s*(?:로|에서|위주로)?|" r"(?:뉴스|기사)\s*(?:위주로|로만)", @@ -531,13 +552,56 @@ def is_small_talk(request: str) -> bool: ) +def _search_site_operators(query: str) -> list[re.Match[str]]: + """Only standalone positive operators, not quoted text or excluded sites.""" + quoted = iter(_quoted_spans(query)) + span = next(quoted, None) + operators = [] + for match in _HINT_SITE.finditer(query): + while span and span[1] <= match.start(): + span = next(quoted, None) + if span and span[0] <= match.start() < span[1]: + continue + if match.start() and not query[match.start() - 1].isspace(): + continue + operators.append(match) + return operators + + +def search_site_scope(query: str, site: object = None) -> tuple[str, str]: + """Use the existing single-domain hint grammar for model-written operators too.""" + operators = _search_site_operators(query) + if any(query[match.end() : match.end() + 1] in {"/", ":"} for match in operators): + raise ValueError("search site must be a domain, not a path or port") + inline = {match[1].lower() for match in operators} + explicit = str(site or "").strip().lower().removeprefix("site:") + if explicit and not _HINT_SITE.fullmatch("site:" + explicit): + raise ValueError("invalid search site") + sites = inline | ({explicit} if explicit else set()) + if len(sites) > 1: + raise ValueError("multiple search sites require separate queries") + scoped = next(iter(sites), "") + if inline: + parts = [] + start = 0 + for match in operators: + parts.append(query[start : match.start()]) + start = match.end() + parts.append(query[start:]) + query = re.sub(r"\s+", " ", " ".join(parts)).strip() + if not query: + raise ValueError("search query needs a subject beside site") + return query, scoped + + def search_hints(request: str) -> dict[str, object]: """What the user's own words say about where and how to search.""" text = request or "" hints: dict[str, object] = {} - if match := _HINT_SITE.search(text): - hints["site"] = match.group(1).lower() - elif _HINT_OFFICIAL.search(text): + sites = {match[1].lower() for match in _search_site_operators(text)} + if len(sites) == 1: + hints["site"] = next(iter(sites)) + elif not sites and _HINT_OFFICIAL.search(text): hints["official"] = True for pattern, span in _HINT_RANGE: if pattern.search(text): diff --git a/apps/api/app/services/tools/builtin.py b/apps/api/app/services/tools/builtin.py index f5fd9374..716a6856 100644 --- a/apps/api/app/services/tools/builtin.py +++ b/apps/api/app/services/tools/builtin.py @@ -374,9 +374,11 @@ async def _searxng( hides it); a `site:go.kr` lane for `official`; and `site`, `time_range` and `language` hints on every lane. Lane hits that fit the question come first.""" + from app.services.context import search_site_scope + hints = hints or {} search_url = f"{base_url.rstrip('/')}/search" - site = str(hints.get("site") or "").strip().lstrip("site:") + query, site = search_site_scope(query, hints.get("site")) q = f"{query} site:{site}" if site else query base: dict[str, Any] = {"q": q, "format": "json", "safesearch": 2, "language": "ko-KR"} if hints.get("language") in _LANGUAGES: @@ -387,9 +389,14 @@ async def _searxng( lane = _LANES.get("news" if fresh and kind == "web" else kind) if lane: lane_params = {**base, **lane} + if hints.get("time_range") in _TIME_RANGES: + lane_params["time_range"] = hints["time_range"] if kind == "papers": # Titles are English; a Korean locale drags in unrelated Korean journals. - lane_params.update(q=_latin_only(query), language="en") + paper_query = _latin_only(query) + lane_params.update( + q=f"{paper_query} site:{site}" if site else paper_query, language="en" + ) lane_requests.append(("kind", lane_params)) if kind == "web" and base["language"] != "en" and not site: names, _ = _anchors(query) @@ -427,6 +434,16 @@ def collect(payload: dict[str, Any], *, lane: str = "", lane_query: str = "") -> if not isinstance(laned, BaseException) and laned.status_code < 400: collect(laned.json(), lane=tag, lane_query=str(params["q"])) collect(general.json()) + if site: + scoped_hits = [] + for hit in hits: + try: + host = urlsplit(hit["url"]).hostname or "" + except ValueError: + continue + if host == site or host.endswith("." + site): + scoped_hits.append(hit) + hits = scoped_hits terms = _terms(query) # A `site:` lane answers with whatever the domain has; a hit sharing no # word with the question is that, not an answer. diff --git a/apps/api/tests/test_search_query_planning.py b/apps/api/tests/test_search_query_planning.py new file mode 100644 index 00000000..602d12f0 --- /dev/null +++ b/apps/api/tests/test_search_query_planning.py @@ -0,0 +1,235 @@ +"""Long requests get query planning without changing search consent or source scope.""" + +import pytest +from test_freshness_runtime import _capture_normal_route, _events, _RouteDb +from test_privacy import _external_model, _patch_guard_dependencies, _request + +from app.models.chat import ChatSession +from app.models.user import User +from app.routers import sessions +from app.schemas.chat import SendMessage +from app.services import context +from app.services.tools import builtin +from app.services.tools.base import Tool + +LONG_REQUEST = ( + "지금 확인 가능한 원/달러 환율을 찾아줘. 1달러가 몇 원인지, 매매기준율인지 다른 " + "종류인지, 기준 시각과 출처를 함께 알려줘. 실시간 값이 아니면 가장 최근 확인값이라고 " + "구분해줘. 요청한 수치와 관계없는 예시는 추가하지 말고 확인된 범위만 설명해줘." +) + + +@pytest.mark.parametrize("words", ["한국장학재단 공식 공지", "공식 공고에서 일정 확인"]) +def test_official_notice_is_a_structured_search_hint(words): + assert context.search_hints(words).get("official") is True + + +async def _capture_route(monkeypatch, question, toggle, *, strict=False, supports_tools=True): + user = User(email="synthetic@example.test", password_hash="hash", name="Synthetic") + model = {**_external_model("synthetic/model"), "supportsTools": supports_tools} + if strict: + model.update(strictLocal=True, dataBoundary="self_hosted", creditCost=0) + session = ChatSession(user_id=user.id, model=model["id"]) + await _patch_guard_dependencies(monkeypatch, session=session, models=[model], blocks=[]) + captured = _capture_normal_route(monkeypatch) + builds = [] + + async def search(_args): + pytest.fail("Capturing the route must not execute a search") + + tool = Tool( + name="web_search", description="synthetic", parameters={"type": "object"}, + run=search, label="search", read_only=True, + ) + + async def build_tools(*_args, **kwargs): + builds.append(kwargs) + return [tool] if kwargs["web_search"] else [] + + monkeypatch.setattr(sessions, "build_tools", build_tools) + response = await sessions.send_message( + session.id, SendMessage(content=question, web_search=toggle), + _request(), user, _RouteDb(), + ) + await _events(response) + captured["build_calls"] = builds + return captured + + +@pytest.mark.asyncio +async def test_long_request_plans_search_in_the_forced_model_hop(monkeypatch): + captured = await _capture_route(monkeypatch, LONG_REQUEST, True) + assert captured["force_tool"] == "web_search" + assert captured["preset_call"] is None + assert any(message.get("content") == LONG_REQUEST for message in captured["messages"]) + + +@pytest.mark.asyncio +async def test_short_preset_preserves_supplied_period_and_domain(monkeypatch): + words = "최근 한 달 2026년 1학기 공지 site:catalog.example.test" + captured = await _capture_route(monkeypatch, words, True) + name, arguments = captured["preset_call"] + assert name == "web_search" + assert "2026년 1학기" in arguments["query"] + assert arguments["site"] == "catalog.example.test" + assert arguments["time_range"] == "month" + + +@pytest.mark.asyncio +async def test_long_request_does_not_turn_an_off_toggle_on(monkeypatch): + captured = await _capture_route(monkeypatch, LONG_REQUEST, False) + assert captured["force_tool"] is None + assert captured["preset_call"] is None + assert captured["tools"] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unavailable", ["strict", "no_tools"]) +async def test_long_planning_does_not_bypass_model_tool_boundaries(monkeypatch, unavailable): + captured = await _capture_route( + monkeypatch, LONG_REQUEST, True, + strict=unavailable == "strict", supports_tools=unavailable != "no_tools", + ) + assert captured["force_tool"] is None + assert captured["preset_call"] is None + assert captured["tools"] == [] + if unavailable == "strict": + assert captured["model"]["strictLocal"] is True + assert all(not args["web_search"] for args in captured["build_calls"]) + else: + assert captured["build_calls"] == [] + + +class _Response: + def raise_for_status(self): + return None + + status_code = 200 + + def json(self): + return {"results": [ + {"title": "Project release", "url": f"https://{host}/releases", "content": "release"} + for host in [ + "docs.example.test", "sub.docs.example.test", "docs.example.test.evil.test", + ] + ]} + + +class _Client: + calls = [] + + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def get(self, _url, *, params): + self.calls.append(params) + return _Response() + + +@pytest.mark.asyncio +async def test_inline_site_stays_structured_on_every_search_lane(monkeypatch): + monkeypatch.setattr(builtin.httpx, "AsyncClient", _Client) + _Client.calls = [] + hits = await builtin._searxng( + "https://search.example.test", "Project latest release site:docs.example.test", 5, + fresh=True, hints={"time_range": "week"}, + ) + assert len(_Client.calls) == 2 + assert all(call["q"].count("site:docs.example.test") == 1 for call in _Client.calls) + assert all(call["time_range"] == "week" for call in _Client.calls) + assert {hit["url"] for hit in hits} == { + "https://docs.example.test/releases", "https://sub.docs.example.test/releases", + } + + +@pytest.mark.asyncio +async def test_conflicting_inline_and_structured_sites_do_not_broaden(monkeypatch): + monkeypatch.setattr(builtin.httpx, "AsyncClient", _Client) + _Client.calls = [] + with pytest.raises(ValueError, match="site"): + await builtin._searxng( + "https://search.example.test", "release site:docs.example.test", 5, + hints={"site": "other.example.test"}, + ) + assert _Client.calls == [] + + +@pytest.mark.asyncio +async def test_multiple_inline_sites_are_not_silently_reduced_to_one(monkeypatch): + monkeypatch.setattr(builtin.httpx, "AsyncClient", _Client) + _Client.calls = [] + with pytest.raises(ValueError, match="site"): + await builtin._searxng( + "https://search.example.test", "release site:a.example.test OR site:b.example.test", 5, + ) + assert _Client.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("suffix", ["/2026/releases", ":8443"]) +async def test_unsupported_site_prefix_is_not_broadened_to_a_domain(monkeypatch, suffix): + monkeypatch.setattr(builtin.httpx, "AsyncClient", _Client) + _Client.calls = [] + query = f"release site:docs.example.test{suffix}" + assert context.search_query(query) == query + with pytest.raises(ValueError, match="site"): + await builtin._searxng("https://search.example.test", query, 5) + assert _Client.calls == [] + + +@pytest.mark.parametrize("query", [ + "Project release -site:docs.example.test", + '"release site:docs.example.test notes"', +]) +def test_excluded_or_quoted_site_is_not_a_positive_scope(query): + assert context.search_site_scope(query) == (query, "") + + +def test_structured_site_prefix_is_not_a_character_strip(): + assert context.search_site_scope("release", "tesla.example.test") == ( + "release", "tesla.example.test", + ) + + +def test_only_the_unquoted_positive_operator_becomes_scope(): + assert context.search_site_scope( + '"site:literal.example.test in a manual" release site:docs.example.test' + ) == ('"site:literal.example.test in a manual" release', "docs.example.test") + + +@pytest.mark.parametrize("query", [ + "Project release -site:docs.example.test", + '"release site:docs.example.test notes"', +]) +def test_short_query_and_hints_do_not_invert_excluded_or_quoted_sites(query): + assert "site" not in context.search_hints(query) + assert context.search_query(query) == query + + +def test_short_conflicting_sites_reach_the_tool_without_silent_reduction(): + query = "release site:a.example.test OR site:b.example.test" + assert "site" not in context.search_hints(query) + assert context.search_query(query) == query + with pytest.raises(ValueError, match="site"): + context.search_site_scope( + context.search_query(query), context.search_hints(query).get("site") + ) + + +@pytest.mark.asyncio +async def test_papers_lane_keeps_the_requested_site_and_period(monkeypatch): + monkeypatch.setattr(builtin.httpx, "AsyncClient", _Client) + _Client.calls = [] + await builtin._searxng( + "https://search.example.test", "Transformer attention site:docs.example.test", 5, + kind="papers", hints={"time_range": "year"}, + ) + assert len(_Client.calls) == 2 + assert all(call["q"].count("site:docs.example.test") == 1 for call in _Client.calls) + assert all(call["time_range"] == "year" for call in _Client.calls)