feat(diarychat): Screen 3 voice-to-text chatroom v1 (long-polling + AI welcome/auto-response)#41
Merged
Merged
Conversation
- Flyway V6: diary_chat_room (unique per diary), diary_chat_participant, diary_chat_message (source=user|ai|system with optional event_type metadata for system rows so join/leave/ai_toggle/ai_typing/ai_failed events share the message timeline and single poll cursor) - Kotlin entities with auto-managed counters (participantCount) and last_activity_at for pruning/freshness checks - DiaryChatRoom.aiAssistantEnabled defaults to true so rooms are chat-with-AI by default; host can disable - Spring Data repositories with helpers for cursor-based history (findBeforeDesc / findAfter), retention pruning (deleteOlderThan), and idempotent participant counting - Constants: 20-participant cap, 2000-char message length 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Endpoints (all require Bearer auth):
- POST /chatrooms idempotent by diaryId; first call
creates room, auto-joins creator as host, records participant_joined
system event. Rejects private diaries with DIARY_FORBIDDEN
- GET /chatrooms/{id} room metadata
- GET /chatrooms/{id}/participants
- POST /chatrooms/{id}/join idempotent; enforces 20-participant
cap (CHATROOM_PARTICIPANT_LIMIT 409)
- POST /chatrooms/{id}/leave host cannot leave (CHATROOM_FORBIDDEN)
- POST /chatrooms/{id}/ai-toggle host-only; broadcasts ai_toggle_changed
Architecture
- DiaryChatReader/Writer interfaces + impls wrap the JPA repositories
- DiaryChatRoomFacade orchestrates room lifecycle + event emission
- Events (participant_joined/left, ai_toggle_changed) persisted as
source=SYSTEM rows in diary_chat_message with JSON payload so both
messages and events share a single cursor (messageId)
- DiaryChatPollViewAssembler projects system rows into events[] and
user/ai rows into items[] for the poll API (M4)
- DiaryChatPollingHub skeleton (ConcurrentHashMap<roomId, DeferredResult>
registry) — subscribe/unsubscribe + notifyRoom hook; DeferredResult
wiring completes in M4
- DiaryChatAuthorResolver batches User lookup; defines the synthetic
AI author (userId=-1, username="Jamo AI")
Errors
- CHATROOM_NOT_FOUND (404), CHATROOM_FORBIDDEN (403),
CHATROOM_PARTICIPANT_LIMIT (409), POLL_CURSOR_EXPIRED (410)
- New GlobalExceptionHandler mappings for the 409/410 cases
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- POST /chatrooms/{id}/messages
- Requires caller to be a room participant (CHATROOM_FORBIDDEN)
- Rejects empty/oversize text with CHATROOM_MESSAGE_TOO_LONG (≤ 2000)
- Persists with source=USER, touches lastActivityAt, notifies pollers
- AI response hook is deferred to M5 (keyed on room.aiAssistantEnabled)
- GET /chatrooms/{id}/messages?before=&size=
- Cursor by messageId; default size 30, capped 1..100
- Fetches size+1 and drops the oldest row to detect hasMore
- Returns items ascending by messageId, oldestMessageId for paging
- Filters out SYSTEM rows so events never pollute the history view
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GET /chatrooms/{id}/messages/poll?after=&wait=
- Immediate DB check: returns at once if items/events exist after cursor
- Otherwise registers a DeferredResult subscription, configures
onTimeout → empty 200 response (never 408), onCompletion cleanup
- wait is clamped to 1..60 seconds to stay under typical gateway limits
- DiaryChatPollingHub holds per-room CopyOnWriteArrayList of
(after, DeferredResult); notifyRoom(roomId, supplier) asks the
assembler for each subscriber's own after and only wakes those whose
view is non-empty (no busy-spin on no-op notifications)
- Cursor validation
- after=0 always accepted (first connection)
- after beyond last message: accepted (client ahead of server)
- after refers to purged message OR createdAt > 7 days old:
410 POLL_CURSOR_EXPIRED so the client reloads history
- RoomFacade/MessageFacade switched to supplier form
`{ after -> assembler.pollForRoom(roomId, after) }` so every waiter
sees only the delta it needs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DiaryChatAiService.sendWelcome runs once when a room is first created; builds a Korean tutor prompt seeded with the diary's three lines and posts an AI message inviting participants to chat - DiaryChatAiService.respondToUserMessage runs after every user-source message (room.aiAssistantEnabled=true) using the last 20 non-system messages as conversational context. AI-source messages do not trigger recursion (loop guard at the entry) - AI flow announces ai_typing immediately and falls back to ai_failed when the model call throws; both ride the existing system-message event channel so pollers see them through the single cursor - Triggering uses Spring's ApplicationEventPublisher with TransactionalEventListener(AFTER_COMMIT) + @async, so the AI work starts only after the originating transaction commits and runs on the existing async executor (no nested-transaction reads of uncommitted rows) - ChatClient is reused (not /chat/generate persona service) with a chatroom-specific prompt; fallback strings keep the room alive on AI errors - DiaryChatMessageRepository.findRecentNonSystem + Reader.findRecentNonSystemMessages added for prompt history assembly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Daily cron at 04:00 UTC drops chat messages older than 90 days (covers user/AI/system rows). Reuses the existing @EnableScheduling context wired by AuthPropertiesConfig - Aligns with the 7-day POLL_CURSOR_EXPIRED window (any cursor that could survive retention is still recent enough to validate) - Transactional wrapper required by @Modifying delete query 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Flag §7 header as ✅ implementation complete - Add §7.0 decision matrix answering the seven open questions raised by the original PRD (AI subject/trigger, participant cap, retention, cursor expiry, async hop, permission policy) - Add §7.0.1 spec-vs-implementation diff: events stored on diary_chat_message via source=system + event_type (single cursor model); aiAssistantEnabled default flipped to true; AI identity (-1, "Jamo AI"); single-instance polling; host cannot leave; audioUrl stored as-is 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
docs/prd/backend_api_request.md§7(Screen 3) 백엔드 v1을 단일 PR로 구현했습니다. 핵심 결정 → AI 자동 응답(방 단위 토글, default ON) + 일기 3줄 기반 웰컴 메시지 + 롱폴링 단일 인스턴스 + 20명 정원 + 90일 보존.DiaryChatPollingHub가ConcurrentHashMap<roomId, List<DeferredResult>>로 fan-out.마일스톤별 커밋
feat(diarychat): add diary chat room domain entities and repositoriesdiary_chat_message의source=system + event_type으로 통합 (단일 cursor 모델)feat(diarychat): add chatroom CRUD, join/leave, AI-toggle endpointsfeat(diarychat): add message send and history endpointsfeat(diarychat): add long-polling endpoint with DeferredResultfeat(diarychat): add AI welcome and auto-response (Spring AI ChatClient)feat(diarychat): add 90-day message retention scheduler@Scheduled배치docs(prd): mark Screen 3 ... as implemented결정 7항 회신 (PRD §7.0 에도 기록)
ChatClient재사용 + 채팅방 전용 prompt(DiaryChatAiService). 페르소나/chat/generate재사용 XCHATROOM_PARTICIPANT_LIMIT409)Spec 대비 구현 차이 (PRD §7.0.1)
diary_chat_message.source=SYSTEM + event_type으로 통합. 응답 shape 은 spec 그대로 (items/events 분리). 이유:after={messageId}단일 cursor 정합성 보장aiAssistantEnabled기본값 — spec false → true 로 변경(사용자 결정)author.userId = -1,username = "Jamo AI"(별도 필드 없이 author 객체에 담김)CHATROOM_FORBIDDEN). 방 삭제는 v2신규 파일
CHATROOM_NOT_FOUND/FORBIDDEN/MESSAGE_TOO_LONG/PARTICIPANT_LIMIT,POLL_CURSOR_EXPIRED. ExceptionHandler 매핑 추가됨gpt-4o-mini(기존 설정 그대로). 별도 모델 설정 없음Test plan
POST /api/v1/chatroomsbody{diaryId, aiAssistantEnabled:true}→ 신규 생성 시 host 자동 join + AI 웰컴 메시지가 polling 으로 도착POST /messages후GET /poll?after=N&wait=25→ user 메시지 즉시 + 잠시 후 ai_typing 이벤트 + AI 응답 메시지 도착after가 7일 경과한 messageId 또는 무효한 id → 410 POLL_CURSOR_EXPIRED🤖 Generated with Claude Code