Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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의 통합 그래프 위에서 영속 수집 이벤트의 손상·역전·중복 페이지를
Expand Down
18 changes: 18 additions & 0 deletions desktop/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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 — 저장 원본 재조회와 실시간 이벤트의 분리

‘기억 정리’의 한 행은 대상·항목·시각·확정 단계를 간결하게 표시합니다.
Expand Down
5 changes: 5 additions & 0 deletions docs/ALDEN_CONTINUATION.md
Original file line number Diff line number Diff line change
@@ -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·실제 음성·서명·공증은 미검증.
Expand Down
53 changes: 53 additions & 0 deletions docs/architecture/alden-local-mlx-completion-20261011.md
Original file line number Diff line number Diff line change
@@ -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 소음 및 배터리/자원 부담을 비교한다. 서명 자격·공증 권한·배포 대상을 확인한 후에만 운영 앱을 전환한다.
73 changes: 67 additions & 6 deletions scripts/alden_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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)}}]}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions tests/test_alden_unit3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"}],
}
)

Expand Down Expand Up @@ -1175,7 +1175,8 @@ def test_generate_never_uses_reasoning_as_a_spoken_reply(self):
"message": {
"content": "",
"reasoning_content": "내부 추론을 음성으로 읽으면 안 됩니다.",
}
},
"finish_reason": "stop",
}
]
}
Expand Down
Loading