diff --git a/docker-compose.yml b/docker-compose.yml index 4cc7605..9907dbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,7 +71,7 @@ services: # serialised under load and pushed TTS to 7-8s; GPU does ~0.3s/sentence. MELO_DEVICE: ${MELO_DEVICE:-cuda} # Optional single-language lock for replies (empty = user's own language). - OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-} + OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-ko} # Drop the pre-loop planner LLM call to cut voice-reply latency on small # hardware (the planner adds a full model round-trip per turn). PLANNER_ENABLED: ${PLANNER_ENABLED:-0} diff --git a/src/jarvis/reply/engine.py b/src/jarvis/reply/engine.py index 9cc3db3..2ddfe99 100644 --- a/src/jarvis/reply/engine.py +++ b/src/jarvis/reply/engine.py @@ -825,6 +825,70 @@ def _build_enrichment_context_hint(cfg, recent_messages: list) -> Optional[str]: return "\n\n".join(parts) if parts else None +# Site tokens (proper nouns, not language patterns) → controlBrowser search site. +_SITE_TOKEN_MAP = { + "네이버": "naver", "naver": "naver", + "구글": "google", "google": "google", + "유튜브": "youtube", "유투브": "youtube", "youtube": "youtube", + "다음": "daum", "daum": "daum", + "빙": "bing", "bing": "bing", +} +# Search / open intent words (Korean deployment + English). Kept explicit because +# this is a DETERMINISTIC fast-path — the small chat model can't be trusted to +# emit the controlBrowser call reliably, so when the user names a site AND +# expresses a search/open intent we run it directly, no LLM judgement. +_SEARCH_INTENT_WORDS = ( + "검색해줘", "검색해", "검색", "찾아줘", "찾아봐", "찾아", "열어줘", "열어", + "들어가줘", "들어가", "띄워줘", "띄워", "보여줘", + "search for", "search", "look up", "find", "open", "go to", "navigate", +) + + +def _maybe_deterministic_site_search(text: str, db, cfg, language) -> Optional[str]: + """When broadcasting AND the user names a site AND asks to search/open it, + run controlBrowser.search directly so the result actually appears on screen. + + The 3B chat model reliably narrates ("검색하겠습니다") instead of emitting the + controlBrowser tool call, so site-specified search is executed + deterministically here rather than left to the model. Fail-open: any problem + returns None and the normal reply flow continues. + """ + try: + from . import turn_state + if not getattr(cfg, "stream_browser", True): + return None + if not turn_state.get_broadcasting(): + return None + low = (text or "").lower() + site = tok = None + for _t, _key in _SITE_TOKEN_MAP.items(): + if _t in low: + site, tok = _key, _t + break + if not site or not any(w in low for w in _SEARCH_INTENT_WORDS): + return None + import re + q = re.sub(re.escape(tok) + r"(에서|에다가|에다|에|로|를|을)?", " ", text, flags=re.IGNORECASE) + for w in sorted(_SEARCH_INTENT_WORDS, key=len, reverse=True): + q = re.sub(re.escape(w), " ", q, flags=re.IGNORECASE) + q = re.sub(r"\s+", " ", q).strip(" .,!?。") + if not q: + q = text + from ..tools.registry import run_tool_with_retries + res = run_tool_with_retries( + db=db, cfg=cfg, tool_name="controlBrowser", + tool_args={"action": "search", "site": site, "query": q}, + system_prompt="", original_prompt="", redacted_text=redact(text), + max_retries=1, language=language, + ) + if res and getattr(res, "success", False): + debug_log(f"deterministic site search executed: {site} '{q}'", "tools") + return res.reply_text or f"{site}에서 '{q}'를 검색해 화면에 띄웠습니다." + except Exception as e: # noqa: BLE001 + debug_log(f"deterministic site search failed (fail-open): {e}", "tools") + return None + + def run_reply_engine(db: "Database", cfg, tts: Optional[Any], text: str, dialogue_memory: "DialogueMemory", language: Optional[str] = None) -> Optional[str]: @@ -849,6 +913,13 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any], # Step 1: Redact sensitive information redacted = redact(text) + # Step 0.5: Deterministic on-screen site search. If the user named a site and + # asked to search/open it while broadcasting, do it directly — the small chat + # model otherwise just narrates without calling the browser tool. + _site_search_reply = _maybe_deterministic_site_search(text, db, cfg, language) + if _site_search_reply is not None: + return _site_search_reply + # Step 2: Check for recent dialogue context recent_messages = [] is_new_conversation = True