Skip to content

✨ feat: 자연어 조회 품질 측정과 실패 신호 - #258

Merged
njm1250 merged 60 commits into
devfrom
feat/assistant-telemetry
Oct 17, 2026
Merged

njm1250 merged 60 commits into
devfrom
feat/assistant-telemetry

Conversation

@pcwadarong

Copy link
Copy Markdown
Contributor

베이스가 dev가 아니라 feat/assistant-nl-query(#203)예요. #203이 머지되면 따라 들어갑니다.

목적

"조회가 성공했다"와 "질문에 답했다"가 지금 같은 숫자로 집계돼서, 실사용 품질을 알 수 없어요. 그 둘을 쪼개고, 틀렸을 가능성이 있는 자리를 결정론으로 모아 남겨요.

전 후
성공의 정의 outcome: 'success' 하나 exec_ok(실행 성공) · answered(질문 충족) 분리
실패 분류 없음 8개 버킷 + 결정론 신호 6종
기록 위치 stdout → CloudWatch (흘러가면 끝) assistant_query_logs 테이블 (90일)
대화 식별 없음 session_id · turn_index
정책 위반 가시성 validationFailures 숫자에 묻힘 policy_violations 배열로 분리

적용 범위: 자연어 조회 경로만. 사용자에게 보이는 응답은 한 글자도 바뀌지 않아요.

설계

1. answered를 지금 채우지 않아요 — 일부러 NULL이에요

exec_ok   = true   검증 통과 + 읽기 전용 TX 실행 완료   ← 서버가 결정론으로 안다
answered  = null   질문에 실제로 답했는지              ← 결정론으로는 알 수 없다

exec_ok = true인데 '다음 합주'가 아니라 '모든 미래 합주'를 조회한 답일 수 있어요. 그걸 코드가 직접 알 수는 없어요.

판정(LLM 또는 사람)을 이 PR에 넣지 않은 이유는 순서예요. 판정기를 넣으려면 판정기가 맞는지 검증할 사람 라벨이 먼저 필요하고, 그 라벨을 만들 실사용 로그가 지금 0건이에요. 판정기부터 넣으면 점수가 이상할 때 판정기를 의심하게 되고, 실제 원인이 정답지나 신호 쪽이면 엉뚱한 곳을 고치게 돼요.

exec_ok = true를 성공률로 보고하지 않기로 했어요. 그 숫자는 "실행이 됐다"는 뜻뿐이에요. 문서(assistant-analytics.md)에도 인용블록으로 세워뒀어요.

2. 결정론 신호 6종 — 모델을 쓰지 않아요

신호 기준 의심하는 것
EMPTY_RESULT 실행 성공 + 0행 조건을 너무 좁게 걸었다
ZERO_VALUE 단일 집계가 0 또는 NULL JOIN이 끊겼다
ROW_LIMIT_REACHED 상한 50행 도달 목록을 좁히지 못했다
REGENERATED SQL을 다시 만들었다 프롬프트가 못 잡은 자리
POLICY_VIOLATION 허용 목록 밖 시도 질문이 경계를 시험했다
MISSING_DATE_FILTER 질문에 기간 표현이 있는데 SQL에 시간 조건이 없다 전체 기간을 집계했다

MISSING_DATE_FILTER는 AST를 다시 파싱하지 않아요. 이미 검증기가 뽑아둔 parameterTypes에 DATE/TIMESTAMPTZ가 있는지와, SQL에 CURRENT_DATE 계열이 있는지만 봐요. 같은 사실을 두 번 계산하지 않으려고요.

ZERO_VALUE는 0이 정답인 질문도 있어서 실패로 단정하지 않고 신호로만 둬요. 그래서 bucket은 OK로 남고 판정에 넘어가요.

3. 재질문은 저장하지 않고 질의로 계산해요

가장 싼 오답 탐지기(사람이 바로 다시 물었다 = 앞 답이 틀렸다)인데, 일부러 컬럼으로 안 만들었어요.

쓰기 시점에 계산하면          분석 시점에 계산하면
──────────────────          ──────────────────
앞 턴을 다시 읽어야 함        추가 읽기 0
유사도 기준을 바꾸면          기준을 바꿔도 과거를 다시 해석 가능
  이미 저장한 행이 낡음

session_id · turn_index · question만 남기고, 윈도 함수 질의를 assistant-analytics.md에 적어뒀어요. pg_trgm 확장이 필요한 버전과 완전 일치로 시작하는 버전 둘 다요 — 확장을 켜는 건 별도 결정이라 이 PR에 안 넣었어요.

4. 보안 위반과 의미 위반을 클래스로 나눴어요

SqlPolicyViolationError extends InvalidSqlQueryError 를 추가했어요.

전: 둘 다 InvalidSqlQueryError
    → service가 같은 분기로 받아 재생성
    → validationFailures 숫자 하나에 합쳐짐
    → "허용 목록 밖 테이블을 시도한 질문이 있었나"를 나중에 알 수 없음

후: 상속이라 재생성 정책은 그대로. 분류만 나뉘어 policy_violations 에 남음

경계를 넘어간 것만 정책 위반으로 분류했어요 — TABLE_NOT_ALLOWED, FUNCTION_NOT_ALLOWED, WRITE_STATEMENT_BLOCKED 등 18곳. 반대로 BAND_SCOPE_MISSING·SOFT_DELETE_SCOPE_MISSING 같은 범위 조건 누락은 의미 위반으로 남겨뒀어요. 모델이 흔히 빠뜨리는 것이라 정책 위반에 넣으면 "경계를 시험했나" 신호가 노이즈로 묻혀요.

#203 본문에 "보안·구문 위반은 재생성 없이 즉시 거부" 라고 적혀 있는데, 코드는 assistant.service.ts의 한 분기에서 둘을 같이 받아 둘 다 재생성해요. 이 PR은 분류만 나누고 동작은 바꾸지 않았어요 — 재생성을 막을지는 로그를 보고 판단하는 게 맞다고 봤어요. 거부가 맞다고 보시면 본문과 코드 중 어느 쪽을 맞출지 알려주세요.

5. 측정이 제품을 막지 않게

try {
  await this.repository.recordQueryLog(entry);
} catch (error) {
  this.logger.warn(`질의 측정 기록에 실패했습니다: ${toMessage(error)}`);
}
  • 기록 실패는 warn만 남고 조회 결과는 그대로 나가요
  • ASSISTANT_QUERY_LOG_ENABLED=false 로 끄면 stdout 로그만 남아요 (호출 시점에 읽어서 재배포 불필요)
  • 조회와 같은 트랜잭션에 넣지 않아요 — 읽기 전용 TX에서는 쓸 수 없고, 기록 실패가 조회를 되돌려서도 안 돼요
  • stdout 로그는 그대로 유지했어요. 기존 CloudWatch Logs Insights 질의가 깨지지 않게 필드만 늘렸어요

6. session_id는 측정 전용이에요 — 권한에 쓰이지 않아요

클라이언트가 만든 값이라 신뢰하지 않아요. 밴드 범위는 서버가 멤버십을 확인한 bandId를 $1에 바인딩하는 그대로예요. 이 값을 조작해도 보이는 데이터는 같아요. DTO 주석과 OpenAPI 설명에도 적어뒀어요.

프론트에서는 useState가 아니라 useRef로 들고 있어요. 추천 칩을 연달아 누르면 setState가 반영되기 전에 다음 질문이 나가서 턴 순서가 밀리기 때문이에요.

그리고 다시 시도는 턴을 새로 세지 않아요. 같은 질문을 다시 보내는 것이라, 재시도가 턴 수를 부풀리면 턴별 실패율이 왜곡돼요.

Changes

해시 내용
94031eb ✨ feat: 추가 자연어 조회 품질 측정과 실패 신호

함께 고친 것

  • 정책 위반이 validationFailures에 묻혀 구분되지 않던 문제 (위 4번)

고려했다가 버린 것

안 왜 버렸나
LLM 판정을 지금 붙이기 판정기를 검증할 사람 라벨이 먼저 필요하고, 라벨을 만들 실사용 로그가 0건이에요
재질문을 컬럼으로 저장 유사도 기준을 고치면 저장한 행이 낡아요. 질의로 계산하면 과거를 다시 해석할 수 있어요
WRONG_SCOPE·WRONG_METRIC 버킷을 미리 정의 결정론으로 판정할 수 없어요. 판정이 붙을 때 마이그레이션으로 추가해요
범위 조건 누락을 정책 위반으로 분류 모델이 흔히 빠뜨려서 "경계를 시험했나" 신호가 노이즈에 묻혀요
밴드·사용자에 FK 연결 텔레메트리는 도메인과 수명이 달라요. 밴드가 삭제돼도 측정 기록은 남아야 해요
범용 이벤트 테이블 제품 지표 대부분은 도메인 테이블의 created_at으로 이미 답해요. 정답지가 없는 조회 품질만 따로 남기면 돼요
신호를 전량 저장 신호가 안 붙은 행도 분모로 필요해서 전량 남겨요. 대신 보관 90일로 끊어요

리뷰 포인트

  1. MISSING_DATE_FILTER의 질문 정규식 (assistant-query-signal.ts:38) — 오탐을 줄이려고 좁게 잡았어요. '이번'처럼 단독으로는 기간이 아닌 말은 넣지 않았는데, 빠진 표현이 있으면 알려주세요
  2. 정책 위반 분류 경계 (위 4번) — COLUMN_NOT_ALLOWED를 정책 위반에 넣었는데, 모델이 컬럼명을 틀리는 건 흔해서 노이즈일 수 있어요. 로그를 보고 재분류할 생각인데 처음부터 빼는 게 맞을까요?
  3. session_id를 신뢰하지 않는다는 전제 (위 6번) — 측정 외의 용도로 쓰이지 않는지 봐주세요
  4. 보관 90일을 자동으로 지우는 장치가 아직 없어요 — 지금은 문서상 규칙이에요. 크론을 어디에 둘지(n8n? pg_cron?) 정해지면 붙일게요

Test plan

실제로 실행한 것만 적었어요.

  • pnpm run lint (backend) — 0 errors / 2 warnings (둘 다 기존 schedules.service.spec.ts)
  • pnpm run format:check (backend) — 통과
  • pnpm run build (backend) — 통과
  • npx jest (backend) — 59 suites / 972 tests 통과 (신규 27건)
  • pnpm run test:assistant-evaluation — 17 pass / 0 fail
  • pnpm exec prisma validate · prisma generate — 통과
  • pnpm run lint (frontend) — 0 errors / 1 warning (기존)
  • pnpm run build (frontend) — 통과
  • npx vitest run (frontend) — 98 files / 505 tests 통과 (신규 3건)
  • 실제 DB에 마이그레이션 적용은 미실행 — 로컬 Postgres 없이 작성했어요. prisma migrate dev 는 리뷰 후에요

후속

지금 범위 밖이고, 무엇을 기다리는지 적었어요.

다음 시작 조건
판정기 검증 장치 (사람 라벨 30건 + 합격선 파일 + 판정 실패는 unknown) 이 테이블에 로그 30건 이상 필요. UT 후
LLM 판정 1콜 → answered 채우기 위 합격선 통과 후. 신호 붙은 행 + 무작위 표본에만 붙여요
promoted_to_corpus로 평가 코퍼스 승격 판정 사례가 쌓인 뒤. 질문 원문은 한 글자도 고치지 않아요
호출 성격별 모델 분리 이 테이블에서 100건 샘플을 뽑아 비교표를 만들 수 있을 때
보관 90일 자동 삭제 크론 위치 결정 필요

promoted_to_corpus 컬럼은 지금 아무도 쓰지 않지만 미리 뒀어요. 로그를 쌓기 시작한 뒤에 추가하면 그 전 기간의 행을 승격 후보에서 구분할 수 없어서요.

🤖 Generated with Claude Code

njm1250 and others added 30 commits October 7, 2026 00:44
인증만 mock이고 일부 요청만 실제 서버로 보내면, mock이 발급한 토큰을
서버가 검증하지 못해 401이 난다. 그래서 부분 활성화를 막고
VITE_ENABLE_MSW=false 로 한 번에 끄는 길만 남긴다.

원본 커밋(2c1e26e)에는 인증 응답 파싱·로그인 상태 동기화 수정이 함께
있었으나, 그 부분은 dev 가 자체적으로 고쳐 최종 결과가 동일해졌다.
낡은 구현을 되살리지 않기 위해 의도적으로 제외했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
밴드 목록 mock이 실제 응답과 모양이 달라 목록 테스트가 실제 계약을
검증하지 못했다.

원본 커밋(a3a2fde)에는 밴드·프로필 스키마와 profile-api 파싱 수정이
함께 있었으나, dev 가 #254(GET /users 응답에 대표 세션 추가)에서 같은
파일들을 다시 썼다. 그쪽이 더 새로운 계약이므로 의도적으로 제외하고
mock·테스트 두 파일만 남긴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1인 1행 데이터에서는 중복 집계·중복 명단이 정답으로 채점돼 이전 50/50이 실제로는 42/50이었다. 겸업 멤버의 세션 행을 추가하고 수정에 사용하지 않는 holdout 20문항을 구조 수정 전에 고정했다.
검증기가 첫 의미 위반에서 멈추고 재생성에는 직전 실패만 전달해, 관계를 고치면 DISTINCT를 빠뜨리고 DISTINCT를 고치면 관계를 다시 빠뜨렸다. 보안·구문 검사는 즉시 거부를 유지하고 의미 규칙 위반은 모두 모아 알리며, 재생성에는 모든 시도의 위반과 직전 SQL을 함께 준다.
겸업 멤버는 같은 일정·팀에 세션 행이 여럿이라 모델이 DISTINCT를 빠뜨리면 인원과 명단이 중복됐다. 집계 단위를 질문 정규식으로 추측해 거부하는 대신, 검증을 통과한 SQL의 참여·팀 편성 테이블을 서버가 (일정, 멤버)·(팀, 멤버)당 한 행인 서브쿼리로 바꿔 실행한다. 결과 조건 표시와 평가기는 서브쿼리 별칭을 원래 테이블로 해석한다.
GROUP BY 서브쿼리는 다른 밴드 행까지 참여 테이블 전체를 매번 집계했다. 같은 (일정, 멤버)에서 id가 가장 작은 행만 남기는 NOT EXISTS 조건으로 바꿔 기존 (일정, 멤버, 세션) 인덱스를 사용한다. 이전 실행 SQL 96건의 결과는 두 구현이 같다. 서브쿼리 별칭 해석은 더 이상 필요 없어 되돌렸다.
요약은 행을 다시 나열하지 않고 결론 한 줄만 만든다. 조건은 최상위 AND마다 칩 하나로 나누고 enum·UTC 시각 대신 한글 값·한국 시간·지금·기간으로 보여준다. 응답에 kind와 결과 제목을 넣고, 재생성까지 SQL을 만들지 못한 경우는 503 대신 질문을 바꿔 달라는 REPHRASE 응답으로 돌려준다. 아티스트 확인은 누르면 바로 보낼 질문을 담은 후보 배열로 주고, 추천 질문은 모델 없이 고정 SQL을 같은 검증기로 확인해 실행한다.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Repository: BandCo-House/BandCo/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 013f65b7-7393-43e9-8e3f-afbfb40418f0
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

바가 앱 폭(648) 컨테이너 안에 있어 PC에서 화면 가운데에만 떠 보였다. 바는 화면 폭을 다 채우고 안의 내용만 앱 폭으로 가운데 둔다.
스크롤을 잠글 때 Radix가 body에 스크롤바 폭만큼 margin-right를 주는데, scrollbar-gutter: stable로 비워 둔 자리까지 남아 두 번 보정됐다. 잠긴 동안에는 자리를 비우지 않게 한다.

@njm1250 njm1250 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이제 실행은 됐는데 답이 좀 이상했던 경우도 로그로 같이 살펴볼 수 있겠네요! 감사합니다 수고하셨어요~

Comment thread backend/src/modules/assistant/assistant.service.ts Outdated
Comment thread backend/src/modules/assistant/assistant.service.ts
@IsOptional()
@IsInt({ message: intValidationMessage })
@Min(0, { message: minValidationMessage })
@Max(MAX_TURN_INDEX, { message: maxValidationMessage })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 제한은 대화가 너무 길어지는 걸 막으려는 의도일까요? 100 제한 자체는 괜찮아 보여요. 다만 현재는 상한을 넘으면 400 오류가 나고 재시도도 계속 실패해서, 사용자에게 대화 상한을 안내하고 새 대화로 이어갈 수 있게 처리하면 좋을 것 같아요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도는 대화 길이 제한이 아니라 그냥 컬럼 범위 가드였어요. 그런데 PR 본문에 "측정이 제품을 막지 않게"라고 써놓고 측정 필드로 400을 내고 있는게 모순이라 @max를 빼고 기록할 때 깎도록 했어요.

사용자에게 보이는 대화 상한 안내는 따로 작업하시는 5턴 작업에 더 맞을 것 같은데 부탁드려도 될까요~?

pcwadarong and others added 15 commits October 11, 2026 00:46
리뷰에서 세 가지를 짚어 주셨고 모두 실제 누락이었다.

1. 정책 위반이 의미 위반과 합쳐지면 분류가 사라졌다.
   COLUMN_NOT_ALLOWED와 SELECT_STAR_BLOCKED는 check() 안에서 던져져
   violations 배열에 들어가고, 둘 이상이면 combineViolations가 평범한
   InvalidSqlQueryError로 합친다. 그 순간 instanceof 검사를 빠져나가
   policy_violations가 빈 배열로 남았다.
   클래스 대신 오류가 policyCodes를 직접 들고 다니게 해서, 합쳐도 분류가
   보존되도록 했다. 재생성 정책은 그대로다.

2. 모델 호출·이름 확인 장애가 측정에서 빠졌다.
   두 경로는 재생성으로 풀리지 않아 그대로 다시 던져졌고, 실패율 분모에
   들어가지 않았으며 그때까지 쓴 토큰도 사라졌다.
   SqlGenerationFailedError로 감싸 토큰과 시도 횟수를 들고 나오게 한 뒤,
   INFRA로 기록하고 원인 예외를 그대로 다시 던진다. 사용자 응답과 상태
   코드는 바뀌지 않는다.

3. 측정 필드가 조회를 막았다.
   turnIndex가 100을 넘으면 400이 나고 재시도도 계속 실패했다. 측정 필드
   하나 때문에 조회가 실패하면 측정을 끄게 된다 — 이 PR이 세운 원칙과
   어긋났다. @max를 빼고 기록할 때 상한으로 깎는다. 사용자에게 보이는
   대화 상한 안내는 화면이 담당하므로 이 범위에 넣지 않았다.

그리고 고치면서 발견한 것 둘을 함께 정리했다.

- 이름 확인 장애 테스트가 아티스트 바인딩이 잡히지 않는 SQL을 써서 실행
  단계에서 실패하고 있었다. 통과했지만 의도한 경로가 아니었다.
- generation_failed와 execution_failed가 둘 다 INFRA로 들어가 테이블에서
  구분되지 않았다. outcome 컬럼을 추가해 단계를 가를 수 있게 했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"그중에"처럼 앞 결과를 가리키는 질문은 조건 칩만으로 WHERE 절을 복원하지 못한다. 답마다 원래 SQL·실행 파라미터·결과 건수를 context로 돌려주고, 다음 요청의 history(최대 3턴)로 받는다. 클라이언트가 보낸 SQL은 실행 SQL과 같은 검증을 다시 통과해야 하며 파싱 후 다시 만든 문장만 프롬프트에 넣어 주석에 숨긴 지시문을 지운다. 결과 행과 답 문장은 보내지 않는다.

짧은 후속 질문은 현재 질문으로 인원 단위가 정해지지 않을 때만 바로 앞 질문과 이어 붙여 판별한다. 사람 단위 변환은 한 일정 안의 중복만 없애므로 여러 일정에 걸친 인원 검사는 유지한다. 대화 턴 상한(5)을 DTO에서 막고 turnIndex를 로그에 남긴다.
이어서 묻는 질문에만 직전 답의 조회 구조(최대 3턴)와 몇 번째 질문인지를 붙인다. 첫 질문의 요청 본문은 그대로다. 질문이 5개 쌓이면 입력창 대신 새 대화 안내를 보여주고 이어서 묻기 버튼을 숨긴다.
조건 이어받기 4문항, 생략된 대상 이어받기 2문항, 이력을 무시해야 하는 대조 1문항이다. 직전 턴은 정답 SQL로 고정해 첫 질문 정답 여부와 무관하게 지시어 해석만 보고, 제품과 같은 재검증을 거쳐 이력으로 넣는다.
범위 루트를 쿼리 전체에서 모으던 방식을 SELECT마다 따로 검사하도록 바꾼다. 범위 루트는 각 SELECT의 WHERE 최상위 AND 조건에서만 인정하고, 서브쿼리는 바깥에서 이미 범위 안인 별칭에만 기댈 수 있다. LEFT JOIN은 오른쪽, RIGHT JOIN은 왼쪽만 ON 조건으로 범위 안에 넣는다.

- LEFT JOIN bands ON ... AND b.id = $1 은 매칭되지 않은 행의 b.deleted_at이 NULL이라 다른 밴드 행이 남았다.
- NOT EXISTS 안의 b.id = $1 이 바깥 테이블의 범위로 인정됐다.
- SELECT 목록의 맨 이름은 PostgreSQL이 실제 컬럼으로 해석하는데, 같은 이름의 출력 별칭이 있으면 허용 목록 검사를 건너뛰었다. 이제 같은 SELECT의 ORDER BY 항목일 때만 출력 별칭으로 인정한다.

10/11 평가의 모델 출력 276건을 새 검증기로 다시 검사해 통과·거부 결과가 모두 같음을 확인했다.
Guard는 DTO 변환 전 원본 body를 보므로 빈 문자열·공백 presetId도 추천 질문으로 보고 제한을 건너뛰었다. DTO는 이를 undefined로 바꿔 자유 질문으로 모델을 호출한다. 앞뒤 공백을 지운 값이 실제 추천 질문 ID일 때만 제한에서 뺀다.
재생성 3회와 provider 재시도, 출력 잘림 재시도가 곱해져 한 요청이 2분을 넘길 수 있었다. 화면 요청은 10초에 끊기므로 SQL 생성 전체에 9초 마감을 두고, LlmService는 마감을 넘기면 재시도와 다음 호출을 멈추고 남은 시간만큼만 기다린다. 10/11 평가에서 가장 오래 걸린 생성은 4.7초였다.

감사 로그는 userId·bandId와 함께 남으므로 질문의 이메일·전화번호를 가리고, DB 오류는 바인딩 값이 섞일 수 있는 원문 대신 오류 이름과 코드만 남긴다.
LLM_MAX_RETRIES=0이 양수 파서에서 거부돼 기본값 2로 돌아갔다. OpenAI 호환 provider는 거절·도구 호출 응답의 content가 null이라 null.trim()에서 실패했다. 순환 토글을 끄면 첫 provider·첫 key만 쓰는 현재 동작에 맞게 설정 주석도 고친다.
NODE_ENV가 없거나 staging이면 AWS 설정 없이 부팅되던 것을 운영처럼 막는다. 개발 환경변수 동기화는 필수 키 검사 없이도 객체 형태, NODE_ENV=development, 키 이름 형식을 확인해 다른 환경 secret이 개발 파일에 써지지 않게 한다.
늦게 실패한 질문은 그 턴이 지금 대화에 있고 입력창이 비어 있을 때만 되돌린다. 계정이 바뀌면 대화 Provider를 새로 만든다. 시트를 열면 입력창 대신 시트 자체에 포커스를 둬 키보드를 올리지 않으면서 Tab이 시트 밖으로 나가지 않게 하고, 닫을 때 포커스가 body로 가면 헤더의 물어보기 버튼으로 보낸다.
maxRows를 50으로 고정해 서버 상한이 바뀌면 모든 표 응답이 파싱 실패로 보였다. 값은 양의 정수로만 보고 행 수가 maxRows 이하인지만 확인한다.
보안 경계가 앱 레벨 AST 검증기 하나뿐이라 검증기에 구멍이 생기면 앱 계정 권한 그대로 모든 테이블·컬럼이 열렸다. 모델 SQL을 실행할 때만 읽기 전용 트랜잭션 안에서 SET LOCAL ROLE assistant_reader로 바꾸고 set_config('app.band_id', ..., true)를 건다.

- assistant_reader는 카탈로그 허용 컬럼에만 SELECT 권한이 있다. users.email 같은 컬럼은 권한 오류가 난다.
- 18개 테이블에 assistant_reader 전용 RLS 정책을 둬 app.band_id 밴드의 행만 보인다. 설정이 없으면 아무 행도 보이지 않는다. 테이블 소유자인 앱 계정의 다른 조회에는 영향이 없다.
- 앱 계정이 테이블 소유자가 아니면 RLS를 켜는 순간 앱 조회가 막히므로 그 경우 마이그레이션을 실패시킨다.
- 사람 단위 변환의 SELECT *는 카탈로그 컬럼 목록으로 바꿨다. 컬럼 권한과 카탈로그가 어긋나지 않게 카탈로그 테스트에서 마이그레이션의 GRANT와 대조한다.

평가 DB에서 10/11 평가의 실행 SQL 231건을 전용 역할과 앱 계정으로 각각 실행해 결과가 모두 같음을 확인했다(1건은 ORDER BY가 없어 행 순서만 다름). 리뷰의 LEFT JOIN 우회는 자기 밴드 행만, NOT EXISTS 우회는 0행을 돌려준다. DB 실행 p95는 4.6ms에서 13.4ms가 됐다.
app.module은 자연어 조회와 어드민·신고·서비스 상태 모듈을 모두 등록한다. 스토리지는 로컬·테스트에서만 AWS 설정 누락을 허용하는 구조를 유지하고, 어드민 사용량 집계의 listAllObjects도 같은 설정 확인을 거친다. index.css는 dev의 배경 주석과 스크롤 잠금 시 scrollbar-gutter 규칙을 함께 둔다.
#203의 후속 질문·검증기 수정·전용 DB 역할과 dev(어드민 콘솔)를 병합하고 충돌을 정리한다.

- turnIndex는 #203 정의로 통일한다. 1부터 시작하고 최대 5이며 후속 질문에만 보낸다. 서버는 값이 없으면 1로 기록한다. 0부터 세던 측정용 정의와 상한 깎기(MAX_TURN_INDEX)는 없앤다.
- sessionId는 모든 질문에 붙인다. 요청 본문에 담아 두므로 다시 시도해도 같은 세션·같은 턴으로 기록된다.
- 측정 기록에도 후속 질문 수(historyTurns)를 남기고, 질문의 이메일·전화번호를 가린다. 오류 메시지는 원문 대신 이름·코드만 남긴다.
- SQL 생성 9초 마감으로 재생성을 일찍 멈추면 실제 실패한 시도 수를 validationFailures로 남긴다.
- 측정 테이블 마이그레이션을 전용 DB 역할(20261013120000) 뒤인 20261017120000으로 옮긴다. #203이 먼저 배포되면 이전 날짜의 마이그레이션이 뒤늦게 적용되는 순서 꼬임을 피한다.
Base automatically changed from feat/assistant-nl-query to dev October 17, 2026 06:14
dev의 #203 squash 커밋은 이 브랜치가 이미 담고 있는 #203 최종본과 내용이 같다(트리 동일). 같은 변경이 커밋 단위와 squash 단위로 두 번 들어와 생긴 충돌이라 이 브랜치의 내용을 유지한다.
@njm1250
njm1250 merged commit 8668473 into dev Oct 17, 2026
3 checks passed
@njm1250
njm1250 deleted the feat/assistant-telemetry branch October 17, 2026 06:18
njm1250 added a commit that referenced this pull request Oct 17, 2026
<!-- release-prs:start -->
## 포함된 PR

- ✨ feat: 자연어 조회 품질 측정과 실패 신호 (#258)
- 자연어 기반 밴드 데이터 조회 기능 (#203)
<!-- release-prs:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants