diff --git a/README.md b/README.md index a14d4b9b..a035c52b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # Alden +## 2026-10-11 — 로컬 MLX 응답 확정 후에만 TTS·기억 저장 + +기존 27B·Flash-Next 로컬 음성 LLM의 종료 사유가 누락됐거나 +`length`·`content_filter`·`tool_calls`여도 일부 문장을 말하고 저장할 수 있던 경계를 +수정했습니다. 완성된 단일 assistant 답변에 대해서만 `finish_reason=stop`을 +확인한 다음 취소 경계를 거쳐 TTS/영속 assistant 이력을 확정합니다. +SSE [DONE]만 온 응답, 종료 후 추가 텍스트, 중복·손상 JSON, 비정상 수치와 +모델 실패 상태는 완료로 승격하지 않습니다. 확인된 사용자 발화만 보존하고 +다음 유효한 턴으로 이어집니다. + +[실제 소스·시험·배포 전 복구 계약](docs/architecture/alden-local-mlx-completion-20261011.md). +[macOS CI #38072362981](https://github.com/twoimo/alden/actions/runs/38072362981): +175개 실행 중 169개 통과, NumPy 미설치 조건 6개 skip. +이는 실제 Mac 가중치 로딩·마이크/스피커·성능·서명/공증 증거가 아닙니다. +Alden Tauri·Three.js 구성, 사용자 모델 선택 및 라우팅은 변경하지 않았습니다. + ## 2026-10-11 — 이벤트 순서 복구 및 ‘기억 정리’ 재조회 (미설치 검토 브랜치) PR #29의 통합 그래프 위에서 영속 수집 이벤트의 손상·역전·중복 페이지를 diff --git a/desktop/DESIGN.md b/desktop/DESIGN.md index 2b2d291f..f4927032 100644 --- a/desktop/DESIGN.md +++ b/desktop/DESIGN.md @@ -1,5 +1,23 @@ # Alden desktop design contract +## 2026-10-11 — Confirmed voice answer boundary + +A local MLX transport that emits partial text but does not confirm a +single assistant response with `finish_reason=stop` is not a completed +answer. No partial text is spoken or persisted as assistant history when +the response is truncated, tool-directed, filtered, failed, duplicated +or cancelled. Confirmed user input remains in its existing conversation; +the next turn is never replaced by the previous model's late output. + +This is a runtime publication rule, not a new visual "thinking" event. +Voice state, cancellation feedback and aggregate engine telemetry retain +their existing presentation. The model selector is not rewritten and +never silently falls back to Gemini or another remote model. + +[Model response contract and macOS CI scope](../docs/architecture/alden-local-mlx-completion-20261011.md). +Native microphone, TTS echo, latency, GPU residency and install +remain independent physical-device release checks. + ## 2026-10-11 — 저장 원본 재조회와 실시간 이벤트의 분리 ‘기억 정리’의 한 행은 대상·항목·시각·확정 단계를 간결하게 표시합니다. diff --git a/docs/ALDEN_CONTINUATION.md b/docs/ALDEN_CONTINUATION.md index a9092e2c..c22e340e 100644 --- a/docs/ALDEN_CONTINUATION.md +++ b/docs/ALDEN_CONTINUATION.md @@ -1,3 +1,8 @@ +> 2026-10-11 신규: [로컬 MLX 응답 완결성/오발화 방지와 음성 이력 실제 경로](architecture/alden-local-mlx-completion-20261011.md). +> `codex/alden-voice-completion-fence-20261011`는 PR #30 다음의 별도 독립 소스 브랜치다. +> [macOS CI 38072362981](https://github.com/twoimo/alden/actions/runs/38072362981): 175개 중 169개 통과·6개 NumPy skip. 메모리·TTS/SQLite는 고정 모델 전송의 실제 제품 코드 테스트. +> 제품 구성·모델·활성 데이터·운영 프로세스는 원격에서 변경하지 않았다. 사용자 Mac `outputs/STATUS.md`는 유효한 Core 세션 토큰이 없어 갱신하지 못했으며 로컬 모델 로딩/실물 사용은 미검증. + > 2026-10-11 신규: [영속 수집 이벤트 순서·재연결 보호, 변경 전후 버전, 기억 정리 원본 재조회](architecture/alden-activity-readback-20261011.md). > 분리 브랜치 `codex/alden-activity-order-20261011`; macOS CI `38070713835`: Python 60개, UI 471개, TS/Vite 빌드 통과. > **구현됨 / GitHub macOS 실제 실행 검증됨**, 소유자 Mac의 설치·osk-system·실제 음성·서명·공증은 미검증. diff --git a/docs/architecture/alden-local-mlx-completion-20261011.md b/docs/architecture/alden-local-mlx-completion-20261011.md new file mode 100644 index 00000000..506eb7de --- /dev/null +++ b/docs/architecture/alden-local-mlx-completion-20261011.md @@ -0,0 +1,53 @@ +# Alden 로컬 음성 모델 완료 확인과 턴 격리 — 2026-10-11 + +## 변경 계보·안전 경계 + +- 기준: 미병합 PR #30, `c62d6b7763a6644151aa18ff3a4edc7f239e3821`. 상위 스택은 #29 → #28 → #27. +- 독립 구현 브랜치: `codex/alden-voice-completion-fence-20261011`. +- 원본 Mac 작업 복제본, 운영 대화·DB·모델·전송 큐·설정·프로세스는 이 원격 소스 변경의 대상이 아니다. +- **Tauri v2·Three.js, 로컬 MLX inference endpoint allowlist, 모델 선택과 기존 Gemini 라우팅을 바꾸지 않는다.** 로컬 서비스가 미준비이면 클라우드로 자동 전환하지 않는다. + +## 실제 런타임 변화 + +```mermaid +flowchart LR + A[입력 source + conversation_id + turn_id] --> B[AldenVoicePipeline 확인된 사용자 발화] + B --> C[LocalMlxLlm 명시 모델·로컬 HTTP 요청] + C --> D[모델 ID·요청 ID·JSON 무결성] + D --> E{finish_reason == stop?} + E -->|no| F[모델 결과 폐기·turn ERROR] + E -->|yes| G[응답 역할·도구 호출·종료 뒤 데이터 검증] + G -->|fail| F + G -->|pass| H[취소 상태 확인 → TTS] + H --> I[확정 assistant SQLite 이력] + F --> J[확정 user 입력만 유지] +``` + +`scripts/alden_voice.py::LocalMlxLlm`: +- JSON과 SSE에 대해 중복 키, NaN/Infinity와 지수 오버플로를 거부한다. 단일 assistant 답변을 요구하며 잘못된 역할·여러 choice를 거부한다. +- SSE에서 `[DONE]`만 받은 경우나 `finish_reason`이 없는 경우를 완료로 간주하지 않는다. `stop` 이외의 `length`, `tool_calls`, `content_filter`, 알 수 없는 상태를 저장/발화하지 않는다. `length`는 기존 `local_llm_reply_truncated`를 유지한다. +- 종료 뒤 다른 content나 추가 종료 이벤트를 받으면 중복/역전 응답으로 판정해 거부한다. 정상 완료 뒤 `choices=[]`인 별도 usage frame만 허용한다. +- 명시적인 error·failed·incomplete·cancelled 상태가 유효한 글자·`stop`과 함께 오더라도 실패로 처리한다. +- 로컬 MLX `/models`에서 **선택한 모델이 준비되었다고 표시되는지** 확인하는 기존 계약, per-turn request_id 및 conversation/turn/context 헤더, CPU/GPU aggregate `/metrics` 읽기, emergency latch와 cancel guard는 유지한다. 이것이 사용자의 Mac에서 실제 모델 로딩을 확인했다는 뜻은 아니다. +- 모델 오류는 `AldenVoicePipeline`에서 `generation_error`로 표준화하여 외부 서비스의 임의 메시지나 미완료 답변을 사용자에게 전파하지 않는다. 확인된 사용자 입력은 이력에 남지만, TTS가 호출되지 않은 결과는 assistant 이력으로 저장하지 않는다. + +`tests/test_alden_voice_completion.py`는 진짜 `AldenVoicePipeline.process_text`, `LocalMlxLlm`, `AbortToken`, `alden_history.database`를 결합한다. 네트워크/음성 하드웨어만 고정 전송 mock으로 대체하고, 잘못된 응답에서 TTS 호출 횟수=0, assistant 행 수=0, 사용자 입력 보존, 중복 입력의 재게시 차단, 다음 새 턴의 정상 완료를 검사한다. 기존 유닛의 정상 mock에 명시적 `finish_reason=stop`을 넣어 미완료 데이터로 성공을 주장하지 않는다. + +## 실제 검증과 증거 범위 + +[GitHub Actions macOS #38072168914](https://github.com/twoimo/alden/actions/runs/38072168914): 집중 음성·취소·모델 라우트 **66/66 통과**. + +확장 [GitHub Actions macOS #38072362981](https://github.com/twoimo/alden/actions/runs/38072362981), +정확한 CI SHA `b83ac5c34481ac827fa75a5862245658bb393e38`: +**175개 중 169개 통과, 6개 skip(해당 CI Python에 NumPy 없음)**. +검증한 모델/테스트 코드의 Git blob SHA는 독립 구현 브랜치와 동일하다. +초기 실패 실행 `38072109129`, 확장 첫 실패 실행 `38072289600`은 폐기하지 않고 보존한다. 이전 mock이 미완료 응답을 정상으로 취급한 차이를 설명하며 실제 운영 MLX 장애 횟수로 간주하지 않는다. + +이 회차에 **실행하지 않은 항목**: 실제 27B/Flash-Next 가중치 로딩·리비전·양자화·GPU 메모리 적합성, 실제 마이크/스피커와 wake/TTS 억제·끼어들기 지연, macOS Tauri 배포본의 물리 UI 및 서명·공증, 사용자 운영 원본의 외부 osk-system MCP 연결. 추론 처리량과 CPU/GPU peak 비교값을 만들어내지 않는다. + +## 지정 Mac 배포 전 복구 절차 + +1. 유효한 Codex Core `turn_token`이 외부 호스트로부터 전달된 뒤에만 `/Users/twoimo/.codex/attachments/1d528d6c-42be-4bd0-80a1-4883b96474ed/goal-objective.md`의 원문과 전용 복제본의 `outputs/STATUS.md`, `git status`, PR diff, 활성 프로세스를 읽는다. 현재 ChatGPT 세션에는 발급 토큰이 보이지 않는다. +2. 원본·작업 미커밋·DB/전송 큐·모델·런처 설정·설치 앱을 복구 가능한 형태로 보존하고, 병합 전에 PR #30 위 이 브랜치의 단일 기능 변경을 선택적으로 반영한다. +3. 기존 선택 모델에 **실제 로컬 MLX 모델 상태를 조회**하고 하나의 검증된 정상 답변과 length/content_filter/취소 반환에서 TTS·DB 이력의 중복/누락을 실물로 측정한다. +4. 동일 모델·동일 컨텍스트에서 지연, TTFT, 취소 후 출력, STT/TTS 소음 및 배터리/자원 부담을 비교한다. 서명 자격·공증 권한·배포 대상을 확인한 후에만 운영 앱을 전환한다. diff --git a/scripts/alden_voice.py b/scripts/alden_voice.py index da21172d..0d26844f 100644 --- a/scripts/alden_voice.py +++ b/scripts/alden_voice.py @@ -1698,12 +1698,40 @@ def _sample_engine_metrics(self, token: AbortToken) -> dict[str, Any]: sample["observed_at_unix"] = time.time() return sample + @staticmethod + def _decode_completion_payload(raw: bytes) -> dict[str, Any]: + """Fail closed on ambiguous local completion JSON, not just its framing.""" + def finite_float(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError("nonfinite") + return parsed + + def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate key") + result[key] = value + return result + + try: + data = json.loads(raw, parse_float=finite_float, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("nonfinite")), + object_pairs_hook=unique_pairs) + except (ValueError, UnicodeError, TypeError, OverflowError) as error: + raise RuntimeError("local_llm_response_invalid") from error + if not isinstance(data, dict): + raise RuntimeError("local_llm_response_invalid") + return data + def _read_stream(self, response, token: AbortToken, started: float) -> dict[str, Any]: total = 0 parts: list[str] = [] model = None usage = None finish_reason = None + finished = False while True: token.raise_if_cancelled() line = response.readline(LOCAL_LLM_MAX_RESPONSE_BYTES + 1) @@ -1717,9 +1745,9 @@ def _read_stream(self, response, token: AbortToken, started: float) -> dict[str, data = line[5:].strip() if data == b"[DONE]": break - chunk = json.loads(data) - if not isinstance(chunk, dict): - raise RuntimeError("local_llm_response_invalid") + chunk = self._decode_completion_payload(data) + if chunk.get("error") is not None or chunk.get("status") in ("failed", "incomplete", "cancelled"): + raise RuntimeError("local_llm_generation_failed") request_id = chunk.get("id") if isinstance(request_id, str) and re.fullmatch(r"[A-Za-z0-9:_-]{1,128}", request_id): previous_id = self.last_metrics.get("backend_request_id") @@ -1736,11 +1764,25 @@ def _read_stream(self, response, token: AbortToken, started: float) -> dict[str, if not isinstance(choices, list): raise RuntimeError("local_llm_response_invalid") if not choices: - continue + continue # A trailing usage-only frame after finish is valid. + if len(choices) != 1: + raise RuntimeError("local_llm_response_invalid") choice = choices[0] if not isinstance(choice, dict) or not isinstance(choice.get("delta"), dict): raise RuntimeError("local_llm_response_invalid") delta = choice["delta"] + if delta.get("role") not in (None, "assistant"): + raise RuntimeError("local_llm_response_invalid") + if delta.get("tool_calls") or delta.get("function_call"): + raise RuntimeError("local_llm_reply_unconfirmed") + if finished: + # Never append a later generation, duplicated terminal chunk + # or a second request to the already completed assistant turn. + if (delta.get("content") not in (None, "") or + delta.get("reasoning_content") not in (None, "") or + choice.get("finish_reason") is not None): + raise RuntimeError("local_llm_stream_post_completion") + continue content = delta.get("content") if content or delta.get("reasoning_content"): self.last_metrics.setdefault("first_model_token_seconds", time.perf_counter() - started) @@ -1752,6 +1794,11 @@ def _read_stream(self, response, token: AbortToken, started: float) -> dict[str, parts.append(content) if choice.get("finish_reason") is not None: finish_reason = choice["finish_reason"] + if not isinstance(finish_reason, str): + raise RuntimeError("local_llm_response_invalid") + finished = True + if not finished: + raise RuntimeError("local_llm_stream_incomplete") elapsed = time.perf_counter() - started self.last_metrics.update({"elapsed_seconds": elapsed, "usage": usage, "finish_reason": finish_reason}) return {"id": self.last_metrics.get("backend_request_id"), "model": model, "usage": usage, "choices": [{"finish_reason": finish_reason, "message": {"content": "".join(parts)}}]} @@ -1888,8 +1935,15 @@ def _generate_response(self, text: str, token: AbortToken, *, history, started: if len(raw) > LOCAL_LLM_MAX_RESPONSE_BYTES: raise RuntimeError("local_llm_response_too_large") try: - body = json.loads(raw.decode("utf-8", "replace")) - message = body["choices"][0]["message"] + body = self._decode_completion_payload(raw) + if body.get("error") is not None or body.get("status") in ("failed", "incomplete", "cancelled"): + raise ValueError("model reported a failed generation") + choices = body["choices"] + if not isinstance(choices, list) or len(choices) != 1 or not isinstance(choices[0], dict): + raise ValueError("invalid choices") + message = choices[0]["message"] + if not isinstance(message, dict) or message.get("role") not in (None, "assistant"): + raise ValueError("not an assistant message") content = message.get("content") except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc: raise RuntimeError("local_llm_response_invalid") from exc @@ -1905,6 +1959,13 @@ def _generate_response(self, text: str, token: AbortToken, *, history, started: # A transport-complete stream can still contain an unfinished # answer. Do not speak or commit that partial answer as completion. raise RuntimeError("local_llm_reply_truncated") + if finish_reason != "stop": + # content_filter, tool_calls, function_call, unknown/missing status + # and a transport [DONE] without model completion are not consent + # to speak or persist the partial assistant text. + raise RuntimeError("local_llm_reply_unconfirmed") + if message.get("tool_calls") or message.get("function_call"): + raise RuntimeError("local_llm_reply_unconfirmed") if not isinstance(content, str) or not content.strip(): # Never speak a model's private reasoning channel as if it were a # user-facing answer. A reasoning-only response ends this turn. diff --git a/tests/test_alden_unit3.py b/tests/test_alden_unit3.py index 3c6ca53e..16189d4f 100644 --- a/tests/test_alden_unit3.py +++ b/tests/test_alden_unit3.py @@ -975,7 +975,7 @@ def fake_urlopen(request, timeout=0): if request.get_method() == "GET": return FakeJsonResponse(ready_model_catalog(QWEN38_27B_MODEL_ID)) captured["body"] = json.loads(request.data.decode("utf-8")) - return FakeJsonResponse({"choices": [{"message": {"content": " 알겠습니다. "}}]}) + return FakeJsonResponse({"choices": [{"message": {"content": " 알겠습니다. "}, "finish_reason": "stop"}]}) with TemporaryDirectory() as temp_dir: token = AbortController(Path(temp_dir)).token() @@ -1045,7 +1045,7 @@ def fake_urlopen(request, timeout=0): return FakeJsonResponse( { "model": FLASH_NEXT_MODEL_ID.removeprefix("mlx/"), - "choices": [{"message": {"content": "flash reply"}}], + "choices": [{"message": {"content": "flash reply"}, "finish_reason": "stop"}], } ) @@ -1175,7 +1175,8 @@ def test_generate_never_uses_reasoning_as_a_spoken_reply(self): "message": { "content": "", "reasoning_content": "내부 추론을 음성으로 읽으면 안 됩니다.", - } + }, + "finish_reason": "stop", } ] } diff --git a/tests/test_alden_voice_completion.py b/tests/test_alden_voice_completion.py new file mode 100644 index 00000000..bff811e2 --- /dev/null +++ b/tests/test_alden_voice_completion.py @@ -0,0 +1,247 @@ +"""Real voice-turn publication fences around mocked local MLX wire responses. + +The production AldenVoicePipeline, LocalMlxLlm, AbortToken and SQLite voice +history run unmodified. Only the loopback HTTP model and TTS hardware are +deterministic fixtures. No actual microphone, GPU, external model or sends. +""" +import io +import json +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from alden_abort import AbortController +from alden_history import database +from alden_voice import ( + AldenVoicePipeline, LocalMlxLlm, QWEN38_27B_MODEL_ID, + VoiceState, VoiceStatusStore, +) +import alden_voice as voice + + +MODEL = QWEN38_27B_MODEL_ID.removeprefix("mlx/") + + +class WireReply(io.BytesIO): + def __init__(self, body, content_type="application/json"): + super().__init__(body) + self.headers = {"Content-Type": content_type} + + +def response(content, *, finish="stop", role=None, extra=None): + message = {"content": content} + if role is not None: + message["role"] = role + if extra: + message.update(extra) + return { + "id": "chatcmpl-validated", + "model": MODEL, + "choices": [{"message": message, "finish_reason": finish}], + "usage": {"prompt_tokens": 7, "completion_tokens": 3}, + } + + +def sse(chunks, *, done=True): + wire = b"".join( + b"data: " + json.dumps(chunk, ensure_ascii=False, allow_nan=False).encode() + b"\n\n" + for chunk in chunks + ) + return wire + (b"data: [DONE]\n\n" if done else b"") + + +def chunk(*, content=None, finish=None, model=MODEL, role=None, tool_calls=None): + delta = {} + if content is not None: + delta["content"] = content + if role is not None: + delta["role"] = role + if tool_calls is not None: + delta["tool_calls"] = tool_calls + return { + "id": "chatcmpl-validated", + "model": model, + "choices": [{"delta": delta, "finish_reason": finish}], + } + + +class HardwareTts: + def __init__(self): + self.spoken = [] + + def speak(self, text, token): + token.raise_if_cancelled() + self.spoken.append(text) + + +class ModelCompletionFences(unittest.TestCase): + def test_exact_stop_publishes_one_assistant_only_and_retains_turn_identity(self): + body = sse([ + chunk(content="최종"), + chunk(content=" 답변", finish="stop"), + {"id": "chatcmpl-validated", "choices": [], "usage": {"completion_tokens": 3}}, + ]) + result, speech, messages, metrics, calls = self.run_turn(body, "text/event-stream") + self.assertEqual(result.state, VoiceState.ENDED) + self.assertEqual(result.reply, "최종 답변") + self.assertEqual(speech, ["최종 답변"]) + self.assertEqual([row["role"] for row in messages], ["user", "assistant"]) + self.assertEqual(metrics["finish_reason"], "stop") + self.assertEqual(metrics["usage"]["completion_tokens"], 3) + self.assertEqual(metrics["request"]["turn_id"], result.turn_id) + self.assertEqual(metrics["request"]["context_version"], 1) + self.assertEqual(metrics["request"]["conversation_id"], result.conversation_id) + self.assertEqual(metrics["request"]["state"], "completed") + posts = [r for r in calls if r.get_method() == "POST"] + self.assertEqual(len(posts), 1) + self.assertEqual(dict(posts[0].header_items())["X-alden-conversation-id"], result.conversation_id) + + def test_nonterminal_json_and_tool_calls_never_speak_or_persist_model_answer(self): + for finish in (None, "content_filter", "tool_calls", "function_call", "failed", "", 2): + with self.subTest(finish=finish): + self.assert_rejected(json.dumps(response("부분 발화", finish=finish)).encode()) + for extra in ({"tool_calls": [{"id": "call-incomplete"}]}, {"function_call": {"name": "send"}}): + with self.subTest(extra=extra): + self.assert_rejected(json.dumps(response("보내면 안 됨", extra=extra)).encode()) + + def test_explicit_failed_response_cannot_sneak_in_with_successful_choice(self): + for fields in ({"error": {"message": "private provider error"}}, + {"status": "failed"}, {"status": "incomplete"}, + {"status": "cancelled"}): + with self.subTest(fields=fields): + forged = response("위장된 완료", finish="stop") + forged.update(fields) + self.assert_rejected(json.dumps(forged).encode()) + forged_sse = chunk(content="위장된 완료", finish="stop") + forged_sse.update(fields) + self.assert_rejected(sse([forged_sse]), "text/event-stream", + "local_llm_generation_failed") + + def test_nonterminal_sse_and_poststop_injection_never_speak(self): + cases = [ + (sse([chunk(content="끝나지 않은 발화")]), "local_llm_stream_incomplete"), + (sse([chunk(content="부적절한 중단", finish="content_filter")]), "local_llm_reply_unconfirmed"), + (sse([chunk(content="도구 호출", finish="tool_calls")]), "local_llm_reply_unconfirmed"), + (sse([chunk(content="끝", finish="stop"), chunk(content="이후 오염")]), "local_llm_stream_post_completion"), + (sse([chunk(content="끝", finish="stop"), chunk(finish="stop")]), "local_llm_stream_post_completion"), + (sse([chunk(content="도구", tool_calls=[{"id": "call"}], finish="stop")]), "local_llm_reply_unconfirmed"), + (sse([chunk(content="사용자 전용", role="user", finish="stop")]), "local_llm_response_invalid"), + (sse([chunk(content="절단", finish="length")]), "local_llm_reply_truncated"), + (sse([chunk(content="완성")], done=False), "local_llm_stream_incomplete"), + ] + for body, reason in cases: + with self.subTest(reason=reason, data=body[:55]): + self.assert_rejected(body, "text/event-stream", reason) + + def test_ambiguous_and_nonfinite_json_fail_closed_on_local_transport(self): + with self.assertRaisesRegex(RuntimeError, "local_llm_response_invalid"): + LocalMlxLlm._decode_completion_payload(b'{"choices":[],"choices":[]}') + with self.assertRaisesRegex(RuntimeError, "local_llm_response_invalid"): + LocalMlxLlm._decode_completion_payload(b'{"usage":{"tokens":NaN}}') + with self.assertRaisesRegex(RuntimeError, "local_llm_response_invalid"): + LocalMlxLlm._decode_completion_payload(b'{"usage":{"tokens":1e400}}') + self.assert_rejected(b'{"choices":[{"message":{"content":"unsafe"},"finish_reason":"stop"}],' + b'"choices":[{"message":{"content":"spoof"},"finish_reason":"stop"}]}') + self.assert_rejected(sse([chunk(content="어색한 종료", finish="stop")]).replace( + b'"choices": [', b'"usage": NaN, "choices": ['), "text/event-stream", "local_llm_response_invalid") + + def test_rejected_reply_preserves_user_input_and_next_valid_turn_can_complete(self): + with TemporaryDirectory() as directory: + root = Path(directory) + model = LocalMlxLlm(state_root=root) + tts = HardwareTts() + token = AbortController(root).token() + pipe = AldenVoicePipeline(stt=object(), llm=model, tts=tts, token=token, + status=VoiceStatusStore(root), manual_listen=True) + attempts = [ + WireReply(json.dumps(response("조각", finish="content_filter")).encode()), + WireReply(json.dumps(response("검증된 최종 답변", finish="stop")).encode()), + ] + + def opener(request, **_): + if request.full_url.endswith("/models"): + return WireReply(json.dumps({"data": [{"id": MODEL, "loaded": True, "state": "ready"}]}).encode()) + if request.full_url.endswith("/metrics"): + return WireReply(b"vllm:num_requests_running 0\n", "text/plain") + return attempts.pop(0) + + try: + with patch.object(voice, "_local_urlopen", side_effect=opener): + fail = pipe.process_text("첫 질문", event_id="turn-1") + self.assertEqual(fail.state, VoiceState.ERROR) + self.assertFalse(fail.reply) + self.assertEqual(tts.spoken, []) + duplicate = pipe.process_text("첫 질문", event_id="turn-1") + self.assertEqual(duplicate.error_code, "input_ignored") + self.assertEqual(duplicate.turn_id, fail.turn_id) + passed = pipe.process_text("이어지는 질문", event_id="turn-2") + self.assertEqual(passed.state, VoiceState.ENDED) + self.assertEqual(passed.turn_id, fail.turn_id+1) + self.assertEqual(tts.spoken, ["검증된 최종 답변"]) + with database(root) as db: + rows = db.execute("SELECT role, content, turn_id FROM voice_messages " + "WHERE session_id=? ORDER BY turn_id, " + "CASE role WHEN 'user' THEN 0 ELSE 1 END", + (pipe.conversation_id,)).fetchall() + self.assertEqual([(row["role"], row["turn_id"]) for row in rows], + [("user", 1), ("user", 2), ("assistant", 2)]) + self.assertEqual(rows[-1]["content"], "검증된 최종 답변") + finally: + pipe.close() + + def assert_rejected(self, wire, content_type="application/json", reason=None): + result, spoken, history, metrics, calls = self.run_turn(wire, content_type) + self.assertEqual(result.state, VoiceState.ERROR) + self.assertEqual(result.error_code, "generation_error") + self.assertEqual(result.reply, "") + self.assertEqual(spoken, []) + self.assertEqual([row["role"] for row in history], ["user"]) + self.assertEqual(metrics["request"]["state"], "failed") + self.assertEqual(len([request for request in calls if request.get_method() == "POST"]), 1) + if reason: + # The pipeline intentionally redacts the model's private error; + # test the model boundary separately rather than expose it to UI. + with TemporaryDirectory() as directory: + root = Path(directory) + adapter = LocalMlxLlm(state_root=root) + def local_opener(request, **_): + if request.full_url.endswith("/models"): + return WireReply(json.dumps({"data": [{"id": MODEL, "loaded": True, "state": "ready"}]}).encode()) + return WireReply(wire, content_type) + with patch.object(voice, "_local_urlopen", side_effect=local_opener): + with self.assertRaisesRegex(RuntimeError, reason): + adapter.generate("확인", AbortController(root).token()) + + def run_turn(self, wire, content_type="application/json"): + with TemporaryDirectory() as directory: + root = Path(directory) + model = LocalMlxLlm(state_root=root) + tts = HardwareTts() + calls = [] + pipe = AldenVoicePipeline(stt=object(), llm=model, tts=tts, token=AbortController(root).token(), + status=VoiceStatusStore(root), manual_listen=True) + def opener(request, **_): + calls.append(request) + if request.full_url.endswith("/models"): + return WireReply(json.dumps({"data": [{"id": MODEL, "loaded": True, "state": "ready"}]}).encode()) + if request.full_url.endswith("/metrics"): + return WireReply(b"vllm:num_requests_running 0\n", "text/plain") + return WireReply(wire, content_type) + try: + with patch.object(voice, "_local_urlopen", side_effect=opener): + result = pipe.process_text("현재 질문", source="text", event_id="live-event-id") + with database(root) as db: + rows = db.execute("SELECT role, content FROM voice_messages " + "WHERE session_id=? ORDER BY turn_id, " + "CASE role WHEN 'user' THEN 0 ELSE 1 END", + (pipe.conversation_id,)).fetchall() + return result, list(tts.spoken), [dict(row) for row in rows], json.loads(json.dumps(model.last_metrics)), calls + finally: + pipe.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_alden_voice_turns.py b/tests/test_alden_voice_turns.py index 0a243e04..2496a878 100644 --- a/tests/test_alden_voice_turns.py +++ b/tests/test_alden_voice_turns.py @@ -504,7 +504,9 @@ def test_stream_uses_visible_content_and_records_usage_without_reasoning(self): def test_truncated_or_reasoning_only_stream_is_not_an_answer(self): with self.assertRaisesRegex(RuntimeError, "stream_incomplete"): self.generate([{"choices": [{"delta": {"content": "부분 응답"}}]}], done=False) - with self.assertRaisesRegex(RuntimeError, "reply_empty"): + # A reasoning-only reply with [DONE] but no stop finish is an + # unconfirmed stream, not a completed empty assistant message. + with self.assertRaisesRegex(RuntimeError, "stream_incomplete"): self.generate([{"choices": [{"delta": {"reasoning_content": "내부 추론"}}]}]) with self.assertRaisesRegex(RuntimeError, "reply_truncated"): self.generate([{"choices": [{"delta": {"content": "요점은 다음과"}, "finish_reason": "length"}]}])