From a645bc032bc3766c3f2c6fee3b683822841fe53f Mon Sep 17 00:00:00 2001 From: adgk2349 Date: Tue, 26 May 2026 09:21:11 +0900 Subject: [PATCH 1/4] docs: align README runtime status with current implementation --- README.ja.md | 9 ++++++++- README.ko.md | 9 ++++++++- README.md | 9 ++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index f801ada..67bd682 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,6 +18,11 @@ PLOSは SwiftUI アプリと Python サイドカー(FastAPI)を組み合わせ - ハードウェアに応じたモデルカタログ - 一般会話 Direct-First 応答ポリシー +## ランタイム状況 +- ローカル推論バックエンド: `MLX`, `llama.cpp`(主要経路) +- 外部プロバイダ(任意): `OpenAI`, `Anthropic` +- Ollama: 現在の main ブランチでは一次ランタイムとして未統合 + ## アーキテクチャ ```mermaid graph TD @@ -67,6 +72,7 @@ brew install tesseract poppler - Xcodeで `PLOS.xcodeproj` を開く - `PLOS` ターゲットを実行 - サイドカーのライフサイクルはアプリが自動管理 +- 初回確認: チャット画面が開き、簡単なプロンプトに応答すれば正常起動 ## サイドカー単体起動(開発) ```bash @@ -77,6 +83,8 @@ export LOCAL_AI_DATA_DIR="$(pwd)/data" uvicorn local_ai_core.main:create_app --factory --host 127.0.0.1 --port 8787 ``` +`dev-token` はローカル開発用のサンプル値です。 + ## モデル推奨レンジ(実運用目安) - 16GB: 7B/8B中心、12B/14Bは限定的 - 64GB+: 20B/70Bクラス @@ -108,7 +116,6 @@ xcodebuild \ ## ドキュメント - [CONTRIBUTING.ja.md](CONTRIBUTING.ja.md) - [PERFORMANCE.ja.md](PERFORMANCE.ja.md) -- [PLUGIN_RUNTIME_SPEC.md](PLUGIN_RUNTIME_SPEC.md) - [CHANGELOG.ja.md](CHANGELOG.ja.md) - [CHANGELOG.en.md](CHANGELOG.en.md) - [CHANGELOG.ko.md](CHANGELOG.ko.md) diff --git a/README.ko.md b/README.ko.md index c29a916..cf84e9a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -18,6 +18,11 @@ PLOS는 SwiftUI 앱 + Python 사이드카(FastAPI) 구조로, 대화/검색/메 - 하드웨어 등급 기반 모델 카탈로그 - 일반 대화 Direct-First 응답 정책 +## 런타임 상태 +- 로컬 추론 백엔드: `MLX`, `llama.cpp` (기본 경로) +- 외부 프로바이더(선택): `OpenAI`, `Anthropic` +- Ollama: 현재 main 브랜치에서 1급 런타임으로 통합되어 있지 않음 + ## 아키텍처 ```mermaid graph TD @@ -67,6 +72,7 @@ brew install tesseract poppler - Xcode에서 `PLOS.xcodeproj` 열기 - `PLOS` 타깃 실행 - 앱이 사이드카 라이프사이클을 자동 관리 +- 첫 실행 확인: 채팅 화면이 열리고 간단한 프롬프트에 응답하면 정상 기동 ## 사이드카 단독 실행(개발) ```bash @@ -77,6 +83,8 @@ export LOCAL_AI_DATA_DIR="$(pwd)/data" uvicorn local_ai_core.main:create_app --factory --host 127.0.0.1 --port 8787 ``` +`dev-token`은 로컬 개발용 예시값입니다. + ## 모델 권장 구간(실사용 기준) - 16GB: 7B/8B 중심, 12B/14B 제한적 시도 - 64GB+: 20B/70B급 @@ -108,7 +116,6 @@ xcodebuild \ ## 문서 - [CONTRIBUTING.ko.md](CONTRIBUTING.ko.md) - [PERFORMANCE.ko.md](PERFORMANCE.ko.md) -- [PLUGIN_RUNTIME_SPEC.md](PLUGIN_RUNTIME_SPEC.md) - [CHANGELOG.ko.md](CHANGELOG.ko.md) - [CHANGELOG.en.md](CHANGELOG.en.md) - [CHANGELOG.ja.md](CHANGELOG.ja.md) diff --git a/README.md b/README.md index 6c21f1e..4a671aa 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ PLOS combines a native SwiftUI app and a local Python sidecar to run chat, retri - Hardware-aware local model catalog - Conversational direct-first response policy +## Runtime Status +- Local inference backends: `MLX` and `llama.cpp` (primary path) +- External providers (optional): `OpenAI`, `Anthropic` +- Ollama: not integrated as a first-class runtime in current main branch + ## Architecture ```mermaid graph TD @@ -67,6 +72,7 @@ brew install tesseract poppler - Open `PLOS.xcodeproj` in Xcode - Run `PLOS` target - Sidecar lifecycle is managed by the app +- First-run check: if the chat view loads and answers a simple prompt, startup is healthy ## Sidecar Standalone (Dev) ```bash @@ -77,6 +83,8 @@ export LOCAL_AI_DATA_DIR="$(pwd)/data" uvicorn local_ai_core.main:create_app --factory --host 127.0.0.1 --port 8787 ``` +`dev-token` is an example value for local development only. + ## Model Tiers (Practical) - 16GB: 7B/8B class, limited 12B/14B attempts - 64GB+: 20B/70B class @@ -108,7 +116,6 @@ xcodebuild \ ## Docs - [CONTRIBUTING.md](CONTRIBUTING.md) - [PERFORMANCE.md](PERFORMANCE.md) -- [PLUGIN_RUNTIME_SPEC.md](PLUGIN_RUNTIME_SPEC.md) - [CHANGELOG.en.md](CHANGELOG.en.md) - [CHANGELOG.ko.md](CHANGELOG.ko.md) - [CHANGELOG.ja.md](CHANGELOG.ja.md) From 3bc1b02391722566253a43a54f56a3c3c074c314 Mon Sep 17 00:00:00 2001 From: adgk2349 Date: Fri, 26 Jun 2026 09:05:15 +0900 Subject: [PATCH 2/4] refactor: simplify chat routing and direct response flow --- PERFORMANCE.md | 4 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- .../reasoning/orchestrator/strategy_router.py | 34 ++- sidecar/local_ai_core/reasoning/pipeline.py | 2 +- .../general_chat_execution_mixin.py | 279 +++++++----------- sidecar/tests/test_pipeline_chat_log.py | 15 + sidecar/tests/test_strategy_router.py | 58 ++++ 9 files changed, 216 insertions(+), 182 deletions(-) create mode 100644 sidecar/tests/test_pipeline_chat_log.py create mode 100644 sidecar/tests/test_strategy_router.py diff --git a/PERFORMANCE.md b/PERFORMANCE.md index ba89481..0c4ae40 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -17,7 +17,7 @@ This guide provides repeatable checks for local model latency, response quality, ```bash cd sidecar source .venv/bin/activate -pytest -q tests/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py +pytest -q tests/v2/chat/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py ``` ## 2) End-to-End API Regression @@ -47,7 +47,7 @@ Run week exact/filter tests: ```bash cd sidecar source .venv/bin/activate -pytest -q tests/test_v2_pipeline.py -k "week_exact_filter or requested_weeks" +pytest -q tests/v2/chat/test_v2_pipeline.py -k "week_exact_filter or requested_weeks" ``` ## 5) Manual Comparison Matrix (Recommended) diff --git a/README.ja.md b/README.ja.md index 67bd682..9e50e06 100644 --- a/README.ja.md +++ b/README.ja.md @@ -101,7 +101,7 @@ pytest -q ### 重点回帰 ```bash -pytest -q tests/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py +pytest -q tests/v2/chat/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py ``` ### アプリテスト diff --git a/README.ko.md b/README.ko.md index cf84e9a..2718664 100644 --- a/README.ko.md +++ b/README.ko.md @@ -101,7 +101,7 @@ pytest -q ### 핵심 회귀 ```bash -pytest -q tests/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py +pytest -q tests/v2/chat/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py ``` ### 앱 테스트 diff --git a/README.md b/README.md index 4a671aa..6659bc3 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ pytest -q ### Focused regressions ```bash -pytest -q tests/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py +pytest -q tests/v2/chat/test_v2_pipeline.py tests/test_local_inference_sanitize.py tests/test_memory_service_digest.py ``` ### App tests diff --git a/sidecar/local_ai_core/reasoning/orchestrator/strategy_router.py b/sidecar/local_ai_core/reasoning/orchestrator/strategy_router.py index cef7071..db26631 100644 --- a/sidecar/local_ai_core/reasoning/orchestrator/strategy_router.py +++ b/sidecar/local_ai_core/reasoning/orchestrator/strategy_router.py @@ -11,17 +11,35 @@ class StrategyRouter: + _WORKSPACE_RAG_INTENTS = { + ReasoningIntent.FIND_FILE, + ReasoningIntent.SUMMARIZE_FILE, + ReasoningIntent.COMPARE_FILES, + ReasoningIntent.EXPLAIN_CONTENT, + ReasoningIntent.DRAFT_EDIT, + ReasoningIntent.CLASSIFY, + ReasoningIntent.OPEN_FILE, + ReasoningIntent.FOLLOWUP_QUESTION, + ReasoningIntent.FOLLOWUP_REFINE, + ReasoningIntent.CONTINUE_PREVIOUS_RESULT, + ReasoningIntent.SOFT_CONFIRM, + ReasoningIntent.SELECT_PREVIOUS_CANDIDATE, + ReasoningIntent.NEXT_CANDIDATE, + ReasoningIntent.REDUCE_SCOPE, + ReasoningIntent.LIGHTWEIGHT_ACTION_REQUEST, + } + def select(self, *, req, context, strategies: list[Any], force_general_chat: bool): if force_general_chat: return GeneralChatStrategy() - # In GENERAL mode, keep default routing on conversation path unless it is an explicit tool/action intent. - if req.mode == WorkMode.GENERAL and context.parsed_intent.intent not in { - ReasoningIntent.OPEN_FILE, - ReasoningIntent.LIGHTWEIGHT_ACTION_REQUEST, - ReasoningIntent.SELECT_PREVIOUS_CANDIDATE, - ReasoningIntent.NEXT_CANDIDATE, - ReasoningIntent.SYSTEM_ACTION, - }: + # In GENERAL mode, prefer the conversation path only for non-explicit + # conversational intents. Explicit retrieval/task intents should keep + # their dedicated strategy instead of being swallowed by general chat. + if ( + req.mode == WorkMode.GENERAL + and context.parsed_intent.intent not in self._WORKSPACE_RAG_INTENTS + and context.parsed_intent.intent != ReasoningIntent.SYSTEM_ACTION + ): return GeneralChatStrategy() # Keep pure general chat on the conversation-first path except in DEVELOPMENT mode. # Routing general chat into WorkspaceRAG in GENERAL mode caused unnecessary fallback hops. diff --git a/sidecar/local_ai_core/reasoning/pipeline.py b/sidecar/local_ai_core/reasoning/pipeline.py index a27a659..7c54c9c 100644 --- a/sidecar/local_ai_core/reasoning/pipeline.py +++ b/sidecar/local_ai_core/reasoning/pipeline.py @@ -85,7 +85,7 @@ def _chat_log_path() -> Path: return Path.cwd() / "tmp" / "chat_stream_live.jsonl" def _append_chat_log(self, payload: dict[str, Any]) -> None: - if str(os.getenv("LOCAL_AI_CHAT_LOG_ENABLED", "1") or "1").strip().lower() in {"0", "false", "no", "off"}: + if str(os.getenv("LOCAL_AI_CHAT_LOG_ENABLED", "0") or "0").strip().lower() in {"0", "false", "no", "off"}: return try: path = self._chat_log_path() 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 6da931f..6ff4cdc 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 @@ -75,6 +75,113 @@ def _looks_stale_prev_user_echo( sim_curr = cls._text_similarity(ans, cur) return sim_prev >= 0.62 and sim_curr <= 0.52 + @staticmethod + def _extract_direct_count(query: str) -> int | None: + q_raw = str(query or "").strip().lower() + count_match = re.search(r"([2-5])\s*개(?:만)?", q_raw) + if count_match: + try: + return int(count_match.group(1)) + except Exception: + return 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: + return digit_value + except Exception: + return None + if "두 개" in q_raw: + return 2 + if "세 개" in q_raw: + return 3 + if "네 개" in q_raw: + return 4 + if "다섯 개" in q_raw: + return 5 + return None + + @classmethod + def _build_direct_count_execution( + cls, + *, + query: str, + engine_used, + ) -> ExecutionResult | None: + q_raw = str(query or "").strip().lower() + direct_count = cls._extract_direct_count(q_raw) + if direct_count is None or direct_count < 2: + return None + if not any(token in q_raw for token in ("정리", "요약", "제안", "추천", "체크리스트", "루틴", "질문", "아이디어")): + return None + + 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 and "질문" in q_raw: + pool = [ + "오늘 가장 잘한 선택 1가지는 무엇이었나?", + "오늘 막힌 지점 1가지는 무엇이었고, 내일 어떻게 줄일까?", + "내일 유지할 좋은 습관 1가지는 무엇인가?", + "불필요했던 일 1가지를 내일 어떻게 줄일까?", + "내일 가장 먼저 시작할 일은 무엇인가?", + ] + elif "질문" in q_raw: + pool = [ + "지금 가장 중요한 목표 1개는 무엇인가?", + "오늘 막힌 지점 1개를 줄이려면 무엇을 바꿀까?", + "내일 가장 먼저 시작할 일은 무엇인가?", + "지금 바로 10분 안에 할 수 있는 가장 작은 행동은 무엇인가?", + "오늘 끝나기 전에 반드시 확인할 항목은 무엇인가?", + ] + elif ("할 일" in q_raw or "할일" in q_raw): + pool = [ + "가장 중요한 1개를 오전에 먼저 끝내기", + "집중 25분 + 휴식 5분으로 2회 진행하기", + "마감 10분 전에 결과 점검하고 다음 우선순위 기록하기", + "중요 메시지/이메일을 한 번에 처리하기", + "하루 종료 전에 내일 준비물 체크하기", + ] + else: + pool = [ + "가장 영향 큰 항목부터 먼저 처리하기", + "작은 단위로 쪼개서 바로 시작하기", + "중간 점검 시간을 미리 고정하기", + "불필요한 작업 1개 줄이기", + "마무리 전에 결과를 짧게 기록하기", + ] + direct_text = "\n".join(f"{idx}. {item}" for idx, item in enumerate(pool[:direct_count], 1)) + return 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}"], + generated_text=direct_text, + engine_used=engine_used, + used_fallback=False, + runtime_detail=f"direct_count_early;count={direct_count}", + ) + def handles_intent(self, intent: ParsedIntent, followup: FollowUpResolution | None) -> bool: return intent.intent == ReasoningIntent.GENERAL_CHAT @@ -142,89 +249,10 @@ async def execute( # 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}", - ) + execution = self._build_direct_count_execution( + query=context.req.query, + engine_used=context.settings.local_engine, + ) web_memory_for_metadata: dict[str, Any] = {} web_memory_reused = False web_memory_rank_score = 0.0 @@ -984,91 +1012,6 @@ def _direct_exec(text: str, tag: str) -> ExecutionResult: 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 = { diff --git a/sidecar/tests/test_pipeline_chat_log.py b/sidecar/tests/test_pipeline_chat_log.py new file mode 100644 index 0000000..00b7903 --- /dev/null +++ b/sidecar/tests/test_pipeline_chat_log.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from local_ai_core.reasoning.pipeline import ReasoningPipeline + + +def test_append_chat_log_is_disabled_by_default(monkeypatch, tmp_path: Path) -> None: + monkeypatch.delenv("LOCAL_AI_CHAT_LOG_ENABLED", raising=False) + monkeypatch.setenv("LOCAL_AI_CHAT_LOG_PATH", str(tmp_path / "chat.jsonl")) + + pipeline = ReasoningPipeline() + pipeline._append_chat_log({"event": "request_start", "query": "hello"}) + + assert not (tmp_path / "chat.jsonl").exists() diff --git a/sidecar/tests/test_strategy_router.py b/sidecar/tests/test_strategy_router.py new file mode 100644 index 0000000..c9a3eb7 --- /dev/null +++ b/sidecar/tests/test_strategy_router.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from local_ai_core.models import ParsedEntities, ParsedIntent, ParsedTimeFilters, ParsedWorkspaceFilters, ReasoningIntent, WorkMode +from local_ai_core.reasoning.orchestrator.strategy_router import StrategyRouter +from local_ai_core.reasoning.strategies.general_chat import GeneralChatStrategy +from local_ai_core.reasoning.strategies.workspace_rag import WorkspaceRagStrategy + + +def _parsed_intent(intent: ReasoningIntent) -> ParsedIntent: + return ParsedIntent( + intent=intent, + entities=ParsedEntities(), + time_filters=ParsedTimeFilters(), + workspace_filters=ParsedWorkspaceFilters(), + confidence=0.9, + operation="chat", + target=None, + scope="single", + ambiguity="clear", + ) + + +def test_general_mode_keeps_explicit_summary_in_workspace_rag() -> None: + router = StrategyRouter() + req = SimpleNamespace(mode=WorkMode.GENERAL) + context = SimpleNamespace( + parsed_intent=_parsed_intent(ReasoningIntent.SUMMARIZE_FILE), + followup_resolution=None, + ) + + selected = router.select( + req=req, + context=context, + strategies=[WorkspaceRagStrategy(), GeneralChatStrategy()], + force_general_chat=False, + ) + + assert isinstance(selected, WorkspaceRagStrategy) + + +def test_general_mode_keeps_general_chat_for_conversational_intent() -> None: + router = StrategyRouter() + req = SimpleNamespace(mode=WorkMode.GENERAL) + context = SimpleNamespace( + parsed_intent=_parsed_intent(ReasoningIntent.GENERAL_CHAT), + followup_resolution=None, + ) + + selected = router.select( + req=req, + context=context, + strategies=[WorkspaceRagStrategy(), GeneralChatStrategy()], + force_general_chat=False, + ) + + assert isinstance(selected, GeneralChatStrategy) From 8e90e7818881cd667bb3bb5df1ac94c5068419d0 Mon Sep 17 00:00:00 2001 From: adgk2349 Date: Tue, 30 Jun 2026 12:23:09 +0900 Subject: [PATCH 3/4] fix: harden conversational completion flow --- .../inference/parsers/conversational_logic.py | 91 +++++++++++++++++-- .../inference/parsers/prompt_constructor.py | 48 +++------- ...t_local_inference_sanitize_conversation.py | 69 ++++++++++++++ 3 files changed, 168 insertions(+), 40 deletions(-) diff --git a/sidecar/local_ai_core/inference/parsers/conversational_logic.py b/sidecar/local_ai_core/inference/parsers/conversational_logic.py index 91d223d..d69a6c7 100644 --- a/sidecar/local_ai_core/inference/parsers/conversational_logic.py +++ b/sidecar/local_ai_core/inference/parsers/conversational_logic.py @@ -15,6 +15,25 @@ # Component: conversational_logic.py class ConversationalLogic(BaseDelegate): + @staticmethod + def _looks_meta_only_promise_answer(text: str) -> bool: + value = re.sub(r"\s+", " ", str(text or "").strip()) + if not value: + return False + trimmed = re.sub(r"^(?:그럼|그러면|좋아요|알겠습니다|네|자|이제)\s*[,.! ]*", "", value) + if re.search(r"[:\n]", trimmed): + return False + if len(re.findall(r"[.!?]", trimmed)) > 1: + return False + return bool( + re.match( + r"^(?:한\s*번에\s*)?(?:바로\s*)?(?:한\s*줄로\s*|한\s*문장으로만\s*|간단히\s*|짧게\s*|본문만\s*)?" + r"(?:정리|요약|설명|답변|말씀|말해|출력|안내|추천)(?:해\s*|해드리\s*|드리\s*)?" + r"(?:볼게요|할게요|드릴게요|해드릴게요)\.?\s*$", + trimmed, + ) + ) + @staticmethod def _looks_user_role_confusion_answer(text: str) -> bool: value = str(text or "").strip() @@ -42,10 +61,11 @@ def _looks_truncated_conversation_answer(text: str) -> bool: value = str(text or "").strip() if not value: return True - token_count = len(re.findall(r"\S+", value)) - if token_count <= 2: + if re.search(r"(?m)(?:^|[\n ])(?:\*\*)?(?:\d+\.|[-*•])\s*$", value): + return True + if re.search(r"(?:\*\*)?(?:\d+\.)\s*$", value): return True - if token_count <= 5 and re.search(r"(까요\?|게요\.?|습니다\.?|요\.?)$", value): + if value.count("**") % 2 == 1: return True if re.search(r"[:;,(\[{`\-]\s*$", value): return True @@ -89,7 +109,10 @@ def _continue_conversation_once( ).strip() if not tail: return None - merged = f"{str(draft_answer or '').rstrip()} {tail}".strip() + base = str(draft_answer or "").rstrip() + base = re.sub(r"(?:\s*)(?:\*\*)?(?:\d+\.)\s*$", "", base).rstrip() + base = re.sub(r"(?:\s*)[:;,(\[{`\-]\s*$", "", base).rstrip() + merged = f"{base} {tail}".strip() merged = re.sub(r"\s{2,}", " ", merged).strip() return merged @@ -163,6 +186,19 @@ def _generate_conversation_candidate( query=query, response_language=response_language, ) + if answer and not self._looks_meta_only_promise_answer(answer) and self._looks_truncated_conversation_answer(answer): + continued = self._continue_conversation_once( + engine=engine, + query=query, + draft_answer=answer, + response_language=response_language, + profile=profile, + mlx_model_path=mlx_model_path, + llama_model_path=llama_model_path, + max_tokens=max_tokens, + ) + if continued: + answer = continued raw_preview = re.sub(r"\s+", " ", str(raw or "")).strip()[:140] quality_issues = self._conversation_quality_issues( query=query, @@ -215,7 +251,7 @@ def _generate_conversation_candidate( # Even when static fallbacks are disabled, allow a model-based rewrite # if the primary output is invalid (empty/hard issues). This avoids # failing fast into repeated runtime-error fallback text. - rewrite_enabled = str(os.getenv("LOCAL_AI_CONVERSATION_REWRITE_ENABLED", "0") or "0").strip().lower() in { + rewrite_enabled = str(os.getenv("LOCAL_AI_CONVERSATION_REWRITE_ENABLED", "1") or "1").strip().lower() in { "1", "true", "yes", "on" } allow_model_rewrite = bool( @@ -248,6 +284,19 @@ def _generate_conversation_candidate( ) if repaired_answer: + if not self._looks_meta_only_promise_answer(repaired_answer) and self._looks_truncated_conversation_answer(repaired_answer): + continued = self._continue_conversation_once( + engine=engine, + query=query, + draft_answer=repaired_answer, + response_language=response_language, + profile=profile, + mlx_model_path=mlx_model_path, + llama_model_path=llama_model_path, + max_tokens=max_tokens, + ) + if continued: + repaired_answer = continued repaired_issues = self._conversation_quality_issues( query=query, answer=repaired_answer, @@ -440,6 +489,8 @@ def _conversation_hard_issues(self, issues: list[str]) -> list[str]: "avoidable_clarification", "continuation_artifact", "comma_loop_artifact", + "truncated_answer", + "intent_restatement", } return [item for item in issues if item in hard] @@ -644,10 +695,14 @@ def _conversation_quality_issues(self, *, query: str, answer: str, response_lang 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): + if self._looks_meta_only_promise_answer(cleaned): issues.append("meta_only_ack") + if self._looks_truncated_conversation_answer(cleaned): + issues.append("truncated_answer") if self._looks_user_role_confusion_answer(cleaned): issues.append("role_confusion") + if self._looks_intent_restatement_answer(query=query, answer=cleaned): + issues.append("intent_restatement") if response_language == "ko": ko_chars = len(re.findall(r"[가-힣]", cleaned)) @@ -664,6 +719,30 @@ def _conversation_quality_issues(self, *, query: str, answer: str, response_lang issues.append("informal_tone") return issues + @staticmethod + def _looks_intent_restatement_answer(*, query: str, answer: str) -> bool: + q = str(query or "").strip() + a = str(answer or "").strip() + if not q or not a: + return False + lowered = a.lower() + markers = ( + "원하시는 것 같", + "알고 싶으신 것 같", + "추천받고 싶으신 것 같", + "고민 중이시군요", + "찾으시는군요", + "것 같습니다", + ) + if not any(marker in lowered for marker in markers): + return False + if len(re.findall(r"[.!?]", a)) > 2: + return False + substantive_cues = ("1.", "2.", "-", "*", "예를", "굽", "방법", "먼저", "다음", "뒤집", "온도", "소금", "후추") + if any(token in a for token in substantive_cues): + return False + return True + @staticmethod def _looks_avoidable_clarification_answer(*, query: str, answer: str) -> bool: q = str(query or "").strip().lower() diff --git a/sidecar/local_ai_core/inference/parsers/prompt_constructor.py b/sidecar/local_ai_core/inference/parsers/prompt_constructor.py index 3aadaa1..5621566 100644 --- a/sidecar/local_ai_core/inference/parsers/prompt_constructor.py +++ b/sidecar/local_ai_core/inference/parsers/prompt_constructor.py @@ -110,11 +110,9 @@ def _conversational_prompt( context_line = f"참고: {session_summary}\n" return ( "자연스러운 한국어로 답변하세요.\n" - "역할 라벨/메타 설명/내부 지시문은 출력하지 마세요.\n" - "문장을 '께서는', '당신은' 같은 어색한 호칭으로 시작하지 마세요.\n" - "사용자 메시지가 질문이면 답하고, 진술이면 맥락에 맞게 자연스럽게 반응하세요.\n" - "현재 사용자 메시지에 직접 답하세요. 과거 주제를 선택지처럼 다시 나열하지 마세요.\n" - "정보가 치명적으로 부족하지 않다면 되묻기보다 바로 실행 가능한 답을 먼저 제시하세요.\n" + "현재 사용자 메시지에 바로 답하세요.\n" + "메타 설명이나 역할 라벨 없이, 본문부터 시작하세요.\n" + "정보가 아주 부족한 경우가 아니면 되묻지 말고 답을 먼저 주세요.\n" f"{context_line}" f"{query}\n" ) @@ -123,10 +121,9 @@ def _conversational_prompt( context_line = f"Context: {session_summary}\n" return ( "Reply naturally in English.\n" - "If the user message is a statement, respond contextually instead of forcing advice.\n" - "For statement-only messages, avoid unsolicited recommendations unless the user asks.\n" - "Answer the current user message directly and do not re-list older topics as options.\n" - "Unless critical information is missing, provide a directly usable answer first instead of asking follow-up questions.\n" + "Answer the current user message directly.\n" + "Start with the answer itself, without meta commentary or role labels.\n" + "Unless critical information is missing, answer first instead of asking follow-up questions.\n" f"{context_line}" f"{query}\n" ) @@ -140,27 +137,17 @@ def _conversation_rewrite_prompt(self, *, query: str, draft_answer: str, respons draft = (source[:900] if is_code_heavy else re.sub(r"\s+", " ", source)[:260]) if response_language == "ko": return ( - "다음 초안 답변을 자연스러운 한국어 존댓말로 다시 작성해 주세요.\n" - "규칙:\n" - "- 사용자 마지막 질문에 직접 답변\n" - "- 정책/지시문/메타 문장 금지\n" - "- 역할 라벨(User/Assistant/You/A) 금지\n" - "- 같은 문장 반복 금지\n" - "- 한국어 단어 사이 띄어쓰기를 자연스럽게 반드시 적용\n" - "- 목록/마크다운 헤더/과도한 이모지 사용 금지\n" - "- 핵심 의미는 유지하고 간결하게 작성\n" - "- 코드 블록이 있으면 코드 연산자와 줄바꿈을 그대로 보존\n" + "초안 답변을 자연스러운 한국어 존댓말로 고쳐 쓰세요.\n" + "사용자 마지막 메시지에 직접 답하고, 메타 문장 없이 답변 본문부터 바로 쓰세요.\n" + "예고문만 쓰지 말고, 끊긴 문장이 있으면 끝까지 완성하세요.\n" f"사용자 질문: {query}\n" f"초안 답변: {draft}\n" "최종 답변:" ) return ( "Rewrite the draft answer naturally.\n" - "Rules:\n" - "- Directly answer the user's latest message\n" - "- No policy text, no meta commentary, no role labels\n" - "- No repeated sentences\n" - "- If code is present, preserve operators and line breaks exactly\n" + "Answer the user's latest message directly and start with the answer itself.\n" + "Do not output meta commentary or an unfinished sentence.\n" f"User message: {query}\n" f"Draft answer: {draft}\n" "Final answer:" @@ -174,16 +161,9 @@ def _korean_rewrite_prompt(self, *, query: str, draft_answer: str) -> str: ) draft = (source[:900] if is_code_heavy else re.sub(r"\s+", " ", source)[:240]) return ( - "다음 초안 답변을 자연스러운 한국어 존댓말로 다시 작성해 주세요.\n" - "규칙:\n" - "- 사용자 마지막 질문에 직접 답변\n" - "- 정책/지시문/메타 문장 금지\n" - "- 역할 라벨(User/Assistant/You/A) 금지\n" - "- 같은 문장 반복 금지\n" - "- 한국어 단어 사이 띄어쓰기를 자연스럽게 반드시 적용\n" - "- 목록/마크다운 헤더/과도한 이모지 사용 금지\n" - "- 핵심 의미는 유지하고 간결하게 작성\n" - "- 코드 블록이 있으면 코드 연산자와 줄바꿈을 그대로 보존\n" + "초안 답변을 자연스러운 한국어 존댓말로 고쳐 쓰세요.\n" + "사용자 마지막 메시지에 직접 답하고, 메타 문장 없이 답변 본문부터 바로 쓰세요.\n" + "예고문만 쓰지 말고, 끊긴 문장이 있으면 끝까지 완성하세요.\n" f"사용자 질문: {query}\n" f"초안 답변: {draft}\n" "최종 답변:" diff --git a/sidecar/tests/test_local_inference_sanitize_conversation.py b/sidecar/tests/test_local_inference_sanitize_conversation.py index 25ee3ee..f45d3f9 100644 --- a/sidecar/tests/test_local_inference_sanitize_conversation.py +++ b/sidecar/tests/test_local_inference_sanitize_conversation.py @@ -160,6 +160,48 @@ def test_generate_conversational_repairs_hard_issue_even_when_static_fallback_di assert result.used_fallback is False assert "제 성능은 현재 로컬 설정과 모델 크기에 따라 달라집니다." in result.answer +def test_generate_conversational_repairs_meta_only_promise_answer(): + engine = _SequentialStubInferenceEngine( + outputs_by_engine={ + LocalEngine.MLX: [ + "한 문장으로만 요약해 드릴게요.", + "핵심은 오늘 가장 중요한 일 하나를 먼저 끝내는 것입니다.", + ], + LocalEngine.LLAMA_CPP: [None], + } + ) + result = engine.generate_conversational( + query="방금 내용 한 줄로 다시 말해줘", + mode=WorkMode.GENERAL, + profile="recommended", + engine=LocalEngine.MLX, + language_preference="ko", + allow_static_fallback=False, + ) + assert result.used_fallback is False + assert result.answer == "핵심은 오늘 가장 중요한 일 하나를 먼저 끝내는 것입니다." + +def test_generate_conversational_continues_truncated_answer_once(): + engine = _SequentialStubInferenceEngine( + outputs_by_engine={ + LocalEngine.MLX: [ + "프라이팬으로 굽는 핵심은 **1.", + "고기는 굽기 20분 전에 꺼내고 팬을 충분히 달군 뒤 짧고 강하게 굽는 것입니다.", + ], + LocalEngine.LLAMA_CPP: [None], + } + ) + result = engine.generate_conversational( + query="소고기 굽는 법 핵심만 알려줘", + mode=WorkMode.GENERAL, + profile="recommended", + engine=LocalEngine.MLX, + language_preference="ko", + allow_static_fallback=False, + ) + assert result.used_fallback is False + assert "충분히 달군 뒤" in result.answer + def test_generate_conversational_allows_brief_ack_answer(): engine = _SequentialStubInferenceEngine( outputs_by_engine={ @@ -348,3 +390,30 @@ def test_pick_best_conversation_answer_rejects_invalid_primary_meta_response(): ) assert selected is None assert issues == [] + +def test_conversation_quality_issues_flags_meta_only_promise_answer(): + engine = LocalInferenceEngine() + issues = engine._conversation_quality_issues( + query="왜 그렇게 골랐는지 한 문장으로만 말해줘", + answer="설명해 드릴게요.", + response_language="ko", + ) + assert "meta_only_ack" in issues + +def test_conversation_quality_issues_flags_truncated_answer(): + engine = LocalInferenceEngine() + issues = engine._conversation_quality_issues( + query="소고기 굽는 법 핵심만 알려줘", + answer="프라이팬으로 굽는 핵심은 **1.", + response_language="ko", + ) + assert "truncated_answer" in issues + +def test_conversation_quality_issues_flags_intent_restatement_answer(): + engine = LocalInferenceEngine() + issues = engine._conversation_quality_issues( + query="곁들일 거 하나만 추천해줘", + answer="소고기에 곁들일 메뉴를 추천받고 싶으신 것 같습니다.", + response_language="ko", + ) + assert "intent_restatement" in issues From 04ba5f4ba2b53a40f42078bbebf8ae426fa7933a Mon Sep 17 00:00:00 2001 From: adgk2349 Date: Fri, 10 Jul 2026 10:00:37 +0900 Subject: [PATCH 4/4] fix: remove deterministic count response intercept --- .../general_chat_execution_mixin.py | 114 ------------------ 1 file changed, 114 deletions(-) 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 6ff4cdc..3282039 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 @@ -75,113 +75,6 @@ def _looks_stale_prev_user_echo( sim_curr = cls._text_similarity(ans, cur) return sim_prev >= 0.62 and sim_curr <= 0.52 - @staticmethod - def _extract_direct_count(query: str) -> int | None: - q_raw = str(query or "").strip().lower() - count_match = re.search(r"([2-5])\s*개(?:만)?", q_raw) - if count_match: - try: - return int(count_match.group(1)) - except Exception: - return 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: - return digit_value - except Exception: - return None - if "두 개" in q_raw: - return 2 - if "세 개" in q_raw: - return 3 - if "네 개" in q_raw: - return 4 - if "다섯 개" in q_raw: - return 5 - return None - - @classmethod - def _build_direct_count_execution( - cls, - *, - query: str, - engine_used, - ) -> ExecutionResult | None: - q_raw = str(query or "").strip().lower() - direct_count = cls._extract_direct_count(q_raw) - if direct_count is None or direct_count < 2: - return None - if not any(token in q_raw for token in ("정리", "요약", "제안", "추천", "체크리스트", "루틴", "질문", "아이디어")): - return None - - 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 and "질문" in q_raw: - pool = [ - "오늘 가장 잘한 선택 1가지는 무엇이었나?", - "오늘 막힌 지점 1가지는 무엇이었고, 내일 어떻게 줄일까?", - "내일 유지할 좋은 습관 1가지는 무엇인가?", - "불필요했던 일 1가지를 내일 어떻게 줄일까?", - "내일 가장 먼저 시작할 일은 무엇인가?", - ] - elif "질문" in q_raw: - pool = [ - "지금 가장 중요한 목표 1개는 무엇인가?", - "오늘 막힌 지점 1개를 줄이려면 무엇을 바꿀까?", - "내일 가장 먼저 시작할 일은 무엇인가?", - "지금 바로 10분 안에 할 수 있는 가장 작은 행동은 무엇인가?", - "오늘 끝나기 전에 반드시 확인할 항목은 무엇인가?", - ] - elif ("할 일" in q_raw or "할일" in q_raw): - pool = [ - "가장 중요한 1개를 오전에 먼저 끝내기", - "집중 25분 + 휴식 5분으로 2회 진행하기", - "마감 10분 전에 결과 점검하고 다음 우선순위 기록하기", - "중요 메시지/이메일을 한 번에 처리하기", - "하루 종료 전에 내일 준비물 체크하기", - ] - else: - pool = [ - "가장 영향 큰 항목부터 먼저 처리하기", - "작은 단위로 쪼개서 바로 시작하기", - "중간 점검 시간을 미리 고정하기", - "불필요한 작업 1개 줄이기", - "마무리 전에 결과를 짧게 기록하기", - ] - direct_text = "\n".join(f"{idx}. {item}" for idx, item in enumerate(pool[:direct_count], 1)) - return 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}"], - generated_text=direct_text, - engine_used=engine_used, - used_fallback=False, - runtime_detail=f"direct_count_early;count={direct_count}", - ) - def handles_intent(self, intent: ParsedIntent, followup: FollowUpResolution | None) -> bool: return intent.intent == ReasoningIntent.GENERAL_CHAT @@ -246,13 +139,6 @@ 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. - execution = self._build_direct_count_execution( - query=context.req.query, - engine_used=context.settings.local_engine, - ) web_memory_for_metadata: dict[str, Any] = {} web_memory_reused = False web_memory_rank_score = 0.0