Repository navigation
✨ feat: 자연어 조회 품질 측정과 실패 신호 - #258
Conversation
인증만 mock이고 일부 요청만 실제 서버로 보내면, mock이 발급한 토큰을 서버가 검증하지 못해 401이 난다. 그래서 부분 활성화를 막고 VITE_ENABLE_MSW=false 로 한 번에 끄는 길만 남긴다. 원본 커밋(2c1e26e)에는 인증 응답 파싱·로그인 상태 동기화 수정이 함께 있었으나, 그 부분은 dev 가 자체적으로 고쳐 최종 결과가 동일해졌다. 낡은 구현을 되살리지 않기 위해 의도적으로 제외했다. 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을 같은 검증기로 확인해 실행한다.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
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. Comment |
바가 앱 폭(648) 컨테이너 안에 있어 PC에서 화면 가운데에만 떠 보였다. 바는 화면 폭을 다 채우고 안의 내용만 앱 폭으로 가운데 둔다.
스크롤을 잠글 때 Radix가 body에 스크롤바 폭만큼 margin-right를 주는데, scrollbar-gutter: stable로 비워 둔 자리까지 남아 두 번 보정됐다. 잠긴 동안에는 자리를 비우지 않게 한다.
njm1250
left a comment
There was a problem hiding this comment.
이제 실행은 됐는데 답이 좀 이상했던 경우도 로그로 같이 살펴볼 수 있겠네요! 감사합니다 수고하셨어요~
| @IsOptional() | ||
| @IsInt({ message: intValidationMessage }) | ||
| @Min(0, { message: minValidationMessage }) | ||
| @Max(MAX_TURN_INDEX, { message: maxValidationMessage }) |
There was a problem hiding this comment.
이 제한은 대화가 너무 길어지는 걸 막으려는 의도일까요? 100 제한 자체는 괜찮아 보여요. 다만 현재는 상한을 넘으면 400 오류가 나고 재시도도 계속 실패해서, 사용자에게 대화 상한을 안내하고 새 대화로 이어갈 수 있게 처리하면 좋을 것 같아요.
There was a problem hiding this comment.
의도는 대화 길이 제한이 아니라 그냥 컬럼 범위 가드였어요. 그런데 PR 본문에 "측정이 제품을 막지 않게"라고 써놓고 측정 필드로 400을 내고 있는게 모순이라 @max를 빼고 기록할 때 깎도록 했어요.
사용자에게 보이는 대화 상한 안내는 따로 작업하시는 5턴 작업에 더 맞을 것 같은데 부탁드려도 될까요~?
리뷰에서 세 가지를 짚어 주셨고 모두 실제 누락이었다. 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이 먼저 배포되면 이전 날짜의 마이그레이션이 뒤늦게 적용되는 순서 꼬임을 피한다.
목적
"조회가 성공했다"와 "질문에 답했다"가 지금 같은 숫자로 집계돼서, 실사용 품질을 알 수 없어요. 그 둘을 쪼개고, 틀렸을 가능성이 있는 자리를 결정론으로 모아 남겨요.
outcome: 'success'하나exec_ok(실행 성공) ·answered(질문 충족) 분리assistant_query_logs테이블 (90일)session_id·turn_indexvalidationFailures숫자에 묻힘policy_violations배열로 분리적용 범위: 자연어 조회 경로만. 사용자에게 보이는 응답은 한 글자도 바뀌지 않아요.
설계
1.
answered를 지금 채우지 않아요 — 일부러 NULL이에요exec_ok = true인데 '다음 합주'가 아니라 '모든 미래 합주'를 조회한 답일 수 있어요. 그걸 코드가 직접 알 수는 없어요.판정(LLM 또는 사람)을 이 PR에 넣지 않은 이유는 순서예요. 판정기를 넣으려면 판정기가 맞는지 검증할 사람 라벨이 먼저 필요하고, 그 라벨을 만들 실사용 로그가 지금 0건이에요. 판정기부터 넣으면 점수가 이상할 때 판정기를 의심하게 되고, 실제 원인이 정답지나 신호 쪽이면 엉뚱한 곳을 고치게 돼요.
2. 결정론 신호 6종 — 모델을 쓰지 않아요
EMPTY_RESULTZERO_VALUEROW_LIMIT_REACHEDREGENERATEDPOLICY_VIOLATIONMISSING_DATE_FILTERMISSING_DATE_FILTER는 AST를 다시 파싱하지 않아요. 이미 검증기가 뽑아둔parameterTypes에DATE/TIMESTAMPTZ가 있는지와, SQL에CURRENT_DATE계열이 있는지만 봐요. 같은 사실을 두 번 계산하지 않으려고요.ZERO_VALUE는 0이 정답인 질문도 있어서 실패로 단정하지 않고 신호로만 둬요. 그래서bucket은OK로 남고 판정에 넘어가요.3. 재질문은 저장하지 않고 질의로 계산해요
가장 싼 오답 탐지기(사람이 바로 다시 물었다 = 앞 답이 틀렸다)인데, 일부러 컬럼으로 안 만들었어요.
session_id·turn_index·question만 남기고, 윈도 함수 질의를assistant-analytics.md에 적어뒀어요.pg_trgm확장이 필요한 버전과 완전 일치로 시작하는 버전 둘 다요 — 확장을 켜는 건 별도 결정이라 이 PR에 안 넣었어요.4. 보안 위반과 의미 위반을 클래스로 나눴어요
SqlPolicyViolationError extends InvalidSqlQueryError를 추가했어요.경계를 넘어간 것만 정책 위반으로 분류했어요 —
TABLE_NOT_ALLOWED,FUNCTION_NOT_ALLOWED,WRITE_STATEMENT_BLOCKED등 18곳. 반대로BAND_SCOPE_MISSING·SOFT_DELETE_SCOPE_MISSING같은 범위 조건 누락은 의미 위반으로 남겨뒀어요. 모델이 흔히 빠뜨리는 것이라 정책 위반에 넣으면 "경계를 시험했나" 신호가 노이즈로 묻혀요.5. 측정이 제품을 막지 않게
warn만 남고 조회 결과는 그대로 나가요ASSISTANT_QUERY_LOG_ENABLED=false로 끄면 stdout 로그만 남아요 (호출 시점에 읽어서 재배포 불필요)6.
session_id는 측정 전용이에요 — 권한에 쓰이지 않아요클라이언트가 만든 값이라 신뢰하지 않아요. 밴드 범위는 서버가 멤버십을 확인한
bandId를$1에 바인딩하는 그대로예요. 이 값을 조작해도 보이는 데이터는 같아요. DTO 주석과 OpenAPI 설명에도 적어뒀어요.프론트에서는
useState가 아니라useRef로 들고 있어요. 추천 칩을 연달아 누르면setState가 반영되기 전에 다음 질문이 나가서 턴 순서가 밀리기 때문이에요.그리고 다시 시도는 턴을 새로 세지 않아요. 같은 질문을 다시 보내는 것이라, 재시도가 턴 수를 부풀리면 턴별 실패율이 왜곡돼요.
Changes
94031eb함께 고친 것
validationFailures에 묻혀 구분되지 않던 문제 (위 4번)고려했다가 버린 것
WRONG_SCOPE·WRONG_METRIC버킷을 미리 정의created_at으로 이미 답해요. 정답지가 없는 조회 품질만 따로 남기면 돼요리뷰 포인트
MISSING_DATE_FILTER의 질문 정규식 (assistant-query-signal.ts:38) — 오탐을 줄이려고 좁게 잡았어요.'이번'처럼 단독으로는 기간이 아닌 말은 넣지 않았는데, 빠진 표현이 있으면 알려주세요COLUMN_NOT_ALLOWED를 정책 위반에 넣었는데, 모델이 컬럼명을 틀리는 건 흔해서 노이즈일 수 있어요. 로그를 보고 재분류할 생각인데 처음부터 빼는 게 맞을까요?session_id를 신뢰하지 않는다는 전제 (위 6번) — 측정 외의 용도로 쓰이지 않는지 봐주세요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 failpnpm 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건)prisma migrate dev는 리뷰 후에요후속
지금 범위 밖이고, 무엇을 기다리는지 적었어요.
unknown)answered채우기promoted_to_corpus로 평가 코퍼스 승격promoted_to_corpus컬럼은 지금 아무도 쓰지 않지만 미리 뒀어요. 로그를 쌓기 시작한 뒤에 추가하면 그 전 기간의 행을 승격 후보에서 구분할 수 없어서요.🤖 Generated with Claude Code