- gatherUserContext 제거 — 메시지마다 6개 소스를 병렬 조회했지만 USER_CONTEXT_TEMPLATE은 날짜·닉네임·응원팀 3개 필드만 쓴다. 시사 데이터를 도구 조회로 옮기면서 선주입 블록만 지우고 fetch는 남아 있던 것으로, 메시지당 33~64 read가 전량 폐기되고 있었다
- identityContext(Firestore read 0, 이미 로드한 user 문서만 사용)와 gatherSuggestionFlags(추천 질문 전용, 응원팀 미설정 시 일정 조회도 생략)로 분리. 소비되지 않던 필드와 fetchRecentTeamGames·formatRecentResults·formatTodaySchedule 삭제
- fetchScheduleMonth가 리필 직후 전 키(28~31 doc)를 재읽기하던 것을 byDate 메모리 병합으로 교체. loadMonthDayCache를 추출해 단일일 조회도 월 리필 결과를 재사용하게 하고, live 병합을 호출자에서 1회만 수행해 이중 적용을 막았다
- loadHistory를 2단계 페치로 변경. 위기 요청은 일일 한도를 우회하므로 위기 교환쌍 수에 상한이 없고, 그 쌍은 문서 2개를 먹고 윈도잉에서 둘 다 빠진다. 1차 페이지가 다 찼는데도 목표 턴에 못 미칠 때만 한 번 넓혀 재조회한다(평시 추가 쿼리 0회)
- 예약 트랜잭션이 계산한 used를 ReserveOutcome으로 돌려줘 응답 조립의 쿼터 재조회 제거(트랜잭션이 없는 replay·GET /chat/quota는 기존 read 유지)
- loadHistory가 threadExists를 반환해 저장 단계로 전달 — 같은 요청에서 thread 문서를 두 번 읽던 것 제거(위기 경로는 loadHistory를 안 거치므로 optional)
- upsertReport를 create 후 ALREADY_EXISTS 폴백 merge로 전환해 최초 신고의 사전 read 제거
- dailyArchive가 날짜별 인덱스만 읽도록 전환. 백필 마커가 없는 롤아웃 기간에는 원본도 함께 읽어 인덱스에 없는 uid만 보충한다 — "인덱스가 비었을 때만 폴백"으로 두면 미러 배포 당일처럼 인덱스가 부분적으로만 찬 날짜에서 배포 전 투표자가 판정·보상·스트릭 없이 영구 유실된다(그 날짜는 다시 처리되지 않는다)
- 순위표를 run 시작 시 1회 로드해 유저별 count aggregation을 제거(O(N x M) -> 이진 탐색). listAllRankedUsers의 프로젝션에 favoriteTeamCode 추가(문서당 과금이라 read unit은 불변). 로드 실패 시 기존 aggregation으로 폴백
- user 문서를 루프에서 1회만 읽어 computeRankSnapshot과 judgeDay가 공유 — 유저당 3 read 중 1개 제거. 판정 트랜잭션 내부 tx.get은 멱등 가드와 streak read-modify-write 구동에 필수라 유지. getUser 실패 시엔 undefined로 남겨 judgeDay가 재조회하게 한다(null을 넘기면 결석 판정이 조용히 꺼진다)
- reconcileDayVotes가 날짜 경기를 1회 확보해 getGame N+1 제거. run 스코프 Set으로 같은 경기의 processGameEndWithGame 중복 재처리 차단(byUid는 정지된 스냅샷이라 앞 유저가 치유한 경기도 뒤 유저에겐 미판정으로 보인다)
- settleDailyReward에 gameCache 파라미터 추가하고 games 조회를 트랜잭션 조기 return 가드 뒤로 이동 — no_history/already_settled/not_judged 재실행은 games read 0회로 끝난다
- 통산 예측·적중 롤링 카운터 도입(user 문서). computeStats가 전수 스캔 대신 카운터 + 올해 구간 range 조회 1회를 쓴다. 미백필 유저만 1회 스캔 후 백필하며, 이력이 없으면 기준선을 세우지 않는다(오늘로 잡으면 아직 아카이브되지 않은 어제 투표가 영구 누락된다). 기준선을 lastJudgedDate로 앞당기지도 않는다 — judgeDay가 판정 트랜잭션을 voteHistory 기록보다 먼저 커밋하므로 같은 누락이 생긴다
- stats period 범위 검증(연도 2024~올해, 월 1-12, 미래 날짜 거부)과 캐시 키 정규화 추가 — 검증이 없으면 임의 period 값으로 캐시를 매번 미스시켜 전수 집계를 강제할 수 있었고, 같은 주의 7개 날짜가 7개 캐시 엔트리를 만들었다
- precomputeScoreboardCache에 scopes 파라미터 추가 — 탈퇴 경로가 11개 전체 대신 overall과 본인 팀만 재계산(영향 범위가 그 둘뿐이고 existing은 이미 읽은 값이다)
- 테스트 16건 추가: 순위표 동등성 6건, 통산 집계·기준선 7건, dailyArchive 롤아웃 5건(신규 파일)
- /userVotesByDate/{date}/{uid}/{gameId} fan-out 인덱스 추가 — RTDB에는 부분 노드 읽기가 없어서 dailyArchive가 하루치를 처리하려면 /userVotes 트리 전체(전 유저 x 전 보존 날짜)를 내려받아야 했다
- 투표를 쓰는 3곳(submitVote, changeVote, processGameEndWithGame의 판정 결과 주입)이 원자 update 하나로 원본과 미러를 함께 기록
- 삭제 경로도 동기화: deleteUserVoteGame은 양쪽에서, deleteUserVoteIndex는 유저의 날짜 목록을 먼저 읽어 해당 날짜 미러만 정리
- database.rules.json에 userVotesByDate·userVotesByDateMeta를 read/write false로 추가 — 교차 유저 데이터라 서버 전용
- 백필 스크립트 추가(npm run backfill:vote-index). dry-run 기본, --apply로 500건씩 청킹 기록, 멱등. 완료 시 /userVotesByDateMeta/backfilledAt 마커를 남겨 잡이 원본 스캔을 그만두는 근거로 쓴다
- 미러 정합성 테스트 8건 추가
- gameRepository에 날짜 키 공유 캐시(MemCache, 15초) 추가 — 날짜별 경기 목록은 유저 독립 전역 데이터라 요청 간 공유할 수 있다. GET /prediction/games가 요청마다 range 쿼리를 재발행하던 것을 인스턴스당 분당 수 회로 수렴시킨다
- games 문서 쓰기 경로에 무효화 연결: syncGamesForMonth는 여러 날짜에 걸치므로 전량 폐기, forceSyncDay는 해당 날짜만
- createGameDayCache가 공유 캐시를 경유하도록 변경 — 요청마다 새로 만드는 짧은 수명 인스턴스도 실제 쿼리를 유발하지 않는다
- predictionHandlers의 games 분기에 Cache-Control public max-age=15 추가(무인증·전 유저 공통 응답이라 CDN·브라우저 중복 제거가 가능한데 그동안 헤더가 없었다)
- MemCache.getOrFetch가 truthy 대신 히트 여부로 판정하도록 수정 — 캐시된 null이 미스로 취급돼 fetcher가 매번 재실행되던 문제(존재하지 않는 상품 조회가 요청마다 Firestore read 유발)
- MemCache에 peek·clear 추가, 단위 테스트 6건 신설
- isAlreadyExistsError를 pointService에서 middleware/errors로 이동 — 레포지토리가 서비스를 import하는 계층 역전 없이 양쪽에서 쓰기 위함. pointService는 re-export로 기존 호출부 호환 유지
- createOrder에서 getWalletTx 사전 호출과 잔액 사전 검사 제거 — applyPointChangesTx가 같은 트랜잭션에서 동일한 HttpError(409, INSUFFICIENT_BALANCE)를 던지므로 동작은 같고 wallet 문서 tx.get이 2회에서 1회로 준다
- NotificationKey에 predictionRemind·jjaekTalk 추가(마케팅은 opt-in 맵에서 제외)
- GET/POST/PATCH /user 응답에 notifications 맵과 marketingOptIn 포함 — 클라가 서버 설정을 읽을 수 있게 함
- updateMe에 marketingOptIn 처리 추가: 동의 시각은 서버 serverTimestamp로 기록, 철회 시 동의 이력 보존 (기존에는 미파싱으로 단독 PATCH가 400)
- updateNotifications가 전체 맵 재기록 대신 patch 키만 merge 기록하도록 변경(동시 토글 유실 방지)
- userService 테스트 8건 추가(허용/차단 키, boolean 검증, 동의 시각 기록·보존, 기본값)
- backend-writes.md §1.1에 새 쓰기 필드·규칙 반영
- GET /stats/seasons 추가 — 유저의 시즌별 최종 성적(seasonHistory)을 최근 시즌부터 반환
- SeasonRecordDto로 응답 형태 고정, settledAt(Timestamp)은 UTC ISO 문자열로 변환해 유출 방지
- 정산 전 빈 목록·정렬·DTO 변환 테스트 추가
- config/season 문서(id/startDate/endDate)로 시즌 경계 정의 — 문서가 없으면 시즌제 비활성(기존 동작 유지)
- endDate 다음 날 dailyArchive에서 maybeSettleSeason 1회 실행: 랭킹 대상 전원의 최종 성적(tierPoints·티어·동점 동일 rank)을 users/{uid}/seasonHistory/{seasonId}에 아카이브 후 tierPoints 0 리셋·rankSnapshot 제거
- 유저별 아카이브+리셋을 같은 배치로 묶고 settledAt 마커는 전원 완료 후 기록 — 부분 실패 재실행 시 기정산 유저 점수를 collectionGroup으로 되읽어 순위 보존(멱등)
- 스트릭은 시즌과 무관하게 유지(이월 이득은 streakBonus 상한이 제한)
- seasonHistory 본인 읽기 전용 rules와 seasonId collectionGroup 인덱스 추가, 정산 시나리오 테스트 신규 작성
- appointAdmin을 setAdminRole로 교체 — isAdmin boolean 인자로 부여와 해제를 모두 처리
- 자기 자신의 관리자 권한 해제는 409(CANNOT_REMOVE_SELF_ADMIN)로 차단
- 비활성 유저 검사는 권한 부여 시에만 적용, 응답 형태를 {isAdmin, changed}로 변경
- 어드민 핸들러 라우트와 테스트를 새 시그니처로 갱신
- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
- shipped 전환 시 운송사·송장번호(shipment) 필수 검증 및 shippedAt/deliveredAt 기록, POST /admin/order/shipment로 배송정보 정정(배송 시작 이력에 감사 정보 동기화)
- refunded 전환 시 관리자 환불 사유(2~500자) 필수 — 주문 문서·상태 이력·포인트 원장(adminReason/adminActor)에 기록하고 DTO로 노출
- 상품 옵션에 active 필드 추가(구 문서는 활성 간주) — 비활성 옵션 주문은 OPTION_UNAVAILABLE 거부, 활성·교환 가능 상품은 활성 옵션 1개 이상 필요
- OrderDto/OrderStatusHistoryEntryDto/ProductOptionDto 확장 및 관련 테스트 보강
- 수정 파일 줄바꿈 LF 정규화 및 eslint --fix 적용
- 주문 생성 시 예약(홀드) 없이 즉시 차감하고 confirmed 로 시작 — 상태 흐름을 확정 → 배송중 → 배송완료(+어드민 환불)로 간소화
- 유저 취소 엔드포인트(POST /orders/:id/cancel)와 cancelOrder 제거 — 상태 전이는 어드민 전용, 되돌림은 환불 전이 하나로 통일 (차감 txId 를 reversalOf 로 링크)
- order_reserve/order_release 원장 타입과 reserve/capture/release op, 지갑 reservedBalance 제거 — 불변식을 available = earned - spent 로 단순화
- 이전 데이터 전면 삭제에 따라 2026-07 개편 이전 원장 문서용 레거시 정규화(StoredLedgerEntry, balanceAfter 역산, refMonth/refDay 합성)도 제거
- 주문/원장/지갑 테스트를 새 스키마로 갱신하고 orderService 에뮬레이터 테스트 6건 추가 (즉시 차감·잔액 부족·권한·전이 검증·환불 반환)
- GET /admin/users/search: 닉네임 접두어(Firestore orderBy+startAt/endAt) + 이메일 정확 일치(Auth 조회) + uid 정확 일치를 한 입력으로 통합 검색, uid 기준 중복 제거
- GET /admin/users/list: 가입일 내림차순 + 문서 id 커서 페이지네이션 (주문 목록과 동일 패턴)
- AdminUserDto 추가: 날짜는 UTC ISO 변환, 옵션 필드는 키 생략, streak/티어 등 lazy 보정 필드는 기존 원칙대로 제외, active 미백필 문서는 활성으로 간주
- 비활성(탈퇴) 유저도 검색 대상에 포함해 어드민이 상태를 확인할 수 있게 함
- 에뮬레이터 회귀 테스트 5건 추가 (접두어 검색·uid 일치·비활성 표시·빈 q 400·커서 페이지네이션)
- reversalOf 미지정 시 undefined가 원장 문서 스프레드로 흘러가 Firestore가 "Cannot use undefined as a Firestore value"로 지급 전체를 실패시키던 버그 수정
- 조건부 스프레드로 값이 있을 때만 필드를 포함하도록 변경
- 회귀 테스트 추가: reversalOf 없이 지급 시 원장에 키 자체가 없음 + 명시 시 그대로 기록됨 (에뮬레이터 vitest 2건 통과)
- 상품에 재고 없는 옵션(optionLabel/options — 예: 키링 색상)을 도입하고 주문 항목에 optionId/optionName 스냅샷 저장
- 주문 생성 시 옵션 검증: 옵션 상품에 미선택이면 OPTION_REQUIRED, 무옵션 상품에 optionId가 오면 INVALID_INPUT
- 같은 상품의 색상별 항목을 지원하도록 상품 문서를 productId당 한 번만 읽게 정리
- 재고는 별도 플랫폼에서 관리하므로 서버 재고 개념 전면 제거(ProductDoc 재고 필드, availableStock, 주문 예약/차감, stock/adjust 엔드포인트, order/status restock)
- 상세 카탈로그·주문 DTO에 옵션 노출, product/upsert가 optionLabel/options 수용
- 시드/업로드 스크립트의 재고 필드 기록 제거, vitest 계약 테스트 추가
- serverTimestamp 미해석 문서나 레거시 문서에서 toIso가 던져 GET /user가 500이 되는 경로 제거
- UserProfileDto.createdAt을 optional로 두고 toIsoOrUndefined 사용 — 값이 없으면 키를 생략한다
- 스프레드로 조립하던 기존 동작(키 생략)과 동일하게 맞춤
- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
- reward-products/ 이미지를 리사이즈(메인 1080px, 상세 너비 1440px) + WebP(품질 82)로 변환하는 공용 서비스 추가 (sharp)
- Storage 트리거 onRewardImageUploaded(us-central1)로 이후 업로드에도 자동 적용 — 다운로드 토큰을 보존해 기존 URL이 그대로 유효
- 기존 자산 일괄 최적화 스크립트(npm run optimize:reward-images) 추가, 실행 결과 38개 파일 135MB → 5.7MB
- 재처리 무한루프는 rewardImageOptimized 메타데이터 플래그로 방지
- 상품 카탈로그(products/) 신설: 공개 필드(name·pointPrice·이미지)와 재고(total/reserved/safety)를 한 문서로 관리, 목록·상세는 60초 인메모리 캐시로 서빙
- 주문 생성 시 포인트 예약(order_reserve)과 재고 예약을 단일 트랜잭션으로 원자 처리, 주문 ID를 uid_클라이언트멱등키로 고정해 더블탭·재시도 중복 생성 차단
- 주문 상태 머신(reserved→confirmed→preparing→shipped→delivered / cancelled / refunded) 전이 검증과 전이별 capture·release·refund 역거래 원장 기록
- reward HTTP 함수 신설: 지갑·원장 조회, 상품 목록·상세, 교환 자격, 주문 생성·목록·상세·취소 API
- 관리자 라우트 추가: 상품 등록·수정, 재고 조정(가용재고 음수 방지), 주문 상태 변경, 포인트 지급·회수
- Firestore 보안규칙(지갑 본인 read, 상품·주문은 API 전용)과 orders 복합 인덱스(uid+createdAt, status+createdAt) 추가
- 상품 4종 시드(seed:rewards)와 상세 이미지 업로드(upload:reward-assets) 스크립트 추가
- 지갑 문서(users/{uid}/wallet/current)와 문서 ID=멱등키 원장(pointLedger/{txId})으로 포인트 엔진 교체 — 기존 balanceAfter 최신 row 조회 방식 폐기
- 모든 포인트 변경은 pointService.applyPointChangesTx 단일 경로로 처리, available+reserved == totalEarned-totalSpent 불변식을 매 커밋 검증
- 출석 리워드 개편: 일일 20P, 연속 5일 +50P(사이클당 1회), 10일 단위 +100P — attendance/state 문서에 스트릭 상태 저장, 주간·월간 보너스 폐기
- 승부예측 일일 리워드 정산 신설: 전체 참여 50P + 성공 100P + 퍼펙트 50P, judgeDay 이후 voteHistory.rewardSettledAt 플래그와 원장 멱등키로 배치 재실행에도 중복 지급 차단
- 관리자 포인트 지급·회수(adminPointService)와 수동 재정산 디버그 라우트(/debug/settle-reward) 추가
- 소비처 없던 티켓 시스템(dailyAllKill·weeklyMaster)과 위클리마스터 판정 흐름 전체 제거 — StatsResponse.tickets 필드 삭제로 클라 응답 스키마 변경
- 종료 스코어를 "원정:홈" 위치 표기에서 팀명 순서(우리팀:상대)로 정렬 — 순서 불일치로 모델이 홈 승리(8:3)를 "3:8 패배"로 오독하고 패배 서사까지 지어내던 문제 수정
- formatLineupResult 종료 스코어에 승패 명기 추가(기존엔 승패 표기 없음)
- 원정 관점 회귀 테스트 추가. 프로브 실측: 동일 질문 5회 중 오독 2회 → 0회
- get_team_rank_snapshot에 vs 인자 추가 — 순위표의 팀간 승패표(vsRecords)에서 두 팀의 올 시즌 맞대결 승패무를 반환
- formatH2hResult 순수 함수 분리 및 단위 테스트 추가
- 오늘 상대가 아닌 임의 팀과의 상대 전적 질문을 도구 1회 호출로 처리(일정→전적 체이닝도 가능). 기존에는 vsRecords가 도구 계층에서 버려져 모델이 접근할 경로가 없었음
- 도구 호출 후 모델이 빈 응답을 반환하는 경우, 검열 문구 대신 재시도를 유도하는 `EMPTY_REPLY_NOTICE`를 표시하도록 로직을 개선했습니다.
- `chatProviderService`에 무텍스트 STOP 응답 방어 로직을 추가하여, 함수 호출 모드를 NONE으로 강제한 후 1회 재시도를 수행하도록 처리했습니다.
- 분석 서비스의 스키마 버전을 2로 올리고, `ChatOutcome`에 `empty` 타입을 추가하여 빈 응답 사례를 필터링과 구분하여 통계에 기록합니다.
- 회원 탈퇴 시 즉시 삭제 대신 `active: false` 및 `deactivatedAt`을 기록하여 30일간 데이터를 유예하는 소프트 삭제 방식을 도입했습니다.
- 경기 취소 시 투표를 삭제하는 대신 `cancelled: true`로 마킹하여 참여 흔적을 보존하고, 통계 집계에서만 제외하도록 로직을 변경했습니다.
- 유예 기간이 지난 비활성 계정을 영구적으로 파기하는 `accountPurge` 스케줄러를 추가했습니다.
- debug/chatProbe에 messages 배열을 받는 멀티턴 경로 추가 — 전체 대화를 stateless로 리플레이해 atkgen 등 대화형 probe 지원
- 빈/누락 content를 400 대신 200으로 안전 반환 — 외부 보안 스캐너(garak)가 4xx를 치명적 에러로 보고 런 전체를 중단하는 문제 방지
- chatProbeService에 runChatProbeConversation/runMessages 추가(저장·쿼터 없는 읽기 전용 경로 유지)
- `ChatToolCallInfo` 타입에 `label` 필드를 추가하여 클라이언트가 도구 호출을 직관적인 칩 형태로 표시할 수 있도록 개선했습니다.
- `chatToolService`에 도구별 라벨 매핑 테이블(`TOOL_LABELS`)과 이를 적용하는 `withToolLabels` 함수를 구현하여, API 응답 시점에만 라벨이 동적으로 결합되도록 설계했습니다.
- 기존 저장소(Firestore) 로직에는 영향을 주지 않으면서, 메시지 조회 및 전송 결과 반환 시점에 라벨을 주입하여 표현 계층의 유연성을 확보했습니다.
- 테스트 코드를 수정하여 도구 호출 응답 및 이력 조회 시 라벨이 정확하게 포함되는지 검증했습니다.
- 모델이 화면 안내 시 [[NAV:route]] 마커를 붙이면 서버가 검증·제거 후 actions[]로 응답·이력에 실어, 클라가 해당 화면으로 이동하는 버튼을 렌더할 수 있게 함
- chatNavService(마커 파서 + APP_ROUTES 12개 라우트·라벨), NavAction/AppRoute 타입, finalizeExchange 저장·멱등 재반환·이력 매핑까지 전 경로 연결
- 위기/필터로 교체된 응답에는 액션을 붙이지 않고, 목록 밖 route는 드롭(모델 환각 방어)
- actions는 route+label만 전달(실제 경로 매핑·이동은 클라가 보유)
- 분석 이벤트에 navRoutes/navCount 추가, chatNavService 단위 테스트 추가
- 채팅 서비스의 관측성을 확보하기 위해 `chatAnalyticsService`를 도입하여 주요 이벤트(요청 결과, 토큰 사용량, 지연 시간 등)를 구조화된 로그로 기록합니다.
- 개인정보 보호를 위해 메시지 본문은 제외하고 길잇값과 메타데이터 위주로 수집하며, 비동기 로그 싱크를 통해 서비스 응답 지연을 방지했습니다.
- `sendMessage`의 각 단계(차단, 에러, 정상 응답 등)에 로깅을 통합하고, 테스트 코드를 통해 스키마 준수 및 필드 파생 로직의 정확성을 검증했습니다.
- 실시간 중계 상황을 직접 보고 있는 것처럼 말하는 페르소나의 암시를 제거하고, 라이브 스코어 제공 불가 안내를 명확히 했습니다.
- 'bestfriend'와 'commentator' 스타일 모두에서 사용자를 훈계하거나 건전한 응원을 강요하는 설교적 프롬프트를 배제했습니다.
- 사용자의 승리 주장 시 상황을 의심하거나 초를 치지 않고 자연스럽게 호응하도록 유도하며, 점수판 농담 등 스타일별 리액션 가이드를 구체화했습니다.
- 팀 홈구장 정보를 오늘 경기 장소로 단정하지 않도록 금칙을 추가하고, 관련 테스트 케이스를 보강하여 프롬프트의 일관성을 검증했습니다.
- 정중한 해설위원 톤의 "commentator" 스타일 팩을 추가하고 STYLE_PACKS에 등록했습니다(bestfriend/commentator 2종).
- 팀 페르소나에서 말투 요소를 제거했습니다: 반말 샘플 문장을 걷어내고, 1인칭("나"/"저")을 팀 블록이 아니라 스타일 팩이 정하도록 옮겼습니다. 팀 페르소나는 이제 팀색(연고·정서·라이벌·금기·어휘)만 담습니다.
- 공통 프롬프트의 역할 분리를 명확히 했습니다: 말투·캐릭터·1인칭은 스타일이, 팀색은 팀 페르소나가 정하며 팀 페르소나는 말투를 지정하지 않습니다.
- 대화 스타일(3부 기본 스타일 + 짹 아키타입)을 chatStyles.ts의 스타일 팩으로 분리하고, 현재 "익명 야구방 상주러" 스타일을 "bestfriend" id로 등록했습니다.
- COMMON 시스템 프롬프트는 1부 안전·데이터, 2부 앱 능력만 고정으로 남기고 3부 자리에 {{styleBaseStyle}} 플레이스홀더를 두어 스타일 팩에서 주입하도록 했습니다.
- ChatConfig에 stylePack 필드(기본 bestfriend)를 추가해 config/chat.stylePack으로 코드 배포 없이 스타일을 교체할 수 있게 했습니다.
- 조립 결과는 기존과 본문 동일(3부 헤더 주석 1줄 외)로 동작 변화 없이 구조만 모듈화했습니다.
- 공통 프롬프트를 1부 안전·데이터 정책 / 2부 앱 능력 정책(확정 데이터 vs 라이브 데이터 구분) / 3부 기본 대화 스타일의 고정 3층으로 재구성했습니다.
- 캐릭터·말투·팀색을 교체 가능한 4층(짹 페르소나 팩 + 팀 페르소나)으로 분리하고, 친절한 상담형 AI 톤 대신 "익명 야구방 상주러" 반응성 톤으로 전환했습니다.
- 두산(OB)에 실제 팀 페르소나 팩을 추가하고 DEFAULT_TEAM_PERSONAS에 등록해, generic placeholder 대신 팀색이 살아나도록 했습니다.
- 말버릇 "짹" 사용을 희소화하고, 사용자가 주장한 결과는 대화 전제로 수용하되 공식 수치는 도구 확인 없이 단정하지 않도록 했습니다.
- 시스템 프롬프트에서 시사적 데이터(경기 결과·일정·통계 등)를 제거하고, 정체성 정보만 남겨 모델의 '도구 우선 사용' 원칙을 강화했습니다.
- 지식 수준별 가이드를 별도 모듈로 분리하여 시스템이 직접 주입하도록 개선함으로써 교차 유저 캐시 히트율을 최적화했습니다.
- 사용자의 감정적 반응에 우선순위를 두도록 대화 지침을 보완하여 AI의 공감 능력을 향상하고 불필요한 정보 제공을 방지했습니다.
특정 uid로 실제 도구·모델 루프를 돌려 호출된 도구·토큰·속도·캐시 히트를
보고하는 /debug/chatToolsSheet, /debug/chatProbe(Sheet) 엔드포인트를 추가한다
(요청별 model 오버라이드로 A/B 가능). prod에 같은 검증을 돌리는
scripts/chat-tools-sheet.ts도 추가한다.
무인증 + 임의 uid의 개인 데이터를 노출하는 검수용 임시 엔드포인트다.
기본 모델을 gemini-3.1-flash-lite로, 전 모델에 공평한 24초 호출 예산과
2048 토큰 출력 한도로 맞추고, Gemini 3 모델은 thinkingLevel을 쓴다.
개인화 값(닉네임·지식수준)을 공통 프롬프트에서 빼서 모든 유저 공통의
정적 prefix로 만들어 Vertex 교차-유저 implicit caching 히트율을 높인다.
순위(get_team_rank_snapshot)·출석(get_my_attendance_this_month)·예측 복기
(get_prediction_breakdown_date) 도구를 추가하고, 상대 날짜(dayOffset)와
리그 전체 로스터 조회를 지원한다.
모델이 호출한 도구(이름·인자)를 전송 응답과 메시지 이력에 함께 실어
클라이언트가 나중에 활용할 수 있게 한다.
- 특정 경기에서 활약한 선수(WPA 상위)를 조회하는 `get_game_standouts` 도구를 추가하고, 시스템 프롬프트에 과거 기록 검증 로직을 강화했습니다.
- Gemini 3.0 이상 모델의 Thinking 설정을 지원하도록 Provider 계층을 개선하여 모델 세대별 최적의 추론 환경을 구성했습니다.
- 도구 우선 사용 원칙을 구체화하여 AI가 경기 데이터의 사실 관계를 스스로 확인하고 답변하도록 환각 방지 로직을 보완했습니다.
- KBO 경기 라인업(타순) 및 선수 기록을 실시간으로 조회하는 AI 도구(Function Calling) 체계를 구현했습니다.
- Anthropic 및 Google Gemini 모델의 Tool-loop를 지원하도록 Provider 계층을 개선하여, 시점 의존적인 데이터를 모델이 필요할 때만 스스로 조회하도록 최적화했습니다.
- 시스템 프롬프트에 '도구 우선 사용' 원칙을 추가하여 환각(Hallucination) 현상을 방지하고, 미발표 정보나 경기 미진행 시 정확한 사실 확인 로직을 강화했습니다.
- AI 챗봇 '짹'의 페르소나 정의 및 시스템 프롬프트 상수를 추가했습니다.
- 채팅 이력, 쿼터 관리, 요청 멱등성 보장을 위한 Firestore 데이터 모델을 구현했습니다.
- 위기 상황 대응(자해·위해 예고 등) 및 입력/출력 필터링 파이프라인을 구축했습니다.
- Gemini 및 Anthropic 모델을 지원하는 AI Provider 추상화 계층을 마련했습니다.
getScoreboard loaded the full user doc just to use 5 fields. Add
getUserForScoreboard using getAll with a fieldMask so only displayName,
photoUrl, tierPoints, favoriteTeamCode, and rankSnapshot are fetched,
cutting per-request bandwidth. The short-lived me-rank cache (meRankCache,
30s) already covers the rank-count side of R5.
W5: getMe synced photoUrl on every read whenever the token URL string
differed, but providers (Google) rotate query params (=sN-c sizing) for
the same image, causing write churn on a read path. Compare only the URL
before '?' so the write fires only on a genuine photo change.
R6: createMe/updateMe re-read the user with getUser right after writing.
Synthesize the response instead — updateMe from the already-read doc plus
the applied patch; createMe from the written inputs (createdAt approximated
as now, since the stored value uses serverTimestamp and later reads reflect
it). Removes one Firestore read per onboarding/profile edit.
dailyArchive wrote users/{uid} twice per cron run: once for rankSnapshot
and once for the judgment. Extract the read-only computeRankSnapshot, then
pass the pre-judgment snapshot into judgeDay so applyDailyJudgmentTx merges
it into the same patch as the judgment write. The snapshot is computed from
current (pre-judgment) tierPoints before the transaction, preserving the
"rank BEFORE judgment" semantics; on the idempotent guard-skip path no
write occurs (and stale re-snapshotting is avoided). snapshotRankForUser is
kept as a thin wrapper for its existing callers/tests.
waitForCache, the schedule month-poll loop, and getOrFetchDynamic all
polled Firestore every fixed 500ms (up to ~50 reads) while waiting for a
lock holder to populate the cache. Replace the fixed interval with a
shared backoffDelayMs helper (300ms base, doubling, 5s cap) bounded by the
same ~25s timeout, cutting per-waiter read amplification by roughly 5x.
Timeout/fallback semantics are unchanged.
During dailyArchive, reconcileDayVotes may call processGameEndWithGame
per unjudged game, each invalidating every voter's stats cache. Since
dailyArchive already invalidates each archived user once at the end of
its per-user loop, the per-game fan-out is redundant. Add an opt-in
skipInvalidate flag and use it from reconcile. The live onGameCompleted
trigger path keeps invalidating as before. Stale data is impossible:
getStats also self-invalidates on the daily forDate rollover.
dailyArchive wrote the voteHistory doc twice per active user per day:
once data-only before judgeDay, then again with judgment fields inside
judgeDay. Drop the pre-write so judgeDay is the single writer in the
common path. To keep data safety: judgeDay now persists the doc before
userVotes is removed; on the idempotent guard-skip path judgeDay restores
data only if the doc is missing (crash-after-tx recovery); and if judgeDay
throws, dailyArchive still saves data-only before removal, preserving the
prior failure-path behavior. judgment/points/streak results unchanged.