Skip to content

CS 리포트 기능 요구사항 (2026-09) ​

상태: Draft v3 (2026-09-01) — v2: 외부 작업관리 툴 연동 제거 → AI 에이전트 트리아지(§8). v3: 엔드포인트 자기 등록·발견(§6.7) 추가, 접수번호에 엔드포인트 코드 포함 확정(Q2)

대상: hereby 전 앱(Admin · HR · Works(직원앱) · Team Manager · 총무 · 경영 · 재고 · 드라이브) + system-admin 콘솔

범위: 사용자가 불편·버그를 겪었을 때 화면 스크린샷 + 클라이언트 로그 + 실행 환경을 한 번에 담아 서버로 보내고, AI 에이전트가 1차 분류·진단하고, 운영자가 콘솔에서 확인·처리하는 기능

1. 문서 목적 ​

사용자가 서비스를 쓰다가 "이게 안 돼요"를 겪는 순간, 지금은 카톡·전화로 말로 설명하고 운영자가 되물어 재현하는 방식이다. 이 문서는 그 순간을 앱 안에서 한 번의 동작으로 접수하게 만드는 기능 — 이하 CS 리포트 — 의 요구사항을 정리한다.

핵심 원칙 네 가지:

  1. 사용자는 두 가지만 한다 — 무슨 일이 있었는지 한 줄 쓰고, 보내기를 누른다. 스크린샷·로그·환경 정보는 시스템이 자동으로 채운다.
  2. 운영자가 재현하지 않아도 되는 정보를 담는다 — 어느 조직·어느 직원이·어느 앱의 어느 화면에서·어떤 API 호출이 어떤 오류로 실패했는지가 리포트 한 건에 들어 있다.
  3. HR·급여 데이터가 리포트에 새지 않는다 — 로그·스크린샷은 민감 정보 마스킹을 거친 뒤에만 저장한다. 리포트는 조직 데이터의 일부로 취급하며 조직 격리 원칙이 그대로 적용된다.
  4. 리포트는 사람보다 먼저 AI 에이전트가 읽는다 — 접수된 리포트의 1차 독자는 분류·중복 탐지·진단 초안을 만드는 에이전트이고, 운영자는 그 제안을 확인하는 사람이다. 그래서 리포트는 처음부터 에이전트가 소비할 수 있는 구조화된 데이터로 저장한다(§6.1·§8). 외부 작업관리 툴(GitHub Issues·Jira 등)은 쓰지 않는다 — 작업 항목은 hereby 안의 cs_reports가 곧 원장이며, 처리 자동화는 AI 에이전트가 맡는다.
text
[사용자] 문제 발생 ─▶ 리포트 버튼(오류 다이얼로그/헤더/단축키) ─▶ 리포트 모달
                          스크린샷 자동 캡처 · 로그 링버퍼 첨부 · 환경 정보 자동 수집
                                        │ csReport.create (tRPC)
                                        ▼
[API]  cs_reports + cs_report_attachments 저장 (organizationId 격리, S3 presigned 업로드)
                                        │ 접수번호 회신 · 트리아지 에이전트 트리거
                                        ▼
[AI 에이전트]  분류 · 중복/유사 탐지 · requestId ↔ 서버 로그 상관 · 진단 초안 · 회신 초안
                                        │ 제안(triage) 저장 — 확정은 사람이
                                        ▼
[system-admin 콘솔] 전 엔드포인트 fan-out 목록 ─▶ 상세(스크린샷·로그 뷰어·에이전트 제안 카드) ─▶ 승인/수정·상태 변경
                                        │                              │ (Phase 3) 수정 작업 에이전트 핸드오프
                                        ▼                              ▼
[사용자] 인앱 알림으로 처리 결과 수신 · 내 리포트 목록에서 상태 확인     재현 테스트·수정 PR 초안 → 사람 리뷰

2. AS-IS — 이미 있는 것과 없는 것 ​

축현재 상태재사용 여부
문의 접수inquiry.router.ts — 랜딩 도입 상담 공개 접수구. DB 저장 없이 영업 메일 발송만. Inquiry 엔티티 없음재사용 안 함(영업 리드 vs 서비스 CS는 축이 다르다). 이름 충돌을 피해 csReport로 명명
파일 업로드storage.router.getUploadUrl — S3 presigned PUT(로컬 폴백 있음), category enum(profile/document/message/notice/drive), ALLOWED_MIME_TYPES, buildStorageKey(organizationId, category, …)✅ 재사용. category: "cs-report" 추가
서버 로거@hereby/core pino 로거 — 민감 키 리댁션(SENSITIVE_KEYS, redactString), requestId: true 옵션✅ 리댁션 규칙을 클라이언트 로그 정제에 동일 목록으로 재사용
클라이언트 로거packages/sdk/src/utils/logger.ts — console 출력 전용. 메모리 보관 없음 → 리포트에 붙일 "최근 로그"가 존재하지 않음❌ 링버퍼 신설 필요(§5.2)
전역 오류 훅window.onerror / unhandledrejection 리스너 0건(전 앱). tRPC onError는 서버에서만 로깅❌ 신설 필요(§5.2)
요청 상관 IDFastify req.id는 존재하나 응답 헤더로 내려주지 않음. 클라이언트가 실패한 호출을 서버 로그와 짝지을 수단 없음❌ x-request-id 응답 헤더 + tRPC errorFormatter data.requestId 신설(§5.3)
앱 빌드 버전Vite define 없음, VITE_APP_VERSION/git sha 노출 없음. 직원앱은 OTA 번들(ota_bundles.bundleVersion) 개념만 있고 클라이언트가 자기 번들 버전을 모른다❌ 빌드 시 주입(§5.4)
감사 기록audit.service.ts — redactAuditInput(민감 키 + 4000자 제한)✅ 입력 정제 패턴 재사용
운영자 콘솔tools/system-admin — 탭 구조(organizations/attendance/…), 전 엔드포인트 fan-out(orchestrator/fanout.ts, 부분 실패 정상), systemProcedure(OPERATOR 전용)✅ cs-reports 탭 추가, 목록은 fan-out
엔드포인트 레지스트리orchestrator/endpoints.ts — SYSADMIN_ENDPOINTS env(JSON) 수동 등록·pull 전용. 서버가 스스로 알릴 길이 없어, env 등록을 빼먹은 서버는 콘솔에 보이지 않는다❌ 자기 등록(announce/heartbeat) + 콘솔 로컬 DB 레지스트리 신설(§6.7). env는 dev 부트스트랩용으로만 유지
알림NotificationEvent/Delivery, NotificationType.SYSTEM, in_app/push. 이메일은 email.service.ts sendEmail✅ 처리 결과 회신 = SYSTEM 알림, 운영자 접수 알림 = 메일
다이얼로그showError/showAlert(@hereby/components) — 오류가 사용자에게 보이는 유일한 공식 경로✅ 오류 다이얼로그에 「리포트 보내기」 진입점(§4.1)
모바일직원앱 Capacitor 8 — @capacitor/app(버전 조회 가능), @capacitor/privacy-screen 활성(백그라운드 시 화면 가림)웹뷰 캡처는 DOM 기반이라 privacy-screen 영향 없음. 네이티브 스크린샷 플러그인은 도입하지 않음(§5.1)
AI 에이전트 코어services/ai/agent.service.ts — Claude tool-use 루프(self-hosted), 프로바이더 추상화, AiModule 단위 모델 설정, 호출 기록(ai_conversations/ai_messages), 사용자 피드백(AiFeedbackRating). doc_embeddings(pgvector, Voyage 1024d) + raw SQL 유사도 검색 패턴. 설계: design-ai-architecture.md✅ 재사용. AiModule.CS_TRIAGE 모듈 추가, tool은 리포트 도메인 read-only 래퍼로 등록(§8). 유사 리포트 검색은 doc_embeddings와 같은 raw SQL 패턴

결론: 저장·업로드·알림·콘솔·에이전트 골격은 있으므로 신규 작업의 중심은 ① 클라이언트 수집 계층(스크린샷·로그 링버퍼·환경·오류 훅), ② cs_reports 도메인(엔티티·라우터·권한), ③ 운영자 콘솔 탭, ④ 트리아지 에이전트(모듈·tool·제안 카드) 네 가지다.


3. 용어 ​

용어정의
리포트(CS Report)사용자가 접수한 한 건. 제목·설명·유형 + 자동 수집 컨텍스트 + 첨부로 구성
컨텍스트(context)시스템이 자동으로 채우는 실행 환경 스냅샷 — 앱·화면·빌드·브라우저/OS·조직·직원·세션 종류
로그 링버퍼클라이언트 메모리에 최근 N건의 로그·오류·API 호출 요약을 순환 보관하는 구조. 리포트 첨부 시에만 서버로 나간다
오류 스냅샷오류 다이얼로그가 떴을 때 그 오류(메시지·코드·requestId·스택)를 잠깐 붙들어 둔 것. 사용자가 그 다이얼로그에서 리포트를 열면 자동 첨부
접수번호사용자에게 보여 주는 리포트 식별자. UUID가 아니라 사람이 읽고 전화로 부를 수 있는 형식 CS-{엔드포인트 코드}-YYMMDD-NNNN(예 CS-KRA-260901-0012). 코드가 들어가므로 엔드포인트가 여러 개여도 번호 하나가 전역 유일(§6.7.1)
운영자PlatformRole.OPERATOR 계정. system-admin 콘솔에서 리포트를 본다
트리아지(triage)접수된 리포트를 분류·우선순위 지정·중복 판정·담당 모듈 추정하는 1차 처리. 이 문서에서는 에이전트가 제안하고 운영자가 확정하는 두 단계를 합쳐 부른다
오류 지문(fingerprint)같은 원인의 리포트를 묶기 위한 해시 — appId + route(동적 세그먼트 정규화) + 오류 code + 스택 상위 3프레임(파일·함수, 줄번호 제외). 빌드가 달라도 같은 버그면 같은 값이 되도록 줄번호를 뺀다
에이전트 제안(triage proposal)에이전트가 리포트에 남기는 구조화된 결과물(§8.2). 운영자가 승인하기 전에는 상태·우선순위를 바꾸지 않는다
엔드포인트독립 API 배포 단위(별도 계정·DB). 부팅 시 ENDPOINT_CODE로 system-admin에 스스로 등록하고(§6.7), 리포트는 엔드포인트별 DB에 저장되며 콘솔이 fan-out으로 모아 본다
엔드포인트 코드배포마다 부여하는 2~6자 식별자(ENDPOINT_CODE, 예 KRA). 레지스트리 id이자 접수번호의 일부. 유일성은 등록 시 instanceId 대조로 콘솔이 검증한다

4. 사용자 시나리오 (직원·관리자 측) ​

4.1 진입점 — 세 곳, 모두 같은 모달 ​

진입점위치자동 첨부
A. 오류 다이얼로그showError 다이얼로그 푸터에 「리포트 보내기」 보조 버튼오류 스냅샷(메시지·코드·requestId·스택) + 다이얼로그가 뜬 시점의 스크린샷
B. 상시 진입데스크톱 앱: 페이지 헤더 우측 「도움말」 드롭다운 항목 「불편·오류 신고」. 직원앱: 「더보기」 탭 메뉴 항목현재 화면 스크린샷
C. 단축키데스크톱 앱 Shift+F1(기존 단축키 모달과 충돌 없는 키)현재 화면 스크린샷
  • FAB(우하단 플로팅)은 추가하지 않는다 — 이미 메신저·AI 두 개가 있고 --ds-fab-dodge 비킴 계약이 세 번째를 감당하도록 설계되지 않았다. 리포트는 상시 노출 가치보다 "오류 순간(A)"의 접근성이 중요하다.
  • A 진입점은 기존 showError 시그니처를 깨지 않는다. showError(opts)에 report?: { error: unknown }을 선택 옵션으로 받고, 옵션이 없어도 마지막 오류 스냅샷이 5초 이내면 자동 연결한다.

4.2 리포트 모달 — 사용자가 보는 것 ​

일반 모달(.ds-modal) 한 장. 위에서 아래로:

  1. 스크린샷 미리보기 — 캡처된 이미지 썸네일. 우측 상단에 「다시 찍기」「제외」. 캡처 실패 시 썸네일 자리에 .ds-notice--info 「이 화면은 자동 캡처가 되지 않아 첨부되지 않았어요. 필요하면 파일로 추가해 주세요.」
    • 미리보기 위에서 드래그로 사각형 가리기(검은 블록) 가능 — 사용자가 본인 판단으로 주민번호·급여액 등을 가릴 수 있어야 한다. 가림은 이미지에 굽고(burn-in), 원본은 보내지 않는다.
  2. 유형 세그먼트(Segment 컴포넌트): 오류 · 불편/개선 · 기능 요청 · 문의. A 진입점은 「오류」, 상시 진입(B·C)은 「불편/개선」 기본 선택 — 상시 진입의 주 용도가 UI 불편·기능 제안이기 때문이다(2026-09-02 결정).
  3. 제목 — 필수, 최대 80자. placeholder 「무엇을 하려다 어떤 문제가 있었나요?」
  4. 설명 — 선택, 최대 2,000자.
  5. 추가 파일 — 선택, 최대 3개, 각 10MB, 이미지·PDF·텍스트·zip(ALLOWED_MIME_TYPES 범위).
  6. 자동 첨부 안내 — 접힌 details 「함께 전송되는 정보」: 앱·화면 주소·앱 버전·브라우저/OS·최근 로그 N건·오류 정보. 펼치면 실제 값을 보여 준다(투명성). 로그 첨부 해제 체크박스 제공 — 기본 켜짐.
  7. 연락 회신 동의 — 체크박스 「처리 결과를 앱 알림으로 받을게요」 기본 켜짐.
  8. 푸터: 「취소」 / 「보내기」(primary). 전송 중 버튼 스피너(ic-spinner), 모달 잠금.

전송 완료 → 모달이 성공 상태로 바뀜: 접수번호 크게 표시 + 「접수번호를 복사」 + 「닫기」. 성공 토스트가 아니라 모달 안에서 보여 주는 이유는 접수번호를 전화로 부를 수 있게 남겨 두기 위해서다.

4.3 내 리포트 ​

  • 위치: 데스크톱 앱 「내 정보」(MyInfoForm) 모달에 「내 리포트」 탭. 직원앱은 「더보기 → 불편·오류 신고 → 내 신고 내역」.
  • 목록: 접수번호 · 제목 · 유형 · 상태 라벨(.ds-label) · 접수일. 상세: 내가 쓴 내용 + 운영자 회신(있을 때). 자동 수집 컨텍스트와 로그는 사용자에게 다시 보여 주지 않는다(운영자용 데이터이며 화면 공간 대비 가치 없음).
  • 열림 상태(접수·확인중)인 리포트는 사용자가 취소할 수 있다(soft delete, deletedBy 기록). 처리중 이후는 취소 불가.

4.4 오프라인·전송 실패 ​

  • 전송 실패(네트워크·5xx) 시 모달을 닫지 않고 .ds-notice--danger 「지금은 보내지 못했어요. 다시 시도해 주세요.」 + 「다시 보내기」. 입력 내용·스크린샷은 유지된다.
  • 직원앱: 실패한 리포트는 @capacitor/preferences에 초안 1건을 보관하고, 다음 앱 실행 시 「보내지 못한 신고가 있어요」 배너로 재전송을 제안한다. 초안 보관 시 로그·스크린샷은 마스킹이 끝난 형태만 저장한다(원본을 기기에 남기지 않는다).
  • 로그인 전 화면(로그인 실패 등)에서의 리포트는 1차 범위 밖. 인증된 사용자만 접수한다(스팸·조직 귀속 문제). 로그인 페이지의 오류 다이얼로그에는 「리포트 보내기」 버튼을 노출하지 않는다.

5. 자동 수집 계층 (클라이언트) ​

packages/components에 CsReport 컴포넌트(모달·상태)와 cs-report/ 수집 유틸을 두어 8개 앱이 같은 코드를 쓴다. 앱별로 하는 일은 진입점 마크업 배치와 createAlpinePlugin({ appId }) 지정뿐이다.

5.1 스크린샷 ​

항목요구사항
방식DOM 기반 캡처(html-to-image 또는 html2canvas 계열, 번들 크기·CSP 호환 기준으로 하나 선택). getDisplayMedia는 사용자 프롬프트가 뜨고 모바일 웹뷰에서 불가하므로 쓰지 않는다. 네이티브 스크린샷 플러그인도 쓰지 않는다 — 웹뷰 안의 DOM이 곧 화면이며, 권한 요청 없이 동일 코드로 8개 앱을 덮는다
시점A 진입점: showError 호출 직전 화면(다이얼로그가 덮기 전). 캡처는 비동기이므로 다이얼로그 렌더 전에 캡처를 시작하고 결과를 스냅샷에 붙인다. B/C: 모달이 열리기 직전
대상document.body 전체 뷰포트. iframe 임베드(admin 안의 drive·attendance 등)는 iframe 내부가 캡처되지 않는다 — 임베드된 앱은 자기 프레임 안에서 자체 리포트 진입점을 갖고, 상위(admin)에서 연 리포트는 iframe 영역이 빈 회색으로 남는 것을 허용한다. 리포트 컨텍스트에 embedded: true와 부모 URL을 기록해 운영자가 알 수 있게 한다
자동 마스킹캡처 전에 data-cs-mask 속성이 붙은 요소를 검은 블록으로 치환한다. 이 속성을 주민등록번호·계좌번호·급여 금액 셀·비밀번호 입력에 붙이는 것은 각 앱의 책임이며, 1차 범위에 다음을 포함한다: 직원 추가/수정 위저드의 RRN·계좌 필드, 시트 워크북의 금액 열(컬럼 타입이 통화인 셀), 급여명세 화면, 로그인 비밀번호 입력
형식·크기WebP(미지원 시 PNG), 최대 긴 변 1920px로 다운스케일, 목표 400KB 이하. 캡처 시간 상한 3초 — 초과하면 포기하고 「자동 캡처 실패」 안내
제외 대상리포트 모달 자신(data-cs-report-host), 확인·알림 다이얼로그(.ds-modal-backdrop--top), iframe 의 안쪽 문서. 다른 팝업·FAB 레이어는 뺀 적이 있다가 되돌렸다 — 사용자가 신고하려는 화면이 사라진다(2026-09-04). 현행 규칙의 정본은 docs/ko/design-system/components/feedback/cs-report.md 「규칙 (강제)」

5.2 로그 링버퍼 + 전역 오류 훅 ​

항목요구사항
구조packages/sdk 브라우저 로거를 확장: console 출력에 더해 메모리 링버퍼 최근 200건을 보관. 각 항목 { ts, level, msg, data?, kind } — kind는 log | error | api | nav
API 호출 요약HerebyClient의 httpBatchLink fetch 래퍼에서 실패한 호출만(!response.ok 또는 tRPC error) 기록: 프로시저 경로·HTTP 상태·tRPC code·requestId·소요 ms. 성공 호출은 최근 20건만 경로·ms로 기록(입력·응답 본문은 절대 담지 않는다)
화면 이동해시/경로 변경 시 nav 항목 기록(이전→다음 경로). 운영자가 "어디서 어디로 가다가"를 읽을 수 있게
전역 오류 훅window.addEventListener("error"), "unhandledrejection"을 Alpine 플러그인 초기화 시 1회 등록. 메시지·스택(상위 20프레임)·발생 URL을 링버퍼에 error로 기록하고 마지막 오류 스냅샷으로 보관(5초 TTL). 다이얼로그를 띄우지는 않는다 — 기존 catch 흐름을 바꾸지 않는다
Alpine 표현식 오류Alpine이 console.warn으로만 남기는 표현식 오류(Alpine Expression Error)도 링버퍼에 담기 위해 Alpine.onError(3.14 미지원 시 console.warn 래핑) 훅
리댁션링버퍼에 넣기 전에 정제한다. 규칙은 @hereby/core의 SENSITIVE_KEYS·redactString과 같은 목록을 브라우저 안전 서브패스(@hereby/core/redact)로 분리해 양쪽이 공유. 추가로 주민등록번호 패턴(\d{6}-?[1-4]\d{6}), 계좌번호로 보이는 10~14자리 숫자열, 이메일은 도메인만 남김(***@domain)
크기직렬화 상한 256KB. 초과 시 오래된 것부터 버린다. 문자열 항목 개당 2,000자 제한
전송 형식JSON 배열을 text/plain이 아닌 application/json 첨부로 S3에 올린다(본문 인라인 저장 아님 — 리포트 행이 커지지 않게)

5.3 요청 상관 ID ​

  • API: Fastify onSend 훅에서 모든 응답에 x-request-id: <req.id>를 내린다. tRPC errorFormatter의 data에 requestId를 추가한다(zodError 옆). CORS exposedHeaders에 x-request-id 추가.
  • 클라이언트: 실패 응답의 requestId를 오류 스냅샷·링버퍼 api 항목에 담는다. showError가 표시하는 다이얼로그 본문 아래에 작은 글씨로 요청 ID: … — 사용자가 전화로 불러 줄 수 있는 두 번째 키.
  • 서버 로그는 같은 값을 trace_id 필드로 담는다(tRPC 접근 로그 tRPC call completed 와 실패 로그 모두, 2026-10-01 부터 — 그 전 실패 로그에는 상관 키가 없었다). 운영자는 접수번호 → requestId → Loki | json | trace_id="…" 로 서버 로그를 곧장 찾는다.

5.4 환경 컨텍스트 ​

리포트 생성 시 클라이언트가 채우고 서버가 검증·보강한다(사용자가 조작할 수 있는 값은 서버 값을 우선).

필드출처비고
appIdcreateAlpinePlugin({ appId }) — admin/hr-system/employee-app/…서버 enum 검증(CsReportApp)
route리포트 시점 location.pathname + hash쿼리스트링은 제외(토큰·검색어 유출 방지)
buildVersionVite define: { __HEREBY_BUILD__ } — package.json version + git short sha + 빌드 시각. 직원앱은 여기에 @capacitor/app getInfo().version/build와 OTA 번들 버전을 덧붙임신규: 8개 앱 vite.config.ts 공통 헬퍼로 주입
platformweb | ios | android (Capacitor.getPlatform())DevicePlatform enum 재사용
userAgent, viewport, devicePixelRatio, language, timezone브라우저timezone은 브라우저 값과 org 값이 다르면 둘 다 기록(시간 표시 버그 진단에 유용)
online, connectionTypenavigator.onLine, navigator.connection?.effectiveType모바일 네트워크 문제 판별
organizationId, employeeId, accountId서버 ctx에서만 채움클라이언트 전송값 무시
sessionKind서버 ctx actingVia — member | owner | support | impersonation | system대행 세션의 리포트는 실행위자 accountId도 함께 기록(감사 이중 기록 원칙)
embedded, parentUrlwindow.self !== window.top§5.1 iframe 참고

6. 서버 — 도메인·API ​

6.1 엔티티 ​

cs_reports (BaseEntity — createdAt/updatedAt/deletedAt/deletedBy 유지, soft delete만)

컬럼타입비고
organizationIduuid, index조직 격리. 모든 조회에 필터
reportNumbervarchar(24), uniqueCS-{ENDPOINT_CODE}-YYMMDD-NNNN. NNNN은 이 엔드포인트 DB의 일자별 시퀀스(경쟁 안전), 코드는 부팅 필수 env(§6.7.1). 코드 덕에 번호 자체가 전 엔드포인트에서 유일 — 콘솔·에이전트·PR 본문 어디서든 번호만으로 가리킨다. 기계용 키는 여전히 UUID id
reporterEmployeeIduuid, nullable직원 세션이면 직원. owner/support 세션이면 null
reporterAccountIduuid항상 기록(실행위자)
sessionKindenum CsReportSessionKind§5.4
typeenum CsReportType — bug | inconvenience | feature_request | question
statusenum CsReportStatus — received | triaged | in_progress | awaiting_release | resolved | wont_fix | duplicate | cancelled상태기계 §6.3
priorityenum CsReportPriority — low | normal | high | urgent, default normal운영자만 변경
titlevarchar(80)
descriptiontext, nullable
appIdenum CsReportApp
routevarchar(512)
contextjsonb§5.4 나머지 필드. redactAuditInput과 같은 정제기를 거친 뒤 저장
errorSnapshotjsonb, nullable{ message, code, requestId, stack, occurredAt }
requestIdvarchar(64), nullable, index오류 스냅샷의 requestId를 컬럼으로도 뽑아 둔다(검색용)
notifyReporterboolean, default true§4.2 회신 동의
resolutionNotetext, nullable사용자에게 보이는 회신
resolvedAt, resolvedByAccountId
duplicateOfIduuid, nullableduplicate 상태일 때 원본
fingerprintvarchar(64), nullable, index오류 지문(§3). 오류 스냅샷이 있는 리포트만. 같은 지문의 열린 리포트 수가 곧 "영향 범위"
triagejsonb, nullable에이전트 제안(§8.2). 승인 전까지는 여기에만 존재하고 status/priority를 건드리지 않는다
triageStateenum CsReportTriageState — pending | proposed | accepted | edited | rejected | skipped제안의 생애. edited = 운영자가 고쳐서 승인(평가 데이터)
triagedAt, inProgressAt, resolvedAttimestamptz, nullable전이 시각 — 처리 시간 지표·에이전트 평가에 사용
embeddingvector(1024), nullable제목+설명+오류 메시지의 Voyage 임베딩. doc_embeddings와 같은 규약(raw SQL 마이그레이션, 엔티티 select 제외, <=> 검색)

cs_report_attachments

컬럼비고
reportIdFK
kindenum CsReportAttachmentKind — screenshot | log | user_file
storageKeybuildStorageKey(organizationId, "cs-report", …) → {orgId}/cs-reports/{reportId}/{timestamp}_{rand}_{name}
mimeType, size, originalName
maskedboolean — 스크린샷에 사용자 가림이 적용됐는지

cs_report_comments — 운영자 내부 메모(사용자 비공개)와 사용자에게 보이는 회신을 한 테이블에서 visibility: internal \| reporter로 구분. 회신은 알림을 발생시킨다. authorKind: operator \| agent로 에이전트가 남긴 진단 노트를 사람 메모와 구분한다 — 에이전트 코멘트는 항상 internal이며, 사용자에게 가는 회신은 사람이 승인한 것만이다.

enum은 전부 packages/core/src/enums/cs-report.enum.ts, Zod 스키마는 packages/core/src/schemas/cs-report.schema.ts(하우스 룰).

6.2 권한 ​

  • 리소스 Resource.CS_REPORT = "csReport" 신설.
  • 사용자 측 프로시저는 scopedProcedure(Resource.CS_REPORT, Action.CREATE / READ) + self scope: 누구나 자기 리포트를 만들고 자기 것만 본다. 기본 역할 시드(default-permissions.ts)의 모든 역할(EMPLOYEE 포함)에 csReport:create.self, csReport:read.self, csReport:delete.self 부여 — 리포트를 못 보내는 역할이 있으면 안 된다.
  • 조직 관리자(SUPER_ADMIN/HR_ADMIN)에게 csReport:read.all을 줄지는 미확정(§11 Q1). 1차는 주지 않는다 — 리포트에 담긴 스크린샷·로그가 다른 직원의 화면이라 조직 내 열람도 신중해야 한다.
  • 운영자 측 프로시저는 systemProcedure(OPERATOR) 하위 csReport.admin.*. 조직 필터는 운영자가 명시적으로 지정하거나 전체.

6.3 상태기계 ​

text
received ─▶ triaged ─▶ in_progress ─▶ awaiting_release ─▶ resolved
    │           │            │   ▲            │
    │           │            │   └────────────┘ (배포 전 재작업)
    │           └──────┬─────┴──▶ wont_fix
    │                  └────────▶ duplicate (duplicateOfId 필수)
    └──▶ cancelled (사용자 취소, received/triaged 에서만)
  • awaiting_release(배포대기, 2026-09-21) — 수정은 끝났지만 아직 배포되지 않은 열림 상태. 콘솔 「열림 전체」에 포함되고 종결이 아니라 resolvedAt·회신 알림이 나가지 않는다. 패치가 나간 뒤 운영자가 resolved로 바꿔야 사용자에게 「처리 결과가 등록됐어요」 알림이 가고 상세 배너가 「처리가 완료됐어요」로 바뀐다 — "적용됐다"는 확인은 이 시점 하나뿐이다. received/triaged/in_progress에서 진입하고, 배포 전 재작업은 in_progress로 되돌린다(종결 재오픈이 아니므로 코멘트 필수 아님).
  • 전이는 운영자 프로시저 csReport.admin.transition에서만, 허용 전이표를 서버가 검증. 역행(resolved → in_progress 재오픈)은 운영자에 한해 허용하되 코멘트 필수. 에이전트는 상태를 직접 바꾸지 못한다 — received → triaged 전이도 운영자가 제안 카드를 승인하는 순간 일어난다(§8.3).
  • 처리 이력 — 전이 한 번 = cs_report_status_events 한 행(append-only: reportId · fromStatus(접수 행은 null) · toStatus · actorKind(reporter|operator|system) · actorId). 접수(create)·사용자 취소(cancelMine)·운영자 전이(admin.transition)가 같은 트랜잭션에서 기록하고, getMine.statusHistory/admin.get.statusEvents가 오래된 순으로 돌려준다. triagedAt/inProgressAt/resolvedAt은 지표용 "처음/마지막" 시각으로 남되 재오픈 왕복은 이력 테이블만 안다. 1898 마이그레이션이 기존 리포트를 시각 컬럼에서 단조 경로로 백필했다(2026-09-11).
  • 사용자 상세 표시 — 상태 뱃지 하나로는 "끝났는지"가 안 읽힌다는 QA(2026-09-11)로 상세 맨 위에 처리 배너(.ds-notice, 종결 셋은 모두 「처리가 완료됐어요」로 시작), 「처리 이력」 타임라인, 답변·resolutionNote가 모두 없는 종결 신고에는 상태별 기본 안내문(화면 기본값, 저장하지 않음)을 둔다. 문구 정본은 CsReport.ts의 describeReportStatus/defaultResolutionMessage/describeStatusEvent.
  • resolved/wont_fix/duplicate 전이 시 notifyReporter가 켜져 있으면 NotificationType.SYSTEM + NotificationAction.UPDATED 인앱/푸시 발송(제목: 「신고하신 {접수번호} 처리 결과가 등록됐어요」).

6.4 tRPC 라우터 csReport ​

프로시저종류권한설명
prepareUploadmutationcreate.self첨부 kind·mime·size를 검증하고 presigned PUT URL 발급. 스크린샷 1·로그 1·사용자 파일 ≤3
createmutationcreate.self본문 + 업로드 완료된 storageKey 목록. 서버가 키 접두({orgId}/cs-reports/)와 개수·크기를 재검증하고 cs_reports + attachments를 한 트랜잭션으로 저장. 접수번호 반환. 운영자 메일 발송은 트랜잭션 밖 fire-and-forget
listMinequeryread.self페이지네이션, 상태 필터
getMinequeryread.self상세 + reporter 가시성 코멘트. 첨부는 반환하지 않음(§4.3)
cancelMinemutationdelete.selfreceived/triaged만. soft delete
admin.listqueryOPERATOR조직·앱·상태·유형·기간·requestId·접수번호 필터 + 페이지네이션
admin.getqueryOPERATOR상세 + 모든 코멘트 + 첨부 다운로드 URL(presigned, 짧은 만료 5분)
admin.transitionmutationOPERATOR상태·우선순위·duplicateOf·회신문
admin.commentmutationOPERATOR내부 메모 or 회신
admin.acceptTriagemutationOPERATOR에이전트 제안을 그대로/수정해서 확정. triageState를 accepted/edited로, 제안 값(유형·우선순위·중복·회신 초안)을 본 컬럼에 반영
admin.rerunTriagemutationOPERATOR제안 재생성(리포트 수정·모델 교체 후). 이전 제안은 ai_messages에 남는다
  • create의 남용 방어: 계정당 분당 5건·일 50건 rate limit(Redis 있으면 Redis, 없으면 프로세스 메모리 — inquiry.router와 같은 수준의 최소 방어). 같은 제목·같은 route로 60초 내 재접수는 새 행을 만들지 않고 기존 접수번호를 돌려준다(더블 클릭 방어).
  • 오류 메시지는 전부 한국어 해요체(TRPCError.message는 화면에 그대로 뜬다).

6.5 운영자 알림 ​

  • 접수 즉시 config.csReport.recipientEmail(env CS_REPORT_RECIPIENT_EMAIL, 미설정 시 발송 생략·warn 로그)로 메일: 접수번호·조직명·앱·유형·제목·route·requestId·콘솔 딥링크. 스크린샷은 메일에 넣지 않는다(민감 정보가 메일함에 남지 않게).
  • priority: urgent로 올라간 리포트는 별도 메일 제목 접두 [긴급].

6.6 보존·삭제 ​

  • 첨부(스크린샷·로그)는 90일 후 S3 객체 삭제 + attachments 행 deletedAt(리포트 본문·코멘트는 유지). 일 1회 in-process 스케줄(기존 setInterval 워커 패턴, 인스턴스 다중 시 advisory lock).
  • 사용자 취소·조직 삭제 시에도 hard delete 없음. 조직 탈퇴 정산은 조직 삭제 정책을 따른다.
  • 운영자가 리포트 상세에서 특정 첨부를 「즉시 파기」할 수 있다(가림이 빠진 민감 화면이 올라온 경우). 파기는 감사 로그(audit_logs, OPERATOR 행위)에 남긴다.

6.7 엔드포인트 자기 등록·발견 (system-admin discovery) ​

리포트는 각 엔드포인트(테넌트 서버) DB에 저장되고 콘솔이 모아 본다. 그런데 현재 레지스트리는 SYSADMIN_ENDPOINTS env뿐이라(pull 모델), 서버를 새로 세우고 env 등록을 빼먹으면 그 서버의 리포트는 콘솔에 보이지 않은 채 조용히 쌓인다 — 접수·메일·에이전트까지는 서버 안에서 정상 동작하므로 아무것도 실패하지 않는다. 이 문제를 없애기 위해 방향을 뒤집는다: 엔드포인트 서버가 뜰 때 스스로 system-admin에 등록하고, 주기적으로 등록 상태를 확인한다. system-admin이 discovery 역할을 맡는다. 이 절은 CS 리포트 전용이 아니라 system-admin 플랫폼 기능이며, 재설계 문서(design-system-admin-redesign.md) 미결 1번(레지스트리 저장 위치)을 "콘솔 로컬 소형 DB"로 확정하는 결정을 포함한다.

6.7.1 엔드포인트 측 — 필수 설정과 정체성 ​

항목요구사항
필수 envENDPOINT_CODE(2~6자 [A-Z0-9], 예 KRA), PUBLIC_API_BASE_URL(콘솔이 되부를 주소), SYSADMIN_URL, SYSADMIN_REGISTRY_SECRET(공유 시크릿), DEPLOY_ENV(dev/stg/prod). 넷 중 하나라도 없으면 부팅 실패(config.ts에 필수 env 검증 신설). "env 하나 빠지면 조용히 이상해진다"를 "배포 단계에서 바로 터진다"로 바꾸는 것이 이 절의 전제
인스턴스 정체성최초 부팅 시 instanceId(UUID)를 생성해 엔드포인트 DB(platform_settings 단일 행)에 영구 저장. 같은 DB를 쓰는 여러 API 프로세스(수평 확장)는 같은 instanceId를 공유 → 등록이 멱등. URL이 바뀌어도 같은 서버임을 콘솔이 알 수 있고, 다른 서버가 같은 코드를 쓰면 instanceId가 달라 충돌로 잡힌다
/healthz기존 version에 endpointCode, instanceId, registry: { state, lastAckAt }를 추가 — 콘솔 없이도 curl 한 번으로 등록 상태 확인 가능
접수번호CS-{ENDPOINT_CODE}-YYMMDD-NNNN(예 CS-KRA-260901-0012). 코드가 번호에 들어가므로 번호 하나가 전역 유일하고, 콘솔 검색·에이전트 참조·PR 본문 어디서든 엔드포인트 지정 없이 통한다. 레지스트리 id = ENDPOINT_CODE로 통일(식별자 두 개를 대응시키는 문제 자체를 없앤다)

6.7.2 프로토콜 — announce / heartbeat ​

text
엔드포인트 API                                   system-admin (discovery)
─────────────                                    ──────────────────────
부팅 완료 ──▶ POST {SYSADMIN_URL}/registry/announce ──▶ HMAC 검증 → endpoints(로컬 DB) upsert
             { code, instanceId, baseUrl, env,          ┌ 신규 코드            → state: pending  (운영자 승인 대기)
               version, startedAt, capabilities }        ├ 알던 코드·같은 instanceId → registered (baseUrl 변경은 갱신+기록)
             + X-Timestamp · X-Signature                 └ 알던 코드·다른 instanceId → conflict   (승인 전까지 fan-out 제외)
          ◀── { state, endpointId, serverTime } ────────┘
60s(±jitter) 주기 ──▶ POST /registry/heartbeat ──▶ lastSeenAt 갱신, state 회신
             { code, instanceId, version, uptime,
               stats: { csReportsPending } }
항목요구사항
인증본문 HMAC-SHA256(SYSADMIN_REGISTRY_SECRET) + 타임스탬프(±5분 창) — 재전송 방어. prod는 baseUrl https 필수. 등록은 콘솔에 어떤 자격증명도 주지 않는다 — 운영자 세션 로그인(세션 매니저)은 지금처럼 엔드포인트별로 따로 한다
승인 정책신규 코드는 pending — 콘솔 「엔드포인트」 탭 상단 배너 「미등록 엔드포인트 발견: KRA (https://…, prod, v1.4.2) — 등록 / 거절」. 운영자 클릭으로 registered. DEPLOY_ENV=dev는 콘솔 설정으로 자동 승인 가능. 아무 서버나 자기를 밀어 넣을 수는 있어도(시크릿 필요) fan-out 대상이 되는 것은 사람이 결정
충돌conflict는 코드 중복 배포이거나 DB를 새로 만든 재구축이다. 콘솔이 두 instanceId·URL·최초 등록일을 나란히 보여 주고, 운영자가 「새 서버가 맞음(교체)」 또는 「거절」을 고른다. 해결 전까지 그 코드는 fan-out에서 빠지고 배너가 남는다
서버 측 반응응답이 pending/conflict/rejected면 logger.warn + /healthz.registry.state에 반영. 서비스는 계속한다 — 콘솔 상태가 테넌트 서비스를 멈추게 하지 않는다. system-admin에 닿지 못하면 지수 백오프(최대 10분)로 재시도, 역시 치명 아님
stale하트비트가 3주기(3분) 끊기면 stale — 목록 배지 + fan-out 결과의 failed와 구분해 표시("죽었다"가 아니라 "소식이 없다"). 30일 이상 stale이면 「보관」 제안, 자동 삭제는 없음
버전하트비트의 version으로 콘솔이 엔드포인트별 API 버전을 안다 → fan-out의 unsupported 판정 근거, CS 리포트 getReleaseInfo(§8.4) 입력

6.7.3 system-admin 측 — 레지스트리 저장과 화면 ​

  • 레지스트리를 env에서 콘솔 로컬 소형 DB(SQLite 또는 콘솔 전용 Postgres 스키마)로 옮긴다: endpoints(code PK, instanceId, baseUrl, env, version, state, firstSeenAt, lastSeenAt, approvedByAccountId, approvedAt, history jsonb). 어느 테넌트 DB에도 두지 않는다는 기존 원칙은 유지.
  • SYSADMIN_ENDPOINTS env는 dev 부트스트랩·오버라이드용으로만 남긴다(local 기본 엔드포인트). env 항목과 announce가 같은 코드로 만나면 announce가 instanceId를 채워 준다.
  • 기존 「엔드포인트」 탭 확장: 상태 컬럼(pending/registered/conflict/stale/rejected) · 버전 · 마지막 하트비트 · 승인자, 승인/거절/교체 버튼, 충돌 비교 패널. 콘솔 상단(전 탭 공통)에 pending/conflict 건수 배지.
  • 검증: fan-out 응답의 /healthz.endpointCode가 레지스트리 코드와 다르면 경고(누군가 env를 바꿔 다른 서버를 같은 URL에 올린 경우).
  • 감사: 승인·거절·교체는 audit_logs(OPERATOR 행위, organizationId: null)에 남긴다.

6.7.4 남는 구멍과 안전망 ​

  • 서버에 SYSADMIN_URL이 없으면 발견할 길이 없다 — 그래서 §6.7.1에서 부팅 필수로 못 박는다.
  • system-admin 자체가 오래 죽어 있으면 announce가 유실된다 — 하트비트가 계속 재시도하므로 콘솔이 살아나는 순간 pending으로 나타난다. 그 사이 리포트는 서버가 보내는 접수 메일(§6.5) 로 알 수 있고, 메일에는 코드·baseUrl을 넣는다. 콘솔이 모르는 코드의 딥링크는 404 대신 「미등록 엔드포인트 — 등록하기」 화면으로 보낸다.
  • 네트워크: 엔드포인트 → system-admin 방향의 인바운드가 열려 있어야 한다(지금은 콘솔 → 엔드포인트만 필요했다). 배포 체크리스트 항목.

7. 운영자 콘솔 (system-admin) ​

7.1 탭 「CS 리포트」 ​

  • tools/system-admin 탭 추가(cs-reports, 아이콘 ic-life-buoy 신설 — 레지스트리 등록 필수).
  • 목록은 전 엔드포인트 fan-out(csReport.admin.list). 열: 엔드포인트 · 접수번호 · 조직 · 앱 · 유형 · 우선순위 · 상태 · 제목 · 접수일. 부분 실패는 상단 .ds-notice--warning으로 「N개 엔드포인트 미연결」 표시(기존 endpoints 탭 패턴).
  • 필터: 상태(기본 = 열림 상태 전체), 앱, 유형, 조직, 기간, 접수번호/requestId 검색.
  • 미처리(received) 건수를 LNB 탭 라벨에 배지로 표시.

7.2 상세 패널(.ds-modal--panel) ​

  • 좌: 스크린샷(클릭 시 라이트박스 — ds-modal-exempt: lightbox), 사용자 제목·설명, 사용자 파일.
  • 우: 에이전트 제안 카드(맨 위, §8.3) → 컨텍스트 표(앱·route·빌드·플랫폼·UA·뷰포트·타임존·네트워크·세션 종류·임베드 여부), 오류 스냅샷(코드·메시지·requestId 복사 버튼·스택), 로그 뷰어 — 링버퍼 JSON을 시간순 표로 렌더(레벨별 색 라벨, api 항목은 경로·상태·ms, nav는 경로 전이), 레벨 필터·텍스트 검색.
  • 하단: 코멘트 타임라인(내부/회신/에이전트 구분 라벨), 상태·우선순위 변경 폼, 「중복 지정」, 「첨부 즉시 파기」.
  • 상태 변경 후 목록 행 즉시 갱신.

7.3 목록의 묶음 보기 ​

  • 같은 fingerprint의 열린 리포트를 한 행으로 접어 보는 「원인별」 토글. 행에 영향 조직 수·리포트 수·최초/최근 접수 시각. 운영자는 개별 건이 아니라 원인 단위로 우선순위를 잡는다 — 리포트 10건이 한 버그일 때 10번 읽지 않게 하는 것이 목록의 첫째 목적이다.

7.4 운영 지표(2차) ​

  • 접수 추이·앱별·유형별 카운트, 평균 처리 시간, 에이전트 제안 수용률(accepted / (accepted+edited+rejected))과 항목별(유형·우선순위·중복) 정확도. 1차 범위 밖, 데이터 모델은 이를 지원하도록 전이 시각과 triageState를 남긴다.

8. AI 에이전트 자동 처리 ​

CS 처리의 반복 노동 — 읽고, 분류하고, 같은 건인지 찾고, 서버 로그를 뒤지고, 답장 쓰기 — 를 에이전트가 먼저 하고 사람이 확인한다. 외부 작업관리 툴로 옮겨 적는 단계는 없다. 기존 AI 아키텍처(design-ai-architecture.md)의 결정을 그대로 따른다: self-hosted tool-use 루프, tool은 기존 서비스의 얇은 read-only 래퍼, 변경은 propose → approve.

8.1 트리거·실행 주체 ​

항목요구사항
트리거csReport.create 커밋 직후 워커 큐에 넣는다(트랜잭션 밖). 사용자 응답은 에이전트를 기다리지 않는다
실행 주체플랫폼 운영 기능이므로 조직의 AI enable 플래그·BYOK와 무관하게 플랫폼 키로 실행하고, 비용은 플랫폼이 진다. AiKeySource.PLATFORM으로 기록
모듈AiModule.CS_TRIAGE 신설. 모델은 모듈별 설정 규칙(env → 기본값)을 따르고, 초기값은 다른 모듈과 같은 opus 계열
실행 컨텍스트사용자 ctx가 아니라 시스템 ctx(리포트가 속한 organizationId로 고정). 조직 밖 데이터를 읽는 tool은 존재하지 않는다 — 유사 리포트 검색만 예외적으로 전 조직을 대상으로 하되, 결과에는 타 조직의 리포트 id·지문·건수만 노출하고 본문은 넘기지 않는다
입력제목·설명·유형·컨텍스트·오류 스냅샷·정제된 로그 링버퍼(§5.2). 스크린샷은 1차에서는 입력하지 않는다(가림이 빠진 민감 화면이 모델로 나갈 위험 — 비전 입력은 §11 Q8)
시간·비용 상한리포트당 tool 호출 12회·60초·토큰 상한(env). 초과 시 triageState: skipped + 사유 기록, 운영자가 수동 처리
실패에이전트 실패는 접수를 절대 막지 않는다. 실패는 logger.warn + skipped. 콘솔에서 「다시 분석」 가능

8.2 에이전트가 만드는 것 — 제안(triage proposal) ​

cs_reports.triage jsonb, Zod 스키마(csReportTriageSchema)로 검증한 뒤에만 저장. 모델 출력이 스키마를 통과하지 못하면 skipped.

필드내용근거 tool
type, priority사용자가 고른 유형 재판정 + 우선순위 제안. 근거 한 줄리포트 본문
module추정 담당 영역 — payroll-sheet | leave | attendance | messenger | auth | drive | …(enum). 콘솔 필터·향후 라우팅 축route·로그·스택
duplicateOf / related[]같은 지문의 열린 리포트(확정 후보) + 임베딩 유사도 상위 5건(참고). 각각 유사도·이유findSimilarReports
serverEvidencerequestId로 찾은 서버 로그 요약 — 오류 클래스·메시지·스택 상위, 같은 시각 같은 조직의 연속 실패 여부lookupServerLog
diagnosis진단 초안 — "무엇이, 어디서, 왜"를 3~5문장. 확신도 high | medium | low, 근거 항목 인용(로그 항목 인덱스·requestId)전체
reproSteps[]로그의 nav·api 항목에서 재구성한 재현 순서링버퍼
replyDraft사용자 회신 초안(해요체) — 접수 확인·예상 원인·우회 방법(있으면). 승인 전에는 사용자에게 가지 않는다전체 + 매뉴얼 검색
knowledgeHits[]doc_embeddings 매뉴얼 검색 결과 — "문의" 유형이면 매뉴얼 링크만으로 닫힐 수 있다searchDocs(기존 DOCS 모듈 tool)

8.3 사람의 확인 — 제안 카드 ​

  • 상세 패널 맨 위에 카드 하나: 유형·우선순위·모듈·중복 후보·진단·회신 초안이 편집 가능한 폼으로 채워져 있다. 버튼은 「이대로 확정」「수정해서 확정」「제안 무시」 셋뿐.
  • 확정(admin.acceptTriage)이 received → triaged 전이를 일으키고, 중복이 확정되면 duplicate 전이까지 한 번에 간다. 회신 초안을 승인하면 reporter 코멘트로 저장되며 그때 알림이 나간다.
  • 카드에는 항상 확신도와 근거 인용이 보인다. 근거 없는 제안(serverEvidence 없음 + low)은 카드 색을 낮춰(.ds-notice--warning) 사람이 더 읽게 만든다.
  • 운영자가 수정한 값과 원 제안의 차이는 edited로 남는다. 이것이 프롬프트 고도화의 학습 데이터다(design-ai-architecture.md §8-2 온라인 평가 패턴).

8.4 tool 목록 (전부 read-only, 조직 고정) ​

tool래핑 대상비고
getReportcsReport.admin.get 내부 서비스본문·컨텍스트·정제 로그
findSimilarReportsfingerprint 일치 + embedding <=> raw SQL타 조직은 id·지문·건수만
lookupServerLog서버 로그 저장소 조회(신규 어댑터 — 현재는 pino stdout뿐이라 검색 가능한 저장처가 필요, §11 Q7)requestId 또는 조직+시각 창
searchDocs기존 DOCS 모듈 tool매뉴얼 근거
getReleaseInfobuildVersion으로 최근 배포 목록·변경 요약(CI 아티팩트 메타)"어제 배포 이후 급증" 판별. 신규

8.5 다음 단계 — 수정 작업 에이전트 핸드오프 (Phase 3) ​

트리아지가 안정되면, 운영자가 확정한 bug 리포트를 수정 작업 에이전트에게 넘기는 버튼을 카드에 더한다. 외부 트래커에 이슈를 만드는 대신 리포트 자체가 작업 지시서다.

  • 입력: 리포트(진단·재현 순서·로그·서버 근거·지문으로 묶인 형제 리포트). 출력: 저장소에 브랜치 + 실패하는 재현 테스트 + 수정 + PR 초안(PR 본문은 이 저장소의 code-explainer 규약). 에이전트는 merge하지 않는다 — 리뷰·머지는 사람.
  • 실행 환경은 API 프로세스 밖(코딩 에이전트 러너 — Agent SDK 기반 워커 또는 CI 잡). cs_reports에는 workItem: { runId, branch, prUrl, state }만 기록하고 러너의 상세 로그는 러너가 가진다.
  • PR이 머지되어 배포되면 getReleaseInfo가 그 지문의 리포트를 찾아 「배포된 빌드에 수정 포함」 회신 초안을 만든다 — 사람이 승인하면 resolved. 여기까지가 "리포트 → 코드 → 회신"의 닫힌 고리다.
  • 요구사항이 아니라 방향이다: 러너 선택·권한(저장소 쓰기 토큰의 범위)·비용 정책은 트리아지 수용률이 목표치(§7.4)를 넘긴 뒤 별도 문서로 결정한다.

8.6 안전 원칙 ​

  • 에이전트는 상태·우선순위·회신을 직접 쓰지 못한다. 쓰기 경로는 admin.acceptTriage 하나이고 그것은 운영자 세션에서만 호출된다.
  • 리포트 본문·로그는 신뢰 불가 입력이다(프롬프트 인젝션). 시스템 프롬프트에 데이터 경계를 명시하고, tool 결과에 섞인 지시문을 무시하도록 한다. 쓰기 tool이 없으므로 인젝션의 최악은 "틀린 제안"이며, 그것은 사람이 거른다.
  • 모델로 나가는 텍스트는 §5.2·§6 정제기를 통과한 것만. 스크린샷은 1차 제외.
  • 모든 실행은 ai_conversations/ai_messages에 남는다(모듈 cs_triage, 조직 = 리포트 조직). 감사·평가 모두 여기서.

9. 비기능 요구사항 ​

항목요구사항
성능링버퍼·오류 훅은 앱 부팅에 5ms 이상 더하지 않는다. 캡처 라이브러리는 동적 import(리포트를 처음 열 때만 로드) — 8개 앱 초기 번들에 들어가지 않는다
개인정보저장되는 모든 텍스트(설명·로그·컨텍스트·오류 스냅샷)는 서버 측 정제기를 한 번 더 거친다(클라이언트 정제를 신뢰하지 않는다). 리포트 데이터는 조직 데이터로 취급 — 조직 격리·soft delete·감사 원칙 동일
접근성모달은 기존 DS 모달 규약(포커스 트랩·ESC·스크롤 락). 가림 도구는 키보드로도 동작(방향키 이동·Enter 확정)해야 한다
디자인 시스템모달 3종·다이얼로그·notice·label·spinner·icon 레지스트리 규칙 준수. 새 색·hex 없음. pnpm check:modals/dialogs/icons/spinners 통과
i18n사용자 노출 문구는 ko 로케일 번들(해요체). 운영자 콘솔은 한국어 고정
테스트단위: 리댁션 규칙(주민번호·계좌·토큰·이메일 도메인), 링버퍼 순환·크기 상한, 접수번호 생성, 오류 지문 정규화, 상태 전이표, create 키 접두 검증, 제안 스키마 검증(스키마 불통과 → skipped). E2E(크리티컬 1개): admin에서 오류 다이얼로그 → 리포트 전송 → API로 행·첨부 존재 검증. 스크린샷 픽셀 검증은 하지 않는다. 에이전트는 오프라인 평가 하네스(services/ai/evals/)에 리포트 골든셋 20건으로 유형·중복·모듈 정확도를 잰다
관측create 성공/실패, 캡처 실패율, 첨부 크기, 에이전트 실행 시간·토큰·triageState 분포를 logger.info로 남긴다(운영 지표 산출용)

10. 범위와 단계 ​

Phase 1 — 접수 파이프라인 (필수) ​

  1. @hereby/core/redact 서브패스 분리 + 브라우저 리댁션 확장(주민번호·계좌·이메일)
  2. SDK 링버퍼 + 전역 오류 훅 + API 실패 호출 기록 + x-request-id 응답 헤더·errorFormatter
  3. 빌드 버전 주입(공통 Vite 헬퍼) — 8개 앱
  4. cs_reports/attachments/comments 엔티티 + 마이그레이션(embedding vector(1024) raw SQL 포함) + enum/schema + 권한 리소스·역할 시드 — 에이전트용 컬럼(fingerprint/triage/triageState/embedding)은 Phase 1에 함께 만든다. 나중에 붙이면 초기 리포트가 평가 데이터에서 빠진다
  5. csReport 라우터(사용자 측 5개 + admin 6개) + storage category: "cs-report"
  6. CsReport 공용 컴포넌트(모달·캡처·가림·전송·성공 상태) + showError 진입점 + data-cs-mask 1차 부착 목록
  7. system-admin 「CS 리포트」 탭(목록 fan-out + 원인별 묶음 + 상세 + 상태 변경 + 코멘트)
  8. 운영자 접수 메일 + 사용자 처리 결과 인앱 알림
  9. 첨부 90일 보존 워커
  10. 오류 지문 계산 + 접수 시 임베딩 생성(에이전트 없이도 「같은 원인 N건」 묶음이 동작)
  11. 엔드포인트 자기 등록·발견(§6.7) — API 부팅 필수 env 검증 + instanceId + /healthz 확장 + announce/heartbeat 클라이언트, system-admin 로컬 DB 레지스트리 + 수신 라우트 + 「엔드포인트」 탭 승인/충돌/stale UI. 다중 엔드포인트 배포에서 콘솔이 리포트를 보는 전제이므로 Phase 1에 둔다

Phase 2 — 트리아지 에이전트 ​

  • AiModule.CS_TRIAGE + 워커 트리거 + tool 4종(getReport / findSimilarReports / searchDocs / lookupServerLog) + 제안 스키마 + 제안 카드 + acceptTriage/rerunTriage
  • lookupServerLog를 위한 서버 로그 검색 어댑터(§11 Q7 결정 선행)
  • 골든셋 20건 오프라인 평가 → 수용률 측정 시작
  • 내 리포트 목록(MyInfoForm 탭 / 직원앱 더보기), 직원앱 실패 초안 보관·재전송, 상시 진입점(헤더 도움말·단축키), 운영 지표

Phase 3 — 수정 작업 에이전트 핸드오프 ​

  • §8.5. 트리아지 수용률이 목표치를 넘긴 뒤 별도 설계 문서로 착수
  • getReleaseInfo tool + 배포 후 자동 회신 초안(닫힌 고리)

범위 밖(명시) ​

  • 로그인 전 리포트, 익명 접수
  • 조직 관리자용 리포트 열람(§11 Q1 결정 후)
  • 화면 녹화(rrweb류 세션 리플레이) — 개인정보 부담 대비 가치 미검증
  • 외부 작업관리 툴(GitHub Issues·Jira·Linear 등) 연동 — 하지 않는다. 작업 원장은 cs_reports, 처리 자동화는 §8의 에이전트. 코드 변경만 저장소 PR로 나가고 그것도 Phase 3 러너가 만든다
  • 에이전트의 자율 회신·자율 상태 변경 — 이 문서의 어느 단계에서도 허용하지 않는다

11. 미확정 사항 ​

#질문기본안
Q1조직 관리자(SUPER_ADMIN)가 자기 조직 직원들의 리포트를 볼 수 있어야 하는가?1차는 불가. 스크린샷이 타 직원의 급여 화면일 수 있어 조직 내 열람도 별도 동의·마스킹 정책이 필요
Q2접수번호에 엔드포인트 식별자를 넣을 것인가? 확정(v3): 넣는다 — CS-{ENDPOINT_CODE}-YYMMDD-NNNN. 레지스트리 id = 코드로 통일번호 하나가 전역 키여야 Phase 3의 에이전트·PR 참조와 지문 묶음이 엔드포인트를 가로질러 동작한다. 길이 비용(3~6자)은 감수
Q2-1콘솔 로컬 레지스트리 DB — SQLite vs 콘솔 전용 Postgres 스키마?SQLite로 시작(콘솔은 단일 인스턴스, 의존성 최소). 콘솔이 다중화되는 시점에 Postgres로
Q2-2DEPLOY_ENV=dev 엔드포인트의 자동 승인을 허용할 것인가?콘솔 설정 플래그로 허용(기본 off). prod/stg는 언제나 사람 승인
Q3첨부 보존 90일이 적절한가?90일. 처리 완료 후 분쟁 대비 기간으로 충분하며 개인정보 최소 보관 원칙과 균형
Q4캡처 라이브러리 선택(html-to-image vs html2canvas)번들·CSS 지원(Tailwind 4 oklch 색 등)을 실측 후 결정. 시트 워크북(캔버스/가상 스크롤) 캡처 결과를 기준으로
Q5운영자 접수 메일 수신처 — 단일 메일 vs 엔드포인트별env 단일 메일. 다중은 Phase 2
Q6자동 첨부 「로그」를 사용자가 끄면 리포트 가치가 크게 떨어지는데, 끌 수 있게 둘 것인가?둔다(투명성). 대신 기본 켜짐 + 「로그를 함께 보내면 더 빨리 해결할 수 있어요」 안내
Q7lookupServerLog가 읽을 서버 로그 저장처 — 지금은 pino stdout뿐이다. 기존 로그 수집기(있다면)에 질의 API가 있는가, 없으면 최근 N일 오류 로그만 DB(api_error_logs)에 남길 것인가?후자로 시작 — requestId·조직·시각 인덱스만 있으면 되고, 로그 인프라 결정과 독립적으로 Phase 2를 진행할 수 있다
Q8스크린샷을 트리아지 입력(비전)으로 넣을 것인가?1차 제외. 자동 가림(data-cs-mask) 적용 범위가 검증되고 운영자가 「이 스크린샷은 안전」을 표시한 건에 한해 재분석 옵션으로만
Q9트리아지를 모든 리포트에 돌릴 것인가, bug 유형만?전부. 「문의」는 매뉴얼 검색만으로 닫히는 비율이 높아 오히려 효과가 크다. 비용은 리포트당 상한(§8.1)으로 통제