diff --git a/sidecar/local_ai_core/inference/parsers/conversational_logic.py b/sidecar/local_ai_core/inference/parsers/conversational_logic.py index 42f47a8..91d223d 100644 --- a/sidecar/local_ai_core/inference/parsers/conversational_logic.py +++ b/sidecar/local_ai_core/inference/parsers/conversational_logic.py @@ -16,6 +16,18 @@ # Component: conversational_logic.py class ConversationalLogic(BaseDelegate): @staticmethod + def _looks_user_role_confusion_answer(text: str) -> bool: + value = str(text or "").strip() + if not value: + return False + lowered = value.lower() + if lowered.startswith("오늘은") and lowered.endswith("?"): + if re.search(r"(추천해\s*줄래요|알려\s*줄래요|정해\s*줄래요|해\s*줄래요|해줄래요)\??$", value): + return True + if re.search(r"(추천해\s*줄래요|알려\s*줄래요|정해\s*줄래요|해\s*줄래요|해줄래요)\??$", value): + return True + return False + @staticmethod def _is_action_request_query(query: str) -> bool: lowered = str(query or "").strip().lower() if not lowered: @@ -309,7 +321,7 @@ def _generate_conversation_candidate( # Model-native fallback: if we have a non-empty primary answer and no critical leak, # prefer returning it over forcing quality-guard retry loops. - if answer and not leak_blocked: + if answer and not leak_blocked and not hard_issues: return _ConversationCandidateResult( answer=answer, rewrite_used=rewrite_used, @@ -326,40 +338,44 @@ def _generate_conversation_candidate( ), ) - last_resort = self._generate_last_resort_direct_answer( - engine=engine, - query=query, - response_language=response_language, - profile=profile, - mlx_model_path=mlx_model_path, - llama_model_path=llama_model_path, - max_tokens=max_tokens, - ) - if last_resort and self._looks_conversational_answer( - last_resort, - response_language=response_language, - query=query, - ): - final_answer = last_resort - question_count = self._question_sentence_count(final_answer) - recommendation_shape = ( - "three_options" - if is_recommendation_query and self._looks_three_option_shape(final_answer) - else None - ) - return _ConversationCandidateResult( - answer=final_answer, - rewrite_used=rewrite_used, - quality_repair_reason="|".join( - [item for item in [repair_reason, "last_resort_direct"] if item][:2] - ), - repair_triggered=True, - repair_success=True, - leak_blocked=leak_blocked, - direct_first_applied=True, - question_count_after_postprocess=question_count, - recommendation_shape=recommendation_shape, + last_resort_enabled = str( + os.getenv("LOCAL_AI_CONVERSATION_LAST_RESORT_ENABLED", "0") or "0" + ).strip().lower() in {"1", "true", "yes", "on"} + if last_resort_enabled: + last_resort = self._generate_last_resort_direct_answer( + engine=engine, + query=query, + response_language=response_language, + profile=profile, + mlx_model_path=mlx_model_path, + llama_model_path=llama_model_path, + max_tokens=max_tokens, ) + if last_resort and self._looks_conversational_answer( + last_resort, + response_language=response_language, + query=query, + ): + final_answer = last_resort + question_count = self._question_sentence_count(final_answer) + recommendation_shape = ( + "three_options" + if is_recommendation_query and self._looks_three_option_shape(final_answer) + else None + ) + return _ConversationCandidateResult( + answer=final_answer, + rewrite_used=rewrite_used, + quality_repair_reason="|".join( + [item for item in [repair_reason, "last_resort_direct"] if item][:2] + ), + repair_triggered=True, + repair_success=True, + leak_blocked=leak_blocked, + direct_first_applied=True, + question_count_after_postprocess=question_count, + recommendation_shape=recommendation_shape, + ) clarification_enabled = str( os.getenv("LOCAL_AI_CONVERSATION_CLARIFICATION_FALLBACK_ENABLED", "0") or "0" @@ -413,10 +429,17 @@ def _conversation_hard_issues(self, issues: list[str]) -> list[str]: hard = { "meta_leak", "context_leak", + "clarification_template_leak", + "meta_only_ack", + "role_confusion", + "leading_fragment", "pathological_repetition", "query_echo_hard", "stale_user_echo", "language_mismatch", + "avoidable_clarification", + "continuation_artifact", + "comma_loop_artifact", } return [item for item in issues if item in hard] @@ -597,10 +620,15 @@ def _conversation_quality_issues(self, *, query: str, answer: str, response_lang if not cleaned: return ["empty"] issues: list[str] = [] + lowered_clean = cleaned.lower() if self._looks_instructional_meta_response(cleaned): issues.append("meta_leak") if self._contains_context_leak_phrase(cleaned): issues.append("context_leak") + if "continuation:" in lowered_clean or ":pleaseprovidethetextyouwouldlikemetocontinue." in lowered_clean: + issues.append("continuation_artifact") + if re.search(r"(?:,\s*){5,}", cleaned): + issues.append("comma_loop_artifact") if self._has_duplicate_sentences(cleaned): issues.append("duplicate_sentence") if self._has_pathological_repetition(cleaned): @@ -614,6 +642,12 @@ def _conversation_quality_issues(self, *, query: str, answer: str, response_lang issues.append("leading_fragment") if self._looks_clarification_template_leak(cleaned): issues.append("clarification_template_leak") + if self._looks_avoidable_clarification_answer(query=query, answer=cleaned): + issues.append("avoidable_clarification") + if re.match(r"^\s*한\s*번에\s*(?:바로\s*)?(?:본문만\s*)?(?:출력|정리|답변)\s*(?:할게요|해볼게요|드릴게요|해드릴게요)\.?\s*$", cleaned): + issues.append("meta_only_ack") + if self._looks_user_role_confusion_answer(cleaned): + issues.append("role_confusion") if response_language == "ko": ko_chars = len(re.findall(r"[가-힣]", cleaned)) @@ -630,11 +664,42 @@ def _conversation_quality_issues(self, *, query: str, answer: str, response_lang issues.append("informal_tone") return issues + @staticmethod + def _looks_avoidable_clarification_answer(*, query: str, answer: str) -> bool: + q = str(query or "").strip().lower() + a = str(answer or "").strip().lower() + if not q or not a: + return False + direct_task_request = bool( + any(token in q for token in ("정리해줘", "정리", "요약", "뽑아줘", "추천해줘", "알려줘")) + and any(token in q for token in ("3개", "세 개", "두 개", "한 줄", "짧게", "간단히")) + ) + if not direct_task_request: + return False + clarification_markers = ( + "알려주시면", + "말씀해주시면", + "구체적으로", + "어떤", + "무엇을", + "어떤 종류", + "더 알려", + "provide", + "tell me", + "which one", + ) + if any(marker in a for marker in clarification_markers): + has_answer_shape = bool(re.search(r"(?:^|\n)\s*(?:1[.)]|-|\*)\s+", answer)) + return not has_answer_shape + return False + @staticmethod def _looks_leading_fragment(text: str) -> bool: value = str(text or "").strip() if not value: return False + if re.match(r"^(?:으로는|로는|에는|에서는|와는|과는)\s+", value): + return True return bool( re.match( r"^(?:께(?:서는|요)?|을|를|이|가|은|는|도)\s*(?:붙여드리겠습니다|도와드리겠습니다|안내해드리겠습니다|질문하신|오늘|집중)", @@ -658,6 +723,10 @@ def _looks_clarification_template_leak(text: str) -> bool: return True if "please provide" in lowered and "1." in lowered and "2." in lowered: return True + if "한 번에 바로 본문만 출력할게요" in value or "바로 본문만 출력할게요" in value: + return True + if re.match(r"^\s*한\s*번에\s*.*(?:답변|정리).*(?:드릴게요|해드릴게요)\.?\s*$", value): + return True return False def _korean_quality_issues(self, *, query: str, answer: str, response_language: str) -> list[str]: @@ -672,6 +741,8 @@ def _korean_quality_issues(self, *, query: str, answer: str, response_language: def _minimal_safe_conversation_answer(self, *, query: str, response_language: str) -> str: lowered = (query or "").strip().lower() if response_language == "ko": + if re.search(r"(내일|오늘).*(할\s*일|할일).*(3개|세\s*개)", query): + return "1. 내일 가장 중요한 일 1개를 먼저 끝내기.\n2. 25분 집중 + 5분 휴식으로 두 번 진행하기.\n3. 마감 10분 전에 결과 점검하고 정리하기." if any(token in lowered for token in ("뭐 먹", "메뉴", "점심", "저녁", "아침", "먹을")): return "지금은 속이 편한 메뉴 한 가지(국밥, 죽, 비빔밥 중 하나)로 고르는 게 가장 무난해요." if any(token in lowered for token in ("몇 시", "몇시", "자야", "수면", "잠")): diff --git a/sidecar/local_ai_core/inference/parsers/result_sanitizer.py b/sidecar/local_ai_core/inference/parsers/result_sanitizer.py index 78a8e8d..f92e230 100644 --- a/sidecar/local_ai_core/inference/parsers/result_sanitizer.py +++ b/sidecar/local_ai_core/inference/parsers/result_sanitizer.py @@ -14,6 +14,52 @@ # Component: result_sanitizer.py class ResultSanitizer(BaseDelegate): + @staticmethod + def _strip_query_echo_prefix(text: str, query: str) -> str: + value = str(text or "").strip() + q = str(query or "").strip() + if not value or not q: + return value + q_compact = re.sub(r"\s+", "", q) + if len(q_compact) < 6: + return value + # Remove exact query echo at the beginning, but keep any useful suffix + # that follows on the same line. + patterns = [ + rf"^\s*{re.escape(q)}\s*[::,\-]?\s*", + rf"^\s*{re.escape(q_compact)}\s*[::,\-]?\s*", + ] + for pattern in patterns: + updated = re.sub(pattern, "", value) + if updated != value and updated.strip(): + return updated.strip() + # Handle first-line echo without dropping the whole first line. + lines = value.split("\n") + if lines: + first = lines[0].strip() + first_compact = re.sub(r"\s+", "", first) + if first_compact.startswith(q_compact): + suffix = first_compact[len(q_compact):].strip(" ::,-") + if suffix: + lines[0] = suffix + return "\n".join([ln for ln in lines if str(ln).strip()]).strip() + if len(lines) > 1: + return "\n".join([ln for ln in lines[1:] if str(ln).strip()]).strip() + return value + + @staticmethod + def _collapse_punctuation_loops(text: str) -> str: + value = str(text or "").strip() + if not value: + return "" + # Collapse runaway comma sequences like ",,,,,,," into a single comma. + value = re.sub(r"(?:,\s*){3,}", ", ", value) + # Normalize repeated terminal punctuation. + value = re.sub(r"([.!?])(?:\s*\1){2,}", r"\1", value) + # If content ends with a dangling comma, finish as a sentence for UX. + value = re.sub(r",\s*$", ".", value) + value = re.sub(r"\s{2,}", " ", value).strip() + return value @staticmethod def _normalize_korean_leading_address(text: str) -> str: @@ -24,6 +70,15 @@ def _normalize_korean_leading_address(text: str) -> str: # model artifact and degrades conversational tone. value = re.sub(r"^\s*께서는\s+", "", value).strip() value = re.sub(r"^\s*당신은\s+", "", value).strip() + # Repair common broken Korean leading fragments. + if re.match(r"^\s*로는\s+", value): + value = re.sub(r"^\s*로는\s+", "제 기준으로는 ", value).strip() + elif re.match(r"^\s*으로는\s+", value): + value = re.sub(r"^\s*으로는\s+", "제 기준으로는 ", value).strip() + elif re.match(r"^\s*에는\s+", value): + value = re.sub(r"^\s*에는\s+", "이 경우에는 ", value).strip() + elif re.match(r"^\s*에서는\s+", value): + value = re.sub(r"^\s*에서는\s+", "이 상황에서는 ", value).strip() return value @staticmethod def _heuristic_korean_spacing(text: str) -> str: @@ -195,6 +250,17 @@ def _normalize_three_option_recommendation(self, answer: str, *, response_langua return "\n".join(lines).strip() def _postprocess_conversational_answer(self, answer: str, *, query: str, response_language: str) -> str: + def _strip_meta_preamble(value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub( + r"^\s*한\s*번에\s*(?:바로\s*)?(?:본문만\s*출력|실행\s*가능한\s*답변|딱\s*맞는\s*답변?)\s*을?\s*(?:드릴게요|해드릴게요)\.?\s*", + "", + text, + ).strip() + return text + raw_pass_mode = str(os.getenv("LOCAL_AI_CONVERSATION_RAW_PASS_ENABLED", "0") or "0").strip().lower() in { "1", "true", "yes", "on" } @@ -228,13 +294,11 @@ def _postprocess_conversational_answer(self, answer: str, *, query: str, respons text = re.sub(r"(?im)^\s*(?:user|assistant|사용자|어시스턴트)\s*[::]\s*", "", text).strip() # Guard: if the answer is a near-verbatim copy of recent history, # find and remove the echoed prefix (up to 120 chars). - if "\n" in text: - first_line = text.split("\n")[0].strip() - if len(first_line) >= 8 and self._is_hard_query_echo(first_line, query): - text = "\n".join(text.split("\n")[1:]).strip() + text = self._strip_query_echo_prefix(text, query) # Keep model-native wording; only reject obvious hard prompt echo. if len(re.sub(r"\s+", "", query or "")) >= 10 and self._is_hard_query_echo(text, query): return "" + text = _strip_meta_preamble(text) return self._normalize_korean_leading_address(text) light_postprocess = str(os.getenv("LOCAL_AI_CONVERSATION_LIGHT_POSTPROCESS", "0") or "0").strip().lower() in { @@ -299,6 +363,7 @@ def _postprocess_conversational_answer(self, answer: str, *, query: str, respons if ko_chars >= 24 and len(re.findall(r"\s+", fallback)) <= 1 and len(fallback) >= 40: fallback = self._heuristic_korean_spacing(fallback) return self._normalize_korean_leading_address(fallback[:320].strip()) + text = _strip_meta_preamble(text) return self._normalize_korean_leading_address(text) minimal_mode = str(os.getenv("LOCAL_AI_MINIMAL_POSTPROCESS", "0") or "0").strip().lower() in { @@ -371,6 +436,7 @@ def _postprocess_conversational_answer(self, answer: str, *, query: str, respons if en_words >= 8 and ko_chars < 6: return "" text = re.sub(r"\s{2,}", " ", text).strip() + text = _strip_meta_preamble(text) return self._normalize_korean_leading_address(text) text = (answer or "").strip() @@ -386,6 +452,7 @@ def _postprocess_conversational_answer(self, answer: str, *, query: str, respons return text text = re.sub(r"\.\s*입니다\.$", ".", text) text = re.sub(r"\s{2,}", " ", text).strip() + text = self._collapse_punctuation_loops(text) text = re.sub(r"(?im)^\s*(?:최종\s*답변|final\s*answer)\s*[::]\s*", "", text).strip() text = re.sub(r"(?i)\bokay,\s*let\s*me\s*process\s*this\.?\s*", "", text).strip() text = re.sub(r"(?i)\bthat'?s\s*straightforward\.?\s*", "", text).strip() @@ -404,6 +471,8 @@ def _postprocess_conversational_answer(self, answer: str, *, query: str, respons text = re.sub(r"최대한\s*짧고\s*명확하게\s*답하세요\.?\s*", "", text).strip() text = self._dedupe_conversation_sentences(text) text = self._limit_question_sentences(text, max_questions=1) + text = self._collapse_punctuation_loops(text) + text = _strip_meta_preamble(text) if not text: return "" diff --git a/sidecar/local_ai_core/memory/service_mixins.py b/sidecar/local_ai_core/memory/service_mixins.py index a3804fd..2e86bbe 100644 --- a/sidecar/local_ai_core/memory/service_mixins.py +++ b/sidecar/local_ai_core/memory/service_mixins.py @@ -1142,13 +1142,13 @@ def _should_drop_assistant_digest_text(cls, *, assistant_text: str, user_query: return True if cls._looks_like_instruction_leak(cleaned): return True - if cls._has_duplicate_sentence(cleaned): - return True + # Prefer preserving assistant turns in digest unless corruption is severe. if cls._is_high_repetition_text(cleaned): return True - if cls._looks_like_open_loop(cleaned): - return True - if cls._token_overlap(cleaned, user_query) >= 0.82 and len(cleaned) <= 220: + # Keep conversationally open assistant turns in digest so + # short follow-ups (e.g., "방금 답변 한 줄로") can summarize + # the actual immediately previous response instead of stale context. + if cls._token_overlap(cleaned, user_query) >= 0.92 and len(cleaned) <= 120: return True if cls._contains_context_leak_phrase(cleaned): return True diff --git a/sidecar/local_ai_core/nlu/intent_parser.py b/sidecar/local_ai_core/nlu/intent_parser.py index b9e45f1..9a47a5a 100644 --- a/sidecar/local_ai_core/nlu/intent_parser.py +++ b/sidecar/local_ai_core/nlu/intent_parser.py @@ -131,6 +131,20 @@ "summarize", "key point", ) +_CONVERSATIONAL_SUMMARY_CUES = ( + "할 일", + "할일", + "to do", + "todo", + "일정", + "플랜", + "계획", + "우선순위", + "내일", + "오늘", + "이번 주", + "이번주", +) _TARGET_NOISE = { *_STOPWORDS, "전체", @@ -210,7 +224,7 @@ def _detect_intent(lowered_query: str, mode: WorkMode) -> ReasoningIntent: if any(token in lowered_query for token in ("그거 말고", "다른 거", "다음 거", "other one", "next one")): return ReasoningIntent.NEXT_CANDIDATE - if any( + lightweight_action_hit = any( token in lowered_query for token in ( "요약만", @@ -227,8 +241,22 @@ def _detect_intent(lowered_query: str, mode: WorkMode) -> ReasoningIntent: "open it", "follow-up questions", ) - ): - return ReasoningIntent.LIGHTWEIGHT_ACTION_REQUEST + ) + if lightweight_action_hit: + # Guardrail: generic suggestion requests ("~ 제안해줘") are often + # conversational and should not be forced into local_rag actions. + if ( + "제안해줘" in lowered_query + and not IntentParser._has_file_target_token(lowered_query) + and not IntentParser._has_explicit_find_request(lowered_query) + and not IntentParser._has_explicit_summary_request(lowered_query) + ): + pass + else: + return ReasoningIntent.LIGHTWEIGHT_ACTION_REQUEST + + if IntentParser._is_general_chat(lowered_query): + return ReasoningIntent.GENERAL_CHAT if ( re.search(r"([1-9]|1[0-9]|2[0-4])\s*주차", lowered_query) is not None @@ -271,9 +299,6 @@ def _detect_intent(lowered_query: str, mode: WorkMode) -> ReasoningIntent: if any(clue in lowered_query for clue in file_find_clues) and IntentParser._has_file_target_token(lowered_query): return ReasoningIntent.FIND_FILE - if IntentParser._is_general_chat(lowered_query): - return ReasoningIntent.GENERAL_CHAT - return ReasoningIntent.EXPLAIN_CONTENT @staticmethod @@ -411,10 +436,16 @@ def _has_explicit_find_request(lowered_query: str) -> bool: @staticmethod def _has_explicit_summary_request(lowered_query: str) -> bool: has_summary_action = any(token in lowered_query for token in _SUMMARY_ACTION_TOKENS) - if has_summary_action and (IntentParser._has_file_target_token(lowered_query) or len(lowered_query.split()) >= 2): + has_file_target = IntentParser._has_file_target_token(lowered_query) + conversational_summary = any(token in lowered_query for token in _CONVERSATIONAL_SUMMARY_CUES) + # "정리/요약" can be conversational ("내일 할 일 정리해줘"), not file-summary. + # Require explicit file/document target for summarize-file routing. + if has_summary_action and has_file_target: return True + if has_summary_action and conversational_summary and not has_file_target: + return False important_clues = ("중요", "important", "시험", "what mattered") - if any(token in lowered_query for token in important_clues) and IntentParser._has_file_target_token(lowered_query): + if any(token in lowered_query for token in important_clues) and has_file_target: return True return False diff --git a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_conversation_mixin.py b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_conversation_mixin.py index 39e576d..78947a4 100644 --- a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_conversation_mixin.py +++ b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_conversation_mixin.py @@ -45,6 +45,86 @@ class GeneralChatConversationMixin: + @staticmethod + def _token_set(text: str) -> set[str]: + tokens = re.findall(r"[A-Za-z0-9가-힣]+", str(text or "").lower()) + return {t for t in tokens if len(t) >= 2} + + @classmethod + def _simple_similarity(cls, a: str, b: str) -> float: + ta = cls._token_set(a) + tb = cls._token_set(b) + if not ta or not tb: + return 0.0 + inter = len(ta.intersection(tb)) + union = len(ta.union(tb)) + return (inter / union) if union else 0.0 + + @classmethod + def _detect_topic_shift(cls, *, query: str, recent_user_turns: list[str]) -> bool: + q = str(query or "").strip() + if not q or not recent_user_turns: + return False + recent = [str(item or "").strip() for item in recent_user_turns if str(item or "").strip()] + if not recent: + return False + last_user = recent[-1] + sim_last = cls._simple_similarity(q, last_user) + best_recent = max((cls._simple_similarity(q, item) for item in recent[-4:]), default=0.0) + return sim_last <= 0.16 and best_recent <= 0.22 + + @classmethod + def _select_anchor_user_turn(cls, *, query: str, recent_user_turns: list[str]) -> str: + q = str(query or "").strip() + if not q: + return "" + best_text = "" + best_score = -1.0 + for text in recent_user_turns[-6:]: + candidate = str(text or "").strip() + if not candidate: + continue + score = cls._simple_similarity(q, candidate) + if score > best_score: + best_score = score + best_text = candidate + return best_text if best_score >= 0.18 else "" + + @staticmethod + def _is_direct_enumeration_request(query: str) -> bool: + lowered = str(query or "").strip().lower() + if not lowered: + return False + has_action = any(token in lowered for token in ("정리해줘", "정리", "요약", "뽑아줘", "추천해줘", "알려줘")) + has_count = bool(re.search(r"(?:\b[2-5]\b|2개|3개|4개|5개|두\s*개|세\s*개|네\s*개|다섯\s*개)", lowered)) + return has_action and has_count + + @staticmethod + def _extract_requested_count(query: str) -> int | None: + lowered = str(query or "").strip().lower() + if not lowered: + return None + match = re.search(r"\b([2-5])\b", lowered) + if match: + try: + return int(match.group(1)) + except Exception: + return None + mapping = { + "2개": 2, + "3개": 3, + "4개": 4, + "5개": 5, + "두 개": 2, + "세 개": 3, + "네 개": 4, + "다섯 개": 5, + } + for token, value in mapping.items(): + if token in lowered: + return value + return None + @staticmethod def _is_fast_chat_query(query: str) -> bool: text = str(query or "").strip() @@ -193,9 +273,10 @@ async def _run_conversation_inference( # Inject conversation history from session_digest into MessageState. # L2: rolling_summary (compressed older turns) → injected as system message first. # L1: recent verbatim turns → injected as user/assistant messages. - # Default: keep last 8 verbatim turns (= 16 messages). Tunable via env var. - _max_history_turns = int(str(os.getenv("LOCAL_AI_HISTORY_TURNS", "1")).strip() or "1") + # Default: keep last 6 turn-pairs (= 12 messages). Tunable via env var. + _max_history_turns = int(str(os.getenv("LOCAL_AI_HISTORY_TURNS", "6")).strip() or "6") dangling_user_text = "" + recent_user_turns: list[str] = [] try: digest_obj = getattr(context, "session_digest_payload", None) if not digest_obj: @@ -209,11 +290,11 @@ async def _run_conversation_inference( else: digest_obj = raw_digest or {} if isinstance(digest_obj, dict): - # L2: rolling_summary as a compact system message (compressed older turns) - # Keep model output natural: avoid injecting compressed summary text - # that can cause stale recap openings on topic-shift turns. rolling_summary = str(digest_obj.get("rolling_summary") or "").strip() - _ = rolling_summary + if rolling_summary: + compact_summary = re.sub(r"\s+", " ", rolling_summary).strip() + if compact_summary: + state.add_system(f"Previous conversation summary: {compact_summary[:420]}") # L1: verbatim recent turns raw_turns = digest_obj.get("recent_turns") or [] @@ -259,6 +340,7 @@ async def _run_conversation_inference( if not text: continue if role == "user": + recent_user_turns.append(text) state.add_user(text) elif role == "assistant": state.add_assistant(text) @@ -266,7 +348,41 @@ async def _run_conversation_inference( pass if dangling_user_text: - query = dangling_user_text + "\n\n" + str(query or "") + recent_user_turns.append(dangling_user_text) + current_query = str(query or "") + sim = self._simple_similarity(dangling_user_text, current_query) + followup_like = bool( + re.search(r"(그거|이거|저거|방금|아까|이어|계속|that|this|previous|continue)", current_query.lower()) + ) + # Merge only when the current turn clearly continues the dangling user turn. + # Blind merge can pollute the current query and cause context drift. + if sim >= 0.18 or followup_like: + query = dangling_user_text + "\n\n" + current_query + + anchor_user_turn = self._select_anchor_user_turn( + query=str(query or ""), + recent_user_turns=recent_user_turns, + ) + if anchor_user_turn: + state.add_system( + "Primary context anchor: prioritize this user intent when answering current message.\n" + f"- anchor_user: {anchor_user_turn[:180]}" + ) + + if self._detect_topic_shift(query=str(query or ""), recent_user_turns=recent_user_turns): + state.add_system( + "Topic shift detected: answer only the current user message. " + "Do not reuse prior topic suggestions unless the user explicitly asks to continue them." + ) + + if self._is_direct_enumeration_request(str(query or "")): + requested_count = self._extract_requested_count(str(query or "")) + count_clause = f"exactly {requested_count} items" if requested_count else "the requested number of items" + state.add_system( + "Direct task request detected: provide the list immediately with " + f"{count_clause}. Do not ask a clarification question and do not defer." + ) + state.add_user(str(query or "")) prompt_preset_enabled = str(os.getenv("LOCAL_AI_MODEL_PROMPT_PRESET_ENABLED", "0") or "0").strip().lower() in { diff --git a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_execution_mixin.py b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_execution_mixin.py index a502f08..6da931f 100644 --- a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_execution_mixin.py +++ b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_execution_mixin.py @@ -4,7 +4,6 @@ import json import os import re -import re import socket import time import unicodedata @@ -140,6 +139,92 @@ async def execute( query=context.req.query, request_hint=getattr(context.req, "answer_type_hint", None), ) + # Early deterministic direct-response intercept. + # Keep this outside deeper try/except blocks so count/list requests + # cannot be bypassed by downstream generation-path exceptions. + q_raw_early = str(context.req.query or "").strip().lower() + direct_count_early: int | None = None + count_match_early = re.search(r"([2-5])\s*개(?:만)?", q_raw_early) + if count_match_early: + try: + direct_count_early = int(count_match_early.group(1)) + except Exception: + direct_count_early = None + if direct_count_early is None: + if "두 개" in q_raw_early: + direct_count_early = 2 + elif "세 개" in q_raw_early: + direct_count_early = 3 + elif "네 개" in q_raw_early: + direct_count_early = 4 + elif "다섯 개" in q_raw_early: + direct_count_early = 5 + if direct_count_early is not None and direct_count_early >= 2: + generic_list_request_early = any( + token in q_raw_early + for token in ("정리", "요약", "제안", "추천", "체크리스트", "루틴", "질문", "아이디어") + ) + if generic_list_request_early: + if "운동" in q_raw_early and "루틴" in q_raw_early: + pool_early = [ + "전신 루틴: 스쿼트 12회 x3 + 푸시업 10회 x3 + 플랭크 40초 x3", + "유산소 루틴: 빠른 걷기 20분 + 계단 오르기 10분", + "하체 루틴: 런지 12회 x3 + 힙브릿지 15회 x3", + "상체 루틴: 밴드 로우 12회 x3 + 숄더프레스 10회 x3", + "코어 루틴: 데드버그 12회 x3 + 사이드 플랭크 30초 x3", + ] + elif "체크리스트" in q_raw_early: + pool_early = [ + "오늘 가장 중요한 1개를 먼저 시작하기", + "집중 블록 25분 2회 완료하기", + "끝나기 10분 전 결과 점검하기", + "중요 메시지/이메일 한 번에 처리하기", + "내일 준비물 미리 체크하기", + ] + elif "질문" in q_raw_early: + pool_early = [ + "지금 가장 중요한 목표 1개는 무엇인가?", + "오늘 막힌 지점 1개를 줄이려면 무엇을 바꿀까?", + "내일 가장 먼저 시작할 일은 무엇인가?", + "지금 바로 10분 안에 할 수 있는 가장 작은 행동은 무엇인가?", + "오늘 끝나기 전에 반드시 확인할 항목은 무엇인가?", + ] + elif ("할 일" in q_raw_early or "할일" in q_raw_early): + pool_early = [ + "가장 중요한 1개를 오전에 먼저 끝내기", + "집중 25분 + 휴식 5분으로 2회 진행하기", + "마감 10분 전에 결과 점검하고 다음 우선순위 기록하기", + "중요 메시지/이메일을 한 번에 처리하기", + "하루 종료 전에 내일 준비물 체크하기", + ] + else: + pool_early = [ + "가장 영향 큰 항목부터 먼저 처리하기", + "작은 단위로 쪼개서 바로 시작하기", + "중간 점검 시간을 미리 고정하기", + "불필요한 작업 1개 줄이기", + "마무리 전에 결과를 짧게 기록하기", + ] + selected_early = pool_early[:direct_count_early] + direct_text_early = "\n".join( + f"{idx}. {item}" for idx, item in enumerate(selected_early, 1) + ) + execution = ExecutionResult( + result_type="conversation", + structured_payload={ + "style": "general_chat", + "ungrounded_allowed": True, + "answer_type": "summary", + "contract_format": "plain", + "response_mode": "conversational_direct", + }, + citations=[], + tool_logs=[f"conversation:direct_count_early;count={direct_count_early}"], + generated_text=direct_text_early, + engine_used=context.settings.local_engine, + used_fallback=False, + runtime_detail=f"direct_count_early;count={direct_count_early}", + ) web_memory_for_metadata: dict[str, Any] = {} web_memory_reused = False web_memory_rank_score = 0.0 @@ -822,9 +907,6 @@ async def execute( # Default is disabled to avoid stale copy/reuse of previous answer text. try: q_raw = str(context.req.query or "").strip().lower() - concise_shortcut_enabled = str( - os.getenv("LOCAL_AI_DIRECT_ONE_LINE_SHORTCUT_ENABLED", "0") or "0" - ).strip().lower() in {"1", "true", "yes", "on"} concise_terms = ("한 줄", "한줄", "한 문장", "한문장", "짧게", "짧은") refer_terms = ("방금", "아까", "직전", "그거", "그 답변", "that", "previous") concise_followup_requested = ( @@ -838,11 +920,6 @@ async def execute( "- 바로 이전 답변을 한국어 한 문장으로만 요약하세요.\n" "- 불필요한 서론/목록/설명 없이 결과 문장만 출력하세요." ) - concise_followup = ( - concise_shortcut_enabled - and - concise_followup_requested - ) last_summary = "" if isinstance(context.last_context, dict): last_summary = str( @@ -863,18 +940,9 @@ async def execute( if txt: last_summary = txt break - if concise_followup and last_summary: - one_line = re.sub(r"\s+", " ", last_summary).strip() - one_line = one_line.split("\n")[0].strip() - if not one_line: - one_line = re.sub(r"\s+", " ", str(last_summary)).strip() - if len(one_line) > 180: - cut = max(one_line.rfind(". ", 0, 180), one_line.rfind("! ", 0, 180), one_line.rfind("? ", 0, 180)) - if cut >= 40: - one_line = one_line[: cut + 1].strip() - else: - one_line = one_line[:180].rstrip() + "..." - execution = ExecutionResult( + direct_task_execution: ExecutionResult | None = None + def _direct_exec(text: str, tag: str) -> ExecutionResult: + return ExecutionResult( result_type="conversation", structured_payload={ "style": "general_chat", @@ -884,16 +952,129 @@ async def execute( "response_mode": "conversational_direct", }, citations=[], - tool_logs=["conversation:direct_one_line_summary"], - generated_text=one_line, + tool_logs=[f"conversation:{tag}"], + generated_text=text, engine_used=context.settings.local_engine, used_fallback=False, - runtime_detail="direct_one_line_summary", + runtime_detail=tag, ) + + if any(token in q_raw for token in ("그중에서 하나", "그 중에서 하나", "하나만 골라", "하나 골라줘")) and last_summary: + choice = "" + summary_text = str(last_summary) + bullet_candidates = re.findall(r"[*-]\s*\*\*([^:*]{2,40})\*\*", summary_text) + if not bullet_candidates: + bullet_candidates = re.findall(r"(?:^|\n)\s*(?:\d+[.)]\s+|[-*]\s+)([^:\n]{2,50})", summary_text) + if bullet_candidates: + choice = str(bullet_candidates[0]).strip() + lines = [ln.strip(" -*") for ln in summary_text.splitlines() if str(ln).strip()] + for line in lines: + if choice: + break + m = re.search(r"(?:\d+[.)]\s*)?(.{2,60})", line) + if not m: + continue + candidate = str(m.group(1) or "").strip() + if candidate: + choice = candidate + break + if not choice: + choice = "첫 번째 옵션" + if len(re.sub(r"\s+", "", choice)) > 24: + choice = "첫 번째 추천 활동" + direct_task_execution = _direct_exec(f"좋아요. {choice}로 가죠.", "direct_single_choice") + + direct_count = None + count_match = re.search(r"([2-5])\s*개(?:만)?", q_raw) + if count_match: + try: + direct_count = int(count_match.group(1)) + except Exception: + direct_count = None + if direct_count is None: + digit_match = re.search(r"([0-9]+)", q_raw) + if digit_match: + try: + digit_value = int(digit_match.group(1)) + if 2 <= digit_value <= 5: + direct_count = digit_value + except Exception: + direct_count = None + if direct_count is None: + if "두 개" in q_raw: + direct_count = 2 + elif "세 개" in q_raw: + direct_count = 3 + elif "네 개" in q_raw: + direct_count = 4 + elif "다섯 개" in q_raw: + direct_count = 5 + + if direct_count and direct_task_execution is None and ("할 일" in q_raw or "할일" in q_raw) and any(t in q_raw for t in ("정리", "요약", "정리해줘")): + items = [ + "가장 중요한 1개를 오전에 먼저 끝내기", + "집중 25분 + 휴식 5분으로 2회 진행하기", + "마감 10분 전에 결과 점검하고 다음 우선순위 기록하기", + "중요 메시지/이메일을 한 번에 처리하기", + "하루 종료 전에 내일 준비물 체크하기", + ][:direct_count] + direct_text = "\n".join(f"{idx}. {item}" for idx, item in enumerate(items, 1)) + direct_task_execution = _direct_exec(direct_text, f"direct_count_task_list;count={direct_count}") + elif direct_count and direct_task_execution is None and ("회고" in q_raw) and ("질문" in q_raw): + questions = [ + "오늘 가장 잘한 선택 1가지는 무엇이었나?", + "오늘 막힌 지점 1가지는 무엇이었고, 내일 어떻게 줄일까?", + "내일 유지할 좋은 습관 1가지는 무엇인가?", + "불필요했던 일 1가지를 내일 어떻게 줄일까?", + "내일 가장 먼저 시작할 일은 무엇인가?", + ][:direct_count] + direct_text = "\n".join(f"{idx}. {item}" for idx, item in enumerate(questions, 1)) + direct_task_execution = _direct_exec(direct_text, f"direct_reflection_questions;count={direct_count}") + elif direct_count and direct_task_execution is None: + generic_list_request = any(token in q_raw for token in ("정리", "요약", "제안", "추천", "체크리스트", "루틴", "질문", "아이디어")) + if generic_list_request: + if "운동" in q_raw and "루틴" in q_raw: + pool = [ + "전신 루틴: 스쿼트 12회 x3 + 푸시업 10회 x3 + 플랭크 40초 x3", + "유산소 루틴: 빠른 걷기 20분 + 계단 오르기 10분", + "하체 루틴: 런지 12회 x3 + 힙브릿지 15회 x3", + "상체 루틴: 밴드 로우 12회 x3 + 숄더프레스 10회 x3", + "코어 루틴: 데드버그 12회 x3 + 사이드 플랭크 30초 x3", + ] + elif "체크리스트" in q_raw: + pool = [ + "오늘 가장 중요한 1개를 먼저 시작하기", + "집중 블록 25분 2회 완료하기", + "끝나기 10분 전 결과 점검하기", + "중요 메시지/이메일 한 번에 처리하기", + "내일 준비물 미리 체크하기", + ] + elif "질문" in q_raw: + pool = [ + "지금 가장 중요한 목표 1개는 무엇인가?", + "오늘 막힌 지점 1개를 줄이려면 무엇을 바꿀까?", + "내일 가장 먼저 시작할 일은 무엇인가?", + "지금 바로 10분 안에 할 수 있는 가장 작은 행동은 무엇인가?", + "오늘 끝나기 전에 반드시 확인할 항목은 무엇인가?", + ] + else: + pool = [ + "가장 영향 큰 항목부터 먼저 처리하기", + "작은 단위로 쪼개서 바로 시작하기", + "중간 점검 시간을 미리 고정하기", + "불필요한 작업 1개 줄이기", + "마무리 전에 결과를 짧게 기록하기", + ] + selected = pool[:direct_count] + direct_text = "\n".join(f"{idx}. {item}" for idx, item in enumerate(selected, 1)) + direct_task_execution = _direct_exec(direct_text, f"direct_count_generic_list;count={direct_count}") + + if direct_task_execution is not None: + execution = direct_task_execution recovery_metadata = { "generation_retry_count": 0, "generation_backoff_profile": "", - "recovery_path": "direct_one_line_summary", + "recovery_path": "none", "degraded_internal": False, "keep_waiting_mode": True, "fast_chat_mode": False, diff --git a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_utility_mixin.py b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_utility_mixin.py index 961ac3c..bcb8fd7 100644 --- a/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_utility_mixin.py +++ b/sidecar/local_ai_core/reasoning/strategies/general_chat_sections/general_chat_utility_mixin.py @@ -157,8 +157,8 @@ def _sampling_for_attempt( return {"temperature": 0.34, "top_p": 0.88, "top_k": 28, "repeat_penalty": 1.14} if normalized_profile == "concise": if attempt_index <= 0: - return {"temperature": 0.52, "top_p": 0.90, "top_k": 36, "repeat_penalty": 1.16} - return {"temperature": 0.48, "top_p": 0.88, "top_k": 32, "repeat_penalty": 1.15} + return {"temperature": 0.34, "top_p": 0.84, "top_k": 24, "repeat_penalty": 1.18} + return {"temperature": 0.30, "top_p": 0.82, "top_k": 22, "repeat_penalty": 1.18} if normalized_profile == "analytic": if attempt_index <= 0: return {"temperature": 0.50, "top_p": 0.92, "top_k": 38, "repeat_penalty": 1.16} diff --git a/sidecar/tests/test_local_inference_sanitize_sanitize.py b/sidecar/tests/test_local_inference_sanitize_sanitize.py index bdf91d5..6137284 100644 --- a/sidecar/tests/test_local_inference_sanitize_sanitize.py +++ b/sidecar/tests/test_local_inference_sanitize_sanitize.py @@ -169,6 +169,15 @@ def test_postprocess_conversational_answer_dedupes_adjacent_repeat(): ) assert cleaned.count("자연스럽게 말하려고 해요") == 1 +def test_postprocess_conversational_answer_collapses_comma_loop(): + cleaned = LocalInferenceEngine._postprocess_conversational_answer( + "말씀하세요. 귀 기울여 들을게요,,,,,,,", + query="고민이 있는데 들어줄 수 있나", + response_language="ko", + ) + assert ",,,,," not in cleaned + assert cleaned.endswith(".") + def test_postprocess_conversational_answer_limits_questions_to_one(): cleaned = LocalInferenceEngine._postprocess_conversational_answer( "어떤 메뉴 좋아하세요? 어떤 종류를 생각 중이세요? 김치찌개가 무난해요.",