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 리포트 — 의 요구사항을 정리한다.
핵심 원칙 네 가지:
- 사용자는 두 가지만 한다 — 무슨 일이 있었는지 한 줄 쓰고, 보내기를 누른다. 스크린샷·로그·환경 정보는 시스템이 자동으로 채운다.
- 운영자가 재현하지 않아도 되는 정보를 담는다 — 어느 조직·어느 직원이·어느 앱의 어느 화면에서·어떤 API 호출이 어떤 오류로 실패했는지가 리포트 한 건에 들어 있다.
- HR·급여 데이터가 리포트에 새지 않는다 — 로그·스크린샷은 민감 정보 마스킹을 거친 뒤에만 저장한다. 리포트는 조직 데이터의 일부로 취급하며 조직 격리 원칙이 그대로 적용된다.
- 리포트는 사람보다 먼저 AI 에이전트가 읽는다 — 접수된 리포트의 1차 독자는 분류·중복 탐지·진단 초안을 만드는 에이전트이고, 운영자는 그 제안을 확인하는 사람이다. 그래서 리포트는 처음부터 에이전트가 소비할 수 있는 구조화된 데이터로 저장한다(§6.1·§8). 외부 작업관리 툴(GitHub Issues·Jira 등)은 쓰지 않는다 — 작업 항목은 hereby 안의
cs_reports가 곧 원장이며, 처리 자동화는 AI 에이전트가 맡는다.
[사용자] 문제 발생 ─▶ 리포트 버튼(오류 다이얼로그/헤더/단축키) ─▶ 리포트 모달
스크린샷 자동 캡처 · 로그 링버퍼 첨부 · 환경 정보 자동 수집
│ 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) |
| 요청 상관 ID | Fastify 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) 한 장. 위에서 아래로:
- 스크린샷 미리보기 — 캡처된 이미지 썸네일. 우측 상단에 「다시 찍기」「제외」. 캡처 실패 시 썸네일 자리에
.ds-notice--info「이 화면은 자동 캡처가 되지 않아 첨부되지 않았어요. 필요하면 파일로 추가해 주세요.」- 미리보기 위에서 드래그로 사각형 가리기(검은 블록) 가능 — 사용자가 본인 판단으로 주민번호·급여액 등을 가릴 수 있어야 한다. 가림은 이미지에 굽고(burn-in), 원본은 보내지 않는다.
- 유형 세그먼트(
Segment컴포넌트): 오류 · 불편/개선 · 기능 요청 · 문의. A 진입점은 「오류」, 상시 진입(B·C)은 「불편/개선」 기본 선택 — 상시 진입의 주 용도가 UI 불편·기능 제안이기 때문이다(2026-09-02 결정). - 제목 — 필수, 최대 80자. placeholder 「무엇을 하려다 어떤 문제가 있었나요?」
- 설명 — 선택, 최대 2,000자.
- 추가 파일 — 선택, 최대 3개, 각 10MB, 이미지·PDF·텍스트·zip(
ALLOWED_MIME_TYPES범위). - 자동 첨부 안내 — 접힌
details「함께 전송되는 정보」: 앱·화면 주소·앱 버전·브라우저/OS·최근 로그 N건·오류 정보. 펼치면 실제 값을 보여 준다(투명성). 로그 첨부 해제 체크박스 제공 — 기본 켜짐. - 연락 회신 동의 — 체크박스 「처리 결과를 앱 알림으로 받을게요」 기본 켜짐.
- 푸터: 「취소」 / 「보내기」(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>를 내린다. tRPCerrorFormatter의data에requestId를 추가한다(zodError옆). CORSexposedHeaders에x-request-id추가. - 클라이언트: 실패 응답의
requestId를 오류 스냅샷·링버퍼api항목에 담는다.showError가 표시하는 다이얼로그 본문 아래에 작은 글씨로요청 ID: …— 사용자가 전화로 불러 줄 수 있는 두 번째 키. - 서버 로그는 같은 값을
trace_id필드로 담는다(tRPC 접근 로그tRPC call completed와 실패 로그 모두, 2026-10-01 부터 — 그 전 실패 로그에는 상관 키가 없었다). 운영자는 접수번호 → requestId → Loki| json | trace_id="…"로 서버 로그를 곧장 찾는다.
5.4 환경 컨텍스트
리포트 생성 시 클라이언트가 채우고 서버가 검증·보강한다(사용자가 조작할 수 있는 값은 서버 값을 우선).
| 필드 | 출처 | 비고 |
|---|---|---|
appId | createAlpinePlugin({ appId }) — admin/hr-system/employee-app/… | 서버 enum 검증(CsReportApp) |
route | 리포트 시점 location.pathname + hash | 쿼리스트링은 제외(토큰·검색어 유출 방지) |
buildVersion | Vite define: { __HEREBY_BUILD__ } — package.json version + git short sha + 빌드 시각. 직원앱은 여기에 @capacitor/app getInfo().version/build와 OTA 번들 버전을 덧붙임 | 신규: 8개 앱 vite.config.ts 공통 헬퍼로 주입 |
platform | web | ios | android (Capacitor.getPlatform()) | DevicePlatform enum 재사용 |
userAgent, viewport, devicePixelRatio, language, timezone | 브라우저 | timezone은 브라우저 값과 org 값이 다르면 둘 다 기록(시간 표시 버그 진단에 유용) |
online, connectionType | navigator.onLine, navigator.connection?.effectiveType | 모바일 네트워크 문제 판별 |
organizationId, employeeId, accountId | 서버 ctx에서만 채움 | 클라이언트 전송값 무시 |
sessionKind | 서버 ctx actingVia — member | owner | support | impersonation | system | 대행 세션의 리포트는 실행위자 accountId도 함께 기록(감사 이중 기록 원칙) |
embedded, parentUrl | window.self !== window.top | §5.1 iframe 참고 |
6. 서버 — 도메인·API
6.1 엔티티
cs_reports (BaseEntity — createdAt/updatedAt/deletedAt/deletedBy 유지, soft delete만)
| 컬럼 | 타입 | 비고 |
|---|---|---|
organizationId | uuid, index | 조직 격리. 모든 조회에 필터 |
reportNumber | varchar(24), unique | CS-{ENDPOINT_CODE}-YYMMDD-NNNN. NNNN은 이 엔드포인트 DB의 일자별 시퀀스(경쟁 안전), 코드는 부팅 필수 env(§6.7.1). 코드 덕에 번호 자체가 전 엔드포인트에서 유일 — 콘솔·에이전트·PR 본문 어디서든 번호만으로 가리킨다. 기계용 키는 여전히 UUID id |
reporterEmployeeId | uuid, nullable | 직원 세션이면 직원. owner/support 세션이면 null |
reporterAccountId | uuid | 항상 기록(실행위자) |
sessionKind | enum CsReportSessionKind | §5.4 |
type | enum CsReportType — bug | inconvenience | feature_request | question | |
status | enum CsReportStatus — received | triaged | in_progress | awaiting_release | resolved | wont_fix | duplicate | cancelled | 상태기계 §6.3 |
priority | enum CsReportPriority — low | normal | high | urgent, default normal | 운영자만 변경 |
title | varchar(80) | |
description | text, nullable | |
appId | enum CsReportApp | |
route | varchar(512) | |
context | jsonb | §5.4 나머지 필드. redactAuditInput과 같은 정제기를 거친 뒤 저장 |
errorSnapshot | jsonb, nullable | { message, code, requestId, stack, occurredAt } |
requestId | varchar(64), nullable, index | 오류 스냅샷의 requestId를 컬럼으로도 뽑아 둔다(검색용) |
notifyReporter | boolean, default true | §4.2 회신 동의 |
resolutionNote | text, nullable | 사용자에게 보이는 회신 |
resolvedAt, resolvedByAccountId | ||
duplicateOfId | uuid, nullable | duplicate 상태일 때 원본 |
fingerprint | varchar(64), nullable, index | 오류 지문(§3). 오류 스냅샷이 있는 리포트만. 같은 지문의 열린 리포트 수가 곧 "영향 범위" |
triage | jsonb, nullable | 에이전트 제안(§8.2). 승인 전까지는 여기에만 존재하고 status/priority를 건드리지 않는다 |
triageState | enum CsReportTriageState — pending | proposed | accepted | edited | rejected | skipped | 제안의 생애. edited = 운영자가 고쳐서 승인(평가 데이터) |
triagedAt, inProgressAt, resolvedAt | timestamptz, nullable | 전이 시각 — 처리 시간 지표·에이전트 평가에 사용 |
embedding | vector(1024), nullable | 제목+설명+오류 메시지의 Voyage 임베딩. doc_embeddings와 같은 규약(raw SQL 마이그레이션, 엔티티 select 제외, <=> 검색) |
cs_report_attachments
| 컬럼 | 비고 |
|---|---|
reportId | FK |
kind | enum CsReportAttachmentKind — screenshot | log | user_file |
storageKey | buildStorageKey(organizationId, "cs-report", …) → {orgId}/cs-reports/{reportId}/{timestamp}_{rand}_{name} |
mimeType, size, originalName | |
masked | boolean — 스크린샷에 사용자 가림이 적용됐는지 |
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 상태기계
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
| 프로시저 | 종류 | 권한 | 설명 |
|---|---|---|---|
prepareUpload | mutation | create.self | 첨부 kind·mime·size를 검증하고 presigned PUT URL 발급. 스크린샷 1·로그 1·사용자 파일 ≤3 |
create | mutation | create.self | 본문 + 업로드 완료된 storageKey 목록. 서버가 키 접두({orgId}/cs-reports/)와 개수·크기를 재검증하고 cs_reports + attachments를 한 트랜잭션으로 저장. 접수번호 반환. 운영자 메일 발송은 트랜잭션 밖 fire-and-forget |
listMine | query | read.self | 페이지네이션, 상태 필터 |
getMine | query | read.self | 상세 + reporter 가시성 코멘트. 첨부는 반환하지 않음(§4.3) |
cancelMine | mutation | delete.self | received/triaged만. soft delete |
admin.list | query | OPERATOR | 조직·앱·상태·유형·기간·requestId·접수번호 필터 + 페이지네이션 |
admin.get | query | OPERATOR | 상세 + 모든 코멘트 + 첨부 다운로드 URL(presigned, 짧은 만료 5분) |
admin.transition | mutation | OPERATOR | 상태·우선순위·duplicateOf·회신문 |
admin.comment | mutation | OPERATOR | 내부 메모 or 회신 |
admin.acceptTriage | mutation | OPERATOR | 에이전트 제안을 그대로/수정해서 확정. triageState를 accepted/edited로, 제안 값(유형·우선순위·중복·회신 초안)을 본 컬럼에 반영 |
admin.rerunTriage | mutation | OPERATOR | 제안 재생성(리포트 수정·모델 교체 후). 이전 제안은 ai_messages에 남는다 |
create의 남용 방어: 계정당 분당 5건·일 50건 rate limit(Redis 있으면 Redis, 없으면 프로세스 메모리 —inquiry.router와 같은 수준의 최소 방어). 같은 제목·같은 route로 60초 내 재접수는 새 행을 만들지 않고 기존 접수번호를 돌려준다(더블 클릭 방어).- 오류 메시지는 전부 한국어 해요체(
TRPCError.message는 화면에 그대로 뜬다).
6.5 운영자 알림
- 접수 즉시
config.csReport.recipientEmail(envCS_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 엔드포인트 측 — 필수 설정과 정체성
| 항목 | 요구사항 |
|---|---|
| 필수 env | ENDPOINT_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
엔드포인트 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_ENDPOINTSenv는 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 |
serverEvidence | requestId로 찾은 서버 로그 요약 — 오류 클래스·메시지·스택 상위, 같은 시각 같은 조직의 연속 실패 여부 | 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 | 래핑 대상 | 비고 |
|---|---|---|
getReport | csReport.admin.get 내부 서비스 | 본문·컨텍스트·정제 로그 |
findSimilarReports | fingerprint 일치 + embedding <=> raw SQL | 타 조직은 id·지문·건수만 |
lookupServerLog | 서버 로그 저장소 조회(신규 어댑터 — 현재는 pino stdout뿐이라 검색 가능한 저장처가 필요, §11 Q7) | requestId 또는 조직+시각 창 |
searchDocs | 기존 DOCS 모듈 tool | 매뉴얼 근거 |
getReleaseInfo | buildVersion으로 최근 배포 목록·변경 요약(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 — 접수 파이프라인 (필수)
@hereby/core/redact서브패스 분리 + 브라우저 리댁션 확장(주민번호·계좌·이메일)- SDK 링버퍼 + 전역 오류 훅 + API 실패 호출 기록 +
x-request-id응답 헤더·errorFormatter - 빌드 버전 주입(공통 Vite 헬퍼) — 8개 앱
cs_reports/attachments/comments엔티티 + 마이그레이션(embedding vector(1024)raw SQL 포함) + enum/schema + 권한 리소스·역할 시드 — 에이전트용 컬럼(fingerprint/triage/triageState/embedding)은 Phase 1에 함께 만든다. 나중에 붙이면 초기 리포트가 평가 데이터에서 빠진다csReport라우터(사용자 측 5개 + admin 6개) + storagecategory: "cs-report"CsReport공용 컴포넌트(모달·캡처·가림·전송·성공 상태) +showError진입점 +data-cs-mask1차 부착 목록- system-admin 「CS 리포트」 탭(목록 fan-out + 원인별 묶음 + 상세 + 상태 변경 + 코멘트)
- 운영자 접수 메일 + 사용자 처리 결과 인앱 알림
- 첨부 90일 보존 워커
- 오류 지문 계산 + 접수 시 임베딩 생성(에이전트 없이도 「같은 원인 N건」 묶음이 동작)
- 엔드포인트 자기 등록·발견(§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/rerunTriagelookupServerLog를 위한 서버 로그 검색 어댑터(§11 Q7 결정 선행)- 골든셋 20건 오프라인 평가 → 수용률 측정 시작
- 내 리포트 목록(
MyInfoForm탭 / 직원앱 더보기), 직원앱 실패 초안 보관·재전송, 상시 진입점(헤더 도움말·단축키), 운영 지표
Phase 3 — 수정 작업 에이전트 핸드오프
- §8.5. 트리아지 수용률이 목표치를 넘긴 뒤 별도 설계 문서로 착수
getReleaseInfotool + 배포 후 자동 회신 초안(닫힌 고리)
범위 밖(명시)
- 로그인 전 리포트, 익명 접수
- 조직 관리자용 리포트 열람(§11 Q1 결정 후)
- 화면 녹화(rrweb류 세션 리플레이) — 개인정보 부담 대비 가치 미검증
- 외부 작업관리 툴(GitHub Issues·Jira·Linear 등) 연동 — 하지 않는다. 작업 원장은
cs_reports, 처리 자동화는 §8의 에이전트. 코드 변경만 저장소 PR로 나가고 그것도 Phase 3 러너가 만든다 - 에이전트의 자율 회신·자율 상태 변경 — 이 문서의 어느 단계에서도 허용하지 않는다
11. 미확정 사항
| # | 질문 | 기본안 |
|---|---|---|
| Q1 | 조직 관리자(SUPER_ADMIN)가 자기 조직 직원들의 리포트를 볼 수 있어야 하는가? | 1차는 불가. 스크린샷이 타 직원의 급여 화면일 수 있어 조직 내 열람도 별도 동의·마스킹 정책이 필요 |
| Q2 | CS-{ENDPOINT_CODE}-YYMMDD-NNNN. 레지스트리 id = 코드로 통일 | 번호 하나가 전역 키여야 Phase 3의 에이전트·PR 참조와 지문 묶음이 엔드포인트를 가로질러 동작한다. 길이 비용(3~6자)은 감수 |
| Q2-1 | 콘솔 로컬 레지스트리 DB — SQLite vs 콘솔 전용 Postgres 스키마? | SQLite로 시작(콘솔은 단일 인스턴스, 의존성 최소). 콘솔이 다중화되는 시점에 Postgres로 |
| Q2-2 | DEPLOY_ENV=dev 엔드포인트의 자동 승인을 허용할 것인가? | 콘솔 설정 플래그로 허용(기본 off). prod/stg는 언제나 사람 승인 |
| Q3 | 첨부 보존 90일이 적절한가? | 90일. 처리 완료 후 분쟁 대비 기간으로 충분하며 개인정보 최소 보관 원칙과 균형 |
| Q4 | 캡처 라이브러리 선택(html-to-image vs html2canvas) | 번들·CSS 지원(Tailwind 4 oklch 색 등)을 실측 후 결정. 시트 워크북(캔버스/가상 스크롤) 캡처 결과를 기준으로 |
| Q5 | 운영자 접수 메일 수신처 — 단일 메일 vs 엔드포인트별 | env 단일 메일. 다중은 Phase 2 |
| Q6 | 자동 첨부 「로그」를 사용자가 끄면 리포트 가치가 크게 떨어지는데, 끌 수 있게 둘 것인가? | 둔다(투명성). 대신 기본 켜짐 + 「로그를 함께 보내면 더 빨리 해결할 수 있어요」 안내 |
| Q7 | lookupServerLog가 읽을 서버 로그 저장처 — 지금은 pino stdout뿐이다. 기존 로그 수집기(있다면)에 질의 API가 있는가, 없으면 최근 N일 오류 로그만 DB(api_error_logs)에 남길 것인가? | 후자로 시작 — requestId·조직·시각 인덱스만 있으면 되고, 로그 인프라 결정과 독립적으로 Phase 2를 진행할 수 있다 |
| Q8 | 스크린샷을 트리아지 입력(비전)으로 넣을 것인가? | 1차 제외. 자동 가림(data-cs-mask) 적용 범위가 검증되고 운영자가 「이 스크린샷은 안전」을 표시한 건에 한해 재분석 옵션으로만 |
| Q9 | 트리아지를 모든 리포트에 돌릴 것인가, bug 유형만? | 전부. 「문의」는 매뉴얼 검색만으로 닫히는 비율이 높아 오히려 효과가 크다. 비용은 리포트당 상한(§8.1)으로 통제 |