암묵 신호: Message.DraftEdited(nullable)를 트윈 승인-발송 경로에서 클라이언트가
보낸 original_draft_text와 실제 발송 텍스트를 diff해 계산(사람 메시지·구버전
클라는 nil, 추측하지 않음). 명시 신호: Message.NaturalnessRating(nullable) +
POST /messages/:id/feedback(트윈 메시지만, 재제출은 덮어씀)로 "이 답장
나답아요?" 원탭 👍/👎을 채팅방에 비침투적으로(모달 아님, 한 번 탭하면
다시 안 보임) 노출. /admin/metrics에 draft_unedited_rate·
naturalness_positive_rate 추가(분모 0 zero-guard), 대시보드 카드 2개 추가.
PoC 실행이나 결론이 아니라 캡처 장치일 뿐 — 실제 자연스러움 %는 N5 이후
실 베타 데이터로 확정한다. docs/roadmap.md·deploy-checklist.md N4-12 동기화.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m
서버가 이미 모든 메시지를 DB에 durable하게 저장하므로 별도 큐를 새로
만들지 않고, 연결이 끊겼던 클라이언트가 그 DB에서 놓친 부분만 다시
받아오는 방식으로 풀었다.
- core-backend: GET /conversations/:id/messages?since_id=<id> 추가
(파라미터 없으면 기존 전체 히스토리 그대로, 비정상 값은 400, 최댓값
초과는 빈 배열). TestListMessagesSinceID로 확인.
- mobile/ws_client.dart: 소켓 onDone/onError에서 그냥 멈추던 것을
backoff(1s→2s→4s→8s→...→30s 캡, 성공 시 리셋) 재연결로 교체하고,
재연결 성공 신호를 reconnects 스트림으로 노출.
- mobile/chat_screen.dart: WidgetsBindingObserver를 추가해 소켓
재연결 신호 + didChangeAppLifecycleState(resumed) 양쪽에서 since_id
캐치업을 호출, 기존 메시지와 id 기준으로 중복 없이 병합.
- mobile/services/message_sync.dart: backoff 계산과 중복 없는 병합을
순수 함수로 뽑아 message_sync_test.dart에서 결정적으로 검증.
실제 소켓 재연결 타이밍이나 앱 백그라운드/포그라운드 전환에서 OS가
소켓을 어떻게 처리하는지는 단위 테스트로 증명할 수 없어 실기기
Android QA가 남아 있음 — roadmap.md/deploy-checklist.md N4-11에
부분 검증으로 기록.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014YSB5PqF38raTxP5ABgr9m