diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5078c6f..0dca256 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,7 @@ CI는 Ruff, compile 검사, pytest/Testcontainers, coverage 게이트, PR 컨테 수행한다. `app`의 **line과 branch coverage는 각각 80% 이상**이어야 하며 미달이면 `ai-ci / check`가 -실패한다(`S15P11A705-110`에서 활성화). 판정은 `tools/check_coverage_gate.py`가 하고 +실패한다. 판정은 `tools/check_coverage_gate.py`가 하고 임계값은 그 파일의 상수다 — CI에서 인자로 덮을 수 없다. `--cov-fail-under`를 쓰지 않는 이유는 그것이 두 지표를 **합산한 하나의 비율**이기 @@ -158,8 +158,7 @@ ai-ci / embedding profile parity 위 검사 이름은 GitHub branch protection의 required status checks와 문자열까지 일치해야 한다. 검사를 추가하거나 이름을 바꿀 때는 **이 절을 먼저 고치고** 하위 문서와 -GitHub 설정을 거기에 맞춘다 — 순서가 뒤집히면 낡은 값이 하위 문서로 퍼진다 -(`S15P11A705-158` 실측). +GitHub 설정을 거기에 맞춘다 — 순서가 뒤집히면 낡은 값이 하위 문서로 퍼진다. ## Feed 협업 경계 @@ -169,7 +168,7 @@ GitHub 설정을 거기에 맞춘다 — 순서가 뒤집히면 낡은 값이 범위, 개인정보 경계, 후보·필터·fallback·impression 의미, Feed 관련 계약 리뷰. - 백엔드 파트는 구현을 소유한다 — 후보 조회, scoring 실행, API와 cursor, requestId, DB·Redis·트랜잭션, impression 저장과 중복 처리, 백엔드 테스트. -- Feed 구현은 `S15P11A705-111` 산하 Task와 `back` 레포 PR로 추적한다. +- Feed 구현은 Jira 작업과 `back` 레포 PR로 추적한다. 레포가 다르면 티켓·브랜치·PR도 분리하고 서로 연결한다. - Feed 계약 변경은 병합 전에 AI 계약 리뷰어에게, 백엔드 런타임 변경은 백엔드 담당자와 AI 계약 리뷰어에게 요청한다. diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 30ea306..76a617a 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -7,17 +7,17 @@ > - **줄 순서가 보장되지 않습니다.** 시간순으로 읽을 때는 표의 날짜 컬럼을 기준으로 삼습니다. > - **기존 줄을 수정하면 중복이 생길 수 있습니다.** 두 브랜치가 같은 줄을 각각 고치면 두 버전이 나란히 남습니다. 이미 있는 줄을 정정할 때는 병합 결과에 중복이 없는지 확인하세요. 줄을 **추가**하는 평소 작업에서는 신경 쓸 필요가 없습니다. > -> **GitHub은 이 설정을 반영하지 않습니다.** 로컬에서 충돌 없이 병합되는 상황도 PR 화면에서는 여전히 `CONFLICTING`으로 표시됩니다. 따라서 `dev`를 브랜치에 머지해 다시 올리는 절차 자체는 그대로 필요합니다. 달라지는 것은 **그 머지에서 손으로 충돌을 풀 일이 없어진다는 것**입니다(`back` 실측 `S15P11A705-91`, `ai` 실측 `S15P11A705-158`). +> **GitHub은 이 설정을 반영하지 않습니다.** 로컬에서 충돌 없이 병합되는 상황도 PR 화면에서는 여전히 `CONFLICTING`으로 표시됩니다. 따라서 `dev`를 브랜치에 머지해 다시 올리는 절차 자체는 그대로 필요합니다. 달라지는 것은 **그 머지에서 손으로 충돌을 풀 일이 없어진다는 것**입니다(`back`·`ai` 관련 Jira 작업에서 실측). | 날짜 | 작업 | 관련 문서 | |---|---|---| -| 2026-08-03 | 단어형 질의로 컷 격자를 다시 훑어 **`τ_abs` 를 질의 길이로 갈랐다**(`S15P11A705-266`, 단어형 0.24 / 문장형 0.30 · `r`=0.60 불변). `-255` 가 「`#87` 은 컷 조정만으로 회복된다」로 판정한 것의 후속이고 **이 티켓이 `ai#87` 을 닫는다.** **`-255` 의 22건으로는 답할 수 없었다** — 그것은 세 이슈의 원인을 가르기 위한 진단 집합이라 무관 통제가 `치과` 하나인데, 컷 값 판단은 「무관 대역이 어디까지 올라오는가」가 전부다. **`-213` 이 정확히 같은 오류를 범한 기록이 있다**(`personal-search.md §6` 이 무관 **1건**으로 「간격 +0.2120, 컷 불필요」를 결론 냈고 15건으로 늘리자 -0.0176 이 되어 뒤집혔다). 무관 통제를 45행으로 늘리자 역전이 **-0.0515 → -0.1687** 로 깊어졌다. **기대 정답을 손으로 짝짓지 않았다** — 단어형은 정답이 여럿이라(`신한`→6건 · `라멘`→2건) 손으로 정하면 판정자 재량이 결과를 만들므로 **본문 문자열 포함으로 계산**했고, 이 기준이 `ai#87` 의 요구(「본문에 있는 말로 검색하면 나와야 한다」)와 정확히 일치한다 — `-255` 가 실측한 「완전 일치는 유사도에 유의미하게 기여하지 않는다」는 **재는 쪽의 기준이 틀렸다가 아니라 임베딩이 그 기준을 못 따라간다**는 뜻이다. 부작용이 이득이었다 — 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 동시에 생겨 **통제 표본이 추가 측정 없이 늘었다**(정답 66행 · 교차 96행 · 무관 45행). 통제를 두 층으로 갈랐다. `cross`(같은 어휘인데 그 소유자엔 없다)는 같은 생활권이라 판별력이 약하고, 합치면 「무관 통과」 기준이 약해진다. 라벨을 요구하지 않는 대신(207행은 손 라벨이 비현실적이다) 꼬리 제거율을 포기하고 무관 통제를 늘렸다. **계약의 전제를 정정한다 — 「문장형 12건 회귀가 주 리스크」는 틀렸다.** 격자 **240조합 전량**에서 문장형 정답 누락 0 이다(이 티켓이 움직이는 방향이 컷을 **푸는** 쪽이라 구조적이고, 문장형 정답 최솟값 0.3642 는 격자 상단보다 위다). **진짜 손실은 다른 열에 있었다.** 문장형 무관 질의의 무노출이 `-213` 의 15건 중 11건(11/15)에서 0.24 단일값이면 **15건 중 5건(5/15)으로 절반 아래로 줄어든다.** 이 정정이 채택안을 바꿨다 — 손실이 「정답 누락」이 아니라 별도 축이라 **「가르면 그 손실이 0 이 된다」는 셋째 길**이 열렸다. **채택값을 정한 것은 회복률이 아니라 「1위 손실」이다**(측정 도중 추가한 지표) — 0.26 은 회복 68/71(96%)로 충분해 보이지만 **컷 전 1위인 정답 3건을 0건으로 만든다** (`비건`→플랜트 0.2583 **1위**인데 결과 0건 · `스팟`→동교공원 0.2438 1위 · `야경`→언덕위 0.2552 1위). 「1위인데 0건」은 컷이 유사도 순서를 존중하지 않는다는 뜻이고 회복률 96% 뒤에 숨는다. **0.24 는 「컷 전 1위 정답을 하나도 잃지 않는 가장 높은 값」이고 0.25 부터 깨진다**(격자를 0.01 간격으로 좁힌 이유 — 후보가 데이터점 하나 `스팟` 0.2438 에 붙어 `-213` 의 0.02 격자에서는 0.24~0.26 사이가 통째로 안 보인다). **`r` 은 가르지 않았다** — 무관 통과를 **어느 값에서도 한 건도** 못 줄이고(`-213` 이 문장형에서 본 「1위를 언제나 남긴다」가 단어형에서 그대로 재현) 단어형 손실도 `r=0.75` 까지 0 이라 이 교환에 참여하지 않는다. 갈랐다면 **재지 않은 축을 코드가 만드는 셈**이다. `τ(문장)=0.34` 도 기각했다(무관 통과가 1/15 로 더 좋지만 이 티켓은 문장형을 재지 않고 `matrix.json` 을 재사용했을 뿐이라 **남의 측정 위에서 남의 마진 판단을 바꾸지 않는다**). 결과 — 단어형 회복 **61/71 → 71/71** · 1위 손실 **5 → 0** · 정답 누락 11/66 → 2/66, 문장형 **완전 불변**, 대가는 단어형 무관 통과 10/45 → 26/45. GMS 임베딩 **배치 1회**(69건) · 격자 240조합은 GMS·DB 미호출. **실서버 대조 87/87 PASS** — 재구성 쪽에 길이 분기를 **다시 적어**(구현 `import` 금지, `-213` 규칙) 「서버가 분기를 실제로 타는가」가 관측되게 했고, 두 하한에서 **결과가 갈리는 60행**만 골랐다(갈리지 않는 행은 서버가 무엇을 하든 통과한다). **`ai#87` 증상 회복을 실서버로 직접 확인** — 「그네」 0건→6건(동교어린이공원 3위) · 「스팟」 0→2건(1위) · 「비건」 0→1건(1위) · 「산책」 0→4건. **441건 통과**(테스트 함수 11건 추가 — 단위 9 + API 관통 2). **검토 확정 10건 중 A·B·C 를 반영했다** — 배선 고정(#4 major)·킬스위치를 분기 앞으로(#2)·`recall_probe.py` 분기 반영(#3)·경계 5자 고정(#8)·공백을 유니코드 전체로(#1·#5)·끊긴 참조 셋(#9·#10)·수치 정정(#6). **넷을 변이 드릴로 확인했다** — 「테스트를 더했다」와 「그 테스트가 그 결함을 잡는다」는 다른 명제라 실제로 어긋내 보고 대응하는 테스트만 실패하는 것을 봤다. **`recall_probe.py` 가 이 티켓이 고친 증상을 계속 「① 컷이 잘랐다」로 읽고 있었다** — 진단 도구가 닫힌 증상을 미해결로 보고하는 상태였고, 분기를 넣으니 `그네`·`스팟`·`산책`·`라멘` 이 「— 통과」로 바뀌고 `신한`·`부캠` 은 ③ 그대로다. 그 도구의 `--replay` 가 **커밋된 행렬을 판정 결과로 덮어쓰던 것**도 함께 막았다(기본은 쓰지 않고, `--out` 이 입력과 같으면 멈춘다) — 행렬은 GMS 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다 · line 99.83%/branch 98.99%(`search_service.py` 100%). **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다** — `_cut(rows, query)` 의 `query` 기본값 때문에 **`, query` 가 사라져도 커버리지 100% 인 채로** 모든 단어형이 0.30 으로 돌아가 `ai#87` 이 재발한다(RED 드릴로 확인). 유사도를 두 하한 사이 `[0.24, 0.30)` 에 두는 것이 요점이고 기존 검색 테스트는 질의와 저장 텍스트가 같아 유사도 1.0 이라 이 배선을 못 잡는다 — **커버리지가 안전을 보증하지 못하는 자리다.** **`-228` 의 `T68`(임베딩이 같은 배치 구성으로도 비결정적 · `|Δsim|` 최대 **0.0044**)이 병합된 뒤 재현성을 실측으로 답했다** — 그 잡음이 이 격자 간격(0.01)·채택 마진(0.0038)과 같은 자릿수라 값 자체가 위협받았다. **두 층을 갈라야 한다** — 격자 내부는 임베딩을 한 번 떠서 굳히고 오프라인으로 훑으므로 애초에 노출되지 않고, 노출되는 것은 **채택값의 절대 마진**이다. 같은 질의 집합을 **회차 3개**로 떠서 대조하니(공통 2,346셀) **|Δsim| 상한 0.000209**(T68 의 21배 작다) · 스프레드>0.0044 인 셀 **0개** · **순위 변동 0건** · 경계점 `스팟` 스프레드 **0.000000** · 격자 판정 τ=0.20~0.30 **전 구간 3회 일치**였다. **답: 이 격자에서 인접 값의 차이는 재측정 운이 아니다.** `T68` 의 조언(「τ 를 다시 정할 일이 있으면 격자를 0.01 보다 촘촘하게 두지 않는다」)은 이 티켓이 이미 지켰다. **왜 `T68` 보다 작은지는 단정하지 않는다** — 그쪽은 프리셋 27개 중 **3개만** 갈렸고(나머지는 코사인 1.000000) 이쪽 질의는 전부 짧은 단어라 텍스트 길이가 관련 있어 **보이나** 통제한 측정이 없다. `T68` 의 처방(`base2` 상시 조건)을 그대로 따라 **`word_sweep.py --repro` 를 상시 관측으로 남겼다** — 한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다. **알려진 약점 둘** — 0.24 의 마진 0.0038 은 재측정 잡음(관측 상한의 18배 여유)이 아니라 **경계를 데이터점 하나가 정한다는 것** 자체가 문제이고(재시딩·데이터 증가로 그 점이 움직이면 `스팟` 이 다시 사라진다), **경계 5자는 측정이 아니라 판단**이다(측정한 단어형이 전부 공백 없는 2~5자라 「글자 수」와 「어절 수」를 가를 수 없었고, 두 조건을 **함께** 요구해 애매한 질의를 문장형=더 세게 자름 쪽으로 기울였다). `ai#86`(`신한`)·`ai#88`(`부캠`)은 그대로 — `-255` 판정대로 컷으로 못 푼다(0.24 로 내려도 카츠요가 6위·8위) (S15P11A705-266) | [단어형 컷 리포트](implements/2026-08-03-word-query-cut.md) · [spec/personal-search §6.1](spec/personal-search.md) · [tools/search_cut/](../tools/search_cut/) · [ai#87](https://github.com/Team-PinLog/ai/issues/87) | -| 2026-08-03 | 운영에서 「본문에 그대로 있는 말로 검색해도 안 나온다」가 셋 올라와(`ai#86` 신한 · `#87` 그네 · `#88` 부캠) **원인을 실측으로 갈랐다**(`S15P11A705-255`). **고치지 않았다 — 컷 값도 모델도 코드도 그대로다.** 측정보다 먼저 배제한 것은 티켓이 「넷째일 수 있다」로 남긴 가능성이다 — 검색 Query 가 `embedding_status = 'COMPLETED'` 를 요구하므로 그 Context 가 `FAILED` 로 굳었으면 유사도를 재는 일 자체가 무의미한데, 전량 42/42 `COMPLETED` · 벡터 42건 · `is_deleted` 0 으로 **정상**이었다. **결론은 「셋 다 같은 원인」이 아니다** — `그네` 는 컷이 잘랐고 풀면 3위(**①**), `신한` 은 풀어도 6위 · `부캠` 은 8위(**③**)다. **가른 것은 계약의 최소 비교군에 더한 무관 기준선이다** — 어느 본문에도 없는 2자 `치과` 의 top-1 이 **0.2953** 인데 본문에 그대로 있는 `스팟` 은 **0.2438**, `그네` 0.2671, `신한` 0.2301 로 **셋 다 그보다 낮다.** 즉 **완전 일치는 유사도에 유의미하게 기여하지 않는다** — `#86` 이 「완전 일치인데 안 걸린다」로 가장 심각하다고 본 전제가 실은 이 현상의 한 사례다. 본문을 고정하고 질의 길이만 바꾸자 경계가 깨끗하게 드러났다 — 본문 A·B 모두 **3자 이상은 전부 ≥0.3189, 2자는 전부 ≤0.2818** 이고 `τ_abs=0.30` 이 그 사이에 있다. 다만 「2자면 안 된다」는 **틀렸다** — `우주` 0.4199 · `공원` 0.3670 이 2자로 통과하며, 갈림은 「그 단어가 본문의 주제를 짚는가(우주·공원)와 부수 정보인가(스팟·신한)」로 **보이나 그것을 통제한 측정은 없다**(사후 해석이다). **`#87` 의 BPE 가설은 반증됐다** — 프로파일이 `text-embedding-3-small` 이라 토크나이저가 `cl100k_base` 로 특정되어 실제로 봤고, `그네`[49706, 76242, 97] 와 `그네팟`[49706, 76242, …] 의 **앞 두 토큰이 같다**(`신한`↔`신한 부트캠프`, `부캠`↔`부트캠프` 도 공유). 한국어가 바이트 단위로 잘게 쪼개져 접두 토큰이 그대로 남으므로 「한 덩어리로 묶여 멀어진다」가 성립하지 않는다 — `그네` 는 부분어라서가 아니라 **다른 2자 질의와 같은 대역**이라서 안 된다. 반면 **`#88` 의 진단은 확증됐다** — `부트캠프` 는 카츠요 1위 0.3254 인데 `부캠` 은 8위 0.2105 이고, 그러면서 `부캠` 이 그대로 있는 쿠로코를 1위(0.4102)로 짚는다. **표기는 알고 `부캠→부트캠프` 의미 연결만 못 한다.** 그래서 처방이 셋 다 다르다 — `#86` 은 원리상 임베딩으로 못 풀고(2자 대역이 무관 질의와 **겹침이 아니라 역전**이라 어떤 임계값으로도 못 가른다) 키워드 검색이 맞고, `#88` 은 의미 연결이라 모델 교체(`-199`)의 영역이며, `#87` 은 컷 조정만으로 회복된다. **`신한` 이 카츠요에서만 낮은 기제는 답하지 못했다** — 0건이 아니라 3건이 나오고 셋 다 「신한」 보유라 검색은 「신한」을 알아보는데, 상위 셋은 「신한 **부캠**」이고 카츠요만 「신한 **부트캠프**」다. 표현 일치 효과는 실측했으나(`신한 부캠` 질의에서 카츠요 0.3189 ↔ `신한 부트캠프` 질의에서 0.3793) **왜 「신한 부캠」이 「신한」 단독에 더 가까운지는 단정하지 않는다.** 본문 길이는 원인이 아니다 — 플랜트(30자) 0.3889 와 카츠요(32자) 0.2301 이 같은 길이에서 갈리고 순위 상관도 약하다(단어형 ρ -0.12 · 문장형 +0.07, 17점 표본이라 유의하지 않다). **`-213` 의 한계와 같은 구조이고 더 심하다** — 저쪽은 문장형에서 정답↔무관이 **-0.0176** 겹쳤고 이번은 단어형에서 **-0.0515** 로 역전이다. 「컷 값으로는 더 못 간다」가 강화된다(단어형을 살리려면 `τ_abs` 를 0.24 아래로 내려야 하는데 그 값은 무관 대역 0.2953 을 통째로 통과시킨다). GMS 임베딩 배치 1회 · 코드 변경 없음 · 로컬 시연 DB(`:15432`)만 사용. **실서버 대조는 하지 않았다**(`-213` 의 `verify_live.py` 가 같은 재구성 규칙으로 27/27 을 이미 확인했고 이 티켓은 규칙이 아니라 질의만 바꿨다 — 다만 이 질의 집합으로 실서버를 때린 것은 아니다) (S15P11A705-255) | [재현율 판별 리포트](implements/2026-08-03-search-recall-probe.md) · [tools/search_cut/](../tools/search_cut/) · [ai#86](https://github.com/Team-PinLog/ai/issues/86)·[#87](https://github.com/Team-PinLog/ai/issues/87)·[#88](https://github.com/Team-PinLog/ai/issues/88) | -| 2026-08-03 | DB 실패에도 응답 본문이 `embedding upstream ...`를 답하던 것을 층 이름별로 가른다(`S15P11A705-229`). `-221`이 상태 코드(`503`/`502`)와 로그는 정확히 맞혔지만 본문만 사실과 어긋난 채 남겨 뒀고, 그 티켓이 *"핸들러를 바꾸지 않는다"*로 확정하며 본문 개정을 후속으로 미뤘다 — 이 티켓이 그 후속이다. `-221`이 이미 둔 하위 타입(`DatabaseTransientError`/`DatabasePermanentError`)을 `app/main.py`의 기존 두 핸들러 안에서 `isinstance`로 보고 `database unavailable`/`database rejected the request`로 가르되, **원인 값은 여전히 싣지 않는다** — `-220`이 세운 "예외 메시지를 본문에 옮기지 않는다"와 `-205`가 실측으로 확정한 누출 경로 차단 원칙을 재확인만 했다. 새 핸들러를 추가하지 않아 `-220`의 "변환은 예외 핸들러 한 곳에서만" 원칙도 그대로다. **공용 계약 `static/05_AI_설계.md`는 반영하지 않았다** — ①그 문서는 `failure-recovery.md`를 파트별 세부 문서로 참조만 하고 응답 본문 문구 자체를 담고 있지 않으며, ②`back` 레포 `AiSearchClient.translate`를 직접 읽어 `502`/`503` 등 이 티켓이 다루는 상태 코드는 본문을 파싱하지 않고 상태 코드만 보고 버림을 확인했다(`serverProfile` 필드를 보는 `422`만 예외). 검증은 `-220`·`-221`의 원칙을 그대로 지켰다 — Fake 없이 실제 `asyncpg` 풀을 실패시켜 관통하는 계약 테스트에 신규 2건(DB 축 새 문구)과 회귀 2건(GMS 문구 불변)을 더했다. RED로 신규 2건이 여전히 `embedding upstream ...`를 답함을 먼저 확인했고, 핸들러 수정 뒤 GREEN. 전체 **425건 통과**, line 99.83%/branch 98.99%(게이트 80/80 유지). 실서버 대조는 별도로 하지 않았다 — 문구만 바뀌었고 `-221`의 관통 테스트가 이미 실제 DB 실패 경로를 검증하고 있어 새로 확인할 것이 없다고 판단했다 (S15P11A705-229) | [오류 문구 분리 리포트](implements/2026-08-03-error-wording-split.md) · [spec/failure-recovery §2.5 「응답 본문」](spec/failure-recovery.md) | -| 2026-07-31 | 검색 중 DB 실패를 일시 오류로 분류해 **503** 으로 내보낸다(`S15P11A705-221`). `-220` 이 `TransientError→503`·`PermanentError→502` 핸들러를 넣으며 **500 을 「우리 코드의 결함」 전용으로 비워 뒀는데**, DB 실패만 아무도 분류하지 않아 그대로 500 이었다 — 커넥션 풀 고갈이나 DB 재기동처럼 기다리면 낫는 상황이 「코드가 깨졌다」와 같은 코드를 썼고 그만큼 `-220` 의 전제가 깨져 있었다. **명세는 이미 이 동작을 규정하고 있었다** — `failure-recovery.md` §2.1 이 *"DB 연결 실패, 잠금 타임아웃, 직렬화 실패"* 를 일시 오류로 못박고 있었으므로 이 티켓은 새 정책이 아니라 **명세와 코드의 간극**을 메운 것이다. **가장 값진 발견은 티켓 전제가 어긋났다는 것이다** — 티켓은 *"asyncpg 예외를 분류"* 라고 적었지만 실측하면 **DB 접속 실패는 `asyncpg` 예외가 아니다.** 포트 거부(`ConnectionRefusedError`)·DNS(`socket.gaierror`)·접속 타임아웃·**커넥션 풀 획득 타임아웃**이 전부 stdlib `OSError` 이고, `08xxx` `PostgresConnectionError` 는 *이미 붙어 있던* 연결이 끊길 때의 SQLSTATE 다. 문구대로 asyncpg 만 분류했으면 **테스트는 전부 통과인데** 티켓의 완료 조건만 조용히 거짓으로 남았을 것이다(T53). 같은 이유로 분류를 repository 함수가 아니라 **세션 경계**(`app/core/db.py`)에 걸었다 — 데코레이터 방식은 `pool.acquire()` 가 그 밖이라 접속 실패가 애초에 들어오지 않는다. 경계는 한 줄이다 — **서버·연결의 상태 때문에 실패했으면 일시적, 우리가 보낸 질의 때문이면 우리 결함.** SQLSTATE 군 단위로 긋고 군 안에서 성격이 갈리는 셋(`25xxx`·`55xxx`·`42xxx`)만 잎으로 집었다(Transient `OSError`(접속 단계)·`08`·`40`·`53`·`57`·`58`·`55P03`·`25P03`·`25P04` / Permanent `28`·`3D000`·`42501`). **미분류(500) 목록이 분류 목록만큼 중요하다** — `42xxx` 나머지·`22xxx`·`23xxx`·`XX000` 은 그대로 500 에 남긴다. 넓게 잡으면 버그가 「일시적으로 사용할 수 없습니다」 뒤에 영구히 숨고 재시도해도 안 되는데 알림이 안 울린다. 가장 논쟁적인 것은 **`asyncpg.InterfaceError` 를 분류하지 않은 것**이다 — 한 타입이 `connection is closed`(수명주기)와 `the server expects 2 arguments, 1 was passed`(우리 결함)를 함께 쓰고, 통째로 감싸면 후자가 숨는다. 수명주기 쪽은 정상 경로에서 나오지 않는다(풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003` 이고 그쪽은 이미 일시 오류다 — 실측). `OSError` 는 **접속 단계에서만** 번역한다 — 커넥션을 넘겨준 뒤의 블록에는 호출부 코드가 함께 들어와 무관한 `OSError` 가 503 으로 둔갑할 수 있다. **`-220` 의 핸들러를 한 줄도 고치지 않았다** — `DatabaseTransientError(TransientError)` 하위 타입이라 기존 핸들러가 그대로 받고, 덤으로 핸들러 로그의 타입 이름이 「게이트웨이가 아니라 DB」를 말한다. 예외 메시지에는 **타입 이름과 SQLSTATE 만** 담는다 — DSN 에 DB 비밀번호가 있고 접속 실패 예외에 host·port 가 섞이며, `-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준이 그대로 적용된다. SQLSTATE 를 남기는 것은 *"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 가르는 유일한 값이라서다(`app.core.db` 로거, 일시 WARN·영구 ERROR). 검증은 `-220` 의 원칙을 지켰다 — **DB 도 Fake 로 바꾸지 않는다.** 닫힌 포트를 가리키는 `min_size=0` 풀·서버가 실제로 죽인 백엔드(`08003`)·실제 `statement_timeout` 취소(`57014`)·실제 `3D000` 으로 냈고(계약 8건), 표 전체는 DB 없이 분류 함수로 고정했다(단위 40건). `min_size=0` 이 필요한 이유가 T54 다 — 운영 `min_size=1` 풀은 접속이 깨지면 **기동**에서 실패해 요청 경로에 닿지 못한다. RED 4건 → 전체 **356건 통과**, line 99.82% branch 98.84%. 실서버 대조는 전용 pgvector 컨테이너를 `docker stop` 해서 냈다(**시연 DB 를 건드리지 않았다**) — `origin/dev` **500 + 트레이스백** ↔ 이 브랜치 **503** `{"detail":"embedding upstream unavailable"}`. **재시도는 넣지 않았다**(DB 재시도는 커넥션 풀 동작과 얽혀 별건). **남은 한계 둘** — 응답 본문이 DB 실패에도 `embedding upstream ...` 을 답하고(상태 코드·로그는 정확하며 `back` 은 본문을 읽지 않는다. 본문 변경은 `-220` 계약의 개정이라 후속), `53100` 디스크 가득 참을 일시적으로 둔 것은 재시도로 낫지 않아 표에서 가장 약한 칸이다 (S15P11A705-221) | [DB 오류 분류 리포트](implements/2026-07-31-db-error-classification.md) · [spec/failure-recovery §2.5](spec/failure-recovery.md) · [T53~T55](troubleshooting/2026-07-31-db-error-pitfalls.md) | -| 2026-07-31 | 판정 프롬프트로 「본문에 근거 없으면 미선택」을 세우려 했으나 **두 개정안 모두 채택하지 않았다**(`S15P11A705-219`). `-210` 이 후보 선정 층(τ)으로는 못 푼다고 끝내며 이 층을 지목했고, 진단은 맞았으나 처방이 따라오지 않는다. B(무엇이 근거가 아닌지를 유형으로 지정)는 오분류 Δ **-0.60행**으로 효과가 없었고, B 가 조항으로 이름 붙여 대상으로 삼은 넷(`265 WALK`·`274`/`289 DRINK`·`284 WITH_FAMILY`)이 **5회 중 5회 그대로**였다. 축을 바꾼 C(후보 목록의 출처를 고지하고 「고르지 않는 것이 기본」을 세움)는 5회에서 Δ -2.60 이었으나 **표본을 10회로 늘리자 -1.60 으로 줄었고**(사전 기준인 범위 비중첩도, 보강한 순열검정 p=0.081 도 미달) — 5회에서 멈추고 기준을 낮췄다면 부풀린 값을 채택했을 것이다. **교환비 0.19 는 `-210` 이 τ 에서 기각한 1.22 보다 훨씬 좋지만, 교환비는 「효과가 있다」가 먼저 서야 의미가 있는 값이고 그 앞 질문을 못 넘었다.** 결정적인 것은 셋이다 — ①**사용자가 보는 손실(fit 0건 Context)이 A·B·C 모두 8.00 ± 0.00** 으로 회차 운으로도 안 움직인다, ②**C 가 대상으로 삼은 유형 다섯이 오히려 늘고 셋이 줄어** 규칙과 실제 변화 사이에 대응이 없다, ③**오분류 30종 중 10/10 으로 고정된 것은 2종뿐**(그마저 같은 본문 한 쌍이라 실질 하나)이고 나머지 25종은 회차마다 구성원이 바뀌는데 **흔들리는 fit 은 6종뿐**이다. 즉 정상 판정은 안정적이고 오분류만 흔들린다. 프롬프트 규칙은 「항상 붙는 것」을 대상으로 삼는 도구인데 그 대상이 사실상 하나뿐이라 잔액이 노이즈와 같은 크기로 나온다. τ 가 **분포가 겹쳐서** 못 갈랐다면, 프롬프트는 **고정된 대상이 없어서** 효과가 없다. A 회차끼리의 불일치 중앙 11/42(26%)가 `-210` 의 대조군 값과 정확히 일치해 **다른 하네스·다른 회차로 재현**됐다. 측정 설계에서 넷을 막았다 — 벤더 폴백을 끄고 단일 고정(`-175` 에서 벤더별 일치도가 0.53~0.93 으로 갈렸다), `labels.yaml` 원본 불변 + `labels_extra.yaml` 로 커버리지만 확장(재판정은 τ 스윕과 달리 **없던 행을 만든다** — 24종), 라벨링 시 조건 은닉(보고 붙이면 라벨이 결론을 따라간다), 사전 기준을 지우지 않고 자를 **더하기**. **실제 판정 모델은 `gpt-4o-mini` 였다** — `.env` 의 `PINLOG_JUDGE_MODEL=gemini-2.5-flash` 는 `-175` 가 `PINLOG_JUDGE_CHAIN` 으로 대체해 읽히지 않는 키인데 값이 그럴듯해(체인 2순위) `-210` 리포트 §7 의 조건 표가 그것을 읽은 것으로 보인다(T43). 측정 중 `confidence` 가 유사도가 못 한 분리를 보여(하한 0.70 에서 unfit 4 제거·fit 유실 0) 후속으로 올릴 뻔했으나, **같은 조건으로 한 번 더 받으니 분리가 사라졌다** — 판정은 선택뿐 아니라 confidence 도 비결정적이다(T47). 그래서 후속은 프롬프트 문구가 아니라 **판정 분산 자체를 줄이는 쪽**(n회 판정 다수결)으로 제안한다. GMS 판정 1,092회·실패 0·코드 변경 없음 | [판정 프롬프트 리포트](implements/2026-07-31-judge-prompt-rule.md) · [T43~T49](troubleshooting/2026-07-31-judge-prompt-ab.md) · `tools/prompt_ab/` | -| 2026-07-29 | S15P11A705-154 — protected Environment 기반 3 runtime owner 값 + Infra PR credential handoff를 canonical workflow로 정렬 | [runtime Secret handoff](implements/2026-07-29-sealed-secret-handoff.md) | -| 2026-07-30 | 판정 LLM 이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터로 풀고 폴백 체인을 넣었다. **429 는 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다** — `-176` 재측정에서 같은 시각·같은 키로 Gemini 만 92% 였고 OpenAI·Anthropic 은 12/12 였다(임베딩 49회가 안 막힌 것과 같은 그림). 순서는 실측대로 `gpt-4o-mini`(100%·0.91s) → `gemini-2.5-flash`(기준선 유지) → `claude-haiku-4-5`(프로바이더가 셋째라 동시 장애 가능성이 가장 낮다)이고, `gpt-4.1-nano`(일치도 0.53)·`flash-lite`(58%)는 기각, 일치도 1위 `gpt-4.1-mini`(0.93)는 응답 1.37s·최대 4.67s 라 속도를 택했다(그 차이는 판정 비결정성 범위 안). **시도 예산을 체인 길이에 곱하지 않았다** — 벤더마다 3회면 최악 3×3×90s = 810s 로 `§3.2` 상한(PROCESSING 만료 600s)을 넘어 재스캔이 살아 있는 판정을 중복 실행한다. `attempts` 를 판정 1건의 총 시도로 두고 n번째 시도가 n번째 벤더를 쓰게 하니 최악이 270s 로 폴백 이전과 같고, 부수 효과가 오히려 본질이었다 — 막힌 벤더에 백오프를 걸고 다시 던지는 것보다 다른 경로로 즉시 넘어가는 편이 성공 확률이 높다(위 실측). **체인을 하나로 줄이면 시도가 그 벤더로 모여 폴백 이전과 정확히 같아지므로 롤백이 설정 한 줄이다.** `PermanentError`(400·401·403)는 폴백하지 않는다 — 키·설정 문제는 다른 벤더에서도 같은 답이고 넘어가면 GMS 호출만 3배가 된다(`-121` 결함 3 의 재발 형태). 구조화 출력 위반은 폴백 사유에 넣었다(방식이 벤더마다 달라 한쪽이 절단·차단으로 깨져도 다른 쪽은 성공할 수 있다). **프로브 스키마를 그대로 옮기지 않았다** — 프로브는 `keywordId` 하나만 요구하는 축약본이었고 운영은 `confidence`·`unmatchedConcepts` 까지 받아야 하는데, OpenAI strict 는 모든 객체에 `additionalProperties: false` 와 전 property `required` 를 요구해 Gemini 스키마를 재사용하면 400 이고 **400 은 영구 오류라 폴백 없이 판정이 실패로 끝난다.** 티켓 범위는 토큰 로그의 `vendor` 였지만 `model_profile` 도 함께 고쳤다 — 폴백이 생긴 순간 설정 1순위와 답한 모델이 갈라지고, 그러면 *"어떤 모델의 판단이었는지 구분"*(`keyword-preset` §5.2)이 거짓이 된다. `PINLOG_JUDGE_MODEL` 은 `PINLOG_JUDGE_CHAIN` 이 대체했고(모델 하나로는 순서를, 벤더 없이는 경로·인증 헤더를 표현할 수 없다) 배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐이다. 형식은 config 가, 지원 벤더인지는 어댑터 레지스트리만 알 수 있어 클라이언트 생성이 본다(둘 다 기동 시점). RED 드릴 2회 실측 — 폴백 무력화 6건 실패, `PermanentError` 폴백 허용 14건 실패(신규 3건 + 기존 재시도 계약 11건). `239 passed` · line 99.78% branch 98.46%. **실 GMS 는 부르지 않았다**(MockTransport 로 세 봉투를 만든다). **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 3순위가 동작 불능인데 모르는 상태가 가능하다(벤더별 확인은 `probe_vendors.py` 수동, 상시 관측은 `-96` 범위). 임베딩 폴백은 제외다 — 프로바이더가 바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, `embedding_profile` 이 바로 그것을 막는 장치다 (S15P11A705-175) | [벤더 폴백 리포트](implements/2026-07-30-judge-vendor-fallback.md) · [spec/failure-recovery §3.4](spec/failure-recovery.md) · [tests/README](../tests/README.md) | +| 2026-08-03 | 단어형 질의로 컷 격자를 다시 훑어 **`τ_abs` 를 질의 길이로 갈랐다**(Jira 작업, 단어형 0.24 / 문장형 0.30 · `r`=0.60 불변). `-255` 가 「`#87` 은 컷 조정만으로 회복된다」로 판정한 것의 후속이고 **이 티켓이 `ai#87` 을 닫는다.** **`-255` 의 22건으로는 답할 수 없었다** — 그것은 세 이슈의 원인을 가르기 위한 진단 집합이라 무관 통제가 `치과` 하나인데, 컷 값 판단은 「무관 대역이 어디까지 올라오는가」가 전부다. **`-213` 이 정확히 같은 오류를 범한 기록이 있다**(`personal-search.md §6` 이 무관 **1건**으로 「간격 +0.2120, 컷 불필요」를 결론 냈고 15건으로 늘리자 -0.0176 이 되어 뒤집혔다). 무관 통제를 45행으로 늘리자 역전이 **-0.0515 → -0.1687** 로 깊어졌다. **기대 정답을 손으로 짝짓지 않았다** — 단어형은 정답이 여럿이라(`신한`→6건 · `라멘`→2건) 손으로 정하면 판정자 재량이 결과를 만들므로 **본문 문자열 포함으로 계산**했고, 이 기준이 `ai#87` 의 요구(「본문에 있는 말로 검색하면 나와야 한다」)와 정확히 일치한다 — `-255` 가 실측한 「완전 일치는 유사도에 유의미하게 기여하지 않는다」는 **재는 쪽의 기준이 틀렸다가 아니라 임베딩이 그 기준을 못 따라간다**는 뜻이다. 부작용이 이득이었다 — 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 동시에 생겨 **통제 표본이 추가 측정 없이 늘었다**(정답 66행 · 교차 96행 · 무관 45행). 통제를 두 층으로 갈랐다. `cross`(같은 어휘인데 그 소유자엔 없다)는 같은 생활권이라 판별력이 약하고, 합치면 「무관 통과」 기준이 약해진다. 라벨을 요구하지 않는 대신(207행은 손 라벨이 비현실적이다) 꼬리 제거율을 포기하고 무관 통제를 늘렸다. **계약의 전제를 정정한다 — 「문장형 12건 회귀가 주 리스크」는 틀렸다.** 격자 **240조합 전량**에서 문장형 정답 누락 0 이다(이 티켓이 움직이는 방향이 컷을 **푸는** 쪽이라 구조적이고, 문장형 정답 최솟값 0.3642 는 격자 상단보다 위다). **진짜 손실은 다른 열에 있었다.** 문장형 무관 질의의 무노출이 `-213` 의 15건 중 11건(11/15)에서 0.24 단일값이면 **15건 중 5건(5/15)으로 절반 아래로 줄어든다.** 이 정정이 채택안을 바꿨다 — 손실이 「정답 누락」이 아니라 별도 축이라 **「가르면 그 손실이 0 이 된다」는 셋째 길**이 열렸다. **채택값을 정한 것은 회복률이 아니라 「1위 손실」이다**(측정 도중 추가한 지표) — 0.26 은 회복 68/71(96%)로 충분해 보이지만 **컷 전 1위인 정답 3건을 0건으로 만든다** (`비건`→플랜트 0.2583 **1위**인데 결과 0건 · `스팟`→동교공원 0.2438 1위 · `야경`→언덕위 0.2552 1위). 「1위인데 0건」은 컷이 유사도 순서를 존중하지 않는다는 뜻이고 회복률 96% 뒤에 숨는다. **0.24 는 「컷 전 1위 정답을 하나도 잃지 않는 가장 높은 값」이고 0.25 부터 깨진다**(격자를 0.01 간격으로 좁힌 이유 — 후보가 데이터점 하나 `스팟` 0.2438 에 붙어 `-213` 의 0.02 격자에서는 0.24~0.26 사이가 통째로 안 보인다). **`r` 은 가르지 않았다** — 무관 통과를 **어느 값에서도 한 건도** 못 줄이고(`-213` 이 문장형에서 본 「1위를 언제나 남긴다」가 단어형에서 그대로 재현) 단어형 손실도 `r=0.75` 까지 0 이라 이 교환에 참여하지 않는다. 갈랐다면 **재지 않은 축을 코드가 만드는 셈**이다. `τ(문장)=0.34` 도 기각했다(무관 통과가 1/15 로 더 좋지만 이 티켓은 문장형을 재지 않고 `matrix.json` 을 재사용했을 뿐이라 **남의 측정 위에서 남의 마진 판단을 바꾸지 않는다**). 결과 — 단어형 회복 **61/71 → 71/71** · 1위 손실 **5 → 0** · 정답 누락 11/66 → 2/66, 문장형 **완전 불변**, 대가는 단어형 무관 통과 10/45 → 26/45. AI API 임베딩 **배치 1회**(69건) · 격자 240조합은 AI API·DB 미호출. **실서버 대조 87/87 PASS** — 재구성 쪽에 길이 분기를 **다시 적어**(구현 `import` 금지, `-213` 규칙) 「서버가 분기를 실제로 타는가」가 관측되게 했고, 두 하한에서 **결과가 갈리는 60행**만 골랐다(갈리지 않는 행은 서버가 무엇을 하든 통과한다). **`ai#87` 증상 회복을 실서버로 직접 확인** — 「그네」 0건→6건(동교어린이공원 3위) · 「스팟」 0→2건(1위) · 「비건」 0→1건(1위) · 「산책」 0→4건. **441건 통과**(테스트 함수 11건 추가 — 단위 9 + API 관통 2). **검토 확정 10건 중 A·B·C 를 반영했다** — 배선 고정(#4 major)·킬스위치를 분기 앞으로(#2)·`recall_probe.py` 분기 반영(#3)·경계 5자 고정(#8)·공백을 유니코드 전체로(#1·#5)·끊긴 참조 셋(#9·#10)·수치 정정(#6). **넷을 변이 드릴로 확인했다** — 「테스트를 더했다」와 「그 테스트가 그 결함을 잡는다」는 다른 명제라 실제로 어긋내 보고 대응하는 테스트만 실패하는 것을 봤다. **`recall_probe.py` 가 이 티켓이 고친 증상을 계속 「① 컷이 잘랐다」로 읽고 있었다** — 진단 도구가 닫힌 증상을 미해결로 보고하는 상태였고, 분기를 넣으니 `그네`·`스팟`·`산책`·`라멘` 이 「— 통과」로 바뀌고 `신한`·`부캠` 은 ③ 그대로다. 그 도구의 `--replay` 가 **커밋된 행렬을 판정 결과로 덮어쓰던 것**도 함께 막았다(기본은 쓰지 않고, `--out` 이 입력과 같으면 멈춘다) — 행렬은 AI API 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다 · line 99.83%/branch 98.99%(`search_service.py` 100%). **길이 분기의 호출부 배선을 API 관통 테스트로 고정했다** — `_cut(rows, query)` 의 `query` 기본값 때문에 **`, query` 가 사라져도 커버리지 100% 인 채로** 모든 단어형이 0.30 으로 돌아가 `ai#87` 이 재발한다(RED 드릴로 확인). 유사도를 두 하한 사이 `[0.24, 0.30)` 에 두는 것이 요점이고 기존 검색 테스트는 질의와 저장 텍스트가 같아 유사도 1.0 이라 이 배선을 못 잡는다 — **커버리지가 안전을 보증하지 못하는 자리다.** **`-228` 의 `T68`(임베딩이 같은 배치 구성으로도 비결정적 · `|Δsim|` 최대 **0.0044**)이 병합된 뒤 재현성을 실측으로 답했다** — 그 잡음이 이 격자 간격(0.01)·채택 마진(0.0038)과 같은 자릿수라 값 자체가 위협받았다. **두 층을 갈라야 한다** — 격자 내부는 임베딩을 한 번 떠서 굳히고 오프라인으로 훑으므로 애초에 노출되지 않고, 노출되는 것은 **채택값의 절대 마진**이다. 같은 질의 집합을 **회차 3개**로 떠서 대조하니(공통 2,346셀) **|Δsim| 상한 0.000209**(T68 의 21배 작다) · 스프레드>0.0044 인 셀 **0개** · **순위 변동 0건** · 경계점 `스팟` 스프레드 **0.000000** · 격자 판정 τ=0.20~0.30 **전 구간 3회 일치**였다. **답: 이 격자에서 인접 값의 차이는 재측정 운이 아니다.** `T68` 의 조언(「τ 를 다시 정할 일이 있으면 격자를 0.01 보다 촘촘하게 두지 않는다」)은 이 티켓이 이미 지켰다. **왜 `T68` 보다 작은지는 단정하지 않는다** — 그쪽은 프리셋 27개 중 **3개만** 갈렸고(나머지는 코사인 1.000000) 이쪽 질의는 전부 짧은 단어라 텍스트 길이가 관련 있어 **보이나** 통제한 측정이 없다. `T68` 의 처방(`base2` 상시 조건)을 그대로 따라 **`word_sweep.py --repro` 를 상시 관측으로 남겼다** — 한 번 재고 문서에만 적으면 다음 사람은 그 조건이 아직 유효한지 알 방법이 없다. **알려진 약점 둘** — 0.24 의 마진 0.0038 은 재측정 잡음(관측 상한의 18배 여유)이 아니라 **경계를 데이터점 하나가 정한다는 것** 자체가 문제이고(재시딩·데이터 증가로 그 점이 움직이면 `스팟` 이 다시 사라진다), **경계 5자는 측정이 아니라 판단**이다(측정한 단어형이 전부 공백 없는 2~5자라 「글자 수」와 「어절 수」를 가를 수 없었고, 두 조건을 **함께** 요구해 애매한 질의를 문장형=더 세게 자름 쪽으로 기울였다). `ai#86`(`신한`)·`ai#88`(`부캠`)은 그대로 — `-255` 판정대로 컷으로 못 푼다(0.24 로 내려도 카츠요가 6위·8위) (Jira 작업) | [단어형 컷 리포트](implements/2026-08-03-word-query-cut.md) · [spec/personal-search §6.1](spec/personal-search.md) · [tools/search_cut/](../tools/search_cut/) · [ai#87](https://github.com/Team-PinLog/ai/issues/87) | +| 2026-08-03 | 운영에서 「본문에 그대로 있는 말로 검색해도 안 나온다」가 셋 올라와(`ai#86` 신한 · `#87` 그네 · `#88` 부캠) **원인을 실측으로 갈랐다**(Jira 작업). **고치지 않았다 — 컷 값도 모델도 코드도 그대로다.** 측정보다 먼저 배제한 것은 티켓이 「넷째일 수 있다」로 남긴 가능성이다 — 검색 Query 가 `embedding_status = 'COMPLETED'` 를 요구하므로 그 Context 가 `FAILED` 로 굳었으면 유사도를 재는 일 자체가 무의미한데, 전량 42/42 `COMPLETED` · 벡터 42건 · `is_deleted` 0 으로 **정상**이었다. **결론은 「셋 다 같은 원인」이 아니다** — `그네` 는 컷이 잘랐고 풀면 3위(**①**), `신한` 은 풀어도 6위 · `부캠` 은 8위(**③**)다. **가른 것은 계약의 최소 비교군에 더한 무관 기준선이다** — 어느 본문에도 없는 2자 `치과` 의 top-1 이 **0.2953** 인데 본문에 그대로 있는 `스팟` 은 **0.2438**, `그네` 0.2671, `신한` 0.2301 로 **셋 다 그보다 낮다.** 즉 **완전 일치는 유사도에 유의미하게 기여하지 않는다** — `#86` 이 「완전 일치인데 안 걸린다」로 가장 심각하다고 본 전제가 실은 이 현상의 한 사례다. 본문을 고정하고 질의 길이만 바꾸자 경계가 깨끗하게 드러났다 — 본문 A·B 모두 **3자 이상은 전부 ≥0.3189, 2자는 전부 ≤0.2818** 이고 `τ_abs=0.30` 이 그 사이에 있다. 다만 「2자면 안 된다」는 **틀렸다** — `우주` 0.4199 · `공원` 0.3670 이 2자로 통과하며, 갈림은 「그 단어가 본문의 주제를 짚는가(우주·공원)와 부수 정보인가(스팟·신한)」로 **보이나 그것을 통제한 측정은 없다**(사후 해석이다). **`#87` 의 BPE 가설은 반증됐다** — 프로파일이 `text-embedding-3-small` 이라 토크나이저가 `cl100k_base` 로 특정되어 실제로 봤고, `그네`[49706, 76242, 97] 와 `그네팟`[49706, 76242, …] 의 **앞 두 토큰이 같다**(`신한`↔`신한 부트캠프`, `부캠`↔`부트캠프` 도 공유). 한국어가 바이트 단위로 잘게 쪼개져 접두 토큰이 그대로 남으므로 「한 덩어리로 묶여 멀어진다」가 성립하지 않는다 — `그네` 는 부분어라서가 아니라 **다른 2자 질의와 같은 대역**이라서 안 된다. 반면 **`#88` 의 진단은 확증됐다** — `부트캠프` 는 카츠요 1위 0.3254 인데 `부캠` 은 8위 0.2105 이고, 그러면서 `부캠` 이 그대로 있는 쿠로코를 1위(0.4102)로 짚는다. **표기는 알고 `부캠→부트캠프` 의미 연결만 못 한다.** 그래서 처방이 셋 다 다르다 — `#86` 은 원리상 임베딩으로 못 풀고(2자 대역이 무관 질의와 **겹침이 아니라 역전**이라 어떤 임계값으로도 못 가른다) 키워드 검색이 맞고, `#88` 은 의미 연결이라 모델 교체(`-199`)의 영역이며, `#87` 은 컷 조정만으로 회복된다. **`신한` 이 카츠요에서만 낮은 기제는 답하지 못했다** — 0건이 아니라 3건이 나오고 셋 다 「신한」 보유라 검색은 「신한」을 알아보는데, 상위 셋은 「신한 **부캠**」이고 카츠요만 「신한 **부트캠프**」다. 표현 일치 효과는 실측했으나(`신한 부캠` 질의에서 카츠요 0.3189 ↔ `신한 부트캠프` 질의에서 0.3793) **왜 「신한 부캠」이 「신한」 단독에 더 가까운지는 단정하지 않는다.** 본문 길이는 원인이 아니다 — 플랜트(30자) 0.3889 와 카츠요(32자) 0.2301 이 같은 길이에서 갈리고 순위 상관도 약하다(단어형 ρ -0.12 · 문장형 +0.07, 17점 표본이라 유의하지 않다). **`-213` 의 한계와 같은 구조이고 더 심하다** — 저쪽은 문장형에서 정답↔무관이 **-0.0176** 겹쳤고 이번은 단어형에서 **-0.0515** 로 역전이다. 「컷 값으로는 더 못 간다」가 강화된다(단어형을 살리려면 `τ_abs` 를 0.24 아래로 내려야 하는데 그 값은 무관 대역 0.2953 을 통째로 통과시킨다). AI API 임베딩 배치 1회 · 코드 변경 없음 · 로컬 시연 DB(`:15432`)만 사용. **실서버 대조는 하지 않았다**(`-213` 의 `verify_live.py` 가 같은 재구성 규칙으로 27/27 을 이미 확인했고 이 티켓은 규칙이 아니라 질의만 바꿨다 — 다만 이 질의 집합으로 실서버를 때린 것은 아니다) (Jira 작업) | [재현율 판별 리포트](implements/2026-08-03-search-recall-probe.md) · [tools/search_cut/](../tools/search_cut/) · [ai#86](https://github.com/Team-PinLog/ai/issues/86)·[#87](https://github.com/Team-PinLog/ai/issues/87)·[#88](https://github.com/Team-PinLog/ai/issues/88) | +| 2026-08-03 | DB 실패에도 응답 본문이 `embedding upstream ...`를 답하던 것을 층 이름별로 가른다(Jira 작업). `-221`이 상태 코드(`503`/`502`)와 로그는 정확히 맞혔지만 본문만 사실과 어긋난 채 남겨 뒀고, 그 티켓이 *"핸들러를 바꾸지 않는다"*로 확정하며 본문 개정을 후속으로 미뤘다 — 이 티켓이 그 후속이다. `-221`이 이미 둔 하위 타입(`DatabaseTransientError`/`DatabasePermanentError`)을 `app/main.py`의 기존 두 핸들러 안에서 `isinstance`로 보고 `database unavailable`/`database rejected the request`로 가르되, **원인 값은 여전히 싣지 않는다** — `-220`이 세운 "예외 메시지를 본문에 옮기지 않는다"와 `-205`가 실측으로 확정한 누출 경로 차단 원칙을 재확인만 했다. 새 핸들러를 추가하지 않아 `-220`의 "변환은 예외 핸들러 한 곳에서만" 원칙도 그대로다. **공용 계약 `static/05_AI_설계.md`는 반영하지 않았다** — ①그 문서는 `failure-recovery.md`를 파트별 세부 문서로 참조만 하고 응답 본문 문구 자체를 담고 있지 않으며, ②`back` 레포 `AiSearchClient.translate`를 직접 읽어 `502`/`503` 등 이 티켓이 다루는 상태 코드는 본문을 파싱하지 않고 상태 코드만 보고 버림을 확인했다(`serverProfile` 필드를 보는 `422`만 예외). 검증은 `-220`·`-221`의 원칙을 그대로 지켰다 — Fake 없이 실제 `asyncpg` 풀을 실패시켜 관통하는 계약 테스트에 신규 2건(DB 축 새 문구)과 회귀 2건(AI API 문구 불변)을 더했다. RED로 신규 2건이 여전히 `embedding upstream ...`를 답함을 먼저 확인했고, 핸들러 수정 뒤 GREEN. 전체 **425건 통과**, line 99.83%/branch 98.99%(게이트 80/80 유지). 실서버 대조는 별도로 하지 않았다 — 문구만 바뀌었고 `-221`의 관통 테스트가 이미 실제 DB 실패 경로를 검증하고 있어 새로 확인할 것이 없다고 판단했다 (Jira 작업) | [오류 문구 분리 리포트](implements/2026-08-03-error-wording-split.md) · [spec/failure-recovery §2.5 「응답 본문」](spec/failure-recovery.md) | +| 2026-07-31 | 검색 중 DB 실패를 일시 오류로 분류해 **503** 으로 내보낸다(Jira 작업). `-220` 이 `TransientError→503`·`PermanentError→502` 핸들러를 넣으며 **500 을 「우리 코드의 결함」 전용으로 비워 뒀는데**, DB 실패만 아무도 분류하지 않아 그대로 500 이었다 — 커넥션 풀 고갈이나 DB 재기동처럼 기다리면 낫는 상황이 「코드가 깨졌다」와 같은 코드를 썼고 그만큼 `-220` 의 전제가 깨져 있었다. **명세는 이미 이 동작을 규정하고 있었다** — `failure-recovery.md` §2.1 이 *"DB 연결 실패, 잠금 타임아웃, 직렬화 실패"* 를 일시 오류로 못박고 있었으므로 이 티켓은 새 정책이 아니라 **명세와 코드의 간극**을 메운 것이다. **가장 값진 발견은 티켓 전제가 어긋났다는 것이다** — 티켓은 *"asyncpg 예외를 분류"* 라고 적었지만 실측하면 **DB 접속 실패는 `asyncpg` 예외가 아니다.** 포트 거부(`ConnectionRefusedError`)·DNS(`socket.gaierror`)·접속 타임아웃·**커넥션 풀 획득 타임아웃**이 전부 stdlib `OSError` 이고, `08xxx` `PostgresConnectionError` 는 *이미 붙어 있던* 연결이 끊길 때의 SQLSTATE 다. 문구대로 asyncpg 만 분류했으면 **테스트는 전부 통과인데** 티켓의 완료 조건만 조용히 거짓으로 남았을 것이다(T53). 같은 이유로 분류를 repository 함수가 아니라 **세션 경계**(`app/core/db.py`)에 걸었다 — 데코레이터 방식은 `pool.acquire()` 가 그 밖이라 접속 실패가 애초에 들어오지 않는다. 경계는 한 줄이다 — **서버·연결의 상태 때문에 실패했으면 일시적, 우리가 보낸 질의 때문이면 우리 결함.** SQLSTATE 군 단위로 긋고 군 안에서 성격이 갈리는 셋(`25xxx`·`55xxx`·`42xxx`)만 잎으로 집었다(Transient `OSError`(접속 단계)·`08`·`40`·`53`·`57`·`58`·`55P03`·`25P03`·`25P04` / Permanent `28`·`3D000`·`42501`). **미분류(500) 목록이 분류 목록만큼 중요하다** — `42xxx` 나머지·`22xxx`·`23xxx`·`XX000` 은 그대로 500 에 남긴다. 넓게 잡으면 버그가 「일시적으로 사용할 수 없습니다」 뒤에 영구히 숨고 재시도해도 안 되는데 알림이 안 울린다. 가장 논쟁적인 것은 **`asyncpg.InterfaceError` 를 분류하지 않은 것**이다 — 한 타입이 `connection is closed`(수명주기)와 `the server expects 2 arguments, 1 was passed`(우리 결함)를 함께 쓰고, 통째로 감싸면 후자가 숨는다. 수명주기 쪽은 정상 경로에서 나오지 않는다(풀에서 갓 받은 커넥션이 죽어 있으면 질의는 `08003` 이고 그쪽은 이미 일시 오류다 — 실측). `OSError` 는 **접속 단계에서만** 번역한다 — 커넥션을 넘겨준 뒤의 블록에는 호출부 코드가 함께 들어와 무관한 `OSError` 가 503 으로 둔갑할 수 있다. **`-220` 의 핸들러를 한 줄도 고치지 않았다** — `DatabaseTransientError(TransientError)` 하위 타입이라 기존 핸들러가 그대로 받고, 덤으로 핸들러 로그의 타입 이름이 「게이트웨이가 아니라 DB」를 말한다. 예외 메시지에는 **타입 이름과 SQLSTATE 만** 담는다 — DSN 에 DB 비밀번호가 있고 접속 실패 예외에 host·port 가 섞이며, `-220` 이 게이트웨이 응답 200자 누출을 발견하고 세운 기준이 그대로 적용된다. SQLSTATE 를 남기는 것은 *"풀이 고갈됐다"* 와 *"DB 가 재기동 중이다"* 를 가르는 유일한 값이라서다(`app.core.db` 로거, 일시 WARN·영구 ERROR). 검증은 `-220` 의 원칙을 지켰다 — **DB 도 Fake 로 바꾸지 않는다.** 닫힌 포트를 가리키는 `min_size=0` 풀·서버가 실제로 죽인 백엔드(`08003`)·실제 `statement_timeout` 취소(`57014`)·실제 `3D000` 으로 냈고(계약 8건), 표 전체는 DB 없이 분류 함수로 고정했다(단위 40건). `min_size=0` 이 필요한 이유가 T54 다 — 운영 `min_size=1` 풀은 접속이 깨지면 **기동**에서 실패해 요청 경로에 닿지 못한다. RED 4건 → 전체 **356건 통과**, line 99.82% branch 98.84%. 실서버 대조는 전용 pgvector 컨테이너를 `docker stop` 해서 냈다(**시연 DB 를 건드리지 않았다**) — `origin/dev` **500 + 트레이스백** ↔ 이 브랜치 **503** `{"detail":"embedding upstream unavailable"}`. **재시도는 넣지 않았다**(DB 재시도는 커넥션 풀 동작과 얽혀 별건). **남은 한계 둘** — 응답 본문이 DB 실패에도 `embedding upstream ...` 을 답하고(상태 코드·로그는 정확하며 `back` 은 본문을 읽지 않는다. 본문 변경은 `-220` 계약의 개정이라 후속), `53100` 디스크 가득 참을 일시적으로 둔 것은 재시도로 낫지 않아 표에서 가장 약한 칸이다 (Jira 작업) | [DB 오류 분류 리포트](implements/2026-07-31-db-error-classification.md) · [spec/failure-recovery §2.5](spec/failure-recovery.md) · [T53~T55](troubleshooting/2026-07-31-db-error-pitfalls.md) | +| 2026-07-31 | 판정 프롬프트로 「본문에 근거 없으면 미선택」을 세우려 했으나 **두 개정안 모두 채택하지 않았다**(Jira 작업). `-210` 이 후보 선정 층(τ)으로는 못 푼다고 끝내며 이 층을 지목했고, 진단은 맞았으나 처방이 따라오지 않는다. B(무엇이 근거가 아닌지를 유형으로 지정)는 오분류 Δ **-0.60행**으로 효과가 없었고, B 가 조항으로 이름 붙여 대상으로 삼은 넷(`265 WALK`·`274`/`289 DRINK`·`284 WITH_FAMILY`)이 **5회 중 5회 그대로**였다. 축을 바꾼 C(후보 목록의 출처를 고지하고 「고르지 않는 것이 기본」을 세움)는 5회에서 Δ -2.60 이었으나 **표본을 10회로 늘리자 -1.60 으로 줄었고**(사전 기준인 범위 비중첩도, 보강한 순열검정 p=0.081 도 미달) — 5회에서 멈추고 기준을 낮췄다면 부풀린 값을 채택했을 것이다. **교환비 0.19 는 `-210` 이 τ 에서 기각한 1.22 보다 훨씬 좋지만, 교환비는 「효과가 있다」가 먼저 서야 의미가 있는 값이고 그 앞 질문을 못 넘었다.** 결정적인 것은 셋이다 — ①**사용자가 보는 손실(fit 0건 Context)이 A·B·C 모두 8.00 ± 0.00** 으로 회차 운으로도 안 움직인다, ②**C 가 대상으로 삼은 유형 다섯이 오히려 늘고 셋이 줄어** 규칙과 실제 변화 사이에 대응이 없다, ③**오분류 30종 중 10/10 으로 고정된 것은 2종뿐**(그마저 같은 본문 한 쌍이라 실질 하나)이고 나머지 25종은 회차마다 구성원이 바뀌는데 **흔들리는 fit 은 6종뿐**이다. 즉 정상 판정은 안정적이고 오분류만 흔들린다. 프롬프트 규칙은 「항상 붙는 것」을 대상으로 삼는 도구인데 그 대상이 사실상 하나뿐이라 잔액이 노이즈와 같은 크기로 나온다. τ 가 **분포가 겹쳐서** 못 갈랐다면, 프롬프트는 **고정된 대상이 없어서** 효과가 없다. A 회차끼리의 불일치 중앙 11/42(26%)가 `-210` 의 대조군 값과 정확히 일치해 **다른 하네스·다른 회차로 재현**됐다. 측정 설계에서 넷을 막았다 — 벤더 폴백을 끄고 단일 고정(`-175` 에서 벤더별 일치도가 0.53~0.93 으로 갈렸다), `labels.yaml` 원본 불변 + `labels_extra.yaml` 로 커버리지만 확장(재판정은 τ 스윕과 달리 **없던 행을 만든다** — 24종), 라벨링 시 조건 은닉(보고 붙이면 라벨이 결론을 따라간다), 사전 기준을 지우지 않고 자를 **더하기**. **실제 판정 모델은 `gpt-4o-mini` 였다** — `.env` 의 `PINLOG_JUDGE_MODEL=gemini-2.5-flash` 는 `-175` 가 `PINLOG_JUDGE_CHAIN` 으로 대체해 읽히지 않는 키인데 값이 그럴듯해(체인 2순위) `-210` 리포트 §7 의 조건 표가 그것을 읽은 것으로 보인다(T43). 측정 중 `confidence` 가 유사도가 못 한 분리를 보여(하한 0.70 에서 unfit 4 제거·fit 유실 0) 후속으로 올릴 뻔했으나, **같은 조건으로 한 번 더 받으니 분리가 사라졌다** — 판정은 선택뿐 아니라 confidence 도 비결정적이다(T47). 그래서 후속은 프롬프트 문구가 아니라 **판정 분산 자체를 줄이는 쪽**(n회 판정 다수결)으로 제안한다. AI API 판정 1,092회·실패 0·코드 변경 없음 | [판정 프롬프트 리포트](implements/2026-07-31-judge-prompt-rule.md) · [T43~T49](troubleshooting/2026-07-31-judge-prompt-ab.md) · `tools/prompt_ab/` | +| 2026-07-29 | Jira 작업 — protected Environment 기반 3 runtime owner 값 + Infra PR credential handoff를 canonical workflow로 정렬 | [runtime Secret handoff](implements/2026-07-29-sealed-secret-handoff.md) | +| 2026-07-30 | 판정 LLM 이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터로 풀고 폴백 체인을 넣었다. **429 는 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다** — `-176` 재측정에서 같은 시각·같은 키로 Gemini 만 92% 였고 OpenAI·Anthropic 은 12/12 였다(임베딩 49회가 안 막힌 것과 같은 그림). 순서는 실측대로 `gpt-4o-mini`(100%·0.91s) → `gemini-2.5-flash`(기준선 유지) → `claude-haiku-4-5`(프로바이더가 셋째라 동시 장애 가능성이 가장 낮다)이고, `gpt-4.1-nano`(일치도 0.53)·`flash-lite`(58%)는 기각, 일치도 1위 `gpt-4.1-mini`(0.93)는 응답 1.37s·최대 4.67s 라 속도를 택했다(그 차이는 판정 비결정성 범위 안). **시도 예산을 체인 길이에 곱하지 않았다** — 벤더마다 3회면 최악 3×3×90s = 810s 로 `§3.2` 상한(PROCESSING 만료 600s)을 넘어 재스캔이 살아 있는 판정을 중복 실행한다. `attempts` 를 판정 1건의 총 시도로 두고 n번째 시도가 n번째 벤더를 쓰게 하니 최악이 270s 로 폴백 이전과 같고, 부수 효과가 오히려 본질이었다 — 막힌 벤더에 백오프를 걸고 다시 던지는 것보다 다른 경로로 즉시 넘어가는 편이 성공 확률이 높다(위 실측). **체인을 하나로 줄이면 시도가 그 벤더로 모여 폴백 이전과 정확히 같아지므로 롤백이 설정 한 줄이다.** `PermanentError`(400·401·403)는 폴백하지 않는다 — 키·설정 문제는 다른 벤더에서도 같은 답이고 넘어가면 AI API 호출만 3배가 된다(`-121` 결함 3 의 재발 형태). 구조화 출력 위반은 폴백 사유에 넣었다(방식이 벤더마다 달라 한쪽이 절단·차단으로 깨져도 다른 쪽은 성공할 수 있다). **프로브 스키마를 그대로 옮기지 않았다** — 프로브는 `keywordId` 하나만 요구하는 축약본이었고 운영은 `confidence`·`unmatchedConcepts` 까지 받아야 하는데, OpenAI strict 는 모든 객체에 `additionalProperties: false` 와 전 property `required` 를 요구해 Gemini 스키마를 재사용하면 400 이고 **400 은 영구 오류라 폴백 없이 판정이 실패로 끝난다.** 티켓 범위는 토큰 로그의 `vendor` 였지만 `model_profile` 도 함께 고쳤다 — 폴백이 생긴 순간 설정 1순위와 답한 모델이 갈라지고, 그러면 *"어떤 모델의 판단이었는지 구분"*(`keyword-preset` §5.2)이 거짓이 된다. `PINLOG_JUDGE_MODEL` 은 `PINLOG_JUDGE_CHAIN` 이 대체했고(모델 하나로는 순서를, 벤더 없이는 경로·인증 헤더를 표현할 수 없다) 배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐이다. 형식은 config 가, 지원 벤더인지는 어댑터 레지스트리만 알 수 있어 클라이언트 생성이 본다(둘 다 기동 시점). RED 드릴 2회 실측 — 폴백 무력화 6건 실패, `PermanentError` 폴백 허용 14건 실패(신규 3건 + 기존 재시도 계약 11건). `239 passed` · line 99.78% branch 98.46%. **실 AI API 는 부르지 않았다**(MockTransport 로 세 봉투를 만든다). **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 3순위가 동작 불능인데 모르는 상태가 가능하다(벤더별 확인은 `probe_vendors.py` 수동, 상시 관측은 `-96` 범위). 임베딩 폴백은 제외다 — 프로바이더가 바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, `embedding_profile` 이 바로 그것을 막는 장치다 (Jira 작업) | [벤더 폴백 리포트](implements/2026-07-30-judge-vendor-fallback.md) · [spec/failure-recovery §3.4](spec/failure-recovery.md) · [tests/README](../tests/README.md) | | 2026-07-23 | AI 공용 설계를 docs `static/05` 단일 원본으로 확립 (docs#2) | [proposals](proposals/README.md) (P16) | | 2026-07-23 | FastAPI AI 서버 구현 명세 작성 + version→deletion race 리네임 (ai#1) | [spec/](spec/) | | 2026-07-23 | Keyword Preset seed 27개 (ai#2) | [implements](implements/2026-07-23-keyword-preset-seed.md), [spec/keyword-preset.md](spec/keyword-preset.md) | @@ -43,41 +43,41 @@ | 2026-07-27 | E3-PR2 — 파이프라인 시나리오 20개(`test_pipeline.py`, 19함수/20시나리오) (ai#18) | [spec/integration-tests.md](spec/integration-tests.md) | | 2026-07-27 | 코드 주석 §참조·`search_path` 서술 정정 (ai#20) | [troubleshooting](troubleshooting/2026-07-24-e3-ci-and-search-path.md) | | 2026-07-27 | E3-PR2 완료 반영(리포트·spec 헤더 갱신) + `preset_cache_ttl_sec` dead config 제거(§5 정합) (ai#24) | [implements](implements/2026-07-24-e3-test-harness.md), [spec/integration-tests.md](spec/integration-tests.md) | -| 2026-07-27 | E2E 실경로 검증(-58) — 실제 GMS 프리셋 27 적재·파이프라인 8건·검색 품질(분리도 +0.2120)·하네스 동등성 9/10·권한 경계 실증(`-61` 근거) + 검증 드라이버 `tools/e2e/` | [implements](implements/2026-07-27-e2e-verification.md), [troubleshooting](troubleshooting/2026-07-27-e2e-env-issues.md) (T22~T24), [tools/e2e/](../tools/e2e/) | +| 2026-07-27 | E2E 실경로 검증(-58) — 실제 AI API 프리셋 27 적재·파이프라인 8건·검색 품질(분리도 +0.2120)·하네스 동등성 9/10·권한 경계 실증(`-61` 근거) + 검증 드라이버 `tools/e2e/` | [implements](implements/2026-07-27-e2e-verification.md), [troubleshooting](troubleshooting/2026-07-27-e2e-env-issues.md) (T22~T24), [tools/e2e/](../tools/e2e/) | | 2026-07-28 | ai#25(인프라 CI 계약 테스트 6) 병합으로 pytest 46→52 확대 → 문서 정합(수치 AI 검증 46 + CI 계약 6·CI 계약 각주·pgvector 불일치 표시) (ai#28) | [tests/README](../tests/README.md), [spec/integration-tests.md](spec/integration-tests.md) | | 2026-07-28 | S1 구현 판단 맥락 복원 — 설계선택 19·불변식·구현결함 불일치·인프라 미복원(I22), 워킹트리·env캐시(T25·T26), 판단변경·기각(P43), partial-resume §3 재조회 판단변경 (ai#29) | [implements](implements/2026-07-28-s1-implementation-recovery.md), [troubleshooting](troubleshooting/2026-07-28-shared-worktree-and-env-cache.md), [P43](proposals/P43-s1-judgment-recovery.md) | -| 2026-07-28 | AI 레포 협업 운영 기준과 Jira→PR 리뷰 절차 수립, `app` branch coverage 비차단 측정 도입 (S15P11A705-108) | [CONTRIBUTING](../CONTRIBUTING.md), [P44](proposals/P44-ai-repository-governance.md), [development](development/) | -| 2026-07-29 | dev 배포 게이트 3종(I23) — `GET /ready`(DB `SELECT 1` + Preset ≥1, GMS 미호출) · `GMS_BASE_URL` `/gmsapi/` 기동 fail-fast(값 미노출 위해 `SettingsError`) · `app.smoke.gms_roundtrip` 양방향 실호출, pytest 52→66 (ai#33 ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 인프라 요청) | [implements](implements/2026-07-29-dev-deployment-gates.md), [README](../README.md) | -| 2026-07-29 | AI 소유 값의 클러스터 전달 경로를 만들었다(I24). GitHub Actions Secret 은 Pod 에 자동 전달되지 않으므로 `kubeseal --raw` 로 값 7종을 개별 봉인해 `encryptedData` 만 artifact 로 넘긴다 — `kubectl create secret --dry-run` 경로는 중간에 평문 base64 YAML 을 만들어 쓰지 않았다. **EMBEDDING 넷은 비밀이 아니지만**(정본 P32) Infra 가 주입 경로를 하나로 요구해 같은 경로로 다룬다. 앱의 기동 검사(`/gmsapi/` 형식·profile 정합)를 봉인 시점으로 앞당겨 배포 전에 실패시키고, controller 인증서는 SHA-256 지문으로 고정했다 — 엉뚱한 공개키로 봉인하면 복호화 실패가 배포 시점에야 드러난다. **실제 봉인은 미실행**(Actions Secret 등록 후 가능) (S15P11A705-96) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | -| 2026-07-29 | 봉인 workflow 의 kubeseal 설치가 체크섬 검증에서 실패하던 것을 고쳤다. `curl -o kubeseal.tar.gz` 로 받아 놓고 manifest 는 `kubeseal-0.27.1-linux-amd64.tar.gz` 를 가리켜, `sha256sum -c` 가 manifest 에 적힌 이름으로 파일을 열다 실패했다 (`No such file or directory` / `FAILED open or read`). 다운로드·검증·해제가 같은 변수를 쓰도록 파일명을 하나로 묶었다. **Infra 가 실제로 실행해서 발견했다** — 병합 전 검증이 YAML 파싱과 `bash -n` 까지였고 그 둘은 이 결함을 잡지 못한다. 이번엔 실제 다운로드·검증·해제를 로컬에서 돌려 통과를 확인했고, 원 버전이 같은 오류로 실패하는 것도 재현했다 (S15P11A705-96) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | -| 2026-07-29 | 발표 시연 데이터를 **back API 경로로** 만드는 시딩 도구 `tools/demo_seed/`(I25)와 E2E 재확인. **SQL 직접 INSERT를 택하지 않았다** — 데이터는 있는데 파이프라인은 안 돈 상태를 만들고, 그러면 "새 Context를 지금 추가하면 Keyword가 붙는다"를 발표 당일에 처음 실행하게 된다. API로 만들면 `-102`의 PENDING 생성과 `/context/process` 호출이 매 건 타므로 **시딩이 곧 통합 검증**이다. SQL은 API가 없는 두 곳(OAuth로만 생기는 `core.member`, soft delete뿐이라 불가능한 `--reset` hard delete)에만 쓰고 `demo-seed` provider 표식으로 범위를 좁혔다. **GMS 29회**(Context 14 × 2 + 프리셋 1)로 잡은 근거는 비용이 아니라 **429** — 판정이 분당 약 2건만 통과한다(실측). 429는 `PROCESSING` 잔류 + `PROCESSING_EXPIRY_SEC` 600초 때문에 Context 하나를 10분 동안 처리 불가로 만들므로, 시딩이 `PENDING`으로 되돌려 회수하는 루프를 넣었다(로컬에 없는 재스캔 대행). `--reset` 2회 완주로 재현 확인, 시연 3종(검색 4질의 전원 1위 · 피드 8장 소유자 4명 · Keyword 32행) 전부 통과. `tools/e2e/` 4종 중 2종 정상(검색 수치가 I21과 소수점 넷째 자리까지 동일 — **임베딩은 결정적**), `run_equivalence`는 429로 중단(회귀 아님). **back 소관 발견 2건**(탐색 슬롯이 최고점 후보를 흡수해 팔로우 소유자가 하단 배치 · Record 상세 `keywords` 고정 빈 배열)은 back 코드를 고치지 않고 계약 질문으로 올린다 (S15P11A705-58) | [데모 시딩 리포트](implements/2026-07-29-demo-seeding.md), [tools/demo_seed/](../tools/demo_seed/) | -| 2026-07-29 | 설정값을 비밀/공개로 가르고 **공개 값의 정본을 코드로 옮겼다**(P45). Embedding Profile 넷만 기본값이 없어 배포 설정에만 존재했고, 그 결과 **Profile 교체가 git 이력도 리뷰도 남기지 않았다** — 기존 임베딩을 전부 조회 대상에서 빼는 결정인데 콘솔 편집 한 번으로 가능했다. 값 자체는 공용 계약 `05 §7.1` 표에 공개돼 있어 숨겨진 적도 없다. 주입을 **덮어쓰기**로 낮추면 원 논거(*"배포 설정 누락이 조용한 불일치가 된다"*)의 전제인 '누락'이 사라지고, 불일치 탐지는 기동 시 `_profile_consistency` 와 런타임 §3.1 이 그대로 맡는다. `config/*.yaml` 계층 도입은 설정 소스가 셋이 되어 기각했다. **공용 계약 개정(docs#27)이 선행이다** — 처음에 하위 명세만 고쳤다가 `CONTRIBUTING` 의 *"공용 계약과 충돌하면 소유 파트와 합의한 뒤 양쪽 문서를 갱신"* 을 어긴 것을 발견하고 되돌렸다. 봉인 대상도 7종 → 3종으로 줄었다(정본이 이미지에 있으면 주입할 것이 없다) (S15P11A705-96) | [P45](proposals/P45-public-config-in-code.md), [model-profile](spec/model-profile.md), docs#27 | -| 2026-07-29 | 테스트 pgvector 를 운영·back 실제 버전에 digest 까지 정합화했다. `conftest.py` 가 `0.8.1-pg16` 인 채로 남아 **테스트가 운영과 다른 DB 에서 돌고 있었다** — back#31 이 `compose.yaml` 을 `0.8.5-pg16@sha256:1d53…` 로 올렸고 운영 pgvector 도 이미 0.8.5(infra#41)였으므로 어긋난 쪽은 ai 였다. digest 를 `back/compose.yaml` 현행에서 재확인해 티켓 본문 값(2026-07-28 관측)과 동일함을 확인하고 고정했다. **태그만 올리지 않은 이유**는 롤링·재태깅 시 같은 태그가 다른 이미지를 가리키기 때문이다. 실측으로 `0.8.1-pg16` → pgvector ext 0.8.1·PostgreSQL 16.12, `0.8.5-pg16@digest` → 0.8.5·16.14 로 **두 층이 함께 움직였음**을 확인했고 69 테스트 전부 통과해 동작 변화는 드러나지 않았다. 불일치 경고가 남아 있던 `tests/README`·implements 근거·P41·P43·s1-recovery 미결 행도 실제와 맞췄다. **CI 러너에서 digest pull 이 network policy 상 통과하는지는 로컬에서 확인 불가 — PR CI 가 첫 검증이다** (S15P11A705-122) | [tests/README](../tests/README.md), [implements](implements/2026-07-24-e3-test-harness.md), [P43](proposals/P43-s1-judgment-recovery.md) | -| 2026-07-30 | infra 공용 action pin 을 병합 후 정본으로 옮기고, 보존 규칙 위반을 복원하고, `dev` 2단 브랜치를 준비했다. **pin `84458bf3` 은 `infra#82` 병합 전 브랜치 커밋이라 `infra` `main` 과 diverged 였다**(ahead 6·behind 6, `action.yml` 내용도 달랐다) — `16bfae0d` 로 옮기기 전에 대상 SHA 의 입력 계약(`policy`·`revision`)과 출력이 byte 단위로 같은지 직접 대조했고, 유일한 차이는 back OAuth `CLIENT_ID` 3종의 `unset` 방어 추가였다. workflow 와 contract test 의 `ACTION` 상수는 문자열이 정확히 일치해야 step 을 찾으므로 **한쪽만 바꾸면 `StopIteration` 으로 깨진다**(실측 RED 2건). 또 `ai#39` 가 handoff 리포트를 86줄→42줄로 덮어써 `implements` 보존 원칙(*"삭제하지 않고 상태 표시만 갱신"*)을 위반한 것을 복원했다 — **현재 내용을 지우지 않고** 상태 노트를 상단에, 인프라와 합의한 설계 근거 4건(`--raw` 선택·중간 평문 YAML 회피·strict scope·기동 검증)을 하단 보존 절로 얹었다(+101/−1, 유일한 삭제는 갱신된 SHA 줄). `dev` 는 트리거만 넓히고 **image publish 는 `main` 전용으로 남겼다** — `infra` 의 `ai-image-update.yaml` 이 source branch 를 `main` 으로 어서션하므로 넓히면 GitOps 반영이 거부된다 (S15P11A705-154) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [CONTRIBUTING](../CONTRIBUTING.md), [ai#40](https://github.com/Team-PinLog/ai/pull/40) | -| 2026-07-30 | BD-39 가 *"두 값이 같다"* 로 명시한 명제를 사람의 눈에서 CI 로 옮겼다. Embedding Profile 정본을 `back` 의 `application.yml` 리터럴로 두는 (a)안은 그대로 두고 **대조 장치만** 붙인다 — `back#98` 리뷰가 "그 명제를 지키는 장치가 현재 사람의 눈뿐"이라고 지적한 것에 대한 값싼 응답이다. 두 레포가 모두 public 이라 raw endpoint 를 **무인증**으로 읽는다. 토큰을 쓰지 않은 것은 편의가 아니라 경계다 — 주면 `ai` CI 가 타 레포 접근 권한을 상시로 들고 다닌다(대신 `back` 이 private 이 되면 조회 실패로 exit 1 이며 조용히 통과하지 않는다). **런타임 값이 아니라 선언된 리터럴을 비교한다** — 양쪽 다 환경변수 덮어쓰기를 허용하므로 프로세스 환경이 결과를 바꾸면 CI 가 무엇을 검증하는지 알 수 없게 된다. `ai` 쪽은 `Settings()` 를 인스턴스화하지 않고 필드 선언만 읽는다(인스턴스화는 환경변수와 profile 정합 검증을 함께 돌린다). 조회 실패도 exit 1 로 두었다. 실제 두 값이 같은 동안 이 잡은 늘 통과라 대조기의 탐지 능력 자체는 검증되지 않은 채 남으므로, 네트워크를 타지 않는 14 케이스로 그것을 못박고 **CI 에서 실제 RED 를 관측했다**(run `30507610160` — 리터럴을 `v1`→`v2` 로만 어긋내 `config.py` 의 profile 정합 검증은 통과시킨 채 새 잡만 실패하게 만들었다). 리뷰가 함께 제안한 **"기동 시 FastAPI 조회 대조"는 채택하지 않았다** — `app/api/probe.py` 가 *"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 로 비노출을 명시하므로 Profile 노출 엔드포인트 신설은 그 결정의 개정이 되고, 이 티켓 범위 밖이다. BD-39 문서 자체도 개정하지 않았다. `ai#40` 줄 누락도 함께 메웠다 (S15P11A705-156) | [ai#40](https://github.com/Team-PinLog/ai/pull/40), [back#98 리뷰](https://github.com/Team-PinLog/back/pull/98), [probe.py](../app/api/probe.py) | -| 2026-07-30 | 두 클라이언트가 HTTP 상태 코드를 **정반대로** 분류하던 것을 명세에 맞췄다. `embedding` 은 `>= 500` 만 Transient 로 보아 **`429` 한 번에 Context 가 영구 실패**했고(`failure-recovery` §2.1 은 Transient), `llm` 은 **모든** non-200 을 Transient 로 보아 **인증 실패가 재스캔 주기 5분마다 GMS 호출을 만들었다**(§2.2 는 `400`·`401`·`403` 을 Permanent). 원인은 두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이라, 매핑을 `errors.py` 의 `classify_http_status` 한 곳으로 모았다. §2 가 *"`errors.py` 에서 분류한다"* 로 지목한 파일이다. `429` 를 `>= 500` **보다 먼저** 판정해야 4xx 로 떨어지지 않으므로 그 순서를 회귀 테스트로 못박았다. 재시도(§3.1, 총 3회·`0.5→1.0s`·상한 `4.0s`·full jitter)는 `client/retry.py` 에 넣고 **재시도 대상을 `TransientError` 한 종류로 뒀다** — §3.1 의 대상 목록이 §2.1 의 Transient 집합과 같으므로 재시도용 상태 코드 표를 두 번째로 만들지 않았다. **상태 코드 표가 두 곳에 있으면 서로 어긋나게 되고, 어긋난 결과가 위 두 오분류였다.** 백오프 값을 `Settings` 로 열지 않은 것은 §3.2 의 상한(*"두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s"*)에 묶인 값이기 때문이다 — env 로 열면 그 상한이 배포마다 달라진다. 현재 최악값 `3 × (60 + 90) + 2 × 1.5 = 453s` 이고 **부등식 자체를 테스트가 지킨다**. 대신 `RetryPolicy` 를 생성자 인자로 받아 테스트가 `sleep`·`jitter` 를 주입한다(실제로 잠드는 테스트를 만들지 않는다). 구조화 출력 위반은 `SchemaViolationError(TransientError)` 로 두어 재시도 중엔 Transient 로 동작하고 소진 시 `judge` 가 `PermanentError` 로 **승격**한다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있고, 승격을 client 안에서 끝내야 service 가 보는 분류가 §2 의 두 종류로 유지된다(하위 타입인 채 새면 `except TransientError` 가 먼저 잡아 무한 재판정이 되므로 그것도 테스트로 막았다). Embedding 응답 형식 위반은 프로바이더가 같은 형식으로 답하므로 재시도 없이 즉시 Permanent 다. **결함의 근본 원인은 테스트였다** — `fakes.py` 의 `raise_exc` 가 어느 테스트에서도 쓰이지 않아(grep 0건) Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없었다. 그 경로를 실제로 주입하자 **티켓에 없던 결함이 하나 더 드러났다**: `keyword_service` 에 `PermanentError` 핸들러가 아예 없어(`context_processing` 광범위 except 없음, `context.py` 는 `BackgroundTasks.add_task`) `llm_client` 만 고치면 401 이 **BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다** — 고치려던 무한 재시도가 경로만 바꿔 남는 구조였다(되돌려 RED 확인). `embedding_service` 는 이 결선을 이미 갖고 있어 비대칭이던 쪽만 맞췄고 **상태 전이 규칙은 바꾸지 않았다**. 로그 레벨도 §2.1 WARN·§2.2 ERROR 로 맞췄다(둘 다 INFO/WARNING 이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 않았다). client HTTP 계층 테스트는 신설했고 이는 `integration-tests` §4.2(*"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"*)와 **충돌이 아니다** — §4.2 는 *파이프라인이 client 를 무엇으로 대체하는가* 의 규칙이고, 인터페이스 Fake 는 client 를 통째로 대체하므로 `_embed_batch` 가 429 를 어떻게 분류하는지 볼 수 없다. **`docs/spec/` 은 고치지 않았다**(§4.2 의 계층 구분 명문화는 후속 별건). `132 passed`(착수 baseline 74), Docker 29.6.1 Testcontainers 전량. **Circuit Breaker 와 타임아웃 60/90 재산정은 티켓 제외 범위** — 전자는 인증 실패 같은 전 서비스 영향 오류가 개별 Context FAILED 로 누적되는 문제를 남기고, 후자는 재시도 도입으로 최악값이 150s → 453s 가 된 것과 함께 별건이다 (S15P11A705-121) | [구현 리포트](implements/2026-07-30-retry-and-error-classification.md), [spec/failure-recovery.md](spec/failure-recovery.md), [tests/README](../tests/README.md), [ai#44](https://github.com/Team-PinLog/ai/pull/44) | -| 2026-07-30 | `dev` 전환의 마지막 조각 — `CONTRIBUTING.md` 가 2단 구성으로 개정된 뒤 `main` 을 가리킨 채 남은 문서 6곳을 정합시켰다(`docs/development/workflow.md` 4·8·30·33·53행, `P44` 52행). **`image publish` 의 `main` 기준 서술은 유지했다** — `infra` 의 `ai-image-update.yaml` 이 `test "$SOURCE_BRANCH" = main` 으로 어서션하므로 이것까지 `dev` 로 바꾸면 GitOps 반영이 끊긴다. `workflow.md` 는 이제 "`dev` 에 병합하는 실행 순서" 이므로, 그 문서만 읽는 사람이 `main` 이 언제 무엇을 받는지 모르게 되는 것을 막기 위해 §5 에 릴리스 병합(`dev`→`main`)과 publish 가 거기서 일어난다는 서술을 한 줄 넣었다. `P44` 는 `상태: Accepted` 인 결정 문서라 표 값만 조용히 바꾸지 않고, 작성 시점(2026-07-28)에는 `main` 단일 구성이었고 2단 전환이 `-154`·`-158` 에서 이뤄졌다는 각주를 함께 남겼다 — `implements/` 와 달리 `proposals/` 에 삭제 금지 원칙은 없지만, Accepted 결정문을 이력 없이 고치면 무엇이 언제 정해졌는지가 사라진다. **`ai-ci / check` 와 `ai-ci / embedding profile parity` 둘 다 required 로 확인**했으나(`main`·`dev` 동일) `CONTRIBUTING.md` 병합 조건 절은 여전히 `ai-ci / check` 하나만 적고 있다 — 정본 재개정은 이 티켓 범위 밖이라 문서를 정본에 맞춰 두고 갭만 올린다 (S15P11A705-158) | [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md), [CONTRIBUTING](../CONTRIBUTING.md) | -| 2026-07-30 | required status checks 가 둘인데 문서가 하나만 적던 것을 정정했다. `ai#42` 병합 후 `main`·`dev` 양쪽 protection 에 `ai-ci / embedding profile parity` 가 추가됐으나 정본이 갱신되지 않아, **낡은 값이 `CONTRIBUTING.md`→`workflow.md`→`P44` 로 퍼졌다** — 직전 작업(위 줄)에서 갭을 관측했지만 정본 재개정이 범위 밖이라 하위 문서를 낡은 정본에 맞춰 둔 상태였다. 이번에 **정본을 먼저 고치고 하위를 거기에 맞췄다.** 실제 설정을 API 로 직접 대조한 결과 `main`·`dev` 가 **완전히 동일**했다(strict·검사 둘·미해결 대화 차단·관리자 포함·승인 0). 그래서 검사 목록만 고치지 않고 **`관리자 포함 보호` 를 `main` 전용으로 적던 서술도 함께 정정**했다 — 이것도 틀린 값이었고, 원인은 같은 조건을 두 bullet 에 중복 기재해 한쪽만 낡을 수 있는 구조였다. 조건은 공통으로 한 번만 적고 bullet 은 "무엇을 병합하는가" 차이만 남겼다. `P44` 의 *"문서와 실제 GitHub 설정 불일치"* 완화가 **실제로 실패한 사례**이므로("API 로 재조회" 는 설정을 읽는 것까지만 다루고 문서를 고치는 주체를 정하지 않았다) 완화 자체를 "설정을 바꾼 사람이 같은 작업에서 정본을 갱신한다" 로 강화하고 발생 사실을 각주로 남겼다. 정본에는 검사 이름이 protection 과 문자열까지 일치해야 하며 개편 시 이 절을 먼저 고친다는 순서 규칙을 박아 두었다. 위 줄은 그 시점의 사실 기록이라 고치지 않고 새 줄을 더했다 — WORKLOG 가 `merge=union` 이라 기존 줄 수정은 중복 위험이 있다(직전 작업에서 문서화한 주의의 첫 적용) (S15P11A705-158) | [CONTRIBUTING](../CONTRIBUTING.md), [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md) | -| 2026-07-30 | `app` coverage 를 비차단 측정에서 **병합 차단 게이트**로 전환했다. **티켓 기준선(07-28, 52 tests · line 76.95% · branch 62.5%)이 낡아 착수 시 재측정했다** — 그 사이 `-121` 이 58 테스트를 더해 `b45aa93` 시점 실측은 146 tests · line 88.80% · branch 82.08% 였고, 티켓이 지목한 미검증 4곳 중 **둘은 이미 `-121` 이 덮은 뒤**였다. 두 지표 모두 이미 80% 를 넘겨 게이트만 켜도 통과했지만 그러지 않았다 — **branch 여유가 2 포인트뿐**이라 다음 기능 PR 이 분기를 몇 개 추가하는 것만으로 무관한 이유로 실패한다. 남은 공백(`load_presets.py` **0%**, `main.py` 58%, 재측정으로 드러난 `gms_roundtrip.py` 76%)을 계약·실패 경로로 메워 **181 tests · line 99.74% · branch 98.11%** 로 올렸다. **`--cov-fail-under` 를 쓰지 않았다** — `--cov-branch` 를 켜면 그 값은 statement 와 branch 를 **합산한 하나의 비율**이라(기준선에서 합산 88% · branch 82%) statement 759 개가 branch 106 개를 압도해 **branch 가 60% 대로 떨어져도 통과한다**. 완료 조건이 "각각" 인 이상 합산 게이트는 조건을 검사하지 못하므로 `tools/check_coverage_gate.py` 가 둘을 나눠 판정한다. 임계값은 스크립트 상수이며 **CLI 로 덮을 수 없다**(덮을 수 있으면 CI 가 조용히 낮은 값을 넘겨 무력화할 수 있다). `--cov-branch` 없는 리포트는 branch 키 자체가 없어(실측) 분기 검사가 공짜로 통과하므로 **판정 불가로 끊는다** — "측정하지 못했다" 는 통과가 아니다. **통과만 확인하면 아무것도 강제하지 않는 게이트를 놓치므로 RED 4 종을 실측했다**: 테스트 제거(line 42.82%·branch 34.91%), 임계값 99.9 상향(되돌림), `--cov-branch` 누락, 리포트 부재 — 전부 exit 1. 임계값 하향을 막는 테스트도 함께 실패하는 것을 확인했다. 신설한 두 계층은 **요청 경로 밖이라 파이프라인·API 테스트로는 한 줄도 실행되지 않던 곳**이다 — `test_api.py` 가 lifespan 을 우회하고 `app.state` 에 Fake 를 직접 꽂기 때문이다. lifespan 테스트는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake 로 바꾸지 않았다(생성자는 IO 를 하지 않아 실호출 금지와 충돌하지 않고, Fake 로 바꾸면 그 단언이 사라진다). `if __name__ == "__main__"` 아래는 `runpy` 로 검증하는데, **새 네임스페이스라 캐시된 모듈 패치가 보이지 않아** 원본 클라이언트 모듈의 속성을 갈아 끼워야 한다(함정을 `tests/README` 에 기록). **제외로 수치를 맞추지 않았다** — `pragma`·`omit` 없이 2 line · 2 branch 를 미달로 남겼다. 둘 다 `FOR UPDATE` 로 잠근 직후의 재검사라 같은 트랜잭션에서 도달 불가지만, **도달 불가가 구조에 딸린 성질**이라 저장 로직이 잠금 밖으로 나가면 도달 가능해진다 — `pragma` 를 붙이면 그때 아무도 모르고, 미달로 두면 리포트가 계속 그 줄을 가리킨다(여유 18 포인트라 비용이 없다). 함께 `integration-tests.md` §4.2 에 **계층 구분을 명문화**했다(`-121` 후속) — §4.2 가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"* 라고만 적어 client 단위 테스트의 HTTP mock 이 위반인지가 `-121` 중 **두 번** 질문으로 올라왔고, 판정은 충돌이 아니라 **층이 다름**이었다. 경계를 한 문장으로 고정했다: **`app/client/` 밖의 코드는 HTTP 를 몰라야 하고 그 코드를 검증하는 테스트도 HTTP 를 몰라야 한다.** `-121` 이 `docs/spec/` 을 못 고친 것은 그 작업이 *구현을 계약에 맞추는 것*이었기 때문이고, 이번은 계약 자체의 공백을 메우는 것이라 허용됐다 (S15P11A705-110) | [게이트 리포트](implements/2026-07-30-coverage-gate.md), [spec/integration-tests.md](spec/integration-tests.md), [CONTRIBUTING](../CONTRIBUTING.md), [tests/README](../tests/README.md) | -| 2026-07-30 | 가공 데모 14건에 **실사용자 기록 23건**을 더해 37건으로 전체 통합을 검증했다. 검색 1위 일치 10/12(가공 4/4·실데이터 6/8), 피드·Keyword PASS. 시딩 15분 8초·회수 0회로 `--pace 25`가 GMS 쿼터 안에 들어감을 재확인했다. 실패 2건의 원인이 갈렸다 — 하나는 축약어(「피맥」↔「피자에 맥주」), 하나는 **임베딩 입력이 Context 본문뿐이라 장소명이 검색에 안 걸리는 것**으로 후자는 설계 관측이다. 응답에 실려 오던 GMS 토큰을 두 클라이언트가 전부 버리고 있어 `PINLOG_TOKEN_LOG` 게이트 계측을 신설했고, 판정이 임베딩의 17배(839.6 대 37.7)를 쓰며 그 대부분이 후보 Preset 27개를 매번 싣는 prompt 임을 실측했다 (S15P11A705-174) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27·T28](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | -| 2026-07-30 | **시딩이 느렸던 것은 GMS 가 아니라 우리 `--pace 25` 였다.** 재측정에서 같은 코드가 분당 30건 이상을 통과했고(간격 1s 15/15 · 동시 10건 1.7초), 같은 데이터 37건이 `--pace 1` 로 **42초**에 완주했다 — 15분 8초에서 21.6배. 결과와 토큰은 같았고 검색 정확도도 2회차 10/12 로 재현됐다. `T27` 이 07-29 관측을 상수로 적은 것을 시점·경로 의존으로 정정하고 기본값을 1 로 낮췄다. 벤더 비교에서 **429 가 Gemini 경로에만 나는 것**을 확인해(OpenAI·Anthropic 12/12) 폴백을 `-175` 로 발주했다. 함께 보고서 두 오류를 고쳤다 — 판정 후보는 27개가 아니라 top-10 이고(프리셋 총수와 혼동), 데이터가 원본·계약 자리채움·시연 구성 세 층인데 그 구분 없이 「실데이터」로 뭉뚱그렸다 (S15P11A705-176) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27 정정](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | -| 2026-07-31 | 임베딩 `502` 가 검색 `500` 이 되던 운영 버그(`ai#69`)를 고쳤다. **분류도 재시도도 이미 맞았다** — `-121` 이 `classify_http_status` 로 `502→TransientError` 를 확정했고 `test_client_retry.py` 가 상태 코드별로 단언한다. 비어 있던 것은 **그 예외가 응답이 되는 지점**이었고, `app/main.py` 의 핸들러는 `ProfileMismatchError`(422) 하나뿐이라 분류된 나머지가 uvicorn 까지 올라가 500 이 됐다. 클라이언트 테스트는 예외가 던져지는 것까지 보고 API 테스트는 Fake 를 꽂아 성공 형식만 봐서, **두 파일 사이에 계층 하나가 통째로 비어 있는데 각자는 자기 범위에서 통과하고 있었다.** `TransientError→503` · `PermanentError→502` 로 갈랐고, `502` 를 고른 근거는 **500 을 비워 두는 것**이다 — 분류된 실패까지 500 이면 "AI 가 깨졌나 게이트웨이가 깨졌나" 를 가르려던 목적이 절반만 남는다. 티켓이 열어 둔 *"back 이 재시도해도 소용없다는 신호"* 논거는 **쓰지 않았다**: `AiSearchClient` 는 애초에 검색을 재시도하지 않는다(사용자 요청 경로). **`back` 이 500 과 503 을 구분하지 않는다는 사실을 확인했다** — `translate()` 가 `422`+`serverProfile` 과 `401`/`403` 외 **모든** 상태를 `AiSearchException.unavailable()`(→`503 SEARCH_UNAVAILABLE`)로 묶으므로 사용자 화면은 이미 같고, **이 변경이 바꾸는 것은 화면이 아니라 관측**이다. `ai#69` 두 번째 답변의 *"AI 가 503 을 주면 back 이 전달한다"* 는 맞지만 500 도 이미 그렇게 전달되고 있다는 사실이 빠져 있었다. 그래서 `back` 변경은 하지 않고 별건으로 남긴다. 응답 본문은 **고정 문구 한 줄**이다 — `back` 이 본문을 읽지 않고(파싱하는 것은 `serverProfile` 뿐), 업스트림 상태 코드는 `-197` 계측이 이미 남기며, 예외 메시지를 그대로 실으면 게이트웨이 응답 200자(`resp.text[:200]`)가 응답으로 새기 때문이다. **핸들러 부재는 500 만 낸 게 아니었다** — 대조군 로그에서 예외가 uvicorn 까지 올라가며 트레이스백에 업스트림 본문이 그대로 찍히는 것을 봤고(스텁 401 의 키 힌트가 노출됐다), `probe.py`·§2.4 원칙 4 위반이라 핸들러가 그 누출도 막는다. 검증은 **핸들러를 넣기 전에 RED 14 건**을 확인했고(실패 형태가 「500 이 나왔다」가 아니라 **예외가 응답 계층으로 샜다** 였다 — 운영에서 uvicorn 이 500 을 만드는 바로 그 지점), 넣은 뒤 18 건 GREEN · 전체 309 건 통과다. 테스트만으로는 「핸들러를 넣었다」에 가까워 **로컬 GMS 스텁으로 실제 502 를 만들었다**: 같은 스텁에 물린 uvicorn 두 대에서 `origin/dev`(a5e1142)는 `502`·`401` 둘 다 **HTTP 500**, 이 브랜치는 **503**·**502** 였고 정상 응답은 200 으로 실데이터 5건을 돌려줬다. 계약 테스트는 **Fake client 를 쓰지 않는다** — `raise_exc` 주입은 분류 경로를 건너뛰어 `classify_http_status` 가 바뀌어도 통과하며, 그 모양이 `ai#69` 를 놓친 구멍이다. 명세도 함께 메웠다(`failure-recovery.md` §2.5 · `personal-search.md` §6.2) — **§2.1·§2.2 는 Context 처리 경로의 상태 반영만 정하고 API 층 변환을 말하지 않아서**, 코드만 고치면 같은 공백이 남는다. **DB 실패는 여전히 500 이다** — §2.1 이 DB 연결 실패를 일시 오류로 두지만 `SearchService` 가 asyncpg 예외를 분류하지 않는다. 범위 밖으로 남기고 후속 후보로 올린다 (S15P11A705-220) | [오류 응답 계약](implements/2026-07-31-search-error-contract.md), [failure-recovery §2.5](spec/failure-recovery.md), [personal-search §6.2](spec/personal-search.md), [T43~T45](troubleshooting/2026-07-31-error-contract-pitfalls.md), [ai#69](https://github.com/Team-PinLog/ai/issues/69) | -| 2026-07-31 | 임베딩 개선 축 둘을 교차시켜 **넷을 실경로로** 쟀다(입력에 장소명 결합 × `small`/`large`). 1위 일치는 A 10 · B 9 · C 10 · D 10 / 12 이고 top-3 는 넷 다 12/12 다 — **합계로는 어느 조건도 기준선을 넘지 못한다.** 그런데 합계가 같은 A·C·D 가 **서로 다른 질의에서 실패한다**: 8번(공원)은 `large` 가 고치고(C·D) 장소명 결합과 무관하며, 7번(축약어 「피맥」)은 **둘을 겹쳤을 때만**(D) 통과하고, 1번은 D 에서만 깨지고, 6번은 A 에서 `+0.0087` 차이로 통과해 우연에 가깝다. **`-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 진단이 틀렸다** — 맞았다면 입력에 장소명을 넣은 B 가 고쳤어야 하는데 B 는 못 고쳤고 장소명을 넣지 않은 C 가 고쳤다. 원인은 어휘 부재가 아니라 `small` 이 「그네팟」·「산책하면서 머물다」를 「공원」과 잇지 못한 것으로 보인다. 장소명 결합은 **이득과 손해가 함께 있다.** 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다(1번 0.5021→0.3344). **비용 축은 저장 하나뿐**이다: 벡터가 정확히 2배(6,148→12,292 B)이고 **토큰은 조건 간 차이가 없다**(임베딩 토큰은 입력 텍스트가 정하므로 모델 크기와 무관), 시딩 소요도 42.5~44.2초로 GMS 왕복이 지배한다. `vector(3072)` 이 동작하는 것은 **벡터 인덱스가 없기 때문**이며(37행 순차 스캔) 색인이 필요해지면 pgvector 의 2000차원 상한이 되살아난다. **채택 판단은 하지 않는다** — 12건 표본의 1건 차이이고 세 조건이 동률이며 기대값을 우리가 작성했다(`-174` §3.3). 측정 전제로 두 가지를 고쳤다: `seed.py` 가 `social_account.email` 을 채우지 않아 back `V6__social_account_email_not_null.sql` 에 걸려 **back 이 기동조차 못 하던 것**(시연 도구가 현행 스키마와 어긋나 있었다. `{key}@demo-seed.invalid` — RFC 2606 예약 TLD 라 실재 주소로 오인되지 않는다)과, `--reset` 이 `demo-seed` 소유만 지워 남은 **고아 임베딩 8건**이 저장 비용 평균을 오염시키던 것(하네스가 조건 profile 로 한정해 세게 했다). **로컬 DB 는 복구했다** — `alter_dim.py --to 1536` · 프리셋 실배포 profile 재적재 · 데모 37건 재시딩까지 실행해 `grid-*` 행이 하나도 남지 않은 것을 확인했다. **Flyway 마이그레이션은 만들지 않았다**(측정과 채택은 별건) (S15P11A705-191) | [4조건 측정](implements/2026-07-31-embedding-grid.md), [tools/emb_grid/](../tools/emb_grid/), [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) | -| 2026-07-31 | 시연 도구 결함 3건을 **값이 아니라 「조용함」** 쪽에서 고쳤다. 셋 다 `-191` 이 값은 이미 고쳤고(`email` 채움·고아 8행 삭제) 여기서 만든 것은 재발 경로다. `tools/demo_seed/preflight.py` 가 **`--reset` 보다 먼저** 돌고 걸리면 아무것도 지우지 않은 채 exit 2 로 끝난다 — 결함 3 과 `T28` 이 **둘 다 reset 이 지운 뒤에 실패해** 데이터를 잃었기 때문에 순서가 방어의 절반이다. **결함 1 의 방어 대상을 「NOT NULL 제약」이 아니라 「우리가 값을 주지 않는 컬럼」으로 잡았다** — `email` 은 `V4` 에서 nullable 로 태어났고 그때는 아무 오류도 나지 않으며 `V6` 가 제약을 걸 때는 이미 늦다. `WRITE_CONTRACT` 가 시딩이 직접 INSERT 하는 두 테이블의 **모든 컬럼**을 `SEED`/`DB`/`NULL` 로 선언하고 미선언 컬럼이 나타나면 멈춘다. 이 방향이면 back 이 언제 무엇을 걸지 알 필요가 없어 **back 이 우리 CI 가 아니라는 현실에서 작동한다**. 티켓 후보 중 「기동 스모크」는 넣지 않았다(그 시점엔 DB 가 이미 오염돼 예방이 아니라 관측이다), 「트랜잭션 경계」도 아니다(INSERT 는 정상 커밋됐고 며칠 뒤 다른 프로세스가 죽었다). **결함 2 는 「지우지 않는다」로 판단했다** — `tools/e2e/` 검증 데이터는 **의도적으로** `core` 대응 행이 없어(README 명시) 고아 정의에 전부 걸리므로 자동 삭제는 남의 하네스를 깨뜨린다. 세어서 보여주고 `--prune-orphans` 로 사람이 정한다. 다만 `reset()` 이 `ai.context_keyword_analysis` 를 지우지 않던 것은 논쟁이 아니라 **누락**이라 그냥 고쳤다(**259행 중 222행이 고아**였고 FK 가 없어 DB 도 알려주지 않았다). 목록을 `ORPHAN_TABLES` 하나로 묶어 **빠뜨리면 집계가 먼저 고아로 보고**하게 했다. **결함 3 은 묶는 것과 드러내는 것을 둘 다** — `shared_root()` 가 `--git-common-dir` 로 키 경로를 메인 워킹트리에 고정하고, 인증 프로브가 back 이 그 키를 실제로 받아들이는지 한 번 통과시켜 본다. 경로 고정은 흔한 원인 하나만 없애고 back 에 주입된 키가 다르면 여전히 401 이라 **프로브가 본체**다. **셋 다 일부러 어긋내 RED 를 봤다**(`-156` 과 같은 이유): 판정 함수에 `return []` 뮤턴트를 넣어 5 케이스 RED · 별도 `guardprobe` DB 에 back 마이그레이션 9 개를 적용하고 `ADD COLUMN nickname` 으로 **nullable 추가 시점에 걸리는 것**을 실측 · 키를 어긋내 `HTTP 401` BLOCK · `:5433` 에서 테이블 없음 BLOCK. **실 DB 실행이 오탐 하나를 잡았다** — `GENERATED ALWAYS AS IDENTITY` 는 `column_default` 가 NULL 이라 `id` 가 "NOT NULL 인데 기본값 없음" 으로 걸렸고 단위 테스트는 그 쿼리를 타지 않아 못 봤다(`is_identity`·`is_generated` 포함으로 수정). 조사 중 **로컬 pgvector 가 둘이고 `.env` 의 `DATABASE_URL` 이 시연 정본(`:15432`)이 아니라 07-27 잔재(`:5433`)를 가리키는 것**을 발견해 preflight 첫 줄에 접속 대상을 찍게 했다 — 기본값 자체는 `-174` 절차·타 세션 습관과 얽혀 이 티켓에서 바꾸지 않았다. back 은 §7 절차로 실기동했고 **첫 기동은 로컬 jar 이 07-30 빌드라 V6 를 담지 않아 Flyway 가 거부**해서 `bootJar` 리빌드 후 정상 기동했다. `254 passed` · `ruff check .` 통과 (S15P11A705-198) | [구현 리포트](implements/2026-07-31-seed-guard.md), [tools/demo_seed/](../tools/demo_seed/), [임베딩 4조건](implements/2026-07-31-embedding-grid.md) | -| 2026-07-31 | `dev` 의 세 gate 가 열렸는데 **무엇이 실패하는지 볼 수단이 없던 것**을 메웠다(`-197` 감사 §4-c 의 관측 3종). GMS 호출 1회를 감싸 벤더·모델·상태 코드·결과 분류·소요 시간을 남기고, 60초 창 집계를 한 줄로 낸다. **`_usage.py` 와 합치지 않았다** — 그쪽은 `PINLOG_TOKEN_LOG` 게이트가 있고 **200 응답을 파싱한 뒤**에 불려 실패한 호출을 한 줄도 남기지 않는다. 실패율의 분자가 애초에 거기 없고 dev 배포에는 그 env 가 없어 분모도 없다. 로그로 합치면 반대로 토큰 JSONL 이 실패 행과 섞여 `-174` 집계가 깨진다. **재선점은 쿼리를 갈랐다** — `try_start` 가 신규 시작과 만료 재선점에 같은 rowcount `1` 을 주는데, 호출부가 미리 읽은 값으로 가르면 keyword 단계는 근거가 아예 없고(`try_start` 전에 상태를 안 읽는다) embedding 단계는 경합 창 때문에 거짓 양성이 섞인다. 재선점은 드물지만 「앞선 처리가 600s 안에 못 끝났다」는 신호라 거짓 양성이 섞이면 신호를 못 믿는다. CTE 로 UPDATE 직전 상태를 같은 문장에서 읽어 원자성을 지켰다(추가 조회가 아니므로 state-machine §3.2 와 충돌하지 않는다 — 그 규칙은 `0` 의 이유를 묻지 말라는 것이고 이쪽은 `1` 이 무엇인지를 묻는다). 레벨은 **성공 DEBUG · 실패 WARNING · 창 집계 INFO · 재선점 WARNING** 이다 — 성공까지 INFO 면 정상 트래픽이 실패 행을 덮고, 성공을 안 세면 분모가 없다. 집계는 타이머가 아니라 다음 호출이 밀어내므로 유휴 시 조용하고, 마지막 창은 lifespan `finally` 의 `flush()` 가 낸다. **계측을 붙이다 httpx 가 요청마다 INFO 로 전체 URL 을 남기는 것을 발견했다** — `probe.py` 의 endpoint 미노출 기준을 어기면서 성공 호출까지 INFO 라 새 설계를 무의미하게 만들고 있었다. **caplog 를 로거 이름으로 걸러 읽는 테스트가 이것을 통과시켰고**, 실제 실행 출력을 눈으로 보고서야 드러났다(자기 로거만 보면 옆에서 새는 것을 못 본다). `configure_logging` 이 httpx 를 WARNING 으로 올리고, 로거를 가리지 않고 전부 읽는 테스트를 따로 뒀다. 값 노출 기준은 **벤더·모델은 싣고**(공개 설정 P45, 어느 경로가 막혔는가가 곧 원인) credential·endpoint·요청/응답 본문은 안 싣는다 — 요청 본문에는 사용자 Context 원문이 있다. 전송 실패에서 예외 메시지 대신 타입 이름을 쓰는 것도 httpx 가 메시지에 URL 을 넣기 때문이다. `/metrics` 는 만들지 않았다(`infra ai-serving.md` 검증 순서 7 이 prod 승격 전 별도 승인으로 이관). 기존 `resp.text[:200]` 노출은 진단에 필요해 남기고 별도 판단 항목으로 올린다 (S15P11A705-197) | [GMS 호출 관측](implements/2026-07-31-gms-call-observability.md), [failure-recovery §2.4](spec/failure-recovery.md), [state-machine §3.2](spec/state-machine.md) | -| 2026-07-31 | 개인 검색에 **두 컷**(`τ_abs=0.30` 절대 하한 · `r=0.60` 1위 대비 상대 하한)을 넣고 `limit` 기본값을 공용 계약 08 §6.1 의 `size` 기본값과 같은 20 으로 맞췄다(back 이 `sizeOrDefault()` 로 항상 명시해 보내 **드러나지 않은 채 어긋나 있었다**). **`personal-search.md §6` 의 「컷오프를 적용하지 않는다」를 뒤집는다** — 그 근거는 무관 질의 **1건**의 실측(관련 top-1 최소 0.5263 vs 무관 top-1 최대 0.3143, 간격 +0.2120)이었는데, 무관 질의를 질의 5종 × 소유자 3명 = **15건**으로 늘리자 그 간격이 **-0.0176 으로 뒤집혔다**(「치과 임플란트 상담 받을 곳」 → 연남칼국수 0.3819 가 기대 정답 최솟값 0.3642 를 넘는다). 즉 어떤 `τ_abs` 도 무관 질의를 전부 무노출로 만들면서 정답을 전부 살릴 수 없고, **동시에 「그러므로 컷을 걸지 말자」도 성립하지 않는다** — 컷이 없으면 무관 질의에 보유 기록 전량(소유자별 6·11·17건, 15질의 합 170행)이 반환된다. **티켓이 채택한 조합을 측정이 지지했다.** 검증 질의 12건만 재면 `r` 이 압도적으로 보이지만(`r=0.80` 꼬리 97.4% 제거, `τ_abs` 는 어떤 값과 조합해도 결과 동일) `r` 은 정의상 **1위를 언제나 남겨** 무관 질의를 0.40~0.90 전 구간에서 **15건 중 0건도** 무노출로 만들지 못한다. `τ_abs` 는 반대로 같은 안전 마진에서 꼬리 제거가 약하다(상한의 83% 에서 62.3%, `r` 은 상한의 75% 에서 73.7%). **정답이 있는 질의만 재면 컷의 절반이 안 보인다**는 것이 이 측정의 방법론적 결론이다(T40). 채택값에서 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3%(비관)·71.5%(낙관) 제거이고, 무관 질의는 15건 중 11건 무노출(11/15)이다. **안전 상한(`τ_abs` 0.36 · `r` 0.80)에 붙이지 않고 17%·25% 마진을 뒀다** — 두 축의 상한을 **같은 데이터점 하나**(「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `0.3642`·`r=0.807`, `-174`·`-191` 네 조건에서 계속 실패한 축약어 「피맥」 건)가 정하고, 여기에 **배치 구성이 바뀌면 같은 텍스트의 유사도가 `10⁻⁴` 규모로 흔들리는 것**(0.5264→0.5258, T42)이 겹치는데 상한과 정답 최솟값의 거리가 `0.0042` 였다. 컷은 `LIMIT` **뒤에서** 건다 — 유사도 하위만 자르므로 `WHERE` 와 결과가 같고(단조), 그렇다면 이미 고정된 §4 Query 를 건드리지 않는 쪽이 낫다. `r` 의 기준은 **컷 전 1위**다(살아남은 것의 1위로 재계산하면 자기충족 컷이 된다). 하네스 `tools/search_cut/` 는 `-210` 의 구조를 따라 질의를 한 번 임베딩해 굳히고 격자를 오프라인으로 훑는다(**GMS 임베딩 배치 1회**) — 다만 `-210` 의 재구성이 근사였던 것(후보가 줄면 LLM 판정이 뒤집힌다)과 달리 **검색 경로에는 LLM 이 없어 정확**하고, 그래서 대조군 없이 **정확 일치**를 요구했다(이 브랜치 코드로 띄운 :8002 에 27건 실호출 → **27/27 일치**). 검증 스크립트는 컷 규칙을 구현에서 `import` 하지 않고 다시 적는다(하면 구현이 명세와 달라도 둘이 함께 틀린다). `.search/matrix.json` 을 **커밋했다** — 다시 뜨려면 GMS 를 부르고 `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). 라벨은 `plausible` 을 **넉넉히** 잡아 컷의 이득을 과대평가하지 않는 방향으로 뒀다. **`limit` 을 20 으로 올리는 것 자체는 꼬리를 늘리므로**(반환 104→142행) 컷과 분리해 머지하면 안 된다. 후속: 소유자별 편차를 단일 `τ_abs` 가 모른다(같은 무관 질의가 6 Record 소유자에게 0.2686, 17 Record 소유자에게 0.3819) · 정답/무관 겹침은 컷이 아니라 **임베딩 품질**의 문제다. `291 passed` · line 99.71% · branch 98.12% · `ruff check .` 통과 (S15P11A705-213) | [컷 측정](implements/2026-07-31-search-cut.md), [spec/personal-search.md §6.1](spec/personal-search.md), [T40~T42](troubleshooting/2026-07-31-search-cut-measurement.md), [ai#70](https://github.com/Team-PinLog/ai/pull/70) | -| 2026-07-31 | GMS 오류 응답 본문이 **예외 메시지를 타고 로그로 새던 경로**를 원천에서 막았다(`-205`). 티켓은 「분류 밖 예외 → 트레이스백」 하나를 남은 구멍으로 봤지만 실제 경로는 **여섯이고 그중 다섯이 분류가 정상 동작하는 평상시 경로**다 — `retry.py` 가 재시도 1회마다, 두 service 가 일시·영구 오류마다 예외 객체를 `%s` 로 찍는다. `failure-recovery.md §2.4` 원칙 4 는 응답 본문을 *"어느 레벨에서도 남기지 않습니다"* 라고 적고 있었으나 **코드는 그때도 남기고 있었다**(§2.6 으로 갈라 정정). 실제 GMS 로 네 경로에 오류 19건을 넣어 본문을 실측했다 — **자격 증명은 한 건도 에코되지 않고**(401 은 게이트웨이 고정 문구, 벤더가 답하기 전에 끊는다) endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다**(`Invalid value: 'PIN...def'` — 완전 일치 검사를 빠져나가 「안 샌다」로 오판할 뻔했다, T61). 그래서 규칙의 근거를 「관측된 것을 지운다」가 아니라 **「되돌아올 수 있는 자리를 막는다」**로 잡았다. `app/core/redact.py` 가 자격 증명(`sk-`·`AIza`·JWT·`Bearer`·`key=value`)을 endpoint(URL·맨 호스트)보다 **먼저** 지우고, **마스킹이 절단보다 먼저다**(순서를 뒤집으면 200자 경계에 걸친 키의 앞부분이 남는다). **본문 200자는 지우지 않는다** — 실측한 벤더 400 본문에 마스킹 대상이 한 글자도 없었고 전부 진단 문구였다. 막는 지점을 로그 호출부가 아니라 client 로 잡은 것이 설계의 전부다(트레이스백 경로에는 애초에 호출부가 없다). `tests/test_log_redaction.py` 18건이 AST 로 단일 원천을 지킨다. pytest 404 · line 99.83% / branch 98.99% (`-223` 병합 후 재검증) | [implements](implements/2026-07-31-gms-error-body-redaction.md), [failure-recovery §2.6](spec/failure-recovery.md), [troubleshooting](troubleshooting/2026-07-31-log-redaction-pitfalls.md) (T61~T63) | -| 2026-07-31 | 판정 n회 다수결 — 구현 + 실측 후 **n=1 유지 결론** (S15P11A705-223) | 판정을 `PINLOG_JUDGE_VOTE_N` 회 불러 **엄격 다수결**(`votes*2 > n`)로 접는 경로를 넣고, 기본값 1 이 현행과 **정확히 같음**을 실데이터로 고정했다(회차 30개를 n=1 로 접은 결과 원본과 30/30 일치). **분모를 성공 수가 아니라 n 으로 고정**한 것이 설계의 핵심 — 낮추면 n=3 에서 1회만 성공했을 때 그 1회가 곧 다수결이 되어 「다수결을 켠 채 n=1 을 실행」하는 상태가 되고, 정족수 미달을 「선택 0건 정상 완료」로 저장하면 판정 실패가 성공으로 기록된다(T54). 짝수 n 은 기동 차단 — 동점 때문이 아니라 바로 아래 홀수에 **지배당하기** 때문이다(n=4 는 3표·n=3 은 2표인데 호출은 33% 더 든다). 측정은 **새로 부르지 않고 접었다** — n회 다수결 1회분과 독립 회차 n개의 다수결이 같은 확률변수이므로 회차 30개(1,260호출)로 2,940호출어치 조건 셋을 얻었고(`-210` 이 유사도 행렬로 τ 를 재구성한 것과 같은 수법), 다수결 규칙은 서비스 코드를 그대로 부른다. `run_live.py` 가 `KeywordService._judge_n` 실호출로 대조 — 전 지표 범위 겹침, Context당 호출 정확히 3.0. **다수결은 설계대로 작동했다**: 비결정성 24%→17%→12%, 흔들리던 오분류 13종 완전 제거, 라벨없음 0.80→0.00, **정상 판정을 오히려 늘려 교환비가 이 프로젝트 최초로 음수**(`-210` τ 는 1.22 로 기각됐다). **그런데 오분류 행은 안 준다** — 10.13→9.40→9.17, 범위 겹침, p=0.365/0.210 으로 사전·보강 기준 모두 미달. 이유가 §4 다: 다수결은 소수의견을 지우는 장치라 **다수의견은 반대로 굳힌다** — 지운 13종이 각 0.03~0.37행으로 드물었고(-1.7행) 동시에 70~97%이던 5종을 100%로 굳혀(+0.8행) 잔액이 노이즈 크기로 남는다. 100% 생존한 7종(`274`·`289 DRINK`·`265 WALK`·`284 WITH_FAMILY`·`275`/`290 VIEW_GOOD`·`266 TRENDY`)은 `-219` 가 프롬프트로 못 움직인 것과 **같은 목록**이다 — `-210`(후보 선정)·`-219`(판정 문구)·`-223`(판정 분산)이 서로 다른 층에서 같은 곳을 가리켰고, 남은 것은 **후보의 정의**(프리셋 `description`, `back#136`)로 AI 파트 소관이 아니다. `fit 0건 Context` 8.00 — 세 티켓 연속 불변(다수결이 한 일은 줄인 것이 아니라 표준편차를 0 으로 만든 것). 비용 실측: 호출 3~5배, Context당 지연 1.57→2.01→2.60s(동시 호출이라 n배 아님), 최악 지연은 **n 에 비례하지 않는다**(n=1 3.94s > n=3 3.70s — 꼬리는 GMS 쪽이다). 개선 폭당 호출 115~175회/오분류 1행. **`back#136` 이 7종을 고치면 남는 오분류가 전부 흔들림이 되므로 그때 다시 재야 한다 — 순서가 중요하다.** GMS 2,142호출·실패 0·전량 `gpt-4o-mini`. `386 passed` · line 99.83% · branch 98.98% (`origin/dev` 병합 후. 이 브랜치 단독은 338) · `ruff check .` 통과 | [다수결 측정](implements/2026-07-31-judge-vote.md), [T57~T60](troubleshooting/2026-07-31-judge-vote.md), [tools/judge_vote/](../tools/judge_vote/) | +| 2026-07-28 | AI 레포 협업 운영 기준과 Jira→PR 리뷰 절차 수립, `app` branch coverage 비차단 측정 도입 (Jira 작업) | [CONTRIBUTING](../CONTRIBUTING.md), [P44](proposals/P44-ai-repository-governance.md), [development](development/) | +| 2026-07-29 | dev 배포 게이트 3종(I23) — `GET /ready`(DB `SELECT 1` + Preset ≥1, AI API 미호출) · `GMS_BASE_URL` `/gmsapi/` 기동 fail-fast(값 미노출 위해 `SettingsError`) · `app.smoke.gms_roundtrip` 양방향 실호출, pytest 52→66 (ai#33 ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 인프라 요청) | [implements](implements/2026-07-29-dev-deployment-gates.md), [README](../README.md) | +| 2026-07-29 | AI 소유 값의 클러스터 전달 경로를 만들었다(I24). GitHub Actions Secret 은 Pod 에 자동 전달되지 않으므로 `kubeseal --raw` 로 값 7종을 개별 봉인해 `encryptedData` 만 artifact 로 넘긴다 — `kubectl create secret --dry-run` 경로는 중간에 평문 base64 YAML 을 만들어 쓰지 않았다. **EMBEDDING 넷은 비밀이 아니지만**(정본 P32) Infra 가 주입 경로를 하나로 요구해 같은 경로로 다룬다. 앱의 기동 검사(`/gmsapi/` 형식·profile 정합)를 봉인 시점으로 앞당겨 배포 전에 실패시키고, controller 인증서는 SHA-256 지문으로 고정했다 — 엉뚱한 공개키로 봉인하면 복호화 실패가 배포 시점에야 드러난다. **실제 봉인은 미실행**(Actions Secret 등록 후 가능) (Jira 작업) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | +| 2026-07-29 | 봉인 workflow 의 kubeseal 설치가 체크섬 검증에서 실패하던 것을 고쳤다. `curl -o kubeseal.tar.gz` 로 받아 놓고 manifest 는 `kubeseal-0.27.1-linux-amd64.tar.gz` 를 가리켜, `sha256sum -c` 가 manifest 에 적힌 이름으로 파일을 열다 실패했다 (`No such file or directory` / `FAILED open or read`). 다운로드·검증·해제가 같은 변수를 쓰도록 파일명을 하나로 묶었다. **Infra 가 실제로 실행해서 발견했다** — 병합 전 검증이 YAML 파싱과 `bash -n` 까지였고 그 둘은 이 결함을 잡지 못한다. 이번엔 실제 다운로드·검증·해제를 로컬에서 돌려 통과를 확인했고, 원 버전이 같은 오류로 실패하는 것도 재현했다 (Jira 작업) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [ai#32](https://github.com/Team-PinLog/ai/pull/32) | +| 2026-07-29 | 발표 시연 데이터를 **back API 경로로** 만드는 시딩 도구 `tools/demo_seed/`(I25)와 E2E 재확인. **SQL 직접 INSERT를 택하지 않았다** — 데이터는 있는데 파이프라인은 안 돈 상태를 만들고, 그러면 "새 Context를 지금 추가하면 Keyword가 붙는다"를 발표 당일에 처음 실행하게 된다. API로 만들면 `-102`의 PENDING 생성과 `/context/process` 호출이 매 건 타므로 **시딩이 곧 통합 검증**이다. SQL은 API가 없는 두 곳(OAuth로만 생기는 `core.member`, soft delete뿐이라 불가능한 `--reset` hard delete)에만 쓰고 `demo-seed` provider 표식으로 범위를 좁혔다. **AI API 29회**(Context 14 × 2 + 프리셋 1)로 잡은 근거는 비용이 아니라 **429** — 판정이 분당 약 2건만 통과한다(실측). 429는 `PROCESSING` 잔류 + `PROCESSING_EXPIRY_SEC` 600초 때문에 Context 하나를 10분 동안 처리 불가로 만들므로, 시딩이 `PENDING`으로 되돌려 회수하는 루프를 넣었다(로컬에 없는 재스캔 대행). `--reset` 2회 완주로 재현 확인, 시연 3종(검색 4질의 전원 1위 · 피드 8장 소유자 4명 · Keyword 32행) 전부 통과. `tools/e2e/` 4종 중 2종 정상(검색 수치가 I21과 소수점 넷째 자리까지 동일 — **임베딩은 결정적**), `run_equivalence`는 429로 중단(회귀 아님). **back 소관 발견 2건**(탐색 슬롯이 최고점 후보를 흡수해 팔로우 소유자가 하단 배치 · Record 상세 `keywords` 고정 빈 배열)은 back 코드를 고치지 않고 계약 질문으로 올린다 (Jira 작업) | [데모 시딩 리포트](implements/2026-07-29-demo-seeding.md), [tools/demo_seed/](../tools/demo_seed/) | +| 2026-07-29 | 설정값을 비밀/공개로 가르고 **공개 값의 정본을 코드로 옮겼다**(P45). Embedding Profile 넷만 기본값이 없어 배포 설정에만 존재했고, 그 결과 **Profile 교체가 git 이력도 리뷰도 남기지 않았다** — 기존 임베딩을 전부 조회 대상에서 빼는 결정인데 콘솔 편집 한 번으로 가능했다. 값 자체는 공용 계약 `05 §7.1` 표에 공개돼 있어 숨겨진 적도 없다. 주입을 **덮어쓰기**로 낮추면 원 논거(*"배포 설정 누락이 조용한 불일치가 된다"*)의 전제인 '누락'이 사라지고, 불일치 탐지는 기동 시 `_profile_consistency` 와 런타임 §3.1 이 그대로 맡는다. `config/*.yaml` 계층 도입은 설정 소스가 셋이 되어 기각했다. **공용 계약 개정(docs#27)이 선행이다** — 처음에 하위 명세만 고쳤다가 `CONTRIBUTING` 의 *"공용 계약과 충돌하면 소유 파트와 합의한 뒤 양쪽 문서를 갱신"* 을 어긴 것을 발견하고 되돌렸다. 봉인 대상도 7종 → 3종으로 줄었다(정본이 이미지에 있으면 주입할 것이 없다) (Jira 작업) | [P45](proposals/P45-public-config-in-code.md), [model-profile](spec/model-profile.md), docs#27 | +| 2026-07-29 | 테스트 pgvector 를 운영·back 실제 버전에 digest 까지 정합화했다. `conftest.py` 가 `0.8.1-pg16` 인 채로 남아 **테스트가 운영과 다른 DB 에서 돌고 있었다** — back#31 이 `compose.yaml` 을 `0.8.5-pg16@sha256:1d53…` 로 올렸고 운영 pgvector 도 이미 0.8.5(infra#41)였으므로 어긋난 쪽은 ai 였다. digest 를 `back/compose.yaml` 현행에서 재확인해 티켓 본문 값(2026-07-28 관측)과 동일함을 확인하고 고정했다. **태그만 올리지 않은 이유**는 롤링·재태깅 시 같은 태그가 다른 이미지를 가리키기 때문이다. 실측으로 `0.8.1-pg16` → pgvector ext 0.8.1·PostgreSQL 16.12, `0.8.5-pg16@digest` → 0.8.5·16.14 로 **두 층이 함께 움직였음**을 확인했고 69 테스트 전부 통과해 동작 변화는 드러나지 않았다. 불일치 경고가 남아 있던 `tests/README`·implements 근거·P41·P43·s1-recovery 미결 행도 실제와 맞췄다. **CI 러너에서 digest pull 이 network policy 상 통과하는지는 로컬에서 확인 불가 — PR CI 가 첫 검증이다** (Jira 작업) | [tests/README](../tests/README.md), [implements](implements/2026-07-24-e3-test-harness.md), [P43](proposals/P43-s1-judgment-recovery.md) | +| 2026-07-30 | infra 공용 action pin 을 병합 후 정본으로 옮기고, 보존 규칙 위반을 복원하고, `dev` 2단 브랜치를 준비했다. **pin `84458bf3` 은 `infra#82` 병합 전 브랜치 커밋이라 `infra` `main` 과 diverged 였다**(ahead 6·behind 6, `action.yml` 내용도 달랐다) — `16bfae0d` 로 옮기기 전에 대상 SHA 의 입력 계약(`policy`·`revision`)과 출력이 byte 단위로 같은지 직접 대조했고, 유일한 차이는 back OAuth `CLIENT_ID` 3종의 `unset` 방어 추가였다. workflow 와 contract test 의 `ACTION` 상수는 문자열이 정확히 일치해야 step 을 찾으므로 **한쪽만 바꾸면 `StopIteration` 으로 깨진다**(실측 RED 2건). 또 `ai#39` 가 handoff 리포트를 86줄→42줄로 덮어써 `implements` 보존 원칙(*"삭제하지 않고 상태 표시만 갱신"*)을 위반한 것을 복원했다 — **현재 내용을 지우지 않고** 상태 노트를 상단에, 인프라와 합의한 설계 근거 4건(`--raw` 선택·중간 평문 YAML 회피·strict scope·기동 검증)을 하단 보존 절로 얹었다(+101/−1, 유일한 삭제는 갱신된 SHA 줄). `dev` 는 트리거만 넓히고 **image publish 는 `main` 전용으로 남겼다** — `infra` 의 `ai-image-update.yaml` 이 source branch 를 `main` 으로 어서션하므로 넓히면 GitOps 반영이 거부된다 (Jira 작업) | [handoff 리포트](implements/2026-07-29-sealed-secret-handoff.md), [CONTRIBUTING](../CONTRIBUTING.md), [ai#40](https://github.com/Team-PinLog/ai/pull/40) | +| 2026-07-30 | BD-39 가 *"두 값이 같다"* 로 명시한 명제를 사람의 눈에서 CI 로 옮겼다. Embedding Profile 정본을 `back` 의 `application.yml` 리터럴로 두는 (a)안은 그대로 두고 **대조 장치만** 붙인다 — `back#98` 리뷰가 "그 명제를 지키는 장치가 현재 사람의 눈뿐"이라고 지적한 것에 대한 값싼 응답이다. 두 레포가 모두 public 이라 raw endpoint 를 **무인증**으로 읽는다. 토큰을 쓰지 않은 것은 편의가 아니라 경계다 — 주면 `ai` CI 가 타 레포 접근 권한을 상시로 들고 다닌다(대신 `back` 이 private 이 되면 조회 실패로 exit 1 이며 조용히 통과하지 않는다). **런타임 값이 아니라 선언된 리터럴을 비교한다** — 양쪽 다 환경변수 덮어쓰기를 허용하므로 프로세스 환경이 결과를 바꾸면 CI 가 무엇을 검증하는지 알 수 없게 된다. `ai` 쪽은 `Settings()` 를 인스턴스화하지 않고 필드 선언만 읽는다(인스턴스화는 환경변수와 profile 정합 검증을 함께 돌린다). 조회 실패도 exit 1 로 두었다. 실제 두 값이 같은 동안 이 잡은 늘 통과라 대조기의 탐지 능력 자체는 검증되지 않은 채 남으므로, 네트워크를 타지 않는 14 케이스로 그것을 못박고 **CI 에서 실제 RED 를 관측했다**(run `30507610160` — 리터럴을 `v1`→`v2` 로만 어긋내 `config.py` 의 profile 정합 검증은 통과시킨 채 새 잡만 실패하게 만들었다). 리뷰가 함께 제안한 **"기동 시 FastAPI 조회 대조"는 채택하지 않았다** — `app/api/probe.py` 가 *"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 로 비노출을 명시하므로 Profile 노출 엔드포인트 신설은 그 결정의 개정이 되고, 이 티켓 범위 밖이다. BD-39 문서 자체도 개정하지 않았다. `ai#40` 줄 누락도 함께 메웠다 (Jira 작업) | [ai#40](https://github.com/Team-PinLog/ai/pull/40), [back#98 리뷰](https://github.com/Team-PinLog/back/pull/98), [probe.py](../app/api/probe.py) | +| 2026-07-30 | 두 클라이언트가 HTTP 상태 코드를 **정반대로** 분류하던 것을 명세에 맞췄다. `embedding` 은 `>= 500` 만 Transient 로 보아 **`429` 한 번에 Context 가 영구 실패**했고(`failure-recovery` §2.1 은 Transient), `llm` 은 **모든** non-200 을 Transient 로 보아 **인증 실패가 재스캔 주기 5분마다 AI API 호출을 만들었다**(§2.2 는 `400`·`401`·`403` 을 Permanent). 원인은 두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이라, 매핑을 `errors.py` 의 `classify_http_status` 한 곳으로 모았다. §2 가 *"`errors.py` 에서 분류한다"* 로 지목한 파일이다. `429` 를 `>= 500` **보다 먼저** 판정해야 4xx 로 떨어지지 않으므로 그 순서를 회귀 테스트로 못박았다. 재시도(§3.1, 총 3회·`0.5→1.0s`·상한 `4.0s`·full jitter)는 `client/retry.py` 에 넣고 **재시도 대상을 `TransientError` 한 종류로 뒀다** — §3.1 의 대상 목록이 §2.1 의 Transient 집합과 같으므로 재시도용 상태 코드 표를 두 번째로 만들지 않았다. **상태 코드 표가 두 곳에 있으면 서로 어긋나게 되고, 어긋난 결과가 위 두 오분류였다.** 백오프 값을 `Settings` 로 열지 않은 것은 §3.2 의 상한(*"두 호출의 타임아웃 합 + 재시도 시간 < PROCESSING 만료 600s"*)에 묶인 값이기 때문이다 — env 로 열면 그 상한이 배포마다 달라진다. 현재 최악값 `3 × (60 + 90) + 2 × 1.5 = 453s` 이고 **부등식 자체를 테스트가 지킨다**. 대신 `RetryPolicy` 를 생성자 인자로 받아 테스트가 `sleep`·`jitter` 를 주입한다(실제로 잠드는 테스트를 만들지 않는다). 구조화 출력 위반은 `SchemaViolationError(TransientError)` 로 두어 재시도 중엔 Transient 로 동작하고 소진 시 `judge` 가 `PermanentError` 로 **승격**한다 — LLM 출력은 비결정론적이라 재요청에 성공 여지가 있고, 승격을 client 안에서 끝내야 service 가 보는 분류가 §2 의 두 종류로 유지된다(하위 타입인 채 새면 `except TransientError` 가 먼저 잡아 무한 재판정이 되므로 그것도 테스트로 막았다). Embedding 응답 형식 위반은 프로바이더가 같은 형식으로 답하므로 재시도 없이 즉시 Permanent 다. **결함의 근본 원인은 테스트였다** — `fakes.py` 의 `raise_exc` 가 어느 테스트에서도 쓰이지 않아(grep 0건) Transient/Permanent 파이프라인 경로가 한 번도 실행된 적이 없었다. 그 경로를 실제로 주입하자 **티켓에 없던 결함이 하나 더 드러났다**: `keyword_service` 에 `PermanentError` 핸들러가 아예 없어(`context_processing` 광범위 except 없음, `context.py` 는 `BackgroundTasks.add_task`) `llm_client` 만 고치면 401 이 **BackgroundTasks 까지 새어 트레이스백만 남기고 단계는 PROCESSING 에 머문다** — 고치려던 무한 재시도가 경로만 바꿔 남는 구조였다(되돌려 RED 확인). `embedding_service` 는 이 결선을 이미 갖고 있어 비대칭이던 쪽만 맞췄고 **상태 전이 규칙은 바꾸지 않았다**. 로그 레벨도 §2.1 WARN·§2.2 ERROR 로 맞췄다(둘 다 INFO/WARNING 이어서 일시 장애가 묻히고 배포 설정 문제가 알림에 오르지 않았다). client HTTP 계층 테스트는 신설했고 이는 `integration-tests` §4.2(*"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"*)와 **충돌이 아니다** — §4.2 는 *파이프라인이 client 를 무엇으로 대체하는가* 의 규칙이고, 인터페이스 Fake 는 client 를 통째로 대체하므로 `_embed_batch` 가 429 를 어떻게 분류하는지 볼 수 없다. **`docs/spec/` 은 고치지 않았다**(§4.2 의 계층 구분 명문화는 후속 별건). `132 passed`(착수 baseline 74), Docker 29.6.1 Testcontainers 전량. **Circuit Breaker 와 타임아웃 60/90 재산정은 티켓 제외 범위** — 전자는 인증 실패 같은 전 서비스 영향 오류가 개별 Context FAILED 로 누적되는 문제를 남기고, 후자는 재시도 도입으로 최악값이 150s → 453s 가 된 것과 함께 별건이다 (Jira 작업) | [구현 리포트](implements/2026-07-30-retry-and-error-classification.md), [spec/failure-recovery.md](spec/failure-recovery.md), [tests/README](../tests/README.md), [ai#44](https://github.com/Team-PinLog/ai/pull/44) | +| 2026-07-30 | `dev` 전환의 마지막 조각 — `CONTRIBUTING.md` 가 2단 구성으로 개정된 뒤 `main` 을 가리킨 채 남은 문서 6곳을 정합시켰다(`docs/development/workflow.md` 4·8·30·33·53행, `P44` 52행). **`image publish` 의 `main` 기준 서술은 유지했다** — `infra` 의 `ai-image-update.yaml` 이 `test "$SOURCE_BRANCH" = main` 으로 어서션하므로 이것까지 `dev` 로 바꾸면 GitOps 반영이 끊긴다. `workflow.md` 는 이제 "`dev` 에 병합하는 실행 순서" 이므로, 그 문서만 읽는 사람이 `main` 이 언제 무엇을 받는지 모르게 되는 것을 막기 위해 §5 에 릴리스 병합(`dev`→`main`)과 publish 가 거기서 일어난다는 서술을 한 줄 넣었다. `P44` 는 `상태: Accepted` 인 결정 문서라 표 값만 조용히 바꾸지 않고, 작성 시점(2026-07-28)에는 `main` 단일 구성이었고 2단 전환이 `-154`·`-158` 에서 이뤄졌다는 각주를 함께 남겼다 — `implements/` 와 달리 `proposals/` 에 삭제 금지 원칙은 없지만, Accepted 결정문을 이력 없이 고치면 무엇이 언제 정해졌는지가 사라진다. **`ai-ci / check` 와 `ai-ci / embedding profile parity` 둘 다 required 로 확인**했으나(`main`·`dev` 동일) `CONTRIBUTING.md` 병합 조건 절은 여전히 `ai-ci / check` 하나만 적고 있다 — 정본 재개정은 이 티켓 범위 밖이라 문서를 정본에 맞춰 두고 갭만 올린다 (Jira 작업) | [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md), [CONTRIBUTING](../CONTRIBUTING.md) | +| 2026-07-30 | required status checks 가 둘인데 문서가 하나만 적던 것을 정정했다. `ai#42` 병합 후 `main`·`dev` 양쪽 protection 에 `ai-ci / embedding profile parity` 가 추가됐으나 정본이 갱신되지 않아, **낡은 값이 `CONTRIBUTING.md`→`workflow.md`→`P44` 로 퍼졌다** — 직전 작업(위 줄)에서 갭을 관측했지만 정본 재개정이 범위 밖이라 하위 문서를 낡은 정본에 맞춰 둔 상태였다. 이번에 **정본을 먼저 고치고 하위를 거기에 맞췄다.** 실제 설정을 API 로 직접 대조한 결과 `main`·`dev` 가 **완전히 동일**했다(strict·검사 둘·미해결 대화 차단·관리자 포함·승인 0). 그래서 검사 목록만 고치지 않고 **`관리자 포함 보호` 를 `main` 전용으로 적던 서술도 함께 정정**했다 — 이것도 틀린 값이었고, 원인은 같은 조건을 두 bullet 에 중복 기재해 한쪽만 낡을 수 있는 구조였다. 조건은 공통으로 한 번만 적고 bullet 은 "무엇을 병합하는가" 차이만 남겼다. `P44` 의 *"문서와 실제 GitHub 설정 불일치"* 완화가 **실제로 실패한 사례**이므로("API 로 재조회" 는 설정을 읽는 것까지만 다루고 문서를 고치는 주체를 정하지 않았다) 완화 자체를 "설정을 바꾼 사람이 같은 작업에서 정본을 갱신한다" 로 강화하고 발생 사실을 각주로 남겼다. 정본에는 검사 이름이 protection 과 문자열까지 일치해야 하며 개편 시 이 절을 먼저 고친다는 순서 규칙을 박아 두었다. 위 줄은 그 시점의 사실 기록이라 고치지 않고 새 줄을 더했다 — WORKLOG 가 `merge=union` 이라 기존 줄 수정은 중복 위험이 있다(직전 작업에서 문서화한 주의의 첫 적용) (Jira 작업) | [CONTRIBUTING](../CONTRIBUTING.md), [workflow](development/workflow.md), [P44](proposals/P44-ai-repository-governance.md) | +| 2026-07-30 | `app` coverage 를 비차단 측정에서 **병합 차단 게이트**로 전환했다. **티켓 기준선(07-28, 52 tests · line 76.95% · branch 62.5%)이 낡아 착수 시 재측정했다** — 그 사이 `-121` 이 58 테스트를 더해 `b45aa93` 시점 실측은 146 tests · line 88.80% · branch 82.08% 였고, 티켓이 지목한 미검증 4곳 중 **둘은 이미 `-121` 이 덮은 뒤**였다. 두 지표 모두 이미 80% 를 넘겨 게이트만 켜도 통과했지만 그러지 않았다 — **branch 여유가 2 포인트뿐**이라 다음 기능 PR 이 분기를 몇 개 추가하는 것만으로 무관한 이유로 실패한다. 남은 공백(`load_presets.py` **0%**, `main.py` 58%, 재측정으로 드러난 `gms_roundtrip.py` 76%)을 계약·실패 경로로 메워 **181 tests · line 99.74% · branch 98.11%** 로 올렸다. **`--cov-fail-under` 를 쓰지 않았다** — `--cov-branch` 를 켜면 그 값은 statement 와 branch 를 **합산한 하나의 비율**이라(기준선에서 합산 88% · branch 82%) statement 759 개가 branch 106 개를 압도해 **branch 가 60% 대로 떨어져도 통과한다**. 완료 조건이 "각각" 인 이상 합산 게이트는 조건을 검사하지 못하므로 `tools/check_coverage_gate.py` 가 둘을 나눠 판정한다. 임계값은 스크립트 상수이며 **CLI 로 덮을 수 없다**(덮을 수 있으면 CI 가 조용히 낮은 값을 넘겨 무력화할 수 있다). `--cov-branch` 없는 리포트는 branch 키 자체가 없어(실측) 분기 검사가 공짜로 통과하므로 **판정 불가로 끊는다** — "측정하지 못했다" 는 통과가 아니다. **통과만 확인하면 아무것도 강제하지 않는 게이트를 놓치므로 RED 4 종을 실측했다**: 테스트 제거(line 42.82%·branch 34.91%), 임계값 99.9 상향(되돌림), `--cov-branch` 누락, 리포트 부재 — 전부 exit 1. 임계값 하향을 막는 테스트도 함께 실패하는 것을 확인했다. 신설한 두 계층은 **요청 경로 밖이라 파이프라인·API 테스트로는 한 줄도 실행되지 않던 곳**이다 — `test_api.py` 가 lifespan 을 우회하고 `app.state` 에 Fake 를 직접 꽂기 때문이다. lifespan 테스트는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake 로 바꾸지 않았다(생성자는 IO 를 하지 않아 실호출 금지와 충돌하지 않고, Fake 로 바꾸면 그 단언이 사라진다). `if __name__ == "__main__"` 아래는 `runpy` 로 검증하는데, **새 네임스페이스라 캐시된 모듈 패치가 보이지 않아** 원본 클라이언트 모듈의 속성을 갈아 끼워야 한다(함정을 `tests/README` 에 기록). **제외로 수치를 맞추지 않았다** — `pragma`·`omit` 없이 2 line · 2 branch 를 미달로 남겼다. 둘 다 `FOR UPDATE` 로 잠근 직후의 재검사라 같은 트랜잭션에서 도달 불가지만, **도달 불가가 구조에 딸린 성질**이라 저장 로직이 잠금 밖으로 나가면 도달 가능해진다 — `pragma` 를 붙이면 그때 아무도 모르고, 미달로 두면 리포트가 계속 그 줄을 가리킨다(여유 18 포인트라 비용이 없다). 함께 `integration-tests.md` §4.2 에 **계층 구분을 명문화**했다(`-121` 후속) — §4.2 가 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake"* 라고만 적어 client 단위 테스트의 HTTP mock 이 위반인지가 `-121` 중 **두 번** 질문으로 올라왔고, 판정은 충돌이 아니라 **층이 다름**이었다. 경계를 한 문장으로 고정했다: **`app/client/` 밖의 코드는 HTTP 를 몰라야 하고 그 코드를 검증하는 테스트도 HTTP 를 몰라야 한다.** `-121` 이 `docs/spec/` 을 못 고친 것은 그 작업이 *구현을 계약에 맞추는 것*이었기 때문이고, 이번은 계약 자체의 공백을 메우는 것이라 허용됐다 (Jira 작업) | [게이트 리포트](implements/2026-07-30-coverage-gate.md), [spec/integration-tests.md](spec/integration-tests.md), [CONTRIBUTING](../CONTRIBUTING.md), [tests/README](../tests/README.md) | +| 2026-07-30 | 가공 데모 14건에 **실사용자 기록 23건**을 더해 37건으로 전체 통합을 검증했다. 검색 1위 일치 10/12(가공 4/4·실데이터 6/8), 피드·Keyword PASS. 시딩 15분 8초·회수 0회로 `--pace 25`가 AI API 쿼터 안에 들어감을 재확인했다. 실패 2건의 원인이 갈렸다 — 하나는 축약어(「피맥」↔「피자에 맥주」), 하나는 **임베딩 입력이 Context 본문뿐이라 장소명이 검색에 안 걸리는 것**으로 후자는 설계 관측이다. 응답에 실려 오던 AI API 토큰을 두 클라이언트가 전부 버리고 있어 `PINLOG_TOKEN_LOG` 게이트 계측을 신설했고, 판정이 임베딩의 17배(839.6 대 37.7)를 쓰며 그 대부분이 후보 Preset 27개를 매번 싣는 prompt 임을 실측했다 (Jira 작업) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27·T28](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | +| 2026-07-30 | **시딩이 느렸던 것은 AI API 가 아니라 우리 `--pace 25` 였다.** 재측정에서 같은 코드가 분당 30건 이상을 통과했고(간격 1s 15/15 · 동시 10건 1.7초), 같은 데이터 37건이 `--pace 1` 로 **42초**에 완주했다 — 15분 8초에서 21.6배. 결과와 토큰은 같았고 검색 정확도도 2회차 10/12 로 재현됐다. `T27` 이 07-29 관측을 상수로 적은 것을 시점·경로 의존으로 정정하고 기본값을 1 로 낮췄다. 벤더 비교에서 **429 가 Gemini 경로에만 나는 것**을 확인해(OpenAI·Anthropic 12/12) 폴백을 `-175` 로 발주했다. 함께 보고서 두 오류를 고쳤다 — 판정 후보는 27개가 아니라 top-10 이고(프리셋 총수와 혼동), 데이터가 원본·계약 자리채움·시연 구성 세 층인데 그 구분 없이 「실데이터」로 뭉뚱그렸다 (Jira 작업) | [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) · [T27 정정](troubleshooting/2026-07-30-seeding-quota-and-encoding.md) | +| 2026-07-31 | 임베딩 `502` 가 검색 `500` 이 되던 운영 버그(`ai#69`)를 고쳤다. **분류도 재시도도 이미 맞았다** — `-121` 이 `classify_http_status` 로 `502→TransientError` 를 확정했고 `test_client_retry.py` 가 상태 코드별로 단언한다. 비어 있던 것은 **그 예외가 응답이 되는 지점**이었고, `app/main.py` 의 핸들러는 `ProfileMismatchError`(422) 하나뿐이라 분류된 나머지가 uvicorn 까지 올라가 500 이 됐다. 클라이언트 테스트는 예외가 던져지는 것까지 보고 API 테스트는 Fake 를 꽂아 성공 형식만 봐서, **두 파일 사이에 계층 하나가 통째로 비어 있는데 각자는 자기 범위에서 통과하고 있었다.** `TransientError→503` · `PermanentError→502` 로 갈랐고, `502` 를 고른 근거는 **500 을 비워 두는 것**이다 — 분류된 실패까지 500 이면 "AI 가 깨졌나 게이트웨이가 깨졌나" 를 가르려던 목적이 절반만 남는다. 티켓이 열어 둔 *"back 이 재시도해도 소용없다는 신호"* 논거는 **쓰지 않았다**: `AiSearchClient` 는 애초에 검색을 재시도하지 않는다(사용자 요청 경로). **`back` 이 500 과 503 을 구분하지 않는다는 사실을 확인했다** — `translate()` 가 `422`+`serverProfile` 과 `401`/`403` 외 **모든** 상태를 `AiSearchException.unavailable()`(→`503 SEARCH_UNAVAILABLE`)로 묶으므로 사용자 화면은 이미 같고, **이 변경이 바꾸는 것은 화면이 아니라 관측**이다. `ai#69` 두 번째 답변의 *"AI 가 503 을 주면 back 이 전달한다"* 는 맞지만 500 도 이미 그렇게 전달되고 있다는 사실이 빠져 있었다. 그래서 `back` 변경은 하지 않고 별건으로 남긴다. 응답 본문은 **고정 문구 한 줄**이다 — `back` 이 본문을 읽지 않고(파싱하는 것은 `serverProfile` 뿐), 업스트림 상태 코드는 `-197` 계측이 이미 남기며, 예외 메시지를 그대로 실으면 게이트웨이 응답 200자(`resp.text[:200]`)가 응답으로 새기 때문이다. **핸들러 부재는 500 만 낸 게 아니었다** — 대조군 로그에서 예외가 uvicorn 까지 올라가며 트레이스백에 업스트림 본문이 그대로 찍히는 것을 봤고(스텁 401 의 키 힌트가 노출됐다), `probe.py`·§2.4 원칙 4 위반이라 핸들러가 그 누출도 막는다. 검증은 **핸들러를 넣기 전에 RED 14 건**을 확인했고(실패 형태가 「500 이 나왔다」가 아니라 **예외가 응답 계층으로 샜다** 였다 — 운영에서 uvicorn 이 500 을 만드는 바로 그 지점), 넣은 뒤 18 건 GREEN · 전체 309 건 통과다. 테스트만으로는 「핸들러를 넣었다」에 가까워 **로컬 AI API 스텁으로 실제 502 를 만들었다**: 같은 스텁에 물린 uvicorn 두 대에서 `origin/dev`(a5e1142)는 `502`·`401` 둘 다 **HTTP 500**, 이 브랜치는 **503**·**502** 였고 정상 응답은 200 으로 실데이터 5건을 돌려줬다. 계약 테스트는 **Fake client 를 쓰지 않는다** — `raise_exc` 주입은 분류 경로를 건너뛰어 `classify_http_status` 가 바뀌어도 통과하며, 그 모양이 `ai#69` 를 놓친 구멍이다. 명세도 함께 메웠다(`failure-recovery.md` §2.5 · `personal-search.md` §6.2) — **§2.1·§2.2 는 Context 처리 경로의 상태 반영만 정하고 API 층 변환을 말하지 않아서**, 코드만 고치면 같은 공백이 남는다. **DB 실패는 여전히 500 이다** — §2.1 이 DB 연결 실패를 일시 오류로 두지만 `SearchService` 가 asyncpg 예외를 분류하지 않는다. 범위 밖으로 남기고 후속 후보로 올린다 (Jira 작업) | [오류 응답 계약](implements/2026-07-31-search-error-contract.md), [failure-recovery §2.5](spec/failure-recovery.md), [personal-search §6.2](spec/personal-search.md), [T43~T45](troubleshooting/2026-07-31-error-contract-pitfalls.md), [ai#69](https://github.com/Team-PinLog/ai/issues/69) | +| 2026-07-31 | 임베딩 개선 축 둘을 교차시켜 **넷을 실경로로** 쟀다(입력에 장소명 결합 × `small`/`large`). 1위 일치는 A 10 · B 9 · C 10 · D 10 / 12 이고 top-3 는 넷 다 12/12 다 — **합계로는 어느 조건도 기준선을 넘지 못한다.** 그런데 합계가 같은 A·C·D 가 **서로 다른 질의에서 실패한다**: 8번(공원)은 `large` 가 고치고(C·D) 장소명 결합과 무관하며, 7번(축약어 「피맥」)은 **둘을 겹쳤을 때만**(D) 통과하고, 1번은 D 에서만 깨지고, 6번은 A 에서 `+0.0087` 차이로 통과해 우연에 가깝다. **`-174` 가 8번의 원인으로 지목한 「장소명이 임베딩에 없다」는 진단이 틀렸다** — 맞았다면 입력에 장소명을 넣은 B 가 고쳤어야 하는데 B 는 못 고쳤고 장소명을 넣지 않은 C 가 고쳤다. 원인은 어휘 부재가 아니라 `small` 이 「그네팟」·「산책하면서 머물다」를 「공원」과 잇지 못한 것으로 보인다. 장소명 결합은 **이득과 손해가 함께 있다.** 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다(1번 0.5021→0.3344). **비용 축은 저장 하나뿐**이다: 벡터가 정확히 2배(6,148→12,292 B)이고 **토큰은 조건 간 차이가 없다**(임베딩 토큰은 입력 텍스트가 정하므로 모델 크기와 무관), 시딩 소요도 42.5~44.2초로 AI API 왕복이 지배한다. `vector(3072)` 이 동작하는 것은 **벡터 인덱스가 없기 때문**이며(37행 순차 스캔) 색인이 필요해지면 pgvector 의 2000차원 상한이 되살아난다. **채택 판단은 하지 않는다** — 12건 표본의 1건 차이이고 세 조건이 동률이며 기대값을 우리가 작성했다(`-174` §3.3). 측정 전제로 두 가지를 고쳤다: `seed.py` 가 `social_account.email` 을 채우지 않아 back `V6__social_account_email_not_null.sql` 에 걸려 **back 이 기동조차 못 하던 것**(시연 도구가 현행 스키마와 어긋나 있었다. `{key}@demo-seed.invalid` — RFC 2606 예약 TLD 라 실재 주소로 오인되지 않는다)과, `--reset` 이 `demo-seed` 소유만 지워 남은 **고아 임베딩 8건**이 저장 비용 평균을 오염시키던 것(하네스가 조건 profile 로 한정해 세게 했다). **로컬 DB 는 복구했다** — `alter_dim.py --to 1536` · 프리셋 실배포 profile 재적재 · 데모 37건 재시딩까지 실행해 `grid-*` 행이 하나도 남지 않은 것을 확인했다. **Flyway 마이그레이션은 만들지 않았다**(측정과 채택은 별건) (Jira 작업) | [4조건 측정](implements/2026-07-31-embedding-grid.md), [tools/emb_grid/](../tools/emb_grid/), [실데이터 E2E](implements/2026-07-30-real-data-e2e.md) | +| 2026-07-31 | 시연 도구 결함 3건을 **값이 아니라 「조용함」** 쪽에서 고쳤다. 셋 다 `-191` 이 값은 이미 고쳤고(`email` 채움·고아 8행 삭제) 여기서 만든 것은 재발 경로다. `tools/demo_seed/preflight.py` 가 **`--reset` 보다 먼저** 돌고 걸리면 아무것도 지우지 않은 채 exit 2 로 끝난다 — 결함 3 과 `T28` 이 **둘 다 reset 이 지운 뒤에 실패해** 데이터를 잃었기 때문에 순서가 방어의 절반이다. **결함 1 의 방어 대상을 「NOT NULL 제약」이 아니라 「우리가 값을 주지 않는 컬럼」으로 잡았다** — `email` 은 `V4` 에서 nullable 로 태어났고 그때는 아무 오류도 나지 않으며 `V6` 가 제약을 걸 때는 이미 늦다. `WRITE_CONTRACT` 가 시딩이 직접 INSERT 하는 두 테이블의 **모든 컬럼**을 `SEED`/`DB`/`NULL` 로 선언하고 미선언 컬럼이 나타나면 멈춘다. 이 방향이면 back 이 언제 무엇을 걸지 알 필요가 없어 **back 이 우리 CI 가 아니라는 현실에서 작동한다**. 티켓 후보 중 「기동 스모크」는 넣지 않았다(그 시점엔 DB 가 이미 오염돼 예방이 아니라 관측이다), 「트랜잭션 경계」도 아니다(INSERT 는 정상 커밋됐고 며칠 뒤 다른 프로세스가 죽었다). **결함 2 는 「지우지 않는다」로 판단했다** — `tools/e2e/` 검증 데이터는 **의도적으로** `core` 대응 행이 없어(README 명시) 고아 정의에 전부 걸리므로 자동 삭제는 남의 하네스를 깨뜨린다. 세어서 보여주고 `--prune-orphans` 로 사람이 정한다. 다만 `reset()` 이 `ai.context_keyword_analysis` 를 지우지 않던 것은 논쟁이 아니라 **누락**이라 그냥 고쳤다(**259행 중 222행이 고아**였고 FK 가 없어 DB 도 알려주지 않았다). 목록을 `ORPHAN_TABLES` 하나로 묶어 **빠뜨리면 집계가 먼저 고아로 보고**하게 했다. **결함 3 은 묶는 것과 드러내는 것을 둘 다** — `shared_root()` 가 `--git-common-dir` 로 키 경로를 메인 워킹트리에 고정하고, 인증 프로브가 back 이 그 키를 실제로 받아들이는지 한 번 통과시켜 본다. 경로 고정은 흔한 원인 하나만 없애고 back 에 주입된 키가 다르면 여전히 401 이라 **프로브가 본체**다. **셋 다 일부러 어긋내 RED 를 봤다**(`-156` 과 같은 이유): 판정 함수에 `return []` 뮤턴트를 넣어 5 케이스 RED · 별도 `guardprobe` DB 에 back 마이그레이션 9 개를 적용하고 `ADD COLUMN nickname` 으로 **nullable 추가 시점에 걸리는 것**을 실측 · 키를 어긋내 `HTTP 401` BLOCK · `:5433` 에서 테이블 없음 BLOCK. **실 DB 실행이 오탐 하나를 잡았다** — `GENERATED ALWAYS AS IDENTITY` 는 `column_default` 가 NULL 이라 `id` 가 "NOT NULL 인데 기본값 없음" 으로 걸렸고 단위 테스트는 그 쿼리를 타지 않아 못 봤다(`is_identity`·`is_generated` 포함으로 수정). 조사 중 **로컬 pgvector 가 둘이고 `.env` 의 `DATABASE_URL` 이 시연 정본(`:15432`)이 아니라 07-27 잔재(`:5433`)를 가리키는 것**을 발견해 preflight 첫 줄에 접속 대상을 찍게 했다 — 기본값 자체는 `-174` 절차·타 세션 습관과 얽혀 이 티켓에서 바꾸지 않았다. back 은 §7 절차로 실기동했고 **첫 기동은 로컬 jar 이 07-30 빌드라 V6 를 담지 않아 Flyway 가 거부**해서 `bootJar` 리빌드 후 정상 기동했다. `254 passed` · `ruff check .` 통과 (Jira 작업) | [구현 리포트](implements/2026-07-31-seed-guard.md), [tools/demo_seed/](../tools/demo_seed/), [임베딩 4조건](implements/2026-07-31-embedding-grid.md) | +| 2026-07-31 | `dev` 의 세 gate 가 열렸는데 **무엇이 실패하는지 볼 수단이 없던 것**을 메웠다(`-197` 감사 §4-c 의 관측 3종). AI API 호출 1회를 감싸 벤더·모델·상태 코드·결과 분류·소요 시간을 남기고, 60초 창 집계를 한 줄로 낸다. **`_usage.py` 와 합치지 않았다** — 그쪽은 `PINLOG_TOKEN_LOG` 게이트가 있고 **200 응답을 파싱한 뒤**에 불려 실패한 호출을 한 줄도 남기지 않는다. 실패율의 분자가 애초에 거기 없고 dev 배포에는 그 env 가 없어 분모도 없다. 로그로 합치면 반대로 토큰 JSONL 이 실패 행과 섞여 `-174` 집계가 깨진다. **재선점은 쿼리를 갈랐다** — `try_start` 가 신규 시작과 만료 재선점에 같은 rowcount `1` 을 주는데, 호출부가 미리 읽은 값으로 가르면 keyword 단계는 근거가 아예 없고(`try_start` 전에 상태를 안 읽는다) embedding 단계는 경합 창 때문에 거짓 양성이 섞인다. 재선점은 드물지만 「앞선 처리가 600s 안에 못 끝났다」는 신호라 거짓 양성이 섞이면 신호를 못 믿는다. CTE 로 UPDATE 직전 상태를 같은 문장에서 읽어 원자성을 지켰다(추가 조회가 아니므로 state-machine §3.2 와 충돌하지 않는다 — 그 규칙은 `0` 의 이유를 묻지 말라는 것이고 이쪽은 `1` 이 무엇인지를 묻는다). 레벨은 **성공 DEBUG · 실패 WARNING · 창 집계 INFO · 재선점 WARNING** 이다 — 성공까지 INFO 면 정상 트래픽이 실패 행을 덮고, 성공을 안 세면 분모가 없다. 집계는 타이머가 아니라 다음 호출이 밀어내므로 유휴 시 조용하고, 마지막 창은 lifespan `finally` 의 `flush()` 가 낸다. **계측을 붙이다 httpx 가 요청마다 INFO 로 전체 URL 을 남기는 것을 발견했다** — `probe.py` 의 endpoint 미노출 기준을 어기면서 성공 호출까지 INFO 라 새 설계를 무의미하게 만들고 있었다. **caplog 를 로거 이름으로 걸러 읽는 테스트가 이것을 통과시켰고**, 실제 실행 출력을 눈으로 보고서야 드러났다(자기 로거만 보면 옆에서 새는 것을 못 본다). `configure_logging` 이 httpx 를 WARNING 으로 올리고, 로거를 가리지 않고 전부 읽는 테스트를 따로 뒀다. 값 노출 기준은 **벤더·모델은 싣고**(공개 설정 P45, 어느 경로가 막혔는가가 곧 원인) credential·endpoint·요청/응답 본문은 안 싣는다 — 요청 본문에는 사용자 Context 원문이 있다. 전송 실패에서 예외 메시지 대신 타입 이름을 쓰는 것도 httpx 가 메시지에 URL 을 넣기 때문이다. `/metrics` 는 만들지 않았다(`infra ai-serving.md` 검증 순서 7 이 prod 승격 전 별도 승인으로 이관). 기존 `resp.text[:200]` 노출은 진단에 필요해 남기고 별도 판단 항목으로 올린다 (Jira 작업) | [AI API 호출 관측](implements/2026-07-31-gms-call-observability.md), [failure-recovery §2.4](spec/failure-recovery.md), [state-machine §3.2](spec/state-machine.md) | +| 2026-07-31 | 개인 검색에 **두 컷**(`τ_abs=0.30` 절대 하한 · `r=0.60` 1위 대비 상대 하한)을 넣고 `limit` 기본값을 공용 계약 08 §6.1 의 `size` 기본값과 같은 20 으로 맞췄다(back 이 `sizeOrDefault()` 로 항상 명시해 보내 **드러나지 않은 채 어긋나 있었다**). **`personal-search.md §6` 의 「컷오프를 적용하지 않는다」를 뒤집는다** — 그 근거는 무관 질의 **1건**의 실측(관련 top-1 최소 0.5263 vs 무관 top-1 최대 0.3143, 간격 +0.2120)이었는데, 무관 질의를 질의 5종 × 소유자 3명 = **15건**으로 늘리자 그 간격이 **-0.0176 으로 뒤집혔다**(「치과 임플란트 상담 받을 곳」 → 연남칼국수 0.3819 가 기대 정답 최솟값 0.3642 를 넘는다). 즉 어떤 `τ_abs` 도 무관 질의를 전부 무노출로 만들면서 정답을 전부 살릴 수 없고, **동시에 「그러므로 컷을 걸지 말자」도 성립하지 않는다** — 컷이 없으면 무관 질의에 보유 기록 전량(소유자별 6·11·17건, 15질의 합 170행)이 반환된다. **티켓이 채택한 조합을 측정이 지지했다.** 검증 질의 12건만 재면 `r` 이 압도적으로 보이지만(`r=0.80` 꼬리 97.4% 제거, `τ_abs` 는 어떤 값과 조합해도 결과 동일) `r` 은 정의상 **1위를 언제나 남겨** 무관 질의를 0.40~0.90 전 구간에서 **15건 중 0건도** 무노출로 만들지 못한다. `τ_abs` 는 반대로 같은 안전 마진에서 꼬리 제거가 약하다(상한의 83% 에서 62.3%, `r` 은 상한의 75% 에서 73.7%). **정답이 있는 질의만 재면 컷의 절반이 안 보인다**는 것이 이 측정의 방법론적 결론이다(T40). 채택값에서 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3%(비관)·71.5%(낙관) 제거이고, 무관 질의는 15건 중 11건 무노출(11/15)이다. **안전 상한(`τ_abs` 0.36 · `r` 0.80)에 붙이지 않고 17%·25% 마진을 뒀다** — 두 축의 상한을 **같은 데이터점 하나**(「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `0.3642`·`r=0.807`, `-174`·`-191` 네 조건에서 계속 실패한 축약어 「피맥」 건)가 정하고, 여기에 **배치 구성이 바뀌면 같은 텍스트의 유사도가 `10⁻⁴` 규모로 흔들리는 것**(0.5264→0.5258, T42)이 겹치는데 상한과 정답 최솟값의 거리가 `0.0042` 였다. 컷은 `LIMIT` **뒤에서** 건다 — 유사도 하위만 자르므로 `WHERE` 와 결과가 같고(단조), 그렇다면 이미 고정된 §4 Query 를 건드리지 않는 쪽이 낫다. `r` 의 기준은 **컷 전 1위**다(살아남은 것의 1위로 재계산하면 자기충족 컷이 된다). 하네스 `tools/search_cut/` 는 `-210` 의 구조를 따라 질의를 한 번 임베딩해 굳히고 격자를 오프라인으로 훑는다(**AI API 임베딩 배치 1회**) — 다만 `-210` 의 재구성이 근사였던 것(후보가 줄면 LLM 판정이 뒤집힌다)과 달리 **검색 경로에는 LLM 이 없어 정확**하고, 그래서 대조군 없이 **정확 일치**를 요구했다(이 브랜치 코드로 띄운 :8002 에 27건 실호출 → **27/27 일치**). 검증 스크립트는 컷 규칙을 구현에서 `import` 하지 않고 다시 적는다(하면 구현이 명세와 달라도 둘이 함께 틀린다). `.search/matrix.json` 을 **커밋했다** — 다시 뜨려면 AI API 를 부르고 `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). 라벨은 `plausible` 을 **넉넉히** 잡아 컷의 이득을 과대평가하지 않는 방향으로 뒀다. **`limit` 을 20 으로 올리는 것 자체는 꼬리를 늘리므로**(반환 104→142행) 컷과 분리해 머지하면 안 된다. 후속: 소유자별 편차를 단일 `τ_abs` 가 모른다(같은 무관 질의가 6 Record 소유자에게 0.2686, 17 Record 소유자에게 0.3819) · 정답/무관 겹침은 컷이 아니라 **임베딩 품질**의 문제다. `291 passed` · line 99.71% · branch 98.12% · `ruff check .` 통과 (Jira 작업) | [컷 측정](implements/2026-07-31-search-cut.md), [spec/personal-search.md §6.1](spec/personal-search.md), [T40~T42](troubleshooting/2026-07-31-search-cut-measurement.md), [ai#70](https://github.com/Team-PinLog/ai/pull/70) | +| 2026-07-31 | AI API 오류 응답 본문이 **예외 메시지를 타고 로그로 새던 경로**를 원천에서 막았다(`-205`). 티켓은 「분류 밖 예외 → 트레이스백」 하나를 남은 구멍으로 봤지만 실제 경로는 **여섯이고 그중 다섯이 분류가 정상 동작하는 평상시 경로**다 — `retry.py` 가 재시도 1회마다, 두 service 가 일시·영구 오류마다 예외 객체를 `%s` 로 찍는다. `failure-recovery.md §2.4` 원칙 4 는 응답 본문을 *"어느 레벨에서도 남기지 않습니다"* 라고 적고 있었으나 **코드는 그때도 남기고 있었다**(§2.6 으로 갈라 정정). 실제 AI API 로 네 경로에 오류 19건을 넣어 본문을 실측했다 — **자격 증명은 한 건도 에코되지 않고**(401 은 게이트웨이 고정 문구, 벤더가 답하기 전에 끊는다) endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다**(`Invalid value: 'PIN...def'` — 완전 일치 검사를 빠져나가 「안 샌다」로 오판할 뻔했다, T61). 그래서 규칙의 근거를 「관측된 것을 지운다」가 아니라 **「되돌아올 수 있는 자리를 막는다」**로 잡았다. `app/core/redact.py` 가 자격 증명(`sk-`·`AIza`·JWT·`Bearer`·`key=value`)을 endpoint(URL·맨 호스트)보다 **먼저** 지우고, **마스킹이 절단보다 먼저다**(순서를 뒤집으면 200자 경계에 걸친 키의 앞부분이 남는다). **본문 200자는 지우지 않는다** — 실측한 벤더 400 본문에 마스킹 대상이 한 글자도 없었고 전부 진단 문구였다. 막는 지점을 로그 호출부가 아니라 client 로 잡은 것이 설계의 전부다(트레이스백 경로에는 애초에 호출부가 없다). `tests/test_log_redaction.py` 18건이 AST 로 단일 원천을 지킨다. pytest 404 · line 99.83% / branch 98.99% (`-223` 병합 후 재검증) | [implements](implements/2026-07-31-gms-error-body-redaction.md), [failure-recovery §2.6](spec/failure-recovery.md), [troubleshooting](troubleshooting/2026-07-31-log-redaction-pitfalls.md) (T61~T63) | +| 2026-07-31 | 판정 n회 다수결 — 구현 + 실측 후 **n=1 유지 결론** (Jira 작업) | 판정을 `PINLOG_JUDGE_VOTE_N` 회 불러 **엄격 다수결**(`votes*2 > n`)로 접는 경로를 넣고, 기본값 1 이 현행과 **정확히 같음**을 실데이터로 고정했다(회차 30개를 n=1 로 접은 결과 원본과 30/30 일치). **분모를 성공 수가 아니라 n 으로 고정**한 것이 설계의 핵심 — 낮추면 n=3 에서 1회만 성공했을 때 그 1회가 곧 다수결이 되어 「다수결을 켠 채 n=1 을 실행」하는 상태가 되고, 정족수 미달을 「선택 0건 정상 완료」로 저장하면 판정 실패가 성공으로 기록된다(T54). 짝수 n 은 기동 차단 — 동점 때문이 아니라 바로 아래 홀수에 **지배당하기** 때문이다(n=4 는 3표·n=3 은 2표인데 호출은 33% 더 든다). 측정은 **새로 부르지 않고 접었다** — n회 다수결 1회분과 독립 회차 n개의 다수결이 같은 확률변수이므로 회차 30개(1,260호출)로 2,940호출어치 조건 셋을 얻었고(`-210` 이 유사도 행렬로 τ 를 재구성한 것과 같은 수법), 다수결 규칙은 서비스 코드를 그대로 부른다. `run_live.py` 가 `KeywordService._judge_n` 실호출로 대조 — 전 지표 범위 겹침, Context당 호출 정확히 3.0. **다수결은 설계대로 작동했다**: 비결정성 24%→17%→12%, 흔들리던 오분류 13종 완전 제거, 라벨없음 0.80→0.00, **정상 판정을 오히려 늘려 교환비가 이 프로젝트 최초로 음수**(`-210` τ 는 1.22 로 기각됐다). **그런데 오분류 행은 안 준다** — 10.13→9.40→9.17, 범위 겹침, p=0.365/0.210 으로 사전·보강 기준 모두 미달. 이유가 §4 다: 다수결은 소수의견을 지우는 장치라 **다수의견은 반대로 굳힌다** — 지운 13종이 각 0.03~0.37행으로 드물었고(-1.7행) 동시에 70~97%이던 5종을 100%로 굳혀(+0.8행) 잔액이 노이즈 크기로 남는다. 100% 생존한 7종(`274`·`289 DRINK`·`265 WALK`·`284 WITH_FAMILY`·`275`/`290 VIEW_GOOD`·`266 TRENDY`)은 `-219` 가 프롬프트로 못 움직인 것과 **같은 목록**이다 — `-210`(후보 선정)·`-219`(판정 문구)·`-223`(판정 분산)이 서로 다른 층에서 같은 곳을 가리켰고, 남은 것은 **후보의 정의**(프리셋 `description`, `back#136`)로 AI 파트 소관이 아니다. `fit 0건 Context` 8.00 — 세 티켓 연속 불변(다수결이 한 일은 줄인 것이 아니라 표준편차를 0 으로 만든 것). 비용 실측: 호출 3~5배, Context당 지연 1.57→2.01→2.60s(동시 호출이라 n배 아님), 최악 지연은 **n 에 비례하지 않는다**(n=1 3.94s > n=3 3.70s — 꼬리는 AI API 쪽이다). 개선 폭당 호출 115~175회/오분류 1행. **`back#136` 이 7종을 고치면 남는 오분류가 전부 흔들림이 되므로 그때 다시 재야 한다 — 순서가 중요하다.** AI API 2,142호출·실패 0·전량 `gpt-4o-mini`. `386 passed` · line 99.83% · branch 98.98% (`origin/dev` 병합 후. 이 브랜치 단독은 338) · `ruff check .` 통과 | [다수결 측정](implements/2026-07-31-judge-vote.md), [T57~T60](troubleshooting/2026-07-31-judge-vote.md), [tools/judge_vote/](../tools/judge_vote/) | | 2026-07-31 | 문서 색인 정합을 **CI 로 옮겼다**(티켓 없음). 07-31 하루에 색인 사고가 다섯 번 났고 **대응이 다섯 번 다 「문서에 문장 추가」였다** — 네 번째(T53 중복)는 첫 번째의 대응(T48 「PR 직전에 다시 확인하라」)을 그대로 따랐는데도 났다. 따를 수 없는 규칙이어서가 아니라 *「내가 세는 35분 사이에 남이 병합했는가」* 를 사람이 알 방법이 없기 때문이다. 그중 **셀 수 있는 것**을 `ai-ci / check` 스텝으로 옮겼다 — ①전수 표의 `T##`·`I##` 중복 ②파일 표 ↔ 파일 시스템의 고아·누락 ③전수 표에만 있고 파일 표에 없는 문서(사고 5, `-205` 가 발견). **검사를 먼저 만들어 `dev` 에 돌리고, 위반을 별도 커밋으로 정리한 뒤에 켰다**(순서가 반대였으면 무관한 PR 이 전부 막힌다). 착수 시점 `dev` 에 고아 3건이 있었다 — `-197`·`-223` 은 전수 표에만 링크가 있었고 `-219` 는 어느 표에도 없었다(셋 다 WORKLOG 에는 있다). **일부러 통과시키는 것**을 잡는 것만큼 고정했다: 결번(T9·T10 은 back 레포)과 두 표의 **내용** 불일치, 그리고 **파일 표에만 있고 전수 표가 안 가리키는 문서**. 마지막 것은 켤 수 없다 — 트러블슈팅 전수 표는 설계상 문서를 안 가리켜(문서 링크 0종) 정상 문서 15건이, 구현 쪽은 번호 없는 문서 7건이 위반으로 나온다. 연속성을 검사하면 없던 규칙이 생기고, 일치를 검사하면 **표 이중화라는 문제를 규칙으로 승격**시킨다. 재현은 실제 `docs/` 사본에 사고 1·2·3 을 그대로 심어 확인했고, **검사가 내 문서 두 개의 색인 누락도 잡았다**(T64·T65·I35 를 정하기 전에 돌렸다). 메시지는 「중복입니다」로 끝내지 않고 **다음 빈 번호와 붙여 넣을 표 행**을 준다. **셸이 아니라 Python** 인 것은 `pytest` 가 같은 함수를 불러 CI 와 로컬이 같은 코드를 쓰기 위함이고, **새 잡이 아니라 `check` 안의 스텝**인 것은 필수 상태 검사가 두 이름뿐이라 잡을 늘리면 실패해도 병합을 못 막기 때문이다. 표 이중화 조사 결과 **줄일 수 없다** — 트러블슈팅 전수 표는 문서 링크가 **0종**이고(T↔문서 매핑의 유일한 출처가 파일 표다) 구현 전수 표는 파일 표 24개 중 17개만 덮으며 13행은 애초에 문서가 아닌 산출이다. 두 표는 같은 것의 두 판이 아니라 **서로 다른 두 축**이다. 함께 고친 것 — `merge=union` 이 표 안에 남긴 빈 줄 6개가 **GFM 표를 끊고 있었다**(에디터·diff 에서는 표로 보인다, T64). `400 passed` · line 99.83% · branch 98.98% · `ruff check .` 통과 | [색인 검사 리포트](implements/2026-07-31-docs-index-check.md), [T64·T65](troubleshooting/2026-07-31-docs-index-check.md), [tools/check_docs_index.py](../tools/check_docs_index.py) | -| 2026-08-03 | 읽히지 않는 설정 키 전수조사(`S15P11A705-224`). `-219`(T43)가 이미 `PINLOG_JUDGE_MODEL` 하나를 짚었지만 이번에는 `.env`·`.env.example`·`config.py`·배포 SealedSecret(8키)을 전부 대조하고, `Settings` 필드 15개 각각에 sentinel 값을 런타임 주입해 grep이 아니라 실제 반영 여부로 확인했다(`-210`이 "임계값이 없다"고 적었다가 `config.py:114`에 있었던 사고와 같은 함정을 경계). **읽히지 않는 키는 정확히 2개**(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`)였고 **둘 다 저장소는 이미 정리가 끝나 있었다** — 전자는 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체, 후자는 `ai#24`(2026-07-27)가 이미 `config.py`·`.env.example`에서 제거. 남은 문제는 개발자 로컬 `.env`(gitignored)에만 있던 잔재였고 이번에 주석 처리했다. **계약의 전제도 정정했다** — "`.env` 13 대 `.env.example` 12"를 단일 차집합(하나만 다르다)으로 가정했지만 실제로는 `.env`만의 키 2개(둘 다 읽히지 않음)·`.env.example`만의 키 1개(`PINLOG_JUDGE_CHAIN`, 코드가 읽음)인 양방향 구성이었다 — 둘 다 정상이라 `.env.example`에 추가할 키는 없다. **배포 Secret은 애초에 그 두 키를 담지 않았다**(인프라 소관이라 값은 확인만, 고치지 않음). `-197`(`app/client/_calls.py`) 계측이 벤더·모델을 이미 개별 호출(DEBUG)·창 요약(INFO, 벤더 단위)로 남기고 있어 **보강하지 않았다** — 기본 로그 레벨(INFO)에서는 벤더 단위까지 보이고 벤더→모델은 `PINLOG_JUDGE_CHAIN`(공개 값)과 1:1이라 특정 가능하다. 폴백 체인 구조(`-175`)·`-210` 리포트는 계약대로 손대지 않았다. 코드·`.env.example` 변경 없음(이미 올바른 상태를 확인) | [죽은 설정 키 리포트](implements/2026-08-03-dead-config-keys.md), [T66·T67](troubleshooting/2026-08-03-dead-config-key-audit.md) | -| 2026-08-03 | `back#138` 에 남기고 못 지킨 약속 — GMS 게이트웨이 멀티모달(이미지) 지원 재고 — 을 이행했다(`S15P11A705-227`). 1×1 PNG(68바이트)를 크기 요인 배제를 위해 쓰고(`-205` T62 — 큰 본문은 게이트웨이가 「모델 없음」으로 오진한다), 세 경로(OpenAI·Gemini·Anthropic)에 벤더 스펙 그대로 이미지 파트를 실어 각 1회씩 **총 3호출**로 끝냈다(공용 게이트웨이 최소 침습 원칙). **결론: 지원한다** — 셋 다 200 이고, 응답이 빈 문자열·거부가 아니라 이미지 내용에 대한 실제 답(색상 등 한 단어)이었다 — 게이트웨이가 이미지 파트를 버렸다면 나올 수 없는 응답이다. `back#138` 이 이미지 분석 흐름(`Front → Spring → FastAPI`)의 **유일한 선행 조건**으로 남긴 것이 이걸로 해소된다. 오류 응답을 만들지는 않았다 — 지원 여부 확인이 목적이라 정상 200 으로 끝났고, 오류 본문 형태는 `-205` 가 텍스트 경로에서 이미 실측해 뒀다(같은 게이트웨이 구현이므로 이미지 경로도 같은 형태를 따를 것으로 본다, 별도 확인은 안 함). 스크립트는 세션 스크래치패드에만 두고 커밋하지 않았고, `.env` 값은 메모리에서만 읽어 모든 출력에 키 문자열 치환 + 20자 이상 토큰 마스킹을 거쳤다(안전망 — 실제로는 한 건도 안 걸림, `-205` 결론과 일치). 남은 것 — OpenAI 경로의 `prompt_tokens` 가 8,524 로 텍스트 프롬프트 길이 대비 크게 뜬 것은 원인 조사 없이 관측만 남긴다(이미지 분석 기능 설계 시 토큰 예산에 반영 필요). 429·5xx 본문, 실사용 크기(최대 10 MiB) 이미지, 다중 이미지는 재지 않았다 — 범위 밖 | [비전 재고](implements/2026-08-03-gms-vision-probe.md), [back#138](https://github.com/Team-PinLog/back/issues/138) | -| 2026-08-03 | `-226` 이 명시적으로 한 방향만 켠 문서 색인 검사(④)의 **반대 방향**(파일 표에 번호가 있는데 전수 표에 없다)을 마저 닫았다(`S15P11A705-230`). ④는 전수 표 링크가 매핑의 유일한 출처였기 때문에 반대 방향을 검사할 수 없었다 — 이번에 `docs/implements` 파일 표에 **번호 컬럼을 추가**해 매핑의 출처를 파일 표 자신으로 옮기고, 그 컬럼으로 새 검사(⑤)를 켰다. `docs/troubleshooting` 은 그대로 뒀다 — 전수 표가 설계상 문서를 안 가리켜 컬럼을 넣어도 대조 출처가 못 된다(`-226` 실측 유지). 번호 없던 7건을 다시 세어(계약이 재확인을 요구했다) 하나씩 판정했다 — 5건은 문서에 실코드·실측 산출이 있어 신규 번호(I37~I41), `candidate-threshold` 는 새 번호가 아니라 **이미 있던 I30 에 빠진 반영처 링크**였을 뿐이라 그것만 채웠고, `ticket-audit-96-77` 은 문서 자신이 "만든 것이 아니라 확인한 결과"라고 규정해 「없음」으로 명시했다(빈 칸이 아니라 명시적 상태 — ⑤ 는 빈 칸을 형식 위반으로 잡는다). RED 먼저 확인했다 — 검사·테스트를 작성한 뒤 실제 README에 아직 번호 컬럼이 없는 상태로 돌려 26건 형식 위반을 관측하고, 컬럼을 채워 GREEN 전환했다. **계약이 경고한 T56 함정이 실제로 났다** — `origin/dev` 병합 중 `merge=union` 이 번호 컬럼 없는 낡은 파일 표 전체를 되살렸고, 동시에 병합된 병렬 PR(`#81`, `S15P11A705-227`)이 같은 I36 을 잡아 충돌했다. 낡은 블록은 지우고, 이번에 들어가는 쪽(내 `retry-and-error-classification`)만 다음 빈 번호(I41)로 재번호해 해소했다 — `#81` 의 I36 은 이미 `dev` 에 병합된 채였으므로 그대로 뒀다. `-226` 리포트 §4.1 이 "검사가 있어도 충돌 자체는 안 없어진다"고 적은 것과 정확히 같은 모양으로 재현됐다. `ruff check .` 통과 · 전량 pytest 통과 · line 99.83% · branch 98.99% · `python tools/check_docs_index.py` 통과(I1~I42) | [단방향 검사 리포트](implements/2026-08-03-docs-index-oneway.md), [tools/check_docs_index.py](../tools/check_docs_index.py) | -| 2026-08-03 | `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 판정 경로의 세 층을 각각 재고 전부 「바꾸지 않는다」로 끝난 뒤, 셋이 공통으로 지목한 마지막 축 — **프리셋의 정의** — 을 쟀다(`S15P11A705-228`). **`examples` 만 채택하고 `description` 은 기각한다.** 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩 입력만, `description` 은 임베딩+판정 프롬프트 양쪽) 조건을 `D`·`E`·`DE` 로 갈랐고, **갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻힌 채 배포됐다** — `D` 는 `fit 0건 Context` 를 8.20 → **10.00** 으로 악화시키고 범위가 분리된다(세 티켓 동안 `8.00 ± 0.00` 으로 꿈쩍 않던 지표가 처음 움직였는데 나쁜 쪽이다). `E` 는 오분류 11.00 → 6.90행(p=0.0001) · 정상 손실 -0.80(p=0.16) · 교환비 0.20 이고 **순이득 부호가 라벨 처리 양극단에서 다 양수**다(네 티켓 중 유일). 셋이 못 지운 100% 고정 오분류 7행 중 3행을 지웠고 그중 2행은 판정 이전에 후보에서 빠진다. 사전 기준(범위 비중첩)은 못 넘었고 **무르지 않고 그대로 적었다** — 대신 `-219`·`-223` 에 없던 독립 증거 넷(후보 선정 층·라벨 42건 밖 홀드아웃 30건·순이득 부호·회차 확장에서 효과가 커진 것)을 근거로 판단했다. 자기충족 방어 셋을 뒀다: 대상은 라벨에서 고르되 **수정 내용은 프리셋 텍스트 안에서만 진단**(다른 프리셋 소관 어휘를 걷는다) · 안 고친 22종을 대조군으로 분리 집계 · 42건 밖 본문 30건을 새로 만들어 반대 방향 예측 검증. `DE` 는 라벨 밖 행이 5.60행으로 뛰어 부호가 안 정해지고, **그 라벨을 개정안 설계자가 붙이면 자기 확인이 되므로 모른 채로 넘긴다.** 측정 중 T68(임베딩 API 가 같은 배치 구성으로도 비결정적 — `T42` 의 전제를 정정)·T69(`tau_grid/score.py` 의 τ 격자가 `rank>K` 로 밀린 행을 안 셈, 임베딩이 바뀐 행렬에서만 발현) 발견. **DB 재적재와 `version` 처리는 이 PR 밖**이며 리포트 §8.2 가 근거와 함께 남긴다 — 특히 시드 머리말이 `version` 을 자기 소관이 아니라고 규정하는데 코드는 거기서 읽어, 올릴 경로가 지금 없다 | [프리셋 개정 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md), [tools/preset_desc/](../tools/preset_desc/) | -| 2026-08-03 | `ai#97`(표지 생성)·`ai#98`(장소 제안)이 함께 기다리던 GMS 이미지 실사용 조건을 두 축으로 쟀다(`S15P11A705-253`, 2026-08-03 17:07~17:26 KST · root `https://gms.ssafy.io/gmsapi` · 호출 50회). **축 A(생성)는 지원한다** — `gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG 를 냈고, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 400 이다. **축 B 는 가설 C** — 토큰은 **치수**에 붙고 바이트에는 안 붙는다(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 이 남긴 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이었다(잔차 0). 설계의 핵심은 **치수와 바이트를 따로 움직인 합성 이미지**다 — 실제 사진으로는 세 가설이 같은 곡선을 내 갈리지 않는다. **그리고 티켓이 묻지 않은 것이 더 급했다 — 게이트웨이 요청 본문 상한**(96,024 B 통과 · 129,564 B 거부)이 있고 넘으면 「Model not found in request」로 온다(`-205` T62·`-225` 와 같은 원인). 이미지 원본 약 70 KB 가 확인된 통과 대역이라 **실사용 대화 캡처는 토큰을 쓰기 전에 막힌다** — `ai#98` 의 전제가 바뀐다. 정책 거절 층도 갈렸다(OpenAI 400 / **Gemini 200 + `finishReason=PROHIBITED_CONTENT`**) | [implements](implements/2026-08-03-gms-image-probe.md), [tools/gms_image/](../tools/gms_image/), [비전 재고](implements/2026-08-03-gms-vision-probe.md) | -| 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | -| 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | -| 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | -| 2026-08-07 | 검색 응답에 `keywordMatched` 필드를 추가했다(`S15P11A705-399`). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | -| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 `S15P11A705-400`, BD-52)의 계약을 분리 명시했다(`S15P11A705-402`, 중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 이 브랜치는 `-399`(I59) 병합 전 갈라져 같은 색인 누락을 독립적으로 I58 로 잡아 두고 있었다 — `dev` 병합에서 두 번호가 부딪혀(`-399` 가 먼저 `I59` 로 확정 병합) 이 리포트는 **I60 으로 재확정**했다(T60 과 같은 패턴, `check_docs_index.py` 의 번호는 병합 시점 이후에 확정한다는 원칙대로) | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.md) | -| 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(`S15P11A705-401`). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | +| 2026-08-03 | 읽히지 않는 설정 키 전수조사(Jira 작업). `-219`(T43)가 이미 `PINLOG_JUDGE_MODEL` 하나를 짚었지만 이번에는 `.env`·`.env.example`·`config.py`·배포 SealedSecret(8키)을 전부 대조하고, `Settings` 필드 15개 각각에 sentinel 값을 런타임 주입해 grep이 아니라 실제 반영 여부로 확인했다(`-210`이 "임계값이 없다"고 적었다가 `config.py:114`에 있었던 사고와 같은 함정을 경계). **읽히지 않는 키는 정확히 2개**(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`)였고 **둘 다 저장소는 이미 정리가 끝나 있었다** — 전자는 `-175`가 `PINLOG_JUDGE_CHAIN`으로 대체, 후자는 `ai#24`(2026-07-27)가 이미 `config.py`·`.env.example`에서 제거. 남은 문제는 개발자 로컬 `.env`(gitignored)에만 있던 잔재였고 이번에 주석 처리했다. **계약의 전제도 정정했다** — "`.env` 13 대 `.env.example` 12"를 단일 차집합(하나만 다르다)으로 가정했지만 실제로는 `.env`만의 키 2개(둘 다 읽히지 않음)·`.env.example`만의 키 1개(`PINLOG_JUDGE_CHAIN`, 코드가 읽음)인 양방향 구성이었다 — 둘 다 정상이라 `.env.example`에 추가할 키는 없다. **배포 Secret은 애초에 그 두 키를 담지 않았다**(인프라 소관이라 값은 확인만, 고치지 않음). `-197`(`app/client/_calls.py`) 계측이 벤더·모델을 이미 개별 호출(DEBUG)·창 요약(INFO, 벤더 단위)로 남기고 있어 **보강하지 않았다** — 기본 로그 레벨(INFO)에서는 벤더 단위까지 보이고 벤더→모델은 `PINLOG_JUDGE_CHAIN`(공개 값)과 1:1이라 특정 가능하다. 폴백 체인 구조(`-175`)·`-210` 리포트는 계약대로 손대지 않았다. 코드·`.env.example` 변경 없음(이미 올바른 상태를 확인) | [죽은 설정 키 리포트](implements/2026-08-03-dead-config-keys.md), [T66·T67](troubleshooting/2026-08-03-dead-config-key-audit.md) | +| 2026-08-03 | `back#138` 에 남기고 못 지킨 약속 — AI API 게이트웨이 멀티모달(이미지) 지원 재고 — 을 이행했다(Jira 작업). 1×1 PNG(68바이트)를 크기 요인 배제를 위해 쓰고(`-205` T62 — 큰 본문은 게이트웨이가 「모델 없음」으로 오진한다), 세 경로(OpenAI·Gemini·Anthropic)에 벤더 스펙 그대로 이미지 파트를 실어 각 1회씩 **총 3호출**로 끝냈다(공용 게이트웨이 최소 침습 원칙). **결론: 지원한다** — 셋 다 200 이고, 응답이 빈 문자열·거부가 아니라 이미지 내용에 대한 실제 답(색상 등 한 단어)이었다 — 게이트웨이가 이미지 파트를 버렸다면 나올 수 없는 응답이다. `back#138` 이 이미지 분석 흐름(`Front → Spring → FastAPI`)의 **유일한 선행 조건**으로 남긴 것이 이걸로 해소된다. 오류 응답을 만들지는 않았다 — 지원 여부 확인이 목적이라 정상 200 으로 끝났고, 오류 본문 형태는 `-205` 가 텍스트 경로에서 이미 실측해 뒀다(같은 게이트웨이 구현이므로 이미지 경로도 같은 형태를 따를 것으로 본다, 별도 확인은 안 함). 스크립트는 세션 스크래치패드에만 두고 커밋하지 않았고, `.env` 값은 메모리에서만 읽어 모든 출력에 키 문자열 치환 + 20자 이상 토큰 마스킹을 거쳤다(안전망 — 실제로는 한 건도 안 걸림, `-205` 결론과 일치). 남은 것 — OpenAI 경로의 `prompt_tokens` 가 8,524 로 텍스트 프롬프트 길이 대비 크게 뜬 것은 원인 조사 없이 관측만 남긴다(이미지 분석 기능 설계 시 토큰 예산에 반영 필요). 429·5xx 본문, 실사용 크기(최대 10 MiB) 이미지, 다중 이미지는 재지 않았다 — 범위 밖 | [비전 재고](implements/2026-08-03-gms-vision-probe.md), [back#138](https://github.com/Team-PinLog/back/issues/138) | +| 2026-08-03 | `-226` 이 명시적으로 한 방향만 켠 문서 색인 검사(④)의 **반대 방향**(파일 표에 번호가 있는데 전수 표에 없다)을 마저 닫았다(Jira 작업). ④는 전수 표 링크가 매핑의 유일한 출처였기 때문에 반대 방향을 검사할 수 없었다 — 이번에 `docs/implements` 파일 표에 **번호 컬럼을 추가**해 매핑의 출처를 파일 표 자신으로 옮기고, 그 컬럼으로 새 검사(⑤)를 켰다. `docs/troubleshooting` 은 그대로 뒀다 — 전수 표가 설계상 문서를 안 가리켜 컬럼을 넣어도 대조 출처가 못 된다(`-226` 실측 유지). 번호 없던 7건을 다시 세어(계약이 재확인을 요구했다) 하나씩 판정했다 — 5건은 문서에 실코드·실측 산출이 있어 신규 번호(I37~I41), `candidate-threshold` 는 새 번호가 아니라 **이미 있던 I30 에 빠진 반영처 링크**였을 뿐이라 그것만 채웠고, `ticket-audit-96-77` 은 문서 자신이 "만든 것이 아니라 확인한 결과"라고 규정해 「없음」으로 명시했다(빈 칸이 아니라 명시적 상태 — ⑤ 는 빈 칸을 형식 위반으로 잡는다). RED 먼저 확인했다 — 검사·테스트를 작성한 뒤 실제 README에 아직 번호 컬럼이 없는 상태로 돌려 26건 형식 위반을 관측하고, 컬럼을 채워 GREEN 전환했다. **계약이 경고한 T56 함정이 실제로 났다** — `origin/dev` 병합 중 `merge=union` 이 번호 컬럼 없는 낡은 파일 표 전체를 되살렸고, 동시에 병합된 병렬 PR(`#81`, Jira 작업)이 같은 I36 을 잡아 충돌했다. 낡은 블록은 지우고, 이번에 들어가는 쪽(내 `retry-and-error-classification`)만 다음 빈 번호(I41)로 재번호해 해소했다 — `#81` 의 I36 은 이미 `dev` 에 병합된 채였으므로 그대로 뒀다. `-226` 리포트 §4.1 이 "검사가 있어도 충돌 자체는 안 없어진다"고 적은 것과 정확히 같은 모양으로 재현됐다. `ruff check .` 통과 · 전량 pytest 통과 · line 99.83% · branch 98.99% · `python tools/check_docs_index.py` 통과(I1~I42) | [단방향 검사 리포트](implements/2026-08-03-docs-index-oneway.md), [tools/check_docs_index.py](../tools/check_docs_index.py) | +| 2026-08-03 | `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 판정 경로의 세 층을 각각 재고 전부 「바꾸지 않는다」로 끝난 뒤, 셋이 공통으로 지목한 마지막 축 — **프리셋의 정의** — 을 쟀다(Jira 작업). **`examples` 만 채택하고 `description` 은 기각한다.** 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩 입력만, `description` 은 임베딩+판정 프롬프트 양쪽) 조건을 `D`·`E`·`DE` 로 갈랐고, **갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻힌 채 배포됐다** — `D` 는 `fit 0건 Context` 를 8.20 → **10.00** 으로 악화시키고 범위가 분리된다(세 티켓 동안 `8.00 ± 0.00` 으로 꿈쩍 않던 지표가 처음 움직였는데 나쁜 쪽이다). `E` 는 오분류 11.00 → 6.90행(p=0.0001) · 정상 손실 -0.80(p=0.16) · 교환비 0.20 이고 **순이득 부호가 라벨 처리 양극단에서 다 양수**다(네 티켓 중 유일). 셋이 못 지운 100% 고정 오분류 7행 중 3행을 지웠고 그중 2행은 판정 이전에 후보에서 빠진다. 사전 기준(범위 비중첩)은 못 넘었고 **무르지 않고 그대로 적었다** — 대신 `-219`·`-223` 에 없던 독립 증거 넷(후보 선정 층·라벨 42건 밖 홀드아웃 30건·순이득 부호·회차 확장에서 효과가 커진 것)을 근거로 판단했다. 자기충족 방어 셋을 뒀다: 대상은 라벨에서 고르되 **수정 내용은 프리셋 텍스트 안에서만 진단**(다른 프리셋 소관 어휘를 걷는다) · 안 고친 22종을 대조군으로 분리 집계 · 42건 밖 본문 30건을 새로 만들어 반대 방향 예측 검증. `DE` 는 라벨 밖 행이 5.60행으로 뛰어 부호가 안 정해지고, **그 라벨을 개정안 설계자가 붙이면 자기 확인이 되므로 모른 채로 넘긴다.** 측정 중 T68(임베딩 API 가 같은 배치 구성으로도 비결정적 — `T42` 의 전제를 정정)·T69(`tau_grid/score.py` 의 τ 격자가 `rank>K` 로 밀린 행을 안 셈, 임베딩이 바뀐 행렬에서만 발현) 발견. **DB 재적재와 `version` 처리는 이 PR 밖**이며 리포트 §8.2 가 근거와 함께 남긴다 — 특히 시드 머리말이 `version` 을 자기 소관이 아니라고 규정하는데 코드는 거기서 읽어, 올릴 경로가 지금 없다 | [프리셋 개정 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md), [tools/preset_desc/](../tools/preset_desc/) | +| 2026-08-03 | `ai#97`(표지 생성)·`ai#98`(장소 제안)이 함께 기다리던 AI API 이미지 실사용 조건을 두 축으로 쟀다(Jira 작업, 2026-08-03 17:07~17:26 KST · root `https://api.example.com` · 호출 50회). **축 A(생성)는 지원한다** — `gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG 를 냈고, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 400 이다. **축 B 는 가설 C** — 토큰은 **치수**에 붙고 바이트에는 안 붙는다(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 이 남긴 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이었다(잔차 0). 설계의 핵심은 **치수와 바이트를 따로 움직인 합성 이미지**다 — 실제 사진으로는 세 가설이 같은 곡선을 내 갈리지 않는다. **그리고 티켓이 묻지 않은 것이 더 급했다 — 게이트웨이 요청 본문 상한**(96,024 B 통과 · 129,564 B 거부)이 있고 넘으면 「Model not found in request」로 온다(`-205` T62·`-225` 와 같은 원인). 이미지 원본 약 70 KB 가 확인된 통과 대역이라 **실사용 대화 캡처는 토큰을 쓰기 전에 막힌다** — `ai#98` 의 전제가 바뀐다. 정책 거절 층도 갈렸다(OpenAI 400 / **Gemini 200 + `finishReason=PROHIBITED_CONTENT`**) | [implements](implements/2026-08-03-gms-image-probe.md), [tools/gms_image/](../tools/gms_image/), [비전 재고](implements/2026-08-03-gms-vision-probe.md) | +| 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(Jira 작업, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | +| 2026-08-03 | 표시명 개정(Jira 작업)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | +| 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(Jira 작업·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | +| 2026-08-07 | 검색 응답에 `keywordMatched` 필드를 추가했다(Jira 작업). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(Jira 작업)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | +| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 Jira 작업, BD-52)의 계약을 분리 명시했다(중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 이 브랜치는 `-399`(I59) 병합 전 갈라져 같은 색인 누락을 독립적으로 I58 로 잡아 두고 있었다 — `dev` 병합에서 두 번호가 부딪혀(`-399` 가 먼저 `I59` 로 확정 병합) 이 리포트는 **I60 으로 재확정**했다(T60 과 같은 패턴, `check_docs_index.py` 의 번호는 병합 시점 이후에 확정한다는 원칙대로) | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.md) | +| 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(Jira 작업). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | | 2026-08-07 | 401 리포트 작성 뒤 `test_docs_index.py`가 `docs/implements/README.md`의 색인 두 표(개별 리포트·구현·산출 전수) 모두에서 I58(`2026-08-07-gate-threshold-remeasure.md`) 행이 빠진 것을 잡았다 — 리포트를 커밋하면서 색인 갱신을 놓친 것. 두 표 모두에 행을 추가해 정정했다 | [gate_sweep 리포트](implements/2026-08-07-gate-threshold-remeasure.md) | diff --git a/docs/implements/2026-07-23-fastapi-implementation.md b/docs/implements/2026-07-23-fastapi-implementation.md index 318af86..374319f 100644 --- a/docs/implements/2026-07-23-fastapi-implementation.md +++ b/docs/implements/2026-07-23-fastapi-implementation.md @@ -20,8 +20,8 @@ DB 접근은 asyncpg 와 원시 SQL 로 구현했고 ORM 을 도입하지 않았 | core | `db.py` | asyncpg 풀. `search_path=ai, public` 고정(public 은 vector 확장이 있는 스키마이고, core 는 경로 밖에 유지한다. T21 참조). pgvector 타입 등록 | | core | `errors.py` | 영구/일시 오류와 저장 폐기의 분류 | | core | `security.py` | 내부 공유 시크릿 미들웨어(`/internal/*` 경로에 적용) | -| client | `embedding_client.py` | GMS 의 OpenAI 호환 `/embeddings` 호출(하네스 `embed.py` 를 포팅했다) | -| client | `llm_client.py` | GMS 의 Gemini `generateContent` 호출. responseSchema 와 thinkingBudget=0 을 사용한다 | +| client | `embedding_client.py` | AI API 의 OpenAI 호환 `/embeddings` 호출(하네스 `embed.py` 를 포팅했다) | +| client | `llm_client.py` | AI API 의 Gemini `generateContent` 호출. responseSchema 와 thinkingBudget=0 을 사용한다 | | cache | `preset_cache.py` | 기동 시 `is_active` 이고 Profile 이 일치하는 Preset 을 적재한다. BLOCKED 는 제외하고, 적재 결과가 0건이면 기동에 실패한다 | | repository | `ai_state_repo.py` | 조건부 상태 전이(try_start/complete/fail). 컬럼 조립에는 Stage 열거형만 쓴다 | | repository | `context_embedding_repo.py` | 검색 Query 와 UPSERT(`is_deleted` 는 갱신 대상에서 제외)와 fallback 조회 | @@ -52,7 +52,7 @@ DB 접근은 asyncpg 와 원시 SQL 로 구현했고 ORM 을 도입하지 않았 ## 검증 방법 -로컬에서 `pgvector/pgvector:pg16` 컨테이너를 띄우고 back 레포의 Flyway 마이그레이션(V1/V100/V101)으로 `ai.*` 테이블을 생성했다. 부트스트랩으로 Preset 27건을 적재한 뒤, 실제 GMS(임베딩·Gemini)를 호출해 end-to-end 로 확인했다. +로컬에서 `pgvector/pgvector:pg16` 컨테이너를 띄우고 back 레포의 Flyway 마이그레이션(V1/V100/V101)으로 `ai.*` 테이블을 생성했다. 부트스트랩으로 Preset 27건을 적재한 뒤, 실제 AI API(임베딩·Gemini)를 호출해 end-to-end 로 확인했다. **`/search`** diff --git a/docs/implements/2026-07-23-keyword-matching-eval.md b/docs/implements/2026-07-23-keyword-matching-eval.md index 416d9ca..a28079d 100644 --- a/docs/implements/2026-07-23-keyword-matching-eval.md +++ b/docs/implements/2026-07-23-keyword-matching-eval.md @@ -15,7 +15,7 @@ `tools/keyword_eval/` 에 커밋했다. 팀이 실제 샘플로 재실행할 수 있게 하기 위해서다. 구성은 다음과 같다. -- `embed.py` — GMS 임베딩 호출. 결과를 디스크에 캐시한다. +- `embed.py` — AI API 임베딩 호출. 결과를 디스크에 캐시한다. - `samples.yaml` — 임시 맥락 35개. 프리셋을 아는 작성자가 만든 샘플이라 self-reference 편향이 있으므로, 수치는 절대값이 아니라 경향으로 해석한다. - `test_a/b/c` — 테스트 3종 실행 코드. - `prompts/keyword_judgment.md` — 판정 프롬프트. @@ -37,14 +37,14 @@ ### C-2 — 판정 모델 비교 -확정한 프롬프트로 3사 4모델(gpt-5-mini, gpt-5-nano, claude-haiku-4-5, gemini-2.5-flash)을 GMS 경로에서 실행해 비교했다. 정확도(스키마 준수·선택 분포)는 4모델이 사실상 동일했다. 따라서 경량 tier 모델로 충분하다. 그중 가장 빠르고(1.12s) 토큰을 가장 적게 쓴(25314) `gemini-2.5-flash`(thinkingBudget=0)를 확정했다. gpt-5-nano 는 지연이 가장 길고 토큰을 가장 많이 써서 탈락했다. 모델이 반환하는 confidence 값은 모든 모델에서 변별력이 낮아 랭킹 신호로 사용하지 않는다. Gemini 는 function-calling 응답이 malformed 로 나와서, 대신 `responseSchema` 방식으로 호출한다. +확정한 프롬프트로 3사 4모델(gpt-5-mini, gpt-5-nano, claude-haiku-4-5, gemini-2.5-flash)을 AI API 경로에서 실행해 비교했다. 정확도(스키마 준수·선택 분포)는 4모델이 사실상 동일했다. 따라서 경량 tier 모델로 충분하다. 그중 가장 빠르고(1.12s) 토큰을 가장 적게 쓴(25314) `gemini-2.5-flash`(thinkingBudget=0)를 확정했다. gpt-5-nano 는 지연이 가장 길고 토큰을 가장 많이 써서 탈락했다. 모델이 반환하는 confidence 값은 모든 모델에서 변별력이 낮아 랭킹 신호로 사용하지 않는다. Gemini 는 function-calling 응답이 malformed 로 나와서, 대신 `responseSchema` 방식으로 호출한다. 확정 사항은 [P26](../proposals/P26-keyword-preset-judgment.md)에 반영했다(M4 종결). ## 남은 것 - 팀원이 프리셋을 보지 않고 작성한 실제 샘플로 B/C 를 다시 측정해야 한다. 현재 샘플은 self-reference 편향이 있어, Recall 과 트리키 케이스가 실제로 유효한지는 새 샘플로만 검증할 수 있다. -- GMS 모델별 크레딧 단가표가 나오면 토큰 사용량을 비용으로 환산해야 한다([spec/cost-estimate.md](../spec/cost-estimate.md) §4 공식에 대입). +- AI API 모델별 크레딧 단가표가 나오면 토큰 사용량을 비용으로 환산해야 한다([spec/cost-estimate.md](../spec/cost-estimate.md) §4 공식에 대입). ## 관련 diff --git a/docs/implements/2026-07-24-e3-test-harness.md b/docs/implements/2026-07-24-e3-test-harness.md index 78e0856..4db92f4 100644 --- a/docs/implements/2026-07-24-e3-test-harness.md +++ b/docs/implements/2026-07-24-e3-test-harness.md @@ -11,7 +11,7 @@ ## 하네스 (`tests/`) -- **`conftest.py`** — 세션 스코프의 `PostgresContainer(PGVECTOR_IMAGE)`를 띄운다. 이미지는 현재 `pgvector/pgvector:0.8.5-pg16@sha256:1d53…` 로, 운영 환경과 back 레포 `compose.yaml` 의 이미지와 digest 까지 일치한다(최초에는 `0.8.1-pg16` 이었고 `S15P11A705-122` 에서 정합화했다. 경위는 아래 「결정」 참조). 컨테이너에 `schema/ai_snapshot.sql`(back V1/V100/V101 에서 파생)을 적용한 뒤 asyncpg 풀을 연다. 테스트 간 격리는 TRUNCATE 로 한다. 롤백 격리를 쓰지 않는 이유는 동시성 테스트가 여러 커넥션을 쓰기 때문이다. `settings` fixture 가 Embedding Profile 을 주입하며, 테스트 코드에 Profile 문자열 리터럴을 쓰는 것은 금지다. +- **`conftest.py`** — 세션 스코프의 `PostgresContainer(PGVECTOR_IMAGE)`를 띄운다. 이미지는 현재 `pgvector/pgvector:0.8.5-pg16@sha256:1d53…` 로, 운영 환경과 back 레포 `compose.yaml` 의 이미지와 digest 까지 일치한다(최초에는 `0.8.1-pg16` 이었고 Jira 작업에서 정합화했다. 경위는 아래 「결정」 참조). 컨테이너에 `schema/ai_snapshot.sql`(back V1/V100/V101 에서 파생)을 적용한 뒤 asyncpg 풀을 연다. 테스트 간 격리는 TRUNCATE 로 한다. 롤백 격리를 쓰지 않는 이유는 동시성 테스트가 여러 커넥션을 쓰기 때문이다. `settings` fixture 가 Embedding Profile 을 주입하며, 테스트 코드에 Profile 문자열 리터럴을 쓰는 것은 금지다. - **`fakes.py`** — `FakeEmbeddingClient`/`FakeLLMClient`. 벡터는 sha256 기반으로 결정론적으로 생성한다. 무작위 벡터를 쓰면 유사도 순서 단언이 실행마다 흔들리기 때문이다. 호출 횟수를 기록한다. 여러 시나리오의 핵심 단언이 "호출하지 않았다" 또는 "정확히 한 번 호출했다"이기 때문이다. `on_call` 훅으로 모델 호출과 저장 사이의 시간 창을 결정론적으로 재현한다. sleep 으로 타이밍을 맞추는 방식은 금지다. - **`builders.py`** — `make_state`/`make_embedding`/`make_preset`. `embedding_profile`·`is_deleted`·두 status 컬럼을 항상 명시한다. 본문 버전 인자는 두지 않았다. 버전은 설계에서 제거된 개념이라 테스트 빌더가 되살리면 안 되기 때문이다. 본문 수정 시나리오는 `context_id` 가 다른 두 State 로 표현한다. - **`schema/ai_snapshot.sql`** — 테스트 전용 스키마 스냅샷. `ai` 스키마만 담는다(ai 테이블은 core 로의 FK 가 없다). 파일 헤더에 back 파생 출처와 갱신 누락 위험을 명시했다. @@ -33,7 +33,7 @@ ## 결정 - **Python 3.12 로 통일하고 상한을 `<3.13` 으로 둔다.** GraphRAG 스택(torch/transformers/igraph)의 wheel 이 최신 Python 을 늦게 지원하므로 3.12 가 안전하다. 3.13 지원이 확산되면 상한 완화를 재검토한다. -- **pgvector 이미지는 `0.8.5-pg16` 에 digest 까지 고정한다.** 운영과 back `compose.yaml` 의 실제 이미지와 digest 까지 일치시켜 재현성을 확보했다. 롤링 태그 `pg16` 은 금지한다. 태그만 고정하는 것도 금지한다. 같은 태그가 다른 이미지를 가리킬 수 있기 때문이다. 경위를 남긴다. 최초 결정은 `0.8.1-pg16` 태그 고정이었고 근거는 "back `compose.yaml` 과 일치한다"였다. 그런데 back#31 이 compose 를 `0.8.5-pg16@sha256:1d53…` 로 올리면서 그 근거가 무효가 됐다(운영도 0.8.5 다. infra#41). 어긋난 쪽이 ai 였으므로 `S15P11A705-122` 에서 digest 까지 맞췄다. +- **pgvector 이미지는 `0.8.5-pg16` 에 digest 까지 고정한다.** 운영과 back `compose.yaml` 의 실제 이미지와 digest 까지 일치시켜 재현성을 확보했다. 롤링 태그 `pg16` 은 금지한다. 태그만 고정하는 것도 금지한다. 같은 태그가 다른 이미지를 가리킬 수 있기 때문이다. 경위를 남긴다. 최초 결정은 `0.8.1-pg16` 태그 고정이었고 근거는 "back `compose.yaml` 과 일치한다"였다. 그런데 back#31 이 compose 를 `0.8.5-pg16@sha256:1d53…` 로 올리면서 그 근거가 무효가 됐다(운영도 0.8.5 다. infra#41). 어긋난 쪽이 ai 였으므로 Jira 작업에서 digest 까지 맞췄다. - **lock 파일을 도입한다.** 합류자의 환경 재현성을 위해서다. `requirements.txt` 는 사람이 읽는 하한 명세로, lock 은 정확한 버전 고정으로 역할을 나눈다. - **ai-ci 의 PR 제목 Jira 키 검증.** 형식만 보증하며 티켓이 실제로 존재하는지는 보증하지 않는다. 이 한계는 수용했다. diff --git a/docs/implements/2026-07-27-e2e-verification.md b/docs/implements/2026-07-27-e2e-verification.md index 9d8c771..ea6587d 100644 --- a/docs/implements/2026-07-27-e2e-verification.md +++ b/docs/implements/2026-07-27-e2e-verification.md @@ -1,4 +1,4 @@ -# E2E 검증 — 실제 GMS 경로를 전수 확인했다. 파이프라인·검색은 통과했고, 문서만으로는 기동할 수 없었다 +# E2E 검증 — 실제 AI API 경로를 전수 확인했다. 파이프라인·검색은 통과했고, 문서만으로는 기동할 수 없었다 - **상태**: 완료 - **날짜**: 2026-07-27 @@ -9,7 +9,7 @@ ## 무엇을 검증했나 -ai#5·#6·#11·#14·#16·#17·#18 로 구현이 끝났지만, 그 시점까지 검증된 것은 Fake 기반 테스트 46개(계약 위반·경합 방어)뿐이었다. 실제 GMS 호출, 프리셋 실제 적재, 실제 임베딩 기반의 검색 품질은 한 번도 실행된 적이 없었다. +ai#5·#6·#11·#14·#16·#17·#18 로 구현이 끝났지만, 그 시점까지 검증된 것은 Fake 기반 테스트 46개(계약 위반·경합 방어)뿐이었다. 실제 AI API 호출, 프리셋 실제 적재, 실제 임베딩 기반의 검색 품질은 한 번도 실행된 적이 없었다. 이 세션은 구현에 참여하지 않은 시각에서 `README.md` 와 `docs/spec/` 만 보고 로컬 기동을 재현했다. 절차를 미리 알려주지 않은 것은 의도적이다. 문서만으로 기동이 가능한지가 검증 대상이었기 때문이다. 따라서 막힌 지점 자체가 이 검증의 주 산출물이다. @@ -68,7 +68,7 @@ README 에 `docker build` 만 있고 `docker run` 예시가 없다. 이미지는 ### 프리셋 적재 — 통과 -`python -m app.bootstrap.load_presets` 를 실행했다(실제 GMS 임베딩 1배치 호출). +`python -m app.bootstrap.load_presets` 를 실행했다(실제 AI API 임베딩 1배치 호출). ``` total | embedding_null | profile_kinds | profile | dims | active @@ -229,11 +229,11 @@ INSERT INTO core.boundary_probe VALUES (1); -- INSERT 0 1 "FastAPI 는 `core.*` 에 접근하지 않는다"(README:10, [architecture.md](../spec/architecture.md) §7)는 계약이 로컬에서 검증되지 않는다는 뜻이다. 위반해도 성공하기 때문이다. 이 계약은 현재 코드 리뷰로만 지켜지고 있으며 DB 가 강제하지 않는다. -`search_path = ai, public` 이 `core` 를 검색 경로 밖에 두는 1차 방어선이지만, 스키마를 한정한 참조(`core.foo`)는 그대로 통과한다. 인프라 티켓 `S15P11A705-61`(ai 전용 DB role)의 실증 근거다. 프로브 테이블은 즉시 DROP 했다. +`search_path = ai, public` 이 `core` 를 검색 경로 밖에 두는 1차 방어선이지만, 스키마를 한정한 참조(`core.foo`)는 그대로 통과한다. 인프라 티켓 Jira 작업(ai 전용 DB role)의 실증 근거다. 프로브 테이블은 즉시 DROP 했다. ## Docker -빌드 결과는 360MB, 기동은 정상이며, 기동 로그에 `preset cache loaded: 27 presets` 가 찍혔다. 컨테이너 경유 실제 호출까지 확인했다. `/search` 200(실제 GMS 임베딩), Profile 불일치 422, 시크릿 누락 401. +빌드 결과는 360MB, 기동은 정상이며, 기동 로그에 `preset cache loaded: 27 presets` 가 찍혔다. 컨테이너 경유 실제 호출까지 확인했다. `/search` 200(실제 AI API 임베딩), Profile 불일치 422, 시크릿 누락 401. README 에 없어 이번에 구성해 검증한 명령이다(F5 발견으로 -59 에 인계했다). @@ -295,7 +295,7 @@ ValueError: could not convert string to float: '[0.05609131,0.008399963,...]' | F6 `PRESET_CACHE_TTL_SEC` 미사용 | 문서-구현 불일치 | -59 → 설정·문서·`.env.example`에서 제거(ai#24) | **해결됨** (ai#24) | | F4 검색 하한 실측 근거 | 근거 보강 | -59 → `personal-search.md` §6에 0.3143·간격 +0.2120 기재 | **반영됨** (ai#22) | | 판정 비결정성 | 계약 명시 필요 | 현행 유지(허용) 결정 + -59 → `keyword-preset.md` §4.4 신설 | **반영됨** (ai#22) | -| **F2b 권한 경계 미검증** | 인프라 | **`S15P11A705-61`** (ai 전용 DB role) — 근거는 이 문서 | **미해소** | +| **F2b 권한 경계 미검증** | 인프라 | **Jira 작업** (ai 전용 DB role) — 근거는 이 문서 | **미해소** | | 하네스-운영 코드 분리 | 구조 | 실측상 결과 차이 없음. 프롬프트 사본 3개는 잔존하며 통합은 별건 | 기록 | | BLOCKED 제외 실데이터 미검증 | 커버리지 갭 | 프리셋에 BLOCKED 가 생기면 자연 해소 | 기록 | | T22~T24 환경 이슈 | 재현 가능 | [troubleshooting](../troubleshooting/2026-07-27-e2e-env-issues.md) | 이 PR | @@ -304,12 +304,12 @@ ValueError: could not convert string to float: '[0.05609131,0.008399963,...]' ``` pytest -q 46 passed -python -m app.bootstrap.load_presets OK: 27 presets upserted (실제 GMS) +python -m app.bootstrap.load_presets OK: 27 presets upserted (실제 AI API) uvicorn app.main:app --port 8000 preset cache loaded: 27 presets, /health 200 Context 8건 → /context/process 6.0s에 두 status 전부 COMPLETED 후보 밖 keyword_id 0건 docker build -t pinlog-ai . 360MB -docker run (위 명령) /health 200, /search 200(실 GMS), 422, 401 +docker run (위 명령) /health 200, /search 200(실 AI API), 422, 401 ``` ## 남은 것 diff --git a/docs/implements/2026-07-28-s1-implementation-recovery.md b/docs/implements/2026-07-28-s1-implementation-recovery.md index ab52701..bbf0000 100644 --- a/docs/implements/2026-07-28-s1-implementation-recovery.md +++ b/docs/implements/2026-07-28-s1-implementation-recovery.md @@ -32,7 +32,7 @@ | 10 | Jira 키 검증은 PR 제목만 대상 | squash 병합이므로 PR 제목이 최종 커밋 메시지가 된다 | | 11 | 브랜치 보호는 required check 만 두고, 필수 리뷰 0·`enforce_admins:false` | 1인 레포의 self-merge 와 긴급 대응 여지를 위해서다. 적용 시점을 "CI 그린 후"로 미룬 판단이 옳았음이 확인됐다. 먼저 켰다면 CI 를 고치는 핫픽스 PR 자체가 막혔을 것이다 | | 12 | 동시성 테스트에서 `sleep` 금지, `on_call` 훅 사용 | Fake 의 `on_call` 안에서 `raw_connect` 로 다른 커넥션을 열어 CANCELLED 를 주입하고 `asyncio.gather` 로 경합을 재현한다 | -| 13 | Fake 는 인터페이스 레벨로 만들고 호출 횟수를 기록 | 단언의 핵심이 `call_count == 0/1` 이다. 실제 GMS 호출은 금지다 | +| 13 | Fake 는 인터페이스 레벨로 만들고 호출 횟수를 기록 | 단언의 핵심이 `call_count == 0/1` 이다. 실제 AI API 호출은 금지다 | | 14 | Profile 문자열 리터럴 금지, `settings` fixture 경유 | (근거 기록 없음) | | 15 | builders 에 "본문 버전" 인자를 두지 않음 | Context 는 불변이다. 수정 시나리오는 context_id 가 다른 두 State 로 표현한다 | | 16 | `settings` fixture 에서 `get_settings()` 캐시 재설정 | `main.py` 의 모듈 레벨 `app=create_app()` 이 import 시점에 `.env` 를 캐시하기 때문이다 (→ T26) | @@ -78,7 +78,7 @@ - 저수준 27개(unit·repo·api)와 파이프라인 19함수/20시나리오, 합계 46 passed. 이후 CI 계약 6개가 추가되어 52가 됐다. - 단언의 축은 호출 횟수다. 동시성은 두 커넥션과 `asyncio.gather` 로 재현했고 `sleep` 은 없다. - Testcontainers 로 실제 pgvector 를 띄우고 `ai_snapshot.sql` 적용, TRUNCATE 격리. -- E1 은 로컬 end-to-end 를 실제 GMS 로 확인했다("모임" 질의 유사도 0.56, "가족" 0.72, 타 유저 차단, 422/401/health). +- E1 은 로컬 end-to-end 를 실제 AI API 로 확인했다("모임" 질의 유사도 0.56, "가족" 0.72, 타 유저 차단, 422/401/health). - E2 는 4시나리오를 확인했다(정상 / 후보 0건도 COMPLETED / CANCELLED 거부 / 부분 재개 시 임베딩 호출 0회). - DISTINCT ON 의 대표 선택을 실증했다(record 40 에 context 300·301 이 있을 때 300 을 대표로 반환). @@ -91,11 +91,11 @@ - 크로스레포 사실 드리프트를 어떤 CI 도 잡지 않는다. `e3-test-harness.md:36` 의 "back 과 일치한다"는 서술이 back 의 변경 순간 거짓이 됐지만 감지 수단이 없었다. - CI 러너의 digest pull 이 network policy 에 걸리는지 검증하지 않았다. - `unmatchedConcepts` 는 eval 하네스가 한 번도 실행한 적이 없다. 하네스 스키마에 없었고 구현에서 처음 추가된 필드다. -- 자동 테스트는 Fake 만 쓰고 실제 GMS 를 호출하지 않으므로, 프로바이더의 응답 계약 변화를 테스트가 잡지 못한다. (`추정`) +- 자동 테스트는 Fake 만 쓰고 실제 AI API 를 호출하지 않으므로, 프로바이더의 응답 계약 변화를 테스트가 잡지 못한다. (`추정`) ## 5. spec ↔ 구현 불일치 — 구현 결함 (정상화 아님, 별도 Bug Task 대상) -> 아래는 spec 이 계약으로 명시한 것을 구현이 지키지 않는 상태다. "왜 그렇게 했는가"는 근거가 없어 `미복원`이다. 이 문서는 사실만 기록하며 결함을 설계 판단으로 서술하지 않는다. 실제 수정은 별도 Bug Task `S15P11A705-121`(외부 API 오류 재시도 및 분류 계약 수정)에서 다룬다. +> 아래는 spec 이 계약으로 명시한 것을 구현이 지키지 않는 상태다. "왜 그렇게 했는가"는 근거가 없어 `미복원`이다. 이 문서는 사실만 기록하며 결함을 설계 판단으로 서술하지 않는다. 실제 수정은 별도 Bug Task Jira 작업(외부 API 오류 재시도 및 분류 계약 수정)에서 다룬다. | # | spec 계약 | 실제 구현 | 출처 | 왜(판단) | |---|---|---|---|---| @@ -128,7 +128,7 @@ - 기능별 폴더 분리는 보류했다. architecture §9 에 규칙만 남겼다. - Python 상한 `<3.13` 은 잠정이다. - Jira 상태 전환은 수동이다(-19 미완). -- ~~`conftest.py:23` 이 `0.8.1` 인 채로 남았다(back·운영은 0.8.5)~~ → 해소됨. `S15P11A705-122` 에서 `0.8.5-pg16` + digest 고정으로 정합화했다. +- ~~`conftest.py:23` 이 `0.8.1` 인 채로 남았다(back·운영은 0.8.5)~~ → 해소됨. Jira 작업에서 `0.8.5-pg16` + digest 고정으로 정합화했다. - 사전 검사가 두 번의 별도 커넥션 조회로 나뉘어 있다(TOCTOU 창이 2개). spec §4.1 의 "1회 조회"와 어긋나지만 근거는 `미복원`이다. ## 8. 미복원 항목 (창작 금지, 여기서 확정) diff --git a/docs/implements/2026-07-29-demo-seeding.md b/docs/implements/2026-07-29-demo-seeding.md index c123fb9..fd8542a 100644 --- a/docs/implements/2026-07-29-demo-seeding.md +++ b/docs/implements/2026-07-29-demo-seeding.md @@ -2,7 +2,7 @@ - **상태**: 완료 - **날짜**: 2026-07-29 -- **Jira**: S15P11A705-58 +- **Jira**: Jira 작업 - **관련 PR**: (이 PR) - **근거 계약**: [spec/context-processing.md](../spec/context-processing.md), [spec/personal-search.md](../spec/personal-search.md), [spec/state-machine.md](../spec/state-machine.md) - **선행 기록**: [2026-07-27-e2e-verification.md](2026-07-27-e2e-verification.md) (I21) @@ -52,7 +52,7 @@ SQL 직접 INSERT 의 위험은 비용이 아니라 거짓 성공이다. 화면 그 외 Record·Context·Collection·Follow 는 전부 API 로 만든다. -> **`core` 에 쓰는 것이 계약 위반 아닌가.** 계약이 금지하는 것은 *FastAPI 런타임이* `core` 를 읽고 쓰는 것이다(README:10, [architecture.md](../spec/architecture.md) §7). `tools/` 의 로컬 스크립트는 런타임이 아니고, 앱 코드에는 `core` 참조가 한 줄도 늘지 않았다. I21 이 실증한 대로 로컬 접속 롤은 슈퍼유저라 DB 가 이 경계를 강제하지 않는다(`S15P11A705-61` 미해소). 그래서 쓰기 범위를 표식으로 좁히고 문서에 남기는 것이 현재 쓸 수 있는 유일한 방어선이다. +> **`core` 에 쓰는 것이 계약 위반 아닌가.** 계약이 금지하는 것은 *FastAPI 런타임이* `core` 를 읽고 쓰는 것이다(README:10, [architecture.md](../spec/architecture.md) §7). `tools/` 의 로컬 스크립트는 런타임이 아니고, 앱 코드에는 `core` 참조가 한 줄도 늘지 않았다. I21 이 실증한 대로 로컬 접속 롤은 슈퍼유저라 DB 가 이 경계를 강제하지 않는다(Jira 작업 미해소). 그래서 쓰기 범위를 표식으로 좁히고 문서에 남기는 것이 현재 쓸 수 있는 유일한 방어선이다. ### back 변경은 필요하지 않았다 @@ -63,7 +63,7 @@ SQL 직접 INSERT 의 위험은 비용이 아니라 거짓 성공이다. 화면 --- -## 판단 2 — GMS 실호출 몇 건이 적정인가 +## 판단 2 — AI API 실호출 몇 건이 적정인가 ### 결론: Context 14건 = 실호출 29회 (임베딩 14 + 판정 14 + 프리셋 1배치) @@ -75,11 +75,11 @@ SQL 직접 INSERT 의 위험은 비용이 아니라 거짓 성공이다. 화면 | 탐색 피드 | 타 소유자와 그 Context | 4명 × 2 = 8 | `max-per-owner=2` 라 소유자 4명이 카드 8장의 상한이다 | | Keyword | (위에 자연히 붙는다) | 0 | 별도 Context 를 만들지 않는다 | -Collection 9개와 Follow 2건은 Record 를 재사용하므로 GMS 호출이 늘지 않는다. 소유자당 Collection 을 2개로 둔 것도 같은 이유다. 화면은 채워지고 비용은 그대로다. +Collection 9개와 Follow 2건은 Record 를 재사용하므로 AI API 호출이 늘지 않는다. 소유자당 Collection 을 2개로 둔 것도 같은 이유다. 화면은 채워지고 비용은 그대로다. ### 진짜 제약은 돈이 아니라 429였다 -비용 산정보다 먼저 부딪힌 것은 GMS 게이트웨이의 Gemini 429 다. 판정 호출을 15초 간격으로 12회 던져 측정했다. +비용 산정보다 먼저 부딪힌 것은 AI API 게이트웨이의 Gemini 429 다. 판정 호출을 15초 간격으로 12회 던져 측정했다. ``` 1.8s OK 81.4s OK 146.4s OK @@ -125,7 +125,7 @@ I21 이후 ai `app/` 에 들어온 변경은 `-96` 의 둘(`/ready`·`GMS_BASE_U |---|---| | `run_pipeline.py` | Context 8건 → 6.0초에 두 status 전부 COMPLETED (I21 과 동일) | | `run_search.py` | 계약 3종(200·422·401) 통과, 관련 질의 6건 전부 1위, 분리도 +0.2120 | -| `run_equivalence.py` | GMS 429 로 중단 (아래) | +| `run_equivalence.py` | AI API 429 로 중단 (아래) | | `run_attribution.py` | 미실행 — 위와 같은 사유 | ### 검색 수치가 소수점 넷째 자리까지 같다 @@ -323,7 +323,7 @@ return new RecordDetailResponse( `verify.py` 는 DB 를 세어 통과시키지 않는다. 시연에서 실제로 호출될 두 API 를 그대로 호출하고 그 응답만으로 판정한다. ``` -A. 자연어 검색 POST /internal/v1/search (FastAPI, 주인공 userId, 실 GMS 임베딩) +A. 자연어 검색 POST /internal/v1/search (FastAPI, 주인공 userId, 실 AI API 임베딩) B. 탐색 피드 GET /v1/feed/collections (back, 주인공 인증) C. Keyword B의 응답 keywords + GET /v1/records/{id} ``` diff --git a/docs/implements/2026-07-29-dev-deployment-gates.md b/docs/implements/2026-07-29-dev-deployment-gates.md index ba8942a..9767820 100644 --- a/docs/implements/2026-07-29-dev-deployment-gates.md +++ b/docs/implements/2026-07-29-dev-deployment-gates.md @@ -1,10 +1,10 @@ -# dev 배포 게이트 3종 — `/ready` 프로브, `GMS_BASE_URL` 기동 검증, GMS 양방향 스모크를 구현했다 +# dev 배포 게이트 3종 — `/ready` 프로브, `GMS_BASE_URL` 기동 검증, AI API 양방향 스모크를 구현했다 - **상태**: 완료 - **날짜**: 2026-07-29 - **관련 PR**: [ai#33](https://github.com/Team-PinLog/ai/pull/33) -- **근거 계약**: [ai#32](https://github.com/Team-PinLog/ai/pull/32) `docs/S15P11A705-96-dev-deployment-contract.md` — 인프라(김세민) 요청 §2·§3과 코멘트 합의(2026-07-29) -- **Jira**: [S15P11A705-96](https://ssafy.atlassian.net/browse/S15P11A705-96) +- **근거 계약**: [ai#32](https://github.com/Team-PinLog/ai/pull/32) 배포 계약 문서 — 인프라(김세민) 요청 §2·§3과 코멘트 합의(2026-07-29) +- **Jira**: Jira 작업 ## 무엇을 만들었나 @@ -27,7 +27,7 @@ preset 현재 Embedding Profile 기준 캐시 ≥ 1건 설계 판단 4건을 남긴다. -- **GMS 를 호출하지 않는다.** 준비 판정에 외부 게이트웨이의 가용성을 섞으면, 자기 책임 밖의 장애 때문에 인스턴스가 트래픽에서 빠지게 된다. GMS 도달성은 배포 시점의 스모크가 따로 증명한다(계약 §2 의 요청과 일치한다). +- **AI API 를 호출하지 않는다.** 준비 판정에 외부 게이트웨이의 가용성을 섞으면, 자기 책임 밖의 장애 때문에 인스턴스가 트래픽에서 빠지게 된다. AI API 도달성은 배포 시점의 스모크가 따로 증명한다(계약 §2 의 요청과 일치한다). - **Profile 조건을 재조회하지 않는다.** 캐시는 lifespan 이 `settings.embedding_profile` 로 조회한 행만 담는다(`main.py`). 따라서 캐시 건수가 1건 이상이라는 것이 곧 "현재 Profile 기준 1건 이상"이다. 별도 쿼리를 두는 것은 같은 사실을 두 번 묻는 일이다. - **무인증으로 연다.** `SharedSecretMiddleware` 는 `/internal/` 경로만 가로채므로 `/ready` 는 헤더 없이 호출된다. 프로브가 시크릿을 들고 다니지 않게 하려는 계약상의 의도다. - **응답에 내부 값을 싣지 않는다.** 무인증으로 노출되는 경로이므로 `{"status": ...}` 한 필드만 싣는다. 실패 시 로그에도 예외 타입 이름만 남긴다. asyncpg 예외 메시지에는 DSN 이 섞여 들어올 수 있기 때문이다. @@ -52,7 +52,7 @@ preset 현재 Embedding Profile 기준 캐시 ≥ 1건 검증 대상은 `/gmsapi/` 세그먼트 포함 여부 하나다. scheme·host 형식은 검사하지 않는다. 그쪽 오류는 스모크가 실제 호출로 잡는 편이 확실하고, 검증 규칙을 늘리면 정상 값을 잘못 막을 위험만 커지기 때문이다. -## 3. GMS 양방향 스모크 (`app/smoke/gms_roundtrip.py`) +## 3. AI API 양방향 스모크 (`app/smoke/gms_roundtrip.py`) ```bash python -m app.smoke.gms_roundtrip @@ -78,7 +78,7 @@ python -m app.smoke.gms_roundtrip | 테스트 | `pytest -q` | **66 passed** (기존 52 → +14) | | `/ready` 실기동 | `uvicorn app.main:app --port 8011` → `curl /ready` | `200 {"status":"ready"}` (실 pgvector·preset 27건) | | `/health` 실기동 | `curl /health` | `200 {"status":"ok"}` — 형태 불변 | -| 스모크 정상 | `python -m app.smoke.gms_roundtrip` (실 GMS) | `embedding: ok` / `judge: ok` / exit **0** | +| 스모크 정상 | `python -m app.smoke.gms_roundtrip` (실 AI API) | `embedding: ok` / `judge: ok` / exit **0** | | 스모크 비대칭 실패 | 위 명령 + `PINLOG_JUDGE_MODEL=<미존재 모델>` | `embedding: ok` / `judge: failed (TransientError)` / exit **1** | | URL fail-fast | `GMS_BASE_URL=<세그먼트 없는 값> python -c "import app.main"` | `SettingsError` 로 기동 중단. 메시지에 값 없음 | @@ -89,7 +89,7 @@ python -m app.smoke.gms_roundtrip | `tests/test_api.py` | 6 | `/ready` 200·503(캐시 0건)·503(DB 끊김)·무인증·값 미노출·`/health` 불변 회귀 | | `tests/test_unit.py` | 8 | `GMS_BASE_URL` 거부 2·수용 1·값 미노출 1 · 스모크 집계 3·출력 1 | -스모크의 실제 호출은 단위 테스트로 덮지 않았다. 외부 의존이므로 `_CHECKS` 를 스텁으로 교체해 집계·종료 코드·값 미노출 규약만 검증하고, 실제 GMS 왕복은 위 표의 수동 실측으로 대신했다. 실제 호출을 CI 에 넣으면 GMS 가용성이 CI 성패에 들어오기 때문이다. +스모크의 실제 호출은 단위 테스트로 덮지 않았다. 외부 의존이므로 `_CHECKS` 를 스텁으로 교체해 집계·종료 코드·값 미노출 규약만 검증하고, 실제 AI API 왕복은 위 표의 수동 실측으로 대신했다. 실제 호출을 CI 에 넣으면 AI API 가용성이 CI 성패에 들어오기 때문이다. ## 인프라에 전달할 것 @@ -100,5 +100,5 @@ python -m app.smoke.gms_roundtrip ## 남은 것 - `_profile_consistency` 의 `ValueError` 경로 값 노출(§2 의 범위 밖 관찰) — 별도 티켓으로 다룬다. -- 외부 API retry/error classification — `S15P11A705-121`. dev 배포의 blocker 는 아니다(ai#32 합의). +- 외부 API retry/error classification — Jira 작업. dev 배포의 blocker 는 아니다(ai#32 합의). - `llm_client` 의 `/gmsapi/` 리터럴과 `config.GMS_PATH_SEGMENT` 가 각자 리터럴로 존재한다. 통합은 client 계층 변경이라 이번 범위 밖이다. diff --git a/docs/implements/2026-07-29-sealed-secret-handoff.md b/docs/implements/2026-07-29-sealed-secret-handoff.md index 19b94be..2bde866 100644 --- a/docs/implements/2026-07-29-sealed-secret-handoff.md +++ b/docs/implements/2026-07-29-sealed-secret-handoff.md @@ -1,13 +1,13 @@ # Runtime Secret handoff — AI 값은 Environment 경계에서만 전달한다 -`S15P11A705-154` +Jira 작업 > **상태: 대체(구현 주체 이관) — 설계 근거는 보존** > -> 이 문서의 이전 판(`S15P11A705-96`, 레포 자체 workflow `seal-ai-secrets.yml`)이 기록한 -> 구현은 `S15P11A705-154`에서 Infra 공용 action +> 이 문서의 이전 판(Jira 작업, 레포 자체 workflow `seal-ai-secrets.yml`)이 기록한 +> 구현은 Jira 작업에서 Infra 공용 action > `Team-PinLog/infra/.github/actions/sealedsecret-infra-pr`으로 대체됐다. 아래 -> [대체된 구현의 설계 근거](#대체된-구현의-설계-근거-s15p11a705-96--보존)의 판단은 +> [대체된 구현의 설계 근거](#대체된-구현의-설계-근거-보존)의 판단은 > 폐기된 것이 아니라 해당 action 에 반영되어 있다. > > `docs/implements/README.md`의 보존 원칙("완료된 항목도 삭제하지 않고 상태 표시만 @@ -55,7 +55,7 @@ GitOps revision 으로 되돌리는 방식으로 처리한다. 이 변경은 liv action SHA, 입력과 exact key set 을 검증한다. 실제 Secret 값과 live sealing 결과는 이 변경에서 조회하거나 실행하지 않는다. -## 대체된 구현의 설계 근거 (`S15P11A705-96` · 보존) +## 대체된 구현의 설계 근거 (보존) 이 절은 봉인을 AI 레포 workflow 가 직접 수행했을 때의 판단 기록이다. 실행 주체는 Infra 공용 action 으로 옮겼고, 아래 근거는 그 action 이 이어받았다. 같은 실수를 다시 하지 않기 diff --git a/docs/implements/2026-07-30-coverage-gate.md b/docs/implements/2026-07-30-coverage-gate.md index cd796ff..7b331e2 100644 --- a/docs/implements/2026-07-30-coverage-gate.md +++ b/docs/implements/2026-07-30-coverage-gate.md @@ -1,12 +1,12 @@ -# app coverage 게이트 활성화 — line·branch 각각 80% 이상을 병합 차단 조건으로 전환했다 (S15P11A705-110) +# app coverage 게이트 활성화 — line·branch 각각 80% 이상을 병합 차단 조건으로 전환했다 (Jira 작업) 상태: 완료 · 유형: 구현 · 근거: [CONTRIBUTING.md](../../CONTRIBUTING.md) 검증 절, [integration-tests.md](../spec/integration-tests.md) §4.2 · §5 -`S15P11A705-108` 이 비차단으로 도입한 `app` line·branch coverage 측정을 병합 차단 게이트로 전환했다. 티켓 완료 조건은 *"line 과 branch 각각 80% 이상"* 이다. +Jira 작업 이 비차단으로 도입한 `app` line·branch coverage 측정을 병합 차단 게이트로 전환했다. 티켓 완료 조건은 *"line 과 branch 각각 80% 이상"* 이다. ## 1. 기준선이 낡아 있었다 -티켓 본문의 수치는 2026-07-28(52 tests) 관측이다. 그 사이 `S15P11A705-121`(ai#44, `b45aa93`)이 client 재시도·오류 분류 테스트 58개를 추가해 상황이 바뀌었다. 그래서 착수 시점에 재측정했다. +티켓 본문의 수치는 2026-07-28(52 tests) 관측이다. 그 사이 Jira 작업(ai#44, `b45aa93`)이 client 재시도·오류 분류 테스트 58개를 추가해 상황이 바뀌었다. 그래서 착수 시점에 재측정했다. | | 티켓 기재 (07-28) | 재측정 (`b45aa93` 시점) | 최종 | |---|---|---|---| diff --git a/docs/implements/2026-07-30-judge-vendor-fallback.md b/docs/implements/2026-07-30-judge-vendor-fallback.md index f32df6b..e665c64 100644 --- a/docs/implements/2026-07-30-judge-vendor-fallback.md +++ b/docs/implements/2026-07-30-judge-vendor-fallback.md @@ -1,14 +1,14 @@ -# 판정 LLM 벤더 폴백 — 일시적 오류에서 OpenAI·Gemini·Anthropic 체인으로 넘어가게 했다 (S15P11A705-175) +# 판정 LLM 벤더 폴백 — 일시적 오류에서 OpenAI·Gemini·Anthropic 체인으로 넘어가게 했다 (Jira 작업) 상태: 완료 · 근거 계약: [failure-recovery.md §3.4](../spec/failure-recovery.md) · -관련: [S15P11A705-121 재시도·오류 분류](2026-07-30-retry-and-error-classification.md), -[S15P11A705-174 실데이터 E2E](2026-07-30-real-data-e2e.md) +관련: [Jira 작업 재시도·오류 분류](2026-07-30-retry-and-error-classification.md), +[Jira 작업 실데이터 E2E](2026-07-30-real-data-e2e.md) 판정 LLM 호출이 Gemini 한 경로에 묶여 있던 것을 벤더 어댑터 구조로 바꾸고, 일시적 오류일 때 다음 벤더로 넘어가게 했다. 도메인 코드·DB 스키마 변경은 없다. ## 1. 왜 — 429 는 게이트웨이 전역이 아니라 Gemini 경로에만 있었다 -2026-07-30 실측이다. 같은 시각·같은 GMS 키·같은 판정 작업(Context 5건 × 모델당 12~15회)을 던졌다. 도구는 `tools/keyword_eval/probe_vendors.py` 다. +2026-07-30 실측이다. 같은 시각·같은 AI API 키·같은 판정 작업(Context 5건 × 모델당 12~15회)을 던졌다. 도구는 `tools/keyword_eval/probe_vendors.py` 다. | 모델 | 성공률 | 평균 응답 | prompt | output | 현행과 일치도 | |---|---|---|---|---|---| @@ -21,7 +21,7 @@ OpenAI·Anthropic 경로는 한 번도 막히지 않았다. 임베딩(OpenAI 호환 경로) 49회가 안 막힌 것과 같은 양상이다. 즉 쿼터가 게이트웨이 전역이 아니라 프로바이더 경로별로 걸린다. -429 가 나면 `keyword_status` 가 `PROCESSING` 에 남고 `AiProcessClient` 가 실패를 삼킨다. 화면에는 Context 가 정상 생성된 것으로 보이는데 키워드도 검색도 안 된다. 재스캔(`S15P11A705-159`)이 5분 뒤 복구하지만, 시연 중의 5분은 없는 시간과 같다. +429 가 나면 `keyword_status` 가 `PROCESSING` 에 남고 `AiProcessClient` 가 실패를 삼킨다. 화면에는 Context 가 정상 생성된 것으로 보이는데 키워드도 검색도 안 된다. 재스캔(Jira 작업)이 5분 뒤 복구하지만, 시연 중의 5분은 없는 시간과 같다. ## 2. 확정한 폴백 순서 @@ -73,7 +73,7 @@ Anthropic {root}/api.anthropic.com/v1/messages ## 5. 넘어가는 조건과 넘어가지 않는 조건 -`TransientError`(429·5xx·타임아웃·연결 실패·구조화 출력 위반)만 다음 벤더로 넘어간다. `PermanentError`(400·401·403)는 넘어가지 않는다. 키·설정 문제는 다른 벤더에서도 같은 답이 나오고, 넘어가면 GMS 호출만 3배가 되기 때문이다(`S15P11A705-121` 결함 3의 재발 형태다). +`TransientError`(429·5xx·타임아웃·연결 실패·구조화 출력 위반)만 다음 벤더로 넘어간다. `PermanentError`(400·401·403)는 넘어가지 않는다. 키·설정 문제는 다른 벤더에서도 같은 답이 나오고, 넘어가면 AI API 호출만 3배가 되기 때문이다(Jira 작업 결함 3의 재발 형태다). 구조화 출력 위반을 폴백 사유에 넣은 이유는 벤더마다 구조화 출력 방식이 다르기 때문이다. 한쪽이 응답 절단이나 안전 차단으로 깨져도 다른 쪽은 성공할 수 있다. 재시도가 소진된 후에는 기존과 같이 영구 오류로 승격한다(§2.2). @@ -112,13 +112,13 @@ RED 드릴 2회를 실제로 관측했다. | 1 | `_call_for` 가 항상 1순위를 반환(폴백 무력화) | 6건 실패 — 429 폴백, 시도별 벤더 순서, 오류 메시지의 벤더, 스키마 위반 폴백, 전송 실패 폴백, 토큰 로그 | | 2 | 재시도 드라이버가 `PermanentError` 도 다음 벤더로 넘김 | 14건 실패 — 이 PR 신규 3건(`401` 폴백 금지, `400`·`403` 폴백 금지)과 기존 재시도 계약 11건이 함께 실패한다. 폴백이 §2.2 분류를 재정의하지 않았다는 증거다 | -실제 GMS 호출은 하지 않았다. 이 PR 의 테스트는 `httpx.MockTransport` 로 세 벤더의 응답 봉투를 직접 만든다(tests/README.md — 실호출을 CI 에 넣지 않는다). §1 의 실측은 이 PR 이전에 `probe_vendors.py` 로 수행한 것이고, 어댑터가 실제 GMS 에서 왕복하는지는 `python -m app.smoke.gms_roundtrip` 으로 배포 절차에서 확인한다. +실제 AI API 호출은 하지 않았다. 이 PR 의 테스트는 `httpx.MockTransport` 로 세 벤더의 응답 봉투를 직접 만든다(tests/README.md — 실호출을 CI 에 넣지 않는다). §1 의 실측은 이 PR 이전에 `probe_vendors.py` 로 수행한 것이고, 어댑터가 실제 AI API 에서 왕복하는지는 `python -m app.smoke.gms_roundtrip` 으로 배포 절차에서 확인한다. ## 9. 남는 위험·미검증 - **표본은 Context 5건 · 하루 오후 한 시점이다.** 시간대가 바뀌면 성공률 분포가 달라질 수 있다. 1순위를 바꿔야 할 상황이 오면 설정 한 줄로 되고, 그것이 이 구조의 목적이다. - **일치도 0.83~0.93 의 차이에는 판정 자체의 비결정성이 섞여 있다.** 현행 모델도 같은 Context 를 반복하면 결과가 달라진다. 벤더가 바뀌면 Keyword 가 바뀔 수 있고 그것은 사용자에게 보이는 값이다. -- **Anthropic 은 prompt 가 1973 토큰으로 다른 벤더의 2.4배다.** 토큰 기준 과금이면 3순위가 비싸다. GMS 가 프로바이더별 과금을 어떻게 처리하는지 모르므로 확인 전에는 판단하지 않는다([cost-estimate.md](../spec/cost-estimate.md) §3 주석과 같은 이유다). -- **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 통과가 "세 벤더가 모두 살아 있다"를 뜻하지 않는다. 3순위가 죽어 있는데 모르는 상태가 가능하다. 벤더별 가용성은 `probe_vendors.py` 로 수동 확인한다. 스모크에서 벤더마다 실호출하면 배포 게이트가 GMS 쿼터에 3배로 묶이기 때문이다. 상시 관측은 `S15P11A705-96` 범위다. +- **Anthropic 은 prompt 가 1973 토큰으로 다른 벤더의 2.4배다.** 토큰 기준 과금이면 3순위가 비싸다. AI API 가 프로바이더별 과금을 어떻게 처리하는지 모르므로 확인 전에는 판단하지 않는다([cost-estimate.md](../spec/cost-estimate.md) §3 주석과 같은 이유다). +- **스모크는 체인 전체를 증명하지 않는다.** 1순위가 429 면 2순위로 넘어가 통과하므로, 통과가 "세 벤더가 모두 살아 있다"를 뜻하지 않는다. 3순위가 죽어 있는데 모르는 상태가 가능하다. 벤더별 가용성은 `probe_vendors.py` 로 수동 확인한다. 스모크에서 벤더마다 실호출하면 배포 게이트가 AI API 쿼터에 3배로 묶이기 때문이다. 상시 관측은 Jira 작업 범위다. - **1순위 모델명에 오타가 있으면 400/404 영구 오류로, 폴백 없이 판정이 실패한다.** 폴백 이전과 같은 실패 양상이지만, 체인이 길어질수록 오타 지점도 늘어난다. 기동 시 검증은 형식과 벤더 이름까지이며, 모델명이 실제로 존재하는지는 실제 호출만이 안다(스모크의 몫이다). - 벤더별 프롬프트 튜닝은 하지 않았다. 같은 프롬프트로 일치도 0.83~0.93 이 나왔고, 별건이다. diff --git a/docs/implements/2026-07-30-real-data-e2e.md b/docs/implements/2026-07-30-real-data-e2e.md index 79dee09..cf4dd7c 100644 --- a/docs/implements/2026-07-30-real-data-e2e.md +++ b/docs/implements/2026-07-30-real-data-e2e.md @@ -1,12 +1,12 @@ # 실사용자 데이터 E2E 검증 — 정확도·소요 시간·토큰을 실측했다 -- **티켓**: S15P11A705-174 +- **티켓**: Jira 작업 - **날짜**: 2026-07-30 -- **선행**: [데모 시딩](2026-07-29-demo-seeding.md) (`S15P11A705-58`) · [E2E 검증](2026-07-27-e2e-verification.md) +- **선행**: [데모 시딩](2026-07-29-demo-seeding.md) (Jira 작업) · [E2E 검증](2026-07-27-e2e-verification.md) - **측정 중 발견한 문제**: [T27·T28](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md) -> **2026-07-30 개정 (`S15P11A705-176`).** 초판의 두 서술이 틀려서 고쳤다. -> 판정 후보 수(27 → **10**, §6)와 시딩 소요(15분 → **42초**, §2)다. 후자는 GMS 가 +> **2026-07-30 개정 (Jira 작업).** 초판의 두 서술이 틀려서 고쳤다. +> 판정 후보 수(27 → **10**, §6)와 시딩 소요(15분 → **42초**, §2)다. 후자는 AI API 가 > 느렸던 것이 아니라 우리가 `--pace 25` 로 호출 간격을 벌렸기 때문이다. ## 요약 @@ -33,7 +33,7 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 | ② 계약 자리채움 | `kakaoPlaceId` · `address` · `lat` · `lng` | **없다.** 읽는 코드가 없다 | | ③ 시연 구성 | `collections` · `follows` | **탐색 피드**(§4) | -②는 back 의 `PlacePayload` 가 `@NotBlank`·`@NotNull` 로 요구해서 채운 값이다. 임베딩은 `context` 만 입력으로 받고 Feed MVP 는 region 을 제외했으므로(`S15P11A705-119`) 이 필드들은 어디에도 쓰이지 않는다. 실데이터 23건은 전 건이 같은 좌표와 "미확인" 주소를 쓴다. +②는 back 의 `PlacePayload` 가 `@NotBlank`·`@NotNull` 로 요구해서 채운 값이다. 임베딩은 `context` 만 입력으로 받고 Feed MVP 는 region 을 제외했으므로(Jira 작업) 이 필드들은 어디에도 쓰이지 않는다. 실데이터 23건은 전 건이 같은 좌표와 "미확인" 주소를 쓴다. ③은 AI 파트가 만들었다. 원본은 `장소명 · 맥락` 두 컬럼뿐이었고, Collection 묶음과 팔로우 관계는 시연 3종이 성립하도록 우리가 구성했다. §4 의 PASS 는 그 구조 위의 결과이며, 실제 사용자가 그렇게 묶었을지는 알 수 없다. @@ -50,7 +50,7 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 실데이터는 두 사람의 실제 방문 기록이다(김가현 11건 · 이정헌 12건). 가공 데이터와 달리 `[데모]` 접두사를 붙이지 않았다. 실제 기록을 가공으로 위장하면 오히려 오해를 만들기 때문이다. -## 2. 소요 시간 — 느렸던 것은 GMS 가 아니었다 +## 2. 소요 시간 — 느렸던 것은 AI API 가 아니었다 같은 데이터를 `--pace` 만 바꿔 두 번 시딩했다. @@ -63,7 +63,7 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 ### 왜 `--pace 25` 였나 — 한 시점의 측정을 상수로 읽었다 -초판은 GMS Gemini 쿼터를 "분당 약 2건"으로 두고 그에 맞춰 간격을 벌렸다. 그런데 다음 날 같은 코드가 분당 30건 이상을 통과시켰다. +초판은 AI API Gemini 쿼터를 "분당 약 2건"으로 두고 그에 맞춰 간격을 벌렸다. 그런데 다음 날 같은 코드가 분당 30건 이상을 통과시켰다. ``` 간격 5s × 24회 (prompt 257·86·41) 전부 200. 프롬프트 크기와 무관 @@ -71,7 +71,7 @@ Keyword PASS 74행 · 피드 카드 15/15 표시 동시 10건 10/10 · 1.7초 ``` -GMS 는 SSAFY 공용 게이트웨이라서 쿼터가 우리 전용 할당이 아니고, 프로바이더 경로별로 따로 걸린다. 같은 시각 OpenAI·Anthropic 경로는 12/12 로 막히지 않았다. 자세한 것은 [T27 정정본](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)과 `S15P11A705-175`(벤더 폴백)에 있다. +AI API 는 공용 게이트웨이라서 쿼터가 우리 전용 할당이 아니고, 프로바이더 경로별로 따로 걸린다. 같은 시각 OpenAI·Anthropic 경로는 12/12 로 막히지 않았다. 자세한 것은 [T27 정정본](../troubleshooting/2026-07-30-seeding-quota-and-encoding.md)과 Jira 작업(벤더 폴백)에 있다. | 단계 | 값 | |---|---| @@ -163,7 +163,7 @@ Context 부착 74행 (주인공 6 Record 에 18행) PRIVATE_ONLY 미노출 PASS (ANNIVERSARY* 가 타인 화면에 없음) ``` -관측 1건을 남긴다. `GET /v1/records/{id}` 의 `keywords` 가 `[]` 다. back 의 `RecordDetailResponse.of` 가 `List.of()` 를 고정 반환한다(미구현). 따라서 Record 상세 화면에는 Keyword 가 나오지 않는다. `S15P11A705-58` 에서 이미 발견된 back 소관 결함이며 이 티켓 범위가 아니다. +관측 1건을 남긴다. `GET /v1/records/{id}` 의 `keywords` 가 `[]` 다. back 의 `RecordDetailResponse.of` 가 `List.of()` 를 고정 반환한다(미구현). 따라서 Record 상세 화면에는 Keyword 가 나오지 않는다. Jira 작업에서 이미 발견된 back 소관 결함이며 이 티켓 범위가 아니다. ## 6. 토큰 사용량 @@ -197,7 +197,7 @@ Context 1건 처리 = 임베딩 37.7 + 판정 839.6 ≈ 877 토큰 ### 벤더 비교 — 판정 결과는 벤더 간에 대체로 일치한다 -`S15P11A705-175` 를 위해 같은 판정 작업을 6개 모델에 던졌다(Context 5건 × 모델당 12~15회). +Jira 작업 를 위해 같은 판정 작업을 6개 모델에 던졌다(Context 5건 × 모델당 12~15회). | 모델 | 성공률 | 평균 | prompt | output | 현행과 일치도 | |---|---|---|---|---|---| @@ -249,7 +249,7 @@ python tools/demo_seed/token_report.py .demo/token-usage.jsonl ### 시연 당일에는 시딩하지 말고 스냅샷을 복구한다 -GMS 가 그날 혼잡하면 시딩이 42초가 아니라 15분이 된다. 쿼터는 우리가 통제할 수 없으므로 의존하는 시점을 통제한다. 전날 데이터를 확정하고 스냅샷을 떠 두면, 복구는 GMS 를 한 번도 부르지 않는다. +AI API 가 그날 혼잡하면 시딩이 42초가 아니라 15분이 된다. 쿼터는 우리가 통제할 수 없으므로 의존하는 시점을 통제한다. 전날 데이터를 확정하고 스냅샷을 떠 두면, 복구는 AI API 를 한 번도 부르지 않는다. ```bash # 데이터가 좋을 때 (전날) diff --git a/docs/implements/2026-07-30-retry-and-error-classification.md b/docs/implements/2026-07-30-retry-and-error-classification.md index 07a4861..ee1f771 100644 --- a/docs/implements/2026-07-30-retry-and-error-classification.md +++ b/docs/implements/2026-07-30-retry-and-error-classification.md @@ -1,4 +1,4 @@ -# 외부 API 재시도·오류 분류 정합화 — 두 클라이언트의 반대 분류를 spec 대로 고쳤다 (S15P11A705-121) +# 외부 API 재시도·오류 분류 정합화 — 두 클라이언트의 반대 분류를 spec 대로 고쳤다 (Jira 작업) 상태: 완료 · 유형: 구현 · 근거 명세: [failure-recovery.md](../spec/failure-recovery.md) §2.1 · §2.2 · §3.1 · §3.2 @@ -9,7 +9,7 @@ | | spec | 수정 전 구현 | 결과 | |---|---|---|---| | `429` | Transient (§2.1) | `>= 500` 만 Transient. 나머지 non-200 전부 Permanent | rate limit 한 번에 해당 Context 가 영구 실패로 남는다 | -| LLM `400`·`401`·`403` | Permanent (§2.2) | 모든 non-200 을 Transient | 인증 실패가 재스캔 주기(5분)마다 GMS 호출을 만든다 | +| LLM `400`·`401`·`403` | Permanent (§2.2) | 모든 non-200 을 Transient | 인증 실패가 재스캔 주기(5분)마다 AI API 호출을 만든다 | 두 클라이언트가 각자 상태 코드 표를 들고 있었던 것이 원인이다. 그래서 매핑을 `app/core/errors.py` 의 `classify_http_status` 한 곳으로 모았다. spec §2 가 "`errors.py` 에서 분류한다"고 지목한 지점이다. `429` 를 `>= 500` 보다 먼저 판정해야 4xx 로 떨어지지 않는다. diff --git a/docs/implements/2026-07-31-candidate-threshold.md b/docs/implements/2026-07-31-candidate-threshold.md index 7cf9a08..61ab4c8 100644 --- a/docs/implements/2026-07-31-candidate-threshold.md +++ b/docs/implements/2026-07-31-candidate-threshold.md @@ -1,8 +1,8 @@ # 후보 유사도 임계값 τ — 실데이터로 재검증한 결과 어느 값도 이득이 손실을 넘지 못해 현행 0.30 을 유지한다 -- **티켓**: S15P11A705-210 +- **티켓**: Jira 작업 - **날짜**: 2026-07-31 -- **선행**: [실데이터 E2E](2026-07-30-real-data-e2e.md) (`S15P11A705-174`) · `tools/keyword_eval/REPORT.md` +- **선행**: [실데이터 E2E](2026-07-30-real-data-e2e.md) (Jira 작업) · `tools/keyword_eval/REPORT.md` - **측정 중 발견한 문제**: [T37~T39](../troubleshooting/2026-07-31-tau-measurement.md) - **하네스**: `tools/tau_grid/` @@ -33,10 +33,10 @@ ### 1.1 기존 emb_grid 하네스를 쓰지 않고 별도 하네스를 만든 이유 -`tools/emb_grid`(`S15P11A705-191`)는 조건마다 데이터를 전량 재시딩한다. 그 하네스는 +`tools/emb_grid`(Jira 작업)는 조건마다 데이터를 전량 재시딩한다. 그 하네스는 임베딩 모델과 차원이 실험 조건이므로 조건이 바뀔 때마다 벡터를 다시 만들 수밖에 없다. 반면 τ 는 임베딩을 바꾸지 않고 후보 선정 단계에만 걸리는 값이다. emb_grid 를 그대로 -쓰면 조건마다 GMS 임베딩 호출 42회를 아무 이유 없이 다시 쓰게 된다. +쓰면 조건마다 AI API 임베딩 호출 42회를 아무 이유 없이 다시 쓰게 된다. 그래서 별도 하네스를 작성했다. 구조(조건 정본을 한 파일에 두는 방식 · 사전 검증 · JSON 산출)는 `emb_grid` 를 따랐다. @@ -255,7 +255,7 @@ terse 8건 min 0.2968 p50 0.4725 max 0.5847 (티라미수 최고 → | 데이터 | `pinlog-demo`(:15432) · Context 42건(고유 본문 37 + 중복 5) · 프리셋 27 · 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | | 판정 모델 | `gemini-2.5-flash` (`thinkingBudget=0`) | -| GMS 호출 | 임베딩 16회(프로브) + 판정 81회(검증) | +| AI API 호출 | 임베딩 16회(프로브) + 판정 81회(검증) | ```bash cd ai diff --git a/docs/implements/2026-07-31-db-error-classification.md b/docs/implements/2026-07-31-db-error-classification.md index 4fe80c0..db22008 100644 --- a/docs/implements/2026-07-31-db-error-classification.md +++ b/docs/implements/2026-07-31-db-error-classification.md @@ -1,6 +1,6 @@ # DB 실패의 오류 분류 — 검색 중 DB 접속이 실패하면 500 대신 503 을 반환하게 했다 -- **티켓**: S15P11A705-221 +- **티켓**: Jira 작업 - **상태**: 완료 - **날짜**: 2026-07-31 - **선행**: [검색 API 오류 응답 계약](2026-07-31-search-error-contract.md) (`-220`) · @@ -146,7 +146,7 @@ SQLSTATE 는 공개 어휘이면서 *"풀이 고갈됐다"* 와 *"DB 가 재기 ### 2.6 재시도를 넣지 않는다 -티켓이 확정한 대로다. `-121` 이 정한 짧은 재시도는 GMS 호출 대상이고, DB 재시도는 +티켓이 확정한 대로다. `-121` 이 정한 짧은 재시도는 AI API 호출 대상이고, DB 재시도는 커넥션 풀 동작과 얽혀 있어 별개 문제다. 지금 구조에서 DB 일시 오류는 `503` 으로 나가고 `back` 은 검색을 재시도하지 않으므로(`AiSearchClient`), 사용자 화면은 이 티켓 전후로 같다. 바뀌는 것은 운영자가 보는 관측 정보이고, 이는 `-220` 과 같은 성격의 diff --git a/docs/implements/2026-07-31-docs-index-check.md b/docs/implements/2026-07-31-docs-index-check.md index 6b9bcb5..195a020 100644 --- a/docs/implements/2026-07-31-docs-index-check.md +++ b/docs/implements/2026-07-31-docs-index-check.md @@ -115,7 +115,7 @@ 일부러 심은 것이 아니다. 이 PR 을 만드는 동안 실제로 났다. -PR 을 연 뒤 `origin/dev` 를 병합했더니, 그 사이 `S15P11A705-205` 가 병합되면서 +PR 을 연 뒤 `origin/dev` 를 병합했더니, 그 사이 Jira 작업 가 병합되면서 `T61`·`T62`·`T63` 과 `I34` 를 가져간 상태였다. 나는 병합 전에 검사를 돌려 「다음 번호 `T61`·`I34`」를 받아 그대로 썼다. 세는 시점이 병합보다 앞이었다는 점에서 사고 4 와 완전히 같은 유형이다. diff --git a/docs/implements/2026-07-31-embedding-grid.md b/docs/implements/2026-07-31-embedding-grid.md index e374280..f2d0bc9 100644 --- a/docs/implements/2026-07-31-embedding-grid.md +++ b/docs/implements/2026-07-31-embedding-grid.md @@ -1,8 +1,8 @@ # 임베딩 4조건 실경로 측정 — 합계는 동률이고, 조건마다 실패하는 질의가 다르다 -- **티켓**: S15P11A705-191 +- **티켓**: Jira 작업 - **날짜**: 2026-07-31 -- **선행**: [실사용자 데이터 E2E](2026-07-30-real-data-e2e.md) (`S15P11A705-174`) +- **선행**: [실사용자 데이터 E2E](2026-07-30-real-data-e2e.md) (Jira 작업) - **하네스**: `tools/emb_grid/` — 조건 정의·실행 절차·DB 복구가 그 README 에 있다 ## 요약 @@ -13,7 +13,7 @@ | top-3 | 네 조건 모두 12/12 | | 저장 | small 6,148 B/건 → large 12,292 B/건 (정확히 2배) | | 토큰 | 조건 간 차이 없음. 임베딩 토큰 수는 입력 텍스트가 정한다 | -| 시딩 | 42.5 ~ 44.2초. 조건이 아니라 GMS 왕복 시간이 대부분을 차지한다 | +| 시딩 | 42.5 ~ 44.2초. 조건이 아니라 AI API 왕복 시간이 대부분을 차지한다 | 합계로는 어느 조건도 기준선을 넘지 못한다. 그런데 합계가 같은 A·C·D 는 서로 다른 질의에서 실패한다. 이 표가 이 측정의 핵심 결과다. @@ -155,7 +155,7 @@ COMPLETED 다. `_usage.record` 는 기록 실패를 삼키도록 되어 있으 - 장소명 결합은 이득과 손실이 함께 있다. 7번을 고치지만(D) 1번을 깨뜨리고(D) 6번의 근소한 균형을 반대쪽으로 넘긴다(B). 저장 입력에만 붙고 질의에는 붙지 않는 비대칭이 유사도 전반을 끌어내린다. -- 비용 축은 저장 하나뿐이다. 토큰은 조건 간 차이가 없고 시딩 소요도 GMS 왕복이 +- 비용 축은 저장 하나뿐이다. 토큰은 조건 간 차이가 없고 시딩 소요도 AI API 왕복이 대부분이다. `large` 채택의 비용은 벡터 저장 2배와 기존 임베딩 전량 재생성이다. - `vector(3072)` 는 지금 스키마에서 동작한다. pgvector 의 2000차원 색인 상한에 걸리지 않는 것은 벡터 인덱스가 없기 때문이며(37행 순차 스캔), 데이터가 늘어 diff --git a/docs/implements/2026-07-31-gms-call-observability.md b/docs/implements/2026-07-31-gms-call-observability.md index a8b6a02..f5d9e64 100644 --- a/docs/implements/2026-07-31-gms-call-observability.md +++ b/docs/implements/2026-07-31-gms-call-observability.md @@ -1,6 +1,6 @@ -# GMS 호출·재선점 로그 계측 — 실패율·지연·재선점을 로그로 볼 수 있게 했다 +# AI API 호출·재선점 로그 계측 — 실패율·지연·재선점을 로그로 볼 수 있게 했다 -- **티켓**: S15P11A705-197 +- **티켓**: Jira 작업 `dev`의 세 gate가 전부 열렸는데 무엇이 실패하는지 볼 수단이 없었다. 그 공백을 메웠다. 이번 범위는 로그까지이며 `/metrics` 엔드포인트는 만들지 않았다. @@ -12,11 +12,11 @@ | 항목 | 당시 | 원인 | |---|---|---| -| GMS 요청 성공/실패/지연 | **없음** | 두 클라이언트에 로거가 없었다. 재시도 소진 경고 1건뿐(`retry.py`) | +| AI API 요청 성공/실패/지연 | **없음** | 두 클라이언트에 로거가 없었다. 재시도 소진 경고 1건뿐(`retry.py`) | | embedding/judge 처리 결과 | **부분** | 영구 오류만 `log.error`. 성공·지연은 없음 | | stale recovery 수행/실패 | **없음** | `try_start`가 신규 시작과 재선점을 같은 경로로 처리하고 어느 쪽도 로그가 없다 | -왜 지금이냐면, **GMS 쿼터가 시점·경로에 따라 다르기 때문**이다. 2026-07-29에 "분당 2건" +왜 지금이냐면, **AI API 쿼터가 시점·경로에 따라 다르기 때문**이다. 2026-07-29에 "분당 2건" 으로 관측된 것이 다음 날 같은 코드로 분당 30건을 통과시켰다(`-176` 실측). 실패율을 못 보면 시연 중 느려졌을 때 우리 문제인지 게이트웨이 문제인지 구분할 수 없다. @@ -53,7 +53,7 @@ UPDATE로 처리하고 둘 다 rowcount `1`을 돌려준다. 로그만으로 가 읽은 값이 `PROCESSING`이어도 실제로 재선점했다는 보장이 없다. 재선점은 드물지만 중요한 신호다. 재선점이 났다는 것은 앞선 처리가 만료(600초) 안에 -끝나지 못했다는 뜻이고, 원인은 프로세스 종료 아니면 GMS 지연 둘뿐이다. 거짓 양성이 +끝나지 못했다는 뜻이고, 원인은 프로세스 종료 아니면 AI API 지연 둘뿐이다. 거짓 양성이 섞이면 그 신호 자체를 믿을 수 없게 되므로 정확도를 포기할 수 없었다. 그래서 UPDATE 직전 상태를 같은 SQL 문장 안에서 함께 읽는다. @@ -94,7 +94,7 @@ SELECT (SELECT count(*) FROM started) AS affected, (SELECT status FROM prev) AS ### 2.4 정상 호출은 `DEBUG`, 분모는 창 집계 -성공까지 `INFO`로 남기면 dev 로그가 GMS 호출로 뒤덮여 실패 행을 못 찾는다. 그렇다고 +성공까지 `INFO`로 남기면 dev 로그가 AI API 호출로 뒤덮여 실패 행을 못 찾는다. 그렇다고 성공을 아예 안 세면 실패율의 분모가 없다. 그래서 개별 행은 결과가 레벨을 가르고 (`DEBUG`/`WARNING`/`ERROR`), 60초 창 집계 한 줄만 `INFO`로 낸다. diff --git a/docs/implements/2026-07-31-gms-error-body-redaction.md b/docs/implements/2026-07-31-gms-error-body-redaction.md index ce0c691..849d7ed 100644 --- a/docs/implements/2026-07-31-gms-error-body-redaction.md +++ b/docs/implements/2026-07-31-gms-error-body-redaction.md @@ -1,9 +1,9 @@ # 게이트웨이 오류 본문 마스킹 — 로그로 새는 경로를 원천에서 막는다 -- **티켓**: S15P11A705-205 +- **티켓**: Jira 작업 - **상태**: 완료 - **날짜**: 2026-07-31 -- **선행**: [GMS 호출 계측](2026-07-31-gms-call-observability.md) (`-197`) · +- **선행**: [AI API 호출 계측](2026-07-31-gms-call-observability.md) (`-197`) · [검색 오류 응답 계약](2026-07-31-search-error-contract.md) (`-220`) · [DB 오류 분류](2026-07-31-db-error-classification.md) (`-221`) - **관련**: [`ai#69`](https://github.com/Team-PinLog/ai/issues/69) — 운영 로그 조치는 인프라 소관 @@ -30,7 +30,7 @@ endpoint 누출"*로 위험을 좁혔다. 실제로 재 보니 위험 축이 둘 ### 1.1 어떻게 쟀나 -실제 GMS 키로 네 경로(OpenAI·Gemini·Anthropic·임베딩)에 오류를 **의도적으로** 만들어 +실제 AI API 키로 네 경로(OpenAI·Gemini·Anthropic·임베딩)에 오류를 **의도적으로** 만들어 응답 본문을 받았다. 19건이다. 자격 증명 에코 여부를 보는 401 케이스에는 진짜 키가 아니라 가짜 값을 넣었고, 스크립트는 세션 임시 디렉터리에만 두고 커밋하지 않았다. 응답에 진짜 키가 섞여 나오면 값을 지우고 에코됐다는 사실만 남기도록 짰다. 실제로는 한 건도 걸리지 @@ -46,7 +46,7 @@ endpoint 누출"*로 위험을 좁혔다. 실제로 재 보니 위험 축이 둘 ### 1.2 결과 **자격 증명 — 네 경로 모두 에코하지 않는다.** 401은 게이트웨이가 만드는 고정 문구다. -벤더에 도달하기도 전에 GMS가 끊는다. +벤더에 도달하기도 전에 AI API가 끊는다. ```json {"message":"[GMS 에러] Invalid or expired GMS key","statusCode":401} @@ -58,7 +58,7 @@ endpoint 누출"*로 위험을 좁혔다. 실제로 재 보니 위험 축이 둘 {"message":"[GMS 에러] Model not found in request for domain api.openai.com","statusCode":400} ``` -**게이트웨이 오류와 벤더 오류가 갈린다.** 티켓의 추론대로였다. GMS가 만드는 것은 +**게이트웨이 오류와 벤더 오류가 갈린다.** 티켓의 추론대로였다. AI API가 만드는 것은 `[GMS 에러]`로 시작하고 짧으며(66~98바이트) 고정 문구다. 벤더까지 갔다 온 것은 `[ 에러]`로 시작하고 **벤더 원문을 `error` 필드에 통째로 중첩**한다(215~433바이트). @@ -186,7 +186,7 @@ URL 규칙이 먼저 걸리면 키가 통째로 `` 마스킹에 함께 지 TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건드리지 않기 위해서다. ``` -지운다 api.openai.com · gms.ssafy.io · generativelanguage.googleapis.com +지운다 api.openai.com · https://api.example.com · generativelanguage.googleapis.com 안 건드린다 messages.0.content · contents[0].parts[0] · gemini-2.5-flash · text-embedding-3-small ``` @@ -214,7 +214,7 @@ TLD를 화이트리스트로 한정했다. 점이 든 평범한 식별자를 건 |---|---| | 자격 증명 5형태 | `redact()` 후 값이 남지 않는다 | | endpoint — URL·맨 호스트 | 둘 다 지워지고 주변 문구는 남는다 | -| **실측한 GMS·벤더 본문 4종** | **마스킹을 통과해 원문 그대로다** | +| **실측한 AI API·벤더 본문 4종** | **마스킹을 통과해 원문 그대로다** | | 200자 경계에 걸친 키 | 앞부분도 남지 않는다 | | 임베딩·판정 `PermanentError` | `str(exc)`와 **트레이스백**에 값이 없다 | | 재시도 `WARNING` | `app.client.retry` 행에 값이 없다 | @@ -248,7 +248,7 @@ coverage line 99.83% · branch 98.99% (게이트 80/80) - **실서버 대조를 하지 않았다.** `-220`처럼 uvicorn 두 대를 띄워 로그를 눈으로 비교하는 절차는 밟지 않았다. 이 변경은 응답이 아니라 로그 문자열을 바꾸므로 계약 테스트가 보는 것과 실서버가 내는 것이 같다(`caplog`이 실제 로거 레코드를 받는다). - 대신 §1의 실측을 실제 GMS로 했고, 그 본문이 테스트 픽스처의 정본이다. + 대신 §1의 실측을 실제 AI API로 했고, 그 본문이 테스트 픽스처의 정본이다. - **429 본문을 재지 못했다.** 공용 게이트웨이를 의도적으로 밀어 429를 만드는 것은 같은 키를 쓰는 다른 세션·시연에 영향이 간다. `-197`이 기록한 것은 상태 코드와 헤더 줄뿐이라 본문 형태는 미상이다. 429가 `[GMS 에러]` 계열이면 §1.2와 같고, 벤더 @@ -269,7 +269,7 @@ coverage line 99.83% · branch 98.99% (게이트 80/80) - **운영 로그 조치는 이 티켓 밖이다.** 이미 남은 로그의 Loki 검색과 키 재발급 판단은 `ai#69`로 인프라 파트에 있다. - **`/metrics`는 건드리지 않았다.** prod 승격 전 승인 항목이다(§2.4). -- **거대 요청 본문에서 GMS가 오해를 부르는 400을 낸다**(T62). 이 티켓의 범위는 +- **거대 요청 본문에서 AI API가 오해를 부르는 400을 낸다**(T62). 이 티켓의 범위는 아니지만 오류 분류에 영향이 있다. 자세한 것은 트러블슈팅 문서에 있다. ## 관련 장애 기록 diff --git a/docs/implements/2026-07-31-judge-prompt-rule.md b/docs/implements/2026-07-31-judge-prompt-rule.md index 36bcc04..90defcd 100644 --- a/docs/implements/2026-07-31-judge-prompt-rule.md +++ b/docs/implements/2026-07-31-judge-prompt-rule.md @@ -1,8 +1,8 @@ # 판정 프롬프트 「본문에 근거 없으면 미선택」 — 두 개정안을 재고 둘 다 채택하지 않는다 -- **티켓**: S15P11A705-219 +- **티켓**: Jira 작업 - **날짜**: 2026-07-31 -- **선행**: [후보 임계값 τ](2026-07-31-candidate-threshold.md) (`S15P11A705-210`) — 라벨과 데이터를 그대로 물려받았다 +- **선행**: [후보 임계값 τ](2026-07-31-candidate-threshold.md) (Jira 작업) — 라벨과 데이터를 그대로 물려받았다 - **측정 중 발견한 문제**: [T43~T49](../troubleshooting/2026-07-31-judge-prompt-ab.md) - **하네스**: `tools/prompt_ab/` @@ -33,7 +33,7 @@ `tau_grid/matrix.py` 가 뜬 유사도 행렬을 그대로 쓴다. 후보 집합·본문·프리셋이 조건 사이에 완전히 같아야 하므로 재임베딩도 재시딩도 하지 않는다. τ=0.30 · k=10 고정. -`-210` 의 τ 스윕과 결정적으로 다른 점이 하나 있다. τ 는 유사도 행렬 위에서 GMS 호출 +`-210` 의 τ 스윕과 결정적으로 다른 점이 하나 있다. τ 는 유사도 행렬 위에서 AI API 호출 없이 재구성할 수 있었지만, 프롬프트는 LLM 을 실제로 불러야 한다. 그래서 이 티켓의 설계는 전부 호출 비용과 판정 비결정성을 전제로 세웠다. @@ -320,7 +320,7 @@ confidence 도 비결정적이고, 1회 관측으로 문턱을 정하면 다음 | 데이터 | `pinlog-demo`(:15432) · Context 42건(고유 본문 37 + 중복 5) · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | | 판정 모델 | **`gpt-4o-mini`** — 회차 파일의 `JudgeResult.model` 실측. 설정값이 아니라 실제로 답한 값이다(T43) | -| GMS 호출 | 판정 1,092회 (A 420 · B 210 · C 420 · 확인용 42). 실패 0 · 임베딩 0 | +| AI API 호출 | 판정 1,092회 (A 420 · B 210 · C 420 · 확인용 42). 실패 0 · 임베딩 0 | | 코드 변경 | **없음.** `llm_client.SYSTEM` 을 건드리지 않았다 | ```bash @@ -357,7 +357,7 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" | **판정 분산 자체를 줄인다** — 같은 입력을 n회 판정해 다수결(self-consistency) | §2·§3.4. A 에서 관측된 오분류 27종 중 25종이 판정 변동의 산물이고, 프롬프트 문구로는 해결되지 않는다 | 티켓 미발행. **이 티켓의 실질적 후속.** 호출이 n배라 비용·지연과 함께 봐야 한다 | | confidence 문턱을 **회차 반복으로** 재본다 | §6. 1회분은 깨끗이 갈렸으나 다음 1회에서 무너졌다. 어느 쪽이 참인지 모른다 | 하네스에 기록을 넣어 두었다. `run.py` 를 돌리면 바로 모인다 | | 벤더를 조건으로 둔 재측정 | §4.3. 이 결론의 적용 범위가 `gpt-4o-mini` 하나다 | 분산이 큰 위에서 재는 것이라 회차를 더 늘려야 한다 | -| 라벨 교차 검증 | 결론이 판정자 한 명의 라벨에 걸려 있고, 그 한 명이 개정안 설계자다 | 이의는 해당 행만 고치고 `score_ab.py` 한 번. GMS 를 부르지 않는다 | +| 라벨 교차 검증 | 결론이 판정자 한 명의 라벨에 걸려 있고, 그 한 명이 개정안 설계자다 | 이의는 해당 행만 고치고 `score_ab.py` 한 번. AI API 를 부르지 않는다 | | 저빈도·오작동 프리셋 점검 | `-210` 이 남긴 것. `274/289 DRINK` 가 10/10 으로 매 회차 나타나는 것도 프리셋 쪽 문제일 수 있다 | 프롬프트·τ 와 무관 | 「항상 붙는 오분류」가 실질적으로 하나(가지튀김 → `DRINK`)뿐이라는 것이 마지막 줄의 diff --git a/docs/implements/2026-07-31-judge-vote.md b/docs/implements/2026-07-31-judge-vote.md index faaa387..f124c1f 100644 --- a/docs/implements/2026-07-31-judge-vote.md +++ b/docs/implements/2026-07-31-judge-vote.md @@ -1,6 +1,6 @@ # 판정 n회 다수결 — 판정 변동은 지워지지만 오분류 행 수는 거의 줄지 않는다. n=1 을 유지한다 -- **티켓**: S15P11A705-223 +- **티켓**: Jira 작업 - **날짜**: 2026-07-31 - **선행**: [τ](2026-07-31-candidate-threshold.md)(`-210`) · [프롬프트](2026-07-31-judge-prompt-rule.md)(`-219`) — 라벨과 데이터를 그대로 물려받았다 - **측정 중 발견한 문제**: [T57~T60](../troubleshooting/2026-07-31-judge-vote.md) @@ -231,10 +231,10 @@ n=5 에서 완전히 사라진 오분류가 13종이고, n=5 에서 100% 로 남 | Context 42건 회차 소요 | 65.9s | 84.6s | 109.3s | 동시 호출이라 평균 지연은 n 배가 아니라 1.3~1.7배다. 대신 Context 1건이 만드는 동시 -호출이 n배가 된다. 즉 GMS 부하는 정확히 n배다. +호출이 n배가 된다. 즉 AI API 부하는 정확히 n배다. 최악 지연은 n 에 비례하지 않는다. n=1 의 최악 Context(3.94s)가 n=3(3.70s)보다 크다. -동시 호출의 최악값은 「가장 느린 1회」가 정하는데 그것은 GMS 쪽 지연 꼬리이지 n 이 +동시 호출의 최악값은 「가장 느린 1회」가 정하는데 그것은 AI API 쪽 지연 꼬리이지 n 이 아니기 때문이다. n=5 에서 5.34s 로 오르는 것은 표본을 5개 뽑으면 느린 호출을 만날 확률이 올라가는 것이지, 5배를 기다리는 것이 아니다. @@ -336,7 +336,7 @@ transient 와 permanent 실패가 섞이면 transient 를 우선해 던진다. p | 데이터 | `pinlog-demo`(:15432) · Context 42건 · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | | 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정값이 아니라 실제로 답한 값이다(T43) | -| GMS 호출 | **2,142회** (회차 풀 1,260 · 실경로 n=1 84 · n=3 378 · n=5 420). 실패 **0** · 임베딩 0 | +| AI API 호출 | **2,142회** (회차 풀 1,260 · 실경로 n=1 84 · n=3 378 · n=5 420). 실패 **0** · 임베딩 0 | | 테스트 | `386 passed` · line 99.83% · branch 98.98% (`origin/dev` 병합 후. 이 브랜치 단독은 338) · `ruff check .` 통과 | ```bash @@ -356,7 +356,7 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" 않았다. `_judge_n` 은 `run_live.py` 가 실호출로 덮었지만, DB 저장까지 관통한 n>1 경로는 통합 테스트(Fake)까지다. - **n=5 의 관측이 6개뿐** — n=3 의 10개보다 약하다. 회차를 50개로 늘리면 10개가 - 되지만, GMS 840회를 더 쓰면서 판정 기준은 그대로다. `-219` 가 B 를 5회에서 중단한 + 되지만, AI API 840회를 더 쓰면서 판정 기준은 그대로다. `-219` 가 B 를 5회에서 중단한 것과 같은 판단이다. - **조합한 값과 실경로의 대조가 3관측** — 갈리지 않는다는 것까지만 말한다(§1.4). - **조건 간 표본 독립** — 조합한 조건들이 같은 호출 풀을 공유한다. 평균·범위 비교는 diff --git a/docs/implements/2026-07-31-search-cut.md b/docs/implements/2026-07-31-search-cut.md index 9c7b56c..0c4d505 100644 --- a/docs/implements/2026-07-31-search-cut.md +++ b/docs/implements/2026-07-31-search-cut.md @@ -1,9 +1,9 @@ # 개인 검색 결과 컷 측정 — `τ_abs`=0.30 과 `r`=0.60 을 함께 채택한다 -- **티켓**: S15P11A705-213 +- **티켓**: Jira 작업 - **날짜**: 2026-07-31 -- **선행**: [후보 임계값 τ](2026-07-31-candidate-threshold.md) (`S15P11A705-210`) · - [임베딩 4조건](2026-07-31-embedding-grid.md) (`S15P11A705-191`) +- **선행**: [후보 임계값 τ](2026-07-31-candidate-threshold.md) (Jira 작업) · + [임베딩 4조건](2026-07-31-embedding-grid.md) (Jira 작업) - **하네스**: `tools/search_cut/` — 실행 절차가 그 README 에 있다 - **명세**: [personal-search.md §6.1](../spec/personal-search.md) @@ -74,9 +74,9 @@ ### 하네스 `-210` 의 구조를 따랐다. 벡터를 한 번 떠서 고정하고 격자를 오프라인으로 훑는다. 컷은 -유사도에 걸리고 임베딩에는 걸리지 않으므로 격자마다 GMS 를 부를 이유가 없다. +유사도에 걸리고 임베딩에는 걸리지 않으므로 격자마다 AI API 를 부를 이유가 없다. -총 GMS 호출은 임베딩 배치 1회다(질의 17건이 `_BATCH=128` 안에 들어간다). +총 AI API 호출은 임베딩 배치 1회다(질의 17건이 `_BATCH=128` 안에 들어간다). `ai.context_keyword` 를 건드리지 않으므로 같은 시각 진행 중이던 `-219`(판정 프롬프트)와 충돌하지 않았다. @@ -231,7 +231,7 @@ test_cut_can_return_zero_rows τ_abs 는 0건을 만들 수 있다 따라가지 못한다. - 채택값에서 정답 누락 0 · 빈 결과 0 이다. 꼬리는 비관 기준 76.3% 줄고 무관 질의 15건 중 11건이 0건이 된다. -- 오프라인 재구성이 실서버와 정확히 일치한다. 앞으로 값을 다시 정할 때 GMS 를 부를 +- 오프라인 재구성이 실서버와 정확히 일치한다. 앞으로 값을 다시 정할 때 AI API 를 부를 필요가 없다(`matrix.json` 이 커밋돼 있다). **말할 수 없는 것** diff --git a/docs/implements/2026-07-31-search-error-contract.md b/docs/implements/2026-07-31-search-error-contract.md index 2aac803..17cfaba 100644 --- a/docs/implements/2026-07-31-search-error-contract.md +++ b/docs/implements/2026-07-31-search-error-contract.md @@ -1,10 +1,10 @@ # 검색 API 오류 응답 계약 — 임베딩 502 가 검색 500 이 되던 것을 끊는다 -- **티켓**: S15P11A705-220 +- **티켓**: Jira 작업 - **상태**: 완료 - **날짜**: 2026-07-31 - **선행**: [외부 API 재시도·오류 분류](2026-07-30-retry-and-error-classification.md) (`-121`) · - [GMS 호출 계측](2026-07-31-gms-call-observability.md) (`-197`) + [AI API 호출 계측](2026-07-31-gms-call-observability.md) (`-197`) - **근거**: [`ai#69`](https://github.com/Team-PinLog/ai/issues/69) — 인프라 파트 운영 보고 ## 이 문서가 다루는 것 @@ -67,7 +67,7 @@ | 상태 | 의미 | |---|---| -| 지금 | 전부 500. "AI 가 깨졌나 GMS 가 깨졌나"를 로그 없이 구분할 수 없다 | +| 지금 | 전부 500. "AI 가 깨졌나 AI API 가 깨졌나"를 로그 없이 구분할 수 없다 | | 이후 503 | 게이트웨이 장애·타임아웃. 기다리면 낫는다 | | 이후 502 | 키·모델명·base URL·차원. 배포 설정을 고쳐야 낫는다 | | 이후 500 | 우리 코드의 결함. 비로소 알림 대상이 된다 | @@ -89,10 +89,10 @@ f"embedding error: {resp.status_code} {resp.text[:200]}" # embedding_client.py ``` app.core.errors.PermanentError: embedding error: 401 Unauthorized: -api key sk-live-DEADBEEF rejected by gms.ssafy.io +api key sk-live-DEADBEEF rejected by https://api.example.com ``` -실제 GMS 가 무엇을 담는지는 우리가 통제하지 않는다. `probe.py` 가 세운 +실제 AI API 가 무엇을 담는지는 우리가 통제하지 않는다. `probe.py` 가 세운 *"credential·endpoint·profile 값을 어떤 분기에서도 싣지 않는다"* 와 `-197` 이 로그로 확장한 같은 기준(§2.4 원칙 4)에 어긋난다. 핸들러가 이 누출도 막는다. @@ -179,7 +179,7 @@ Fake client 를 쓰지 않는 것이 이 파일의 요점이다. ### 3.3 실서버 대조 — 502 를 만들어 503 을 봤다 테스트만으로는 「핸들러 코드를 넣었다」까지만 확인된다. 실제 동작을 보기 위해 로컬 -GMS 스텁(`/gmsapi/api.openai.com/v1`)을 +AI API 스텁(`/gmsapi/api.openai.com/v1`)을 띄우고 시연 DB(`:15432`, preset 27건)에 붙인 uvicorn 두 대를 같은 스텁에 물려 대조했다. | 업스트림 | `origin/dev`(a5e1142) | 이 브랜치 | diff --git a/docs/implements/2026-07-31-seed-guard.md b/docs/implements/2026-07-31-seed-guard.md index 180bd86..a830d8b 100644 --- a/docs/implements/2026-07-31-seed-guard.md +++ b/docs/implements/2026-07-31-seed-guard.md @@ -1,6 +1,6 @@ # 시연 도구 결함 3건 — 오류 없이 지나가던 실패를 실행 전에 차단·보고하게 했다 -- **티켓**: S15P11A705-198 +- **티켓**: Jira 작업 - **상태**: 완료 - **날짜**: 2026-07-31 - **선행**: [데모 시딩](2026-07-29-demo-seeding.md) (`-58`) · [실데이터 E2E](2026-07-30-real-data-e2e.md) (`-174`) · [임베딩 4조건](2026-07-31-embedding-grid.md) (`-191`) @@ -194,7 +194,7 @@ back 레포가 없으면(CI·배포) 검사 없이 건너뛴다. 계약이 아 ## 4. 검증 — 방어가 실제로 걸리는가 통과만 확인하면 아무것도 검사하지 않는 장치를 놓친다. 그래서 셋 다 일부러 어긋내 -실패를 확인한 뒤 되돌렸다(`S15P11A705-156`이 완료 조건에 넣은 것과 같은 이유다). +실패를 확인한 뒤 되돌렸다(Jira 작업이 완료 조건에 넣은 것과 같은 이유다). ### 4.1 판정 로직 — 뮤턴트로 확인 diff --git a/docs/implements/2026-07-31-ticket-audit-96-77.md b/docs/implements/2026-07-31-ticket-audit-96-77.md index 501eb43..cfd199e 100644 --- a/docs/implements/2026-07-31-ticket-audit-96-77.md +++ b/docs/implements/2026-07-31-ticket-audit-96-77.md @@ -1,4 +1,4 @@ -# 티켓 대조 감사 — `S15P11A705-96` · `S15P11A705-77` 모두 완료 조건이 충족되어 닫아도 된다 +# 티켓 대조 감사 — 관련 Jira 작업의 완료 조건이 충족되어 닫아도 된다 - **상태**: 완료 - **날짜**: 2026-07-31 @@ -11,7 +11,7 @@ --- -## 1. `S15P11A705-96` — dev 배포용 GMS·모델·bootstrap·관측 계약 확정 +## 1. Jira 작업 — dev 배포용 AI API·모델·bootstrap·관측 계약 확정 ### 1.0 이 티켓의 전제가 바뀌었다 @@ -32,7 +32,7 @@ infra apps/dev/ai/values.yaml (origin/main c0b6a6a) |---|---|---|---| | 1-a | Secret/SealedSecret 승인 경로 준비 | **해소됨** | `ai` `.github/workflows/seal-runtime-secrets.yml`(#34 `299f6a6`, #35 `7d8776a` 체크섬 수정) → artifact `ai-owner-secrets-sealed` run `30431247125` → `infra secrets/dev/ai-owner-secrets.sealedsecret.yaml`(암호문 7키 실물 반영). `deploy/sealed-secrets/pinlog-dev-cert.pem` 지문 대조 포함 | | 1-b | 앱이 읽는 env key names 목록화 | **해소됨(단서 있음)** | `app/core/config.py` 단일 진입점. 런타임 계약은 `infra docs/ai-dev-prerequisites.md` §3 — `ai-owner-secrets` 7키 + `ai-db-credentials` `DATABASE_URL` 1키 = exact 8키, validator가 강제(`infra tools/validate_ai_dev_prerequisites.py secret-keys`). **단서**: 아래 1-b′ | -| 1-b′ | 계약 이후 env key 1건 개명 | **미해소(경미)** | `PINLOG_JUDGE_MODEL` → `PINLOG_JUDGE_CHAIN`(#51 `e057aa8`, `S15P11A705-175`, 2026-07-30). `.env.example:53`이 "옛 키는 이제 무시된다" 명시. ai#32 §4 응답표에는 여전히 `PINLOG_JUDGE_MODEL=gemini-2.5-flash`로 인프라에 전달돼 있다. **봉인 대상도 8키 스키마도 아니어서 배포 동작에는 영향이 없다**(WORKLOG 2026-07-30: "배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐") — 그러나 "env key names를 확정한다"는 완료 조건 기준으로는 전달본이 낡았다 | +| 1-b′ | 계약 이후 env key 1건 개명 | **미해소(경미)** | `PINLOG_JUDGE_MODEL` → `PINLOG_JUDGE_CHAIN`(#51 `e057aa8`, Jira 작업, 2026-07-30). `.env.example:53`이 "옛 키는 이제 무시된다" 명시. ai#32 §4 응답표에는 여전히 `PINLOG_JUDGE_MODEL=gemini-2.5-flash`로 인프라에 전달돼 있다. **봉인 대상도 8키 스키마도 아니어서 배포 동작에는 영향이 없다**(WORKLOG 2026-07-30: "배포 봉인 대상이 아니어서 영향 범위는 로컬 `.env` 뿐") — 그러나 "env key names를 확정한다"는 완료 조건 기준으로는 전달본이 낡았다 | | 1-c | Secret 값 미기재 | **해소됨** | Jira·PR·레포 어디에도 평문 없음. `SettingsError`가 pydantic `input_value` 노출 경로를 막고(`config.py`), `/ready`·스모크 출력이 예외 타입 이름만 남긴다. `tests/test_runtime_secret_contract.py`가 계약을 고정 | | 2-a | embedding·judge model, profile 식별자 확정 | **해소됨** | 정본이 코드로 이전됨(P45, `docs#27`로 공용 계약 `05 §7.1` 개정, `ai#36 463cad5`). `config.py`가 4종 기본값을 보유 — `text-embedding-3-small` / `1536` / `cosine` / `openai-text-embedding-3-small-1536-cosine-v1`. judge는 체인 기본값 3종 | | 2-b | Spring↔FastAPI 참조 방법·불일치 감지 | **해소됨** | 요청 바디 `embeddingProfile` 대조 → 422(`search_service.py`). 기동 시 `_profile_consistency`. CI 리터럴 대조 `tests/test_embedding_profile_parity.py`(#42 `d317f56`). 인프라 preflight가 승인 tuple과 exact match 검사(`ai-dev-prerequisites.md` §3) | @@ -41,7 +41,7 @@ infra apps/dev/ai/values.yaml (origin/main c0b6a6a) | 3-b | idempotency 조건 | **해소됨** | `id` 기준 `ON CONFLICT DO UPDATE` 27건 단일 트랜잭션(`load_presets.py`). 배포 측은 Argo `PreSync` hook + `BeforeHookCreation`으로 재실행 안전성을 요구·전제(`charts/microservice/templates/bootstrap-job.yaml`) | | 3-c | preset provenance | **해소됨(배포 수준)** | 27 preset full SHA-256 `204824bd37e6…4034` → 승인 version `preset-204824bd37e6`, chart가 DNS-label·필수값으로 강제. **DB 행 수준 provenance는 여전히 없다**(YAML `version` 필드 부재 → 모든 행 `version=1`). 배포 아티팩트를 식별하는 목적은 충족되고, "DB의 이 행이 어느 커밋에서 왔는가"는 미해결 — 별건 | | 3-d | 실패·부분적용 시 재시도·복구 | **해소됨** | `backoffLimit: 1`, 실패한 PreSync Job이 Deployment sync를 차단(`ai-serving.md` 공용 chart bootstrap 계약). rollback은 "새 bootstrap version으로 보상 또는 `deployment.enabled: false` 되돌리기, DB 수동 파괴 금지"로 명시. 코드 측 부분 적용은 단일 트랜잭션이라 성립하지 않음 | -| 4-a | readiness 의존성 범위 | **해소됨** | `GET /ready` = DB `SELECT 1` + 현재 Profile 기준 preset 캐시 ≥1건, 200/503 (`app/api/probe.py`, #33 `7e31398`). **GMS는 의도적으로 제외** — 외부 게이트웨이 장애로 인스턴스가 트래픽에서 빠지는 것을 막기 위함이며 인프라 요청과 일치 | +| 4-a | readiness 의존성 범위 | **해소됨** | `GET /ready` = DB `SELECT 1` + 현재 Profile 기준 preset 캐시 ≥1건, 200/503 (`app/api/probe.py`, #33 `7e31398`). **AI API는 의도적으로 제외** — 외부 게이트웨이 장애로 인스턴스가 트래픽에서 빠지는 것을 막기 위함이며 인프라 요청과 일치 | | 4-b | liveness/readiness 분리·startup/grace 시간 | **해소됨** | `/health` 정적 200(startup·liveness 전용, 회귀 테스트로 고정) / `/ready`(readiness). 실배선 확인 — startup `failureThreshold: 30 × periodSeconds: 5` = 150초 허용, `terminationGracePeriodSeconds: 180` | | 4-c | `/metrics` 경로·인증·라벨 cardinality | **무효(범위 이관)** | dev에서 도입하지 않기로 계약됐다 — `metrics.enabled: false`, `ai-serving.md` "prod 전 metrics endpoint/ServiceMonitor 계약 보강", activation 검증 순서 7번이 "prod 승격 전에 readiness 실패 의미·metrics·alert·부하/메모리·모델 cache·GPU scheduling을 **별도 승인**"으로 이관. 티켓 절 제목 자체가 "prod 전"이므로 **확인 결과 dev 범위에 두지 않는다는 합의**가 곧 이 항목의 답이다 | | 4-d | 최소 관측 항목 4종 | **미해소** | 아래 표 | @@ -50,20 +50,20 @@ infra apps/dev/ai/values.yaml (origin/main c0b6a6a) | 요구 | 현재 | 근거 | |---|---|---| -| GMS 요청 성공/실패/지연 | **없음** | `embedding_client.py`·`llm_client.py`에 로거 없음. 재시도 소진 경고 1건만(`client/retry.py:107`). 토큰 계측은 `PINLOG_TOKEN_LOG` 파일이 지정될 때만 도는 선택 경로이며 상시 관측이 아니다(`client/_usage.py`) | +| AI API 요청 성공/실패/지연 | **없음** | `embedding_client.py`·`llm_client.py`에 로거 없음. 재시도 소진 경고 1건만(`client/retry.py:107`). 토큰 계측은 `PINLOG_TOKEN_LOG` 파일이 지정될 때만 도는 선택 경로이며 상시 관측이 아니다(`client/_usage.py`) | | embedding/judge 처리 결과 | **부분** | 영구 오류는 `log.error`로 남는다(`embedding_service.py:61`, `keyword_service.py:103` — ANSWER-96이 지적한 "ERROR 0건"은 해소됨). 성공·지연은 여전히 없음 | | bootstrap 상태 | **있음** | `upserted %d presets (profile=%s)` + stdout `OK: N presets upserted`, Job 종료 코드 | | stale recovery 수행/실패 | **없음** | `try_start`가 신규 시작과 재선점을 같은 경로로 처리하고 어느 쪽도 로그를 남기지 않는다(`ai_state_repo.py:69`) | -관측 3종은 `/metrics` 도입과 같은 작업 덩어리이므로 4-c의 prod 승격 전 승인 항목과 함께 움직인다. **dev 배포를 막지는 않지만, 지금 dev에서 GMS 실패율·재선점 빈도를 볼 수단이 없다는 사실은 그대로다.** +관측 3종은 `/metrics` 도입과 같은 작업 덩어리이므로 4-c의 prod 승격 전 승인 항목과 함께 움직인다. **dev 배포를 막지는 않지만, 지금 dev에서 AI API 실패율·재선점 빈도를 볼 수단이 없다는 사실은 그대로다.** ### 1.2 완료 조건 5개 | # | 완료 조건 | 판정 | 요약 근거 | |---|---|---|---| | 1 | env key names·model·profile이 값 노출 없이 원본 근거와 함께 확정 | **해소됨** | 1-a·1-b·2-a. 단 `PINLOG_JUDGE_CHAIN` 개명이 전달본에 미반영(1-b′) — 배포 무영향 | -| 2 | GMS endpoint/API key의 Secret-safe 전달 절차와 **책임자**가 정해짐 | **해소됨** | 절차 = seal workflow → artifact → GitOps SealedSecret. 책임자 = credential owner **김세민**(`ai-dev-prerequisites.md` §0에 명시), AI 소유 값은 AI 파트 workflow가 봉인 | -| 3 | bootstrap command·idempotency·provenance·재시도/복구가 **재현 가능하게 문서화** | **해소됨(위치 단서)** | 3-a~3-d. 정본이 `infra docs/ai-serving.md`·`ai-dev-prerequisites.md`에 있다. **`ai` 레포에는 이 계약 문서가 없다** — ai#32(`docs/S15P11A705-96-dev-deployment-contract.md`)가 병합되지 않아서다(§1.3) | +| 2 | AI API endpoint/API key의 Secret-safe 전달 절차와 **책임자**가 정해짐 | **해소됨** | 절차 = seal workflow → artifact → GitOps SealedSecret. 책임자 = credential owner **김세민**(`ai-dev-prerequisites.md` §0에 명시), AI 소유 값은 AI 파트 workflow가 봉인 | +| 3 | bootstrap command·idempotency·provenance·재시도/복구가 **재현 가능하게 문서화** | **해소됨(위치 단서)** | 3-a~3-d. 정본이 `infra docs/ai-serving.md`·`ai-dev-prerequisites.md`에 있다. **`ai` 레포에는 이 계약 문서가 없다** — ai#32(배포 계약 문서)가 병합되지 않아서다(§1.3) | | 4 | readiness/liveness/metrics 계약과 prod 승격 전 검증 기준 명시 | **해소됨** | 4-a·4-b로 readiness/liveness 확정, 4-c로 metrics는 prod 승격 전 별도 승인으로 명시 이관. "prod 승격 전 검증 기준"은 `ai-serving.md` activation 검증 순서 1~7 + rollback 절에 있음 | | 5 | dev-only·internal ClusterIP·`pinlog_dev`·Secret 값 금지 경계 미위반 | **해소됨** | `ingress.enabled: false`, `service.type: ClusterIP:8000`, DB는 `pinlog_dev`/role `pinlog_ai_dev`, NetworkPolicy는 5432만, 레포·PR·Jira에 평문 없음 | @@ -72,9 +72,9 @@ infra apps/dev/ai/values.yaml (origin/main c0b6a6a) `-96`의 계약 문서 자체를 담은 PR이 **미병합**이다. ``` -ai#32 docs(S15P11A705-96): define dev deployment approval contract +ai#32 docs(Jira 작업): define dev deployment approval contract 작성 tpals0409(인프라) · 생성 2026-07-29 · 최종 활동 2026-07-30 · state OPEN - base main · 파일 docs/S15P11A705-96-dev-deployment-contract.md (origin 브랜치에만 존재) + base main · 파일 배포 계약 문서 (origin 브랜치에만 존재) ``` AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출했고 인프라 요청 ①②③④에 모두 구현·회신했다(#33·#34·#35). 계약 내용은 인프라 레포 문서로 실효화됐고 배포도 활성화됐으므로, 이 PR의 병합 여부가 배포를 막고 있지는 않다. 다만 병합되지 않는 한 `ai` 레포에는 `-96` 계약 정본이 없다. 타 파트 소유 PR이므로 처분은 그쪽 판단이다. 이 감사에서는 사실만 기록한다. @@ -89,7 +89,7 @@ AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출 --- -## 2. `S15P11A705-77` — 공용 문서 AI 계약 정합 요청 (06·08·05-1) +## 2. Jira 작업 — 공용 문서 AI 계약 정합 요청 (06·08·05-1) **타 파트 소유 문서다. 이 감사는 읽기만 했고 `docs` 레포를 수정하지 않았다.** 기준은 `docs` `origin/main` **3dd7e93**이며, 라인 번호는 그 리비전 기준이다(티켓 본문의 라인 번호는 2026-07-27 시점이라 어긋난다). @@ -142,7 +142,7 @@ AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출 ## 3. 판정 -### 3.1 `S15P11A705-96` — **닫아도 된다** +### 3.1 Jira 작업 — **닫아도 된다** 완료 조건 5개가 모두 충족됐고, 티켓이 막고 있던 activation gate는 이미 열렸다(§1.0). 확인 요청 4개 절도 4-d(최소 관측 항목)를 제외하면 해소됐으며, 4-d는 `/metrics`와 함께 **prod 승격 전 별도 승인**으로 계약상 이관됐다(`infra docs/ai-serving.md` activation 검증 순서 7). @@ -150,13 +150,13 @@ AI 파트는 이 PR에서 요구된 §4 응답표를 리뷰 코멘트로 제출 | 남길 것 | 성격 | 제안 | |---|---|---| -| 최소 관측 항목 3종(GMS 성공·지연, stale 재선점, API 지연)과 `/metrics` 계측 | AI 파트 코드 | **별건 티켓.** prod 승격 전 승인 항목과 한 덩어리다. dev 배포를 막지 않지만, **지금 dev에서 GMS 실패율·재선점 빈도를 볼 수단이 없다** | +| 최소 관측 항목 3종(AI API 성공·지연, stale 재선점, API 지연)과 `/metrics` 계측 | AI 파트 코드 | **별건 티켓.** prod 승격 전 승인 항목과 한 덩어리다. dev 배포를 막지 않지만, **지금 dev에서 AI API 실패율·재선점 빈도를 볼 수단이 없다** | | preset 행 단위 provenance(적재 시각·source SHA) | AI 파트 코드 + back 스키마 | **별건.** 배포 아티팩트 provenance는 `preset-204824bd37e6`로 해소됐고, 남은 것은 "DB의 이 행이 어느 커밋 YAML에서 왔는가" | | `/context/process` 요청에 `embeddingProfile` 대조 없음 | AI 파트 (back 협의) | **별건.** ANSWER-96이 제안한 후속 C이며 현재도 미구현(`app/schema/context.py`에 필드 없음). 검색 경로에만 대조가 있어, 잘못된 profile로 뜬 인스턴스가 새 Context를 조용히 다른 벡터 공간에 저장할 수 있다 | | `PINLOG_JUDGE_CHAIN` 개명이 인프라 전달본에 미반영 | 전달 | 배포 무영향(봉인 대상도 8키 스키마도 아님). 티켓을 닫을 때 사실만 전달하면 충분 | | `ai#32` 미병합 | 타 파트 소유 PR | **우리가 처분하지 않는다.** 계약 정본이 `infra` 문서로 실효화돼 배포를 막지 않는다는 사실만 기록 | -### 3.2 `S15P11A705-77` — **닫아도 된다** +### 3.2 Jira 작업 — **닫아도 된다** 완료 조건 6개가 모두 충족됐다. 정정 요청 8개 중 **5개 반영 · 2개 다르게 반영 · 1개 무효**이며, 미반영은 없다. diff --git a/docs/implements/2026-08-03-dead-config-keys.md b/docs/implements/2026-08-03-dead-config-keys.md index 4e36c71..9536dad 100644 --- a/docs/implements/2026-08-03-dead-config-keys.md +++ b/docs/implements/2026-08-03-dead-config-keys.md @@ -1,6 +1,6 @@ # 읽히지 않는 설정 키 전수조사 — 죽은 키 2개를 확인하고 로컬 `.env` 잔재를 정리했다 -- **티켓**: S15P11A705-224 +- **티켓**: Jira 작업 - **상태**: 완료 이 문서에서 「죽은 키」는 설정 파일에 이름이 남아 있지만 코드가 더 이상 읽지 않는 @@ -97,7 +97,7 @@ dev 배포는 애초에 이 죽은 값을 실어 나른 적이 없다. 배포 ## `-197` 계측 점검 — 실제 응답 모델을 로그에서 확인할 수 있는가 -`app/client/_calls.py`(`S15P11A705-197`)가 이미 벤더·모델을 로그에 남긴다. +`app/client/_calls.py`(Jira 작업)가 이미 벤더·모델을 로그에 남긴다. - 개별 호출: `log.log(level, "gms call kind=%s vendor=%s model=%s status=%s outcome=%s ms=%.0f", ...)` — 성공(`OK`)은 `DEBUG`, 실패는 `WARNING` 이상. @@ -122,9 +122,9 @@ dev 배포는 애초에 이 죽은 값을 실어 나른 적이 없다. 배포 1. `ai/.env`(로컬, gitignored, **저장소에 커밋되지 않음**)에서 두 죽은 키를 주석 처리하고 사유를 적었다: ``` - # [S15P11A705-224] 읽히지 않음 — PINLOG_JUDGE_CHAIN 이 대체(-175). Settings 에 이 alias 필드가 없다. 주석 처리. + # [Jira 작업] 읽히지 않음 — PINLOG_JUDGE_CHAIN 이 대체(-175). Settings 에 이 alias 필드가 없다. 주석 처리. #PINLOG_JUDGE_MODEL=... - # [S15P11A705-224] 읽히지 않음 — config.py 에서 이미 제거됨(ai#24, 2026-07-27). 로컬 .env 잔재. 주석 처리. + # [Jira 작업] 읽히지 않음 — config.py 에서 이미 제거됨(ai#24, 2026-07-27). 로컬 .env 잔재. 주석 처리. #PRESET_CACHE_TTL_SEC=... ``` 이 파일은 이 작업자의 로컬 환경에만 적용된다. 다른 개발자의 로컬 `.env`에 같은 diff --git a/docs/implements/2026-08-03-dev-deploy-gap.md b/docs/implements/2026-08-03-dev-deploy-gap.md index 96bd807..534ac44 100644 --- a/docs/implements/2026-08-03-dev-deploy-gap.md +++ b/docs/implements/2026-08-03-dev-deploy-gap.md @@ -155,9 +155,9 @@ PR(`dev` → `main`)이 아직 열리지 않았기 때문이다. 고칠 것은 | `bdba79a` | `-230` 문서 색인 단방향 검사 | 문서·CI 도구 | | `df016df` | `-229` DB 실패 응답 문구 분리 | **앱 코드** `search_service.py` 오류 본문 | | `7a758d9` | `-224` 죽은 설정 키 전수조사 | 문서 | -| `501faf6` | `-227` GMS 멀티모달 재고 | 문서·프로브 | +| `501faf6` | `-227` AI API 멀티모달 재고 | 문서·프로브 | | `2a36dcb` · `0c23498` | `-226` 문서 색인 정합 CI 이관 | CI 도구 | -| `af72033` | **`-205` GMS 오류 본문 로그 유출 차단** | **앱 코드** `app/client/` 전반 | +| `af72033` | **`-205` AI API 오류 본문 로그 유출 차단** | **앱 코드** `app/client/` 전반 | `-205`(`ai#78`)가 이 건의 지시 목록에서 빠져 있었다. `back#122` 07-31 12:18 코멘트에서 *"이 변경은 다음 릴리스 대상입니다. 방금 게시한 `4d667c3…` 에는 들어 있지 diff --git a/docs/implements/2026-08-03-docs-index-oneway.md b/docs/implements/2026-08-03-docs-index-oneway.md index a7b617b..be7d63c 100644 --- a/docs/implements/2026-08-03-docs-index-oneway.md +++ b/docs/implements/2026-08-03-docs-index-oneway.md @@ -1,6 +1,6 @@ # 구현 리포트 파일 표에 번호 컬럼을 넣어 반대 방향 누락도 잡는다 -- **티켓**: S15P11A705-230 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 - **선행**: [문서 색인 정합 검사](2026-07-31-docs-index-check.md) (티켓 없음) · [T64·T65](../troubleshooting/2026-07-31-docs-index-check.md) - **산출**: [`tools/check_docs_index.py`](../../tools/check_docs_index.py) 의 ⑤ · @@ -106,7 +106,7 @@ entries - ledger = 7건 (재확인, -226 시점과 동일) - `merge=union` 이 낡은 무번호 파일 표 전체를 되살렸다. 내가 번호 컬럼을 넣어 갱신한 26행 아래에, 번호 컬럼 없는 낡은 26행이 다시 붙었다. -- 병렬 PR #81(S15P11A705-227, GMS vision-probe)이 같은 I36 을 잡아 충돌했다. 그쪽은 +- 병렬 PR #81(Jira 작업, AI API vision-probe)이 같은 I36 을 잡아 충돌했다. 그쪽은 병합 시점 dev 의 마지막 번호(I35) 다음을 골랐고, 내 브랜치도 독립적으로 I36 을 골라 같은 번호가 됐다. diff --git a/docs/implements/2026-08-03-error-wording-split.md b/docs/implements/2026-08-03-error-wording-split.md index 8f5d7cf..16225f4 100644 --- a/docs/implements/2026-08-03-error-wording-split.md +++ b/docs/implements/2026-08-03-error-wording-split.md @@ -1,6 +1,6 @@ # 오류 응답 문구 분리 — DB 실패가 더 이상 임베딩을 가리키지 않는다 -- **티켓**: S15P11A705-229 +- **티켓**: Jira 작업 - **상태**: 완료 - **날짜**: 2026-08-03 - **선행**: [검색 API 오류 응답 계약](2026-07-31-search-error-contract.md) (`-220`) · @@ -41,7 +41,7 @@ `-220`이 예외 메시지에 업스트림 응답 본문 200자가 섞일 수 있음을 확인했고(`embedding_ client._embed_batch`), `-221`이 DB 축에서 같은 원칙을 적용해 `db_errors.py`의 예외 -메시지에 타입 이름·SQLSTATE만 남기도록 했다. `-205`가 그 경로의 실제 누출(GMS 응답 +메시지에 타입 이름·SQLSTATE만 남기도록 했다. `-205`가 그 경로의 실제 누출(AI API 응답 200자가 다섯 곳의 로그·트레이스백으로 새던 것)을 실측으로 확정했다. 이 세 근거가 그대로 유효하므로 이 티켓에서도 본문에 예외 메시지·SQLSTATE·host·DSN 어느 것도 싣지 않는다. 값은 로그가 담당하고, 본문은 실패한 하위 시스템의 이름만 말한다. @@ -121,7 +121,7 @@ detail = ( ### 4.2 구현 전 실패 확인 → 구현 후 통과 `app/main.py`를 고치기 전 신규 DB 축 테스트 2건을 먼저 돌려 실패를 확인했다. 두 -테스트 모두 `embedding upstream ...`가 나와 실패했다(GMS 회귀 테스트 2건은 현재 +테스트 모두 `embedding upstream ...`가 나와 실패했다(AI API 회귀 테스트 2건은 현재 동작이 맞았으므로 이미 통과 상태였다). 핸들러에 `isinstance` 분기를 넣은 뒤 4건 모두 통과했다. diff --git a/docs/implements/2026-08-03-gms-image-probe.md b/docs/implements/2026-08-03-gms-image-probe.md index 1086eaa..800eed2 100644 --- a/docs/implements/2026-08-03-gms-image-probe.md +++ b/docs/implements/2026-08-03-gms-image-probe.md @@ -1,18 +1,18 @@ -# GMS 이미지 실사용 조건 — 생성(축 A) · 분석(축 B) +# AI API 이미지 실사용 조건 — 생성(축 A) · 분석(축 B) -- **티켓**: S15P11A705-253 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 - **측정 시각**: **2026-08-03 17:07 ~ 17:26 KST** -- **프로바이더 경로**: `https://gms.ssafy.io/gmsapi` (root) +- **프로바이더 경로**: `https://api.example.com` (root) - **호출 수**: **축 A 20회 · 축 B 30회 = 50회** (양쪽 상한 소진) - **관련**: [`ai#97`](https://github.com/Team-PinLog/ai/issues/97) 컬렉션 표지 생성 · [`ai#98`](https://github.com/Team-PinLog/ai/issues/98) 장소 제안 · - [`S15P11A705-225`](#4-게이트웨이-요청-본문-상한--225-와-겹친다) 긴 Context 오진 + [Jira 작업](#4-게이트웨이-요청-본문-상한--225-와-겹친다) 긴 Context 오진 - **선행**: [비전 재고](2026-08-03-gms-vision-probe.md)(`-227`) — "읽는가"에 답한 문서. 이 문서는 "얼마에·어디까지"에 답한다 - **하네스**: [`tools/gms_image/`](../../tools/gms_image/) -> **이 수치는 상수가 아니다.** GMS 쿼터·상한은 시점과 프로바이더 경로별로 다르다 +> **이 수치는 상수가 아니다.** AI API 쿼터·상한은 시점과 프로바이더 경로별로 다르다 > (T27: 07-29 분당 2건 → 07-30 분당 30건 이상). 위 측정 시각과 경로가 이 표의 유효 > 범위다. @@ -55,7 +55,7 @@ Anthropic 은 시도하지 않았다 — 공개 API 에 이미지 **생성** 엔 ### 1.2 404 는 한 번도 나오지 않았다 — 게이트웨이는 **경로가 아니라 `model` 로 라우팅한다** -칩은 `404`·`401`·`403` 을 구분하라고 했다. 셋 다 관측되지 않았다. GMS 는 모든 거부를 +칩은 `404`·`401`·`403` 을 구분하라고 했다. 셋 다 관측되지 않았다. AI API 는 모든 거부를 `400` + 자체 봉투로 낸다. ``` @@ -69,7 +69,7 @@ Anthropic 은 시도하지 않았다 — 공개 API 에 이미지 **생성** 엔 라우팅한다. 그래서 대조군은 의도한 역할("경로 구성이 맞는지")을 하지 못했지만, 더 나은 답을 줬다. -모델 목록 조회는 GMS 를 통해 구조적으로 불가능하다. 어떤 모델이 열려 있는지는 시도해 +모델 목록 조회는 AI API 를 통해 구조적으로 불가능하다. 어떤 모델이 열려 있는지는 시도해 보는 것 말고 알 방법이 없다. 이 사실은 §4 의 상한 문제와 같은 원인(게이트웨이가 본문을 파싱해 라우팅한다)에서 @@ -126,7 +126,7 @@ Gemini HTTP 200 ← ‼ | 응답 형태 | 의미 | 성격 | |---|---|---| -| `[GMS 에러]` 접두 | 게이트웨이가 막았다 — 모델 allowlist·본문 상한 | GMS 운영 문제 | +| `[GMS 에러]` 접두 | 게이트웨이가 막았다 — 모델 allowlist·본문 상한 | AI API 운영 문제 | | `[ 에러]` 접두 | 벤더가 막았다 — moderation_blocked 등 | 우리 프롬프트 문제 | | 200 인데 `finishReason≠STOP` | 벤더가 오류 없이 거절했다 | 상태 코드로는 보이지 않는다 | @@ -261,7 +261,7 @@ Gemini 경로에서는 더 나쁜 동작이 관측됐다. 거기서는 게이트 형태로 나타난 것이다. 즉 크기 거부가 「모델 오류」로 온다. `-205` T62 가 텍스트 본문에서 본 것과 같은 -형태이고, `S15P11A705-225`(긴 Context 가 모델 오류로 오진)와 같은 원인이다. +형태이고, Jira 작업(긴 Context 가 모델 오류로 오진)와 같은 원인이다. 이미지에서도 재현된다는 것을 이 측정이 확인한다. ### 4.2 어디에 있는가 @@ -286,7 +286,7 @@ base64 가 4/3 로 부풀므로 이미지 크기의 상한은 본문 상한보 확인된 거부 이미지 원본 96,931 B (~95 KB) ``` -> `ai#98` 의 전제가 바뀐다. 대화 캡처 스크린샷은 보통 200 KB ~ 2 MB 다. 현재 GMS 를 +> `ai#98` 의 전제가 바뀐다. 대화 캡처 스크린샷은 보통 200 KB ~ 2 MB 다. 현재 AI API 를 > 통해서는 보낼 수 없다. 토큰 비용을 걱정하기 전에 요청이 400 으로 되돌아온다. > `back#138` 이 전제한 「최대 10 MiB」와는 두 자릿수 차이다. > diff --git a/docs/implements/2026-08-03-gms-vision-probe.md b/docs/implements/2026-08-03-gms-vision-probe.md index f94069c..75da2bd 100644 --- a/docs/implements/2026-08-03-gms-vision-probe.md +++ b/docs/implements/2026-08-03-gms-vision-probe.md @@ -1,10 +1,10 @@ -# GMS 게이트웨이 멀티모달(이미지) 지원 재고 +# AI API 게이트웨이 멀티모달(이미지) 지원 재고 -- **티켓**: S15P11A705-227 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 - **관련**: [`back#138`](https://github.com/Team-PinLog/back/issues/138) — 이 재고를 요구한 이슈 -- **선행 방법론**: [게이트웨이 오류 본문 마스킹](2026-07-31-gms-error-body-redaction.md) (`S15P11A705-205`) — - 실제 GMS로 소수 호출을 만들어 응답 본문으로 게이트웨이/벤더를 가르는 절차를 그대로 썼다 +- **선행 방법론**: [게이트웨이 오류 본문 마스킹](2026-07-31-gms-error-body-redaction.md) (Jira 작업) — + 실제 AI API로 소수 호출을 만들어 응답 본문으로 게이트웨이/벤더를 가르는 절차를 그대로 썼다 ## 요약 @@ -13,17 +13,17 @@ | 결론 | 지원한다. 세 경로(OpenAI · Gemini · Anthropic) 모두 200 이고, 모델이 이미지를 실제로 읽었다 | | 근거 | 1×1 PNG 를 각 벤더 스펙 그대로 실어 보냈고, 세 응답 모두 이미지 내용에 대한 답(색상 등 한 단어)을 반환했다. 빈 응답이나 이미지 무시가 아니다 | | 호출 수 | 3건(경로당 1회). 공용 게이트웨이 부담 최소화 원칙을 지켰다 | -| 영향 | `back#138` 이 제안한 `Front → FastAPI → 이미지 분석` 흐름은 GMS 비전 거부로 막히지 않는다. 이 재고가 유일한 선행 조건이었다(같은 이슈 코멘트 기준) | +| 영향 | `back#138` 이 제안한 `Front → FastAPI → 이미지 분석` 흐름은 AI API 비전 거부로 막히지 않는다. 이 재고가 유일한 선행 조건이었다(같은 이슈 코멘트 기준) | ## 왜 재는가 `back#138`은 대화 캡처 이미지를 FastAPI가 직접 분석하는 흐름(Spring 중계 방식으로 -합의됨)을 제안했다. 그 이슈에서 "GMS 게이트웨이가 비전 요청을 프록시하는지 확인한 +합의됨)을 제안했다. 그 이슈에서 "AI API 게이트웨이가 비전 요청을 프록시하는지 확인한 적이 없다"는 것을 짚었고, 확인이 안 되면 설계 전체가 성립하지 않는다고 남겼다. 같은 이슈에서 "오늘 안에 못 돌렸다"고 재고를 약속만 하고 지키지 못한 이력이 있다. -`S15P11A705-227`로 그 약속을 이행한다. +Jira 작업으로 그 약속을 이행한다. -현재 FastAPI의 GMS 호출은 텍스트 전용이다(임베딩 `text-embedding-3-small`, 판정 +현재 FastAPI의 AI API 호출은 텍스트 전용이다(임베딩 `text-embedding-3-small`, 판정 `gpt-4o-mini → gemini-2.5-flash → claude-haiku-4-5` 벤더 폴백). 코드에 이미지·멀티모달 경로가 없어 실측 없이는 지원 여부를 알 수 없었다. @@ -66,9 +66,9 @@ ## 결론 -GMS 게이트웨이는 세 벤더 경로 모두에서 멀티모달(이미지 입력) 요청을 통과시킨다. +AI API 게이트웨이는 세 벤더 경로 모두에서 멀티모달(이미지 입력) 요청을 통과시킨다. `back#138`이 제안한 `Front → Spring → FastAPI → 이미지 분석`(카카오 검색 포함) 흐름은 -GMS 비전 거부로 막히지 않는다. 같은 이슈에서 이것이 "유일한 선행 조건"으로 남아 +AI API 비전 거부로 막히지 않는다. 같은 이슈에서 이것이 "유일한 선행 조건"으로 남아 있었으므로, 이 결과로 해당 조건은 해소된다. ## 안 한 것 diff --git a/docs/implements/2026-08-03-preset-description.md b/docs/implements/2026-08-03-preset-description.md index 20e2e1d..8689506 100644 --- a/docs/implements/2026-08-03-preset-description.md +++ b/docs/implements/2026-08-03-preset-description.md @@ -1,6 +1,6 @@ # 프리셋 `description`·`examples` 개정 — `examples` 개정만 채택한다. `description` 개정은 키워드가 붙지 않는 Context 를 늘린다 -- **티켓**: S15P11A705-228 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 - **선행**: [τ](2026-07-31-candidate-threshold.md)(`-210`) · [프롬프트](2026-07-31-judge-prompt-rule.md)(`-219`) · [다수결](2026-07-31-judge-vote.md)(`-223`). 세 선행 티켓의 라벨·데이터·집계 코드를 그대로 물려받았다 - **측정 중 발견한 문제**: [T68·T69](../troubleshooting/2026-08-03-preset-description.md) @@ -723,8 +723,8 @@ T69 에서 확인한 결함이다. 고친다면 `in_k` 필터를 없애고 「ra | 데이터 | `pinlog-demo`(:15432) · Context 42건(고유 본문 37 + 중복 5) · 프리셋 27 · 현행 판정 83행 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | | 판정 모델 | **`gpt-4o-mini`** — 전 회차 실측(`JudgeResult.model`). 설정값이 아니라 API 가 실제로 답한 값이다(T43) | -| GMS 판정 | **1,680회** (4조건 × 10회 × 42). 실패 **0** | -| GMS 임베딩 | 배치 **14회** (`matrix` 5 · `probe` 5 · T68 진단 4). 텍스트 329건 | +| AI API 판정 | **1,680회** (4조건 × 10회 × 42). 실패 **0** | +| AI API 임베딩 | 배치 **14회** (`matrix` 5 · `probe` 5 · T68 진단 4). 텍스트 329건 | | 테스트 | `430 passed` · line **99.83%**(1163/1165) · branch **98.99%**(196/198) · 게이트 80% 통과 · `ruff check .` 통과 | | DB 변경 | **없음.** 측정 전 구간에서 `ai.keyword_preset` 을 변경하지 않았다 | | `app/` 변경 | **없음.** 서비스 코드는 건드리지 않았다 | diff --git a/docs/implements/2026-08-03-preset-display-name-deploy-chain.md b/docs/implements/2026-08-03-preset-display-name-deploy-chain.md index c89e2a8..633bb75 100644 --- a/docs/implements/2026-08-03-preset-display-name-deploy-chain.md +++ b/docs/implements/2026-08-03-preset-display-name-deploy-chain.md @@ -3,7 +3,7 @@ - **상태**: 완료 - **날짜**: 2026-08-03 - **유형**: 검증 — 만든 것이 아니라 배포 체인이 어떻게 이어지는가와 미결 판정 하나의 답이다 -- **대상**: `S15P11A705-292`(프리셋 표시명 명사형 통일). 이 문서는 `S15P11A705-293` 에서 쓴다 +- **대상**: 프리셋 표시명 명사형 통일 관련 Jira 작업. 이 문서는 후속 Jira 작업에서 쓴다 - **좌표**: `ai#102`(dev) · `ai#103`(닫힘) · `ai#104`(릴리스) · `infra#185`(이미지 갱신) - **읽는 순서**: §2 가 이 문서의 핵심이다. §1 은 그 판정이 나온 경로, §3 은 다음 릴리스에서 그대로 다시 만나는 것이다. diff --git a/docs/implements/2026-08-03-search-recall-probe.md b/docs/implements/2026-08-03-search-recall-probe.md index 5c081af..b0120c1 100644 --- a/docs/implements/2026-08-03-search-recall-probe.md +++ b/docs/implements/2026-08-03-search-recall-probe.md @@ -1,12 +1,12 @@ # 본문에 있는 말로 검색해도 안 나온다 — 세 이슈의 원인이 서로 다르다는 것을 판별했다 -- **티켓**: S15P11A705-255 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 (측정 10:29 KST) - **이슈**: [ai#86](https://github.com/Team-PinLog/ai/issues/86)(`신한`) · [ai#87](https://github.com/Team-PinLog/ai/issues/87)(`그네`) · [ai#88](https://github.com/Team-PinLog/ai/issues/88)(`부캠`) -- **선행**: [검색 결과 컷](2026-07-31-search-cut.md) (`S15P11A705-213`) · - [임베딩 4조건](2026-07-31-embedding-grid.md) (`S15P11A705-191`) +- **선행**: [검색 결과 컷](2026-07-31-search-cut.md) (Jira 작업) · + [임베딩 4조건](2026-07-31-embedding-grid.md) (Jira 작업) - **하네스**: `tools/search_cut/recall_probe.py` — 실행 절차는 그 README - **성격**: 측정만 한다. 컷 값도 모델도 코드도 고치지 않는다. @@ -200,7 +200,7 @@ embedding_profile openai-text-embedding-3-small-1536-cosine-v1 (설정 ### 부분어 — `#87` 의 BPE 가설은 반증됐다 계약이 「확인 없이 단정하지 마라」고 한 항목이다. 프로파일이 `text-embedding-3-small` -(GMS 를 통한 OpenAI 경로)이므로 토크나이저가 `cl100k_base` 로 특정된다. 실제로 +(AI API 를 통한 OpenAI 경로)이므로 토크나이저가 `cl100k_base` 로 특정된다. 실제로 분해해서 확인했다. ``` @@ -305,7 +305,7 @@ BPE 는 `그네팟` 을 한 덩어리로 묶지 않는다. `cl100k_base` 는 한 **실행한 것** - 벡터 유무 전수 확인 (42/42 `COMPLETED`, 프로파일 일치) -- 질의 22건 × Record 17건 유사도 측정. **GMS 임베딩 배치 1회** +- 질의 22건 × Record 17건 유사도 측정. **AI API 임베딩 배치 1회** - 컷 재구성 — `SearchService._cut` 을 다시 적어 순서(LIMIT → 컷)까지 맞춤 - 토크나이저 실측 — `cl100k_base` 로 질의·본문 분해와 공유 토큰 확인 - 본문 길이 × 유사도 순위 상관 @@ -346,7 +346,7 @@ BPE 는 `그네팟` 을 한 덩어리로 묶지 않는다. `cl100k_base` 는 한 | 경로 | 수명 | | |---|---|---| | `tools/search_cut/recall_probe.py` | **영구** | 하네스. `--replay`(판정 재계산) · `--lengths`(길이 상관) | -| `.search/recall_probe.json` | **영구(커밋)** | 유사도 행렬. 다시 뜨려면 GMS 를 부른다. `.gitignore` 예외에 등록 | +| `.search/recall_probe.json` | **영구(커밋)** | 유사도 행렬. 다시 뜨려면 AI API 를 부른다. `.gitignore` 예외에 등록 | | 이 문서 | **영구** | | `matrix.json` 과 같은 원칙으로 커밋한다. Context 본문을 담지 않고 Record 대표 diff --git a/docs/implements/2026-08-03-word-query-cut.md b/docs/implements/2026-08-03-word-query-cut.md index 0f40611..98e0a0c 100644 --- a/docs/implements/2026-08-03-word-query-cut.md +++ b/docs/implements/2026-08-03-word-query-cut.md @@ -1,10 +1,10 @@ # 단어형 질의로 컷 격자를 다시 훑는다 — `τ_abs` 를 질의 길이로 가른다 -- **티켓**: S15P11A705-266 +- **티켓**: Jira 작업 - **날짜**: 2026-08-03 (측정 10:57 KST · 실서버 대조 11:1x KST) - **이슈**: [ai#87](https://github.com/Team-PinLog/ai/issues/87)(`그네`) — 이 티켓이 닫는다 -- **선행**: [재현율 판별](2026-08-03-search-recall-probe.md) (`S15P11A705-255`) · - [검색 결과 컷](2026-07-31-search-cut.md) (`S15P11A705-213`) +- **선행**: [재현율 판별](2026-08-03-search-recall-probe.md) (Jira 작업) · + [검색 결과 컷](2026-07-31-search-cut.md) (Jira 작업) - **하네스**: `tools/search_cut/word_matrix.py` · `word_sweep.py` — 실행 절차는 그 README - **명세**: [personal-search.md §6.1](../spec/personal-search.md) @@ -87,7 +87,7 @@ expect(질의, 소유자) = { 그 소유자의 Record 중 본문에 질의가 장소명은 정답 기준에서 뺐다. 임베딩이 받는 것은 `context` 하나뿐이라(`demo_data.yaml` §①) 장소명을 넣으면 모델에 주지 않은 정보를 기대하게 -된다. 「진우네 초밥」의 본문에 「초밥」이 없는 것이 그 예이고, 가드가 GMS 를 부르기 +된다. 「진우네 초밥」의 본문에 「초밥」이 없는 것이 그 예이고, 가드가 AI API 를 부르기 전에 그 질의를 잡아냈다. 부작용이 이득이었다. 같은 질의를 소유자 셋에게 던지면 정답 있는 행과 없는 행이 @@ -222,7 +222,7 @@ offtopic 45행 PinLog 범주 밖 (`-213` 무관 5종의 단어형) **무관 ### 실측했다 — 회차 3개 -같은 질의 집합을 세 번 떠서 대조했다(`word_sweep.py --repro`). 회차별 GMS 임베딩 배치 +같은 질의 집합을 세 번 떠서 대조했다(`word_sweep.py --repro`). 회차별 AI API 임베딩 배치 1회씩, 2026-08-03 10:57 · 11:23 · 11:24 KST. ``` @@ -311,10 +311,10 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 **실행한 것** -- 단어형 54건 × 소유자 3명 = 207행 측정. **GMS 임베딩 배치 1회**(69건) -- 격자 240조합(τ_abs 20 × r 12)을 단어형·문장형 **동시에** 훑음. GMS·DB 미호출 +- 단어형 54건 × 소유자 3명 = 207행 측정. **AI API 임베딩 배치 1회**(69건) +- 격자 240조합(τ_abs 20 × r 12)을 단어형·문장형 **동시에** 훑음. AI API·DB 미호출 - **재현성 회차 3개**(§재현성) — `T68` 대응. 변동 상한 0.000209 · 격자 판정 전 구간 - 3회 일치 · 경계점 `스팟` 스프레드 0.000000. 회차당 GMS 배치 1회씩 추가 + 3회 일치 · 경계점 `스팟` 스프레드 0.000000. 회차당 AI API 배치 1회씩 추가 - **실서버 대조 87/87 PASS** — 이 브랜치 코드로 띄운 서버(:8002)에 문장형 27건 + 두 하한에서 결과가 갈리는 단어형 60행을 던졌다. 갈리지 않는 행은 서버가 무엇을 하든 통과하므로 표본에서 뺐다. 재구성 쪽에 길이 분기를 다시 적어(구현 `import` 금지, @@ -432,12 +432,12 @@ app/service/search_service.py `_cut(rows, query)` · `_is_word_query` 9줄 | 경로 | 수명 | | |---|---|---| -| `tools/search_cut/word_matrix.py` | **영구** | 단어형 행렬. GMS 배치 1회. 가드 둘이 재기 전에 멈춘다 | -| `tools/search_cut/word_sweep.py` | **영구** | 두 행렬 동시 격자. GMS·DB 미호출 | +| `tools/search_cut/word_matrix.py` | **영구** | 단어형 행렬. AI API 배치 1회. 가드 둘이 재기 전에 멈춘다 | +| `tools/search_cut/word_sweep.py` | **영구** | 두 행렬 동시 격자. AI API·DB 미호출 | | `tools/search_cut/verify_live.py` | **영구(확장)** | 길이 분기를 재구성에 다시 적고, 분기가 드러나는 행만 골라 던진다 | -| `.search/word_grid.json` | **영구(커밋)** | 단어형 유사도 행렬. 다시 뜨려면 GMS 를 부른다. `.gitignore` 예외 등록 | +| `.search/word_grid.json` | **영구(커밋)** | 단어형 유사도 행렬. 다시 뜨려면 AI API 를 부른다. `.gitignore` 예외 등록 | | `.search/word_sweep.json` | **휘발** | 격자 결과. 행렬에서 언제든 재구성된다 | -| `.search/word_grid_run2.json` · `run3` | **휘발** | 재현성 회차. GMS 를 부르면 다시 뜬다 — `word_grid.json` 과 달리 **보존 가치가 없다**(같은 값을 재는 것이 목적이므로) | +| `.search/word_grid_run2.json` · `run3` | **휘발** | 재현성 회차. AI API 를 부르면 다시 뜬다 — `word_grid.json` 과 달리 **보존 가치가 없다**(같은 값을 재는 것이 목적이므로) | | 이 문서 | **영구** | | `word_grid.json` 은 `matrix.json`·`recall_probe.json` 과 같은 원칙으로 커밋한다. diff --git a/docs/implements/2026-08-05-fusion-measurement.md b/docs/implements/2026-08-05-fusion-measurement.md index 297b9ad..d0621ea 100644 --- a/docs/implements/2026-08-05-fusion-measurement.md +++ b/docs/implements/2026-08-05-fusion-measurement.md @@ -1,10 +1,10 @@ # Keyword fusion 1단계 실측 — 병합 방식·floor 격자와 채택 기준 대조 -- **티켓**: `S15P11A705-336` +- **티켓**: Jira 작업 - **날짜**: 2026-08-05 (실측 17:1x~17:5x KST · 원 실행은 leo 브랜치 코드 대행 실측) - **하네스**: `tools/search_cut/{rank_score,fusion,fusion_sweep,keyword_matrix}.py` — 실행 절차는 그 README - **기준 문서**: [P48](../proposals/P48-search-signal-expansion.md) §1·§2·§6.1 · 발견 문제의 기록 [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md) -- **성격**: **재기만 한다.** 앱 런타임 코드를 바꾸지 않는다. 채택(fusion 활성화) 여부는 이 리포트가 정하지 않는다 — 후속 티켓(`S15P11A705-339`)의 판단 재료다. +- **성격**: **재기만 한다.** 앱 런타임 코드를 바꾸지 않는다. 채택(fusion 활성화) 여부는 이 리포트가 정하지 않는다 — 후속 티켓(Jira 작업)의 판단 재료다. ## 요약 @@ -28,7 +28,7 @@ | DB | 시연 DB `:15432` (`pinlog-demo-postgres-1`) · Record 42건 · 소유자 3명 | | Preset | 27건 v1 (PUBLIC 25 · PRIVATE_ONLY 2) · keyword 판정 83건 · confidence NULL 0건 | | profile | `openai-text-embedding-3-small-1536-cosine-v1` | -| GMS 호출 | `word_matrix` 배치 1회 · `recall_probe` 재생성 · `keyword_matrix` 배치 1회(질의 92건) — 이후 sweep 은 전부 파일만 읽음 | +| AI API 호출 | `word_matrix` 배치 1회 · `recall_probe` 재생성 · `keyword_matrix` 배치 1회(질의 92건) — 이후 sweep 은 전부 파일만 읽음 | | 컷 | `tau_abs=0.30` · `tau_word=0.24` · `r=0.60` · `limit=20` (현행 기본값) | `word_grid.json`·`recall_probe.json` 은 `context_id` 를 포함해 재생성했다(P48 §4.1 — keyword 조인 요건). 재생성으로 유사도가 기존 커밋본과 10⁻⁴ 규모로 다를 수 있다(T42·T68) — baseline 재현 대조가 그 흔들림이 판정을 바꾸지 않았음을 보인다. @@ -129,7 +129,7 @@ floor 0.35 에서 무관 무노출이 baseline 으로 완전히 돌아오고, | 경로 | 수명 | |---|---| -| `.search/keyword_matrix.json` | **영구(커밋)** — 재생성에 GMS 배치 1회. `.gitignore` 예외 등록 | +| `.search/keyword_matrix.json` | **영구(커밋)** — 재생성에 AI API 배치 1회. `.gitignore` 예외 등록 | | `.search/word_grid.json` · `recall_probe.json` | **영구(커밋 갱신)** — `context_id` 포함 재생성분 | | 스윕 결과 JSON (`fusion_sweep*.json` · `rank_baseline.json`) | **미커밋** — artifact 에서 재구성 가능(기존 규약). 수치는 이 리포트 표가 보존본 | | 이 문서 | 영구 | diff --git a/docs/implements/2026-08-05-search-rank-baseline.md b/docs/implements/2026-08-05-search-rank-baseline.md index 440dd92..0e22e36 100644 --- a/docs/implements/2026-08-05-search-rank-baseline.md +++ b/docs/implements/2026-08-05-search-rank-baseline.md @@ -2,10 +2,10 @@ - **티켓**: 미발급([P48](../proposals/P48-search-signal-expansion.md) 0단계, 런타임 변경 없음) - **날짜**: 2026-08-05 -- **하네스**: `tools/search_cut/rank_score.py` — 기존 행렬만 읽는다. **DB 0회 · GMS 0회** +- **하네스**: `tools/search_cut/rank_score.py` — 기존 행렬만 읽는다. **DB 0회 · AI API 0회** - **산출**: 이 문서 §3의 표가 **보존본**이다. `--json` 산출물(`.search/rank_baseline.json`)은 커밋하지 않는다 — `.gitignore`가 "스윕 결과는 matrix에서 언제든 재구성되므로 남기지 - 않는다"고 정했고, 이 계산은 GMS·DB를 부르지 않아 재생성 비용이 0이다 + 않는다"고 정했고, 이 계산은 AI API·DB를 부르지 않아 재생성 비용이 0이다 - **선행**: [검색 결과 컷](2026-07-31-search-cut.md)(`-213`) · [원인 판별](2026-08-03-search-recall-probe.md)(`-255`) · [단어형 컷](2026-08-03-word-query-cut.md)(`-266`) - **성격**: 재기만 한다. **컷 값도 모델도 앱 코드도 고치지 않는다.** diff --git a/docs/implements/2026-08-05-short-query-boundary.md b/docs/implements/2026-08-05-short-query-boundary.md index 6637da6..6b31f0d 100644 --- a/docs/implements/2026-08-05-short-query-boundary.md +++ b/docs/implements/2026-08-05-short-query-boundary.md @@ -1,6 +1,6 @@ # 짧은 질의가 어디서 탈락하는가 — 층 관측과 단어형 경계 두 정의 -- **티켓**: S15P11A705-273 (측정 축) · S15P11A705-272 (처방 후보 중 하나) +- **관련 Jira 작업**: 측정 축 · 처방 후보 중 하나 - **날짜**: 2026-08-05 (측정 16:40~16:5x KST) - **선행**: [재현율 판별](2026-08-03-search-recall-probe.md) (`-255`) · [단어형 컷 격자](2026-08-03-word-query-cut.md) (`-266`) · @@ -188,7 +188,7 @@ pair (그네, 공원) → spaced "그네 공원" 5자 · 2어절 · 공백 ``` 쌍 176종 × 2형태 × 소유자 3명 = 1,128행 (정답 396 · 교차 660 · 무관 72) -GMS 임베딩 376건 (배치 3회) +AI API 임베딩 376건 (배치 3회) ``` | 대역 | | 행 | 현행 하한 | 글자 수 정의 | 어절 수 정의 | @@ -428,10 +428,10 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 - **층별 프로브 22건 · 실서버 대조 22/22 일치.** 이 브랜치 코드로 띄운 서버(:8003). 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 `import` 금지, `-213` 규칙) -- **경계 행렬 1,128행.** 쌍 176종 × 2형태 × 소유자 3명. GMS 임베딩 376건(배치 3회). +- **경계 행렬 1,128행.** 쌍 176종 × 2형태 × 소유자 3명. AI API 임베딩 376건(배치 3회). 가드 둘이 재기 전에 확인한다 — 무관 통제가 본문에 있는지 전수 대조, 쌍이 어느 소유자 본문에도 없는지 -- **규칙 6종 × 전량/대역별/초점 격자.** DB·GMS 미호출(행렬만 읽는다) +- **규칙 6종 × 전량/대역별/초점 격자.** DB·AI API 미호출(행렬만 읽는다) - **`-266` 행렬 재판정** — 세 정의가 1어절 대역에서 완전히 같음을 확인 - **`recall_probe.py --replay`** — 현행 하한에서 `그네`·`스팟`·`산책`·`라멘` 통과, `신한`·`부캠`·`신한 부캠` 만 남음 @@ -504,10 +504,10 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 | 경로 | 수명 | | |---|---|---| -| `tools/search_cut/boundary_matrix.py` | **영구** | 짝 행렬. GMS 배치 3회. 가드 둘이 재기 전에 멈춘다 | -| `tools/search_cut/boundary_sweep.py` | **영구** | 규칙 6종 격자 · 짝 대조 · 경계 분포. **DB·GMS 미호출** | +| `tools/search_cut/boundary_matrix.py` | **영구** | 짝 행렬. AI API 배치 3회. 가드 둘이 재기 전에 멈춘다 | +| `tools/search_cut/boundary_sweep.py` | **영구** | 규칙 6종 격자 · 짝 대조 · 경계 분포. **DB·AI API 미호출** | | `tools/search_cut/layer_probe.py` | **영구** | 층별 잔존 건수 + 실서버 대조. `--no-live` 로 서버 없이도 | -| `.search/boundary_grid.json` | **영구(커밋)** | 1,128행. 다시 뜨려면 GMS 를 부른다. `.gitignore` 예외 등록 | +| `.search/boundary_grid.json` | **영구(커밋)** | 1,128행. 다시 뜨려면 AI API 를 부른다. `.gitignore` 예외 등록 | | `.search/layer_probe.json` | **영구(커밋)** | 층 판정과 실서버 응답 건수 | | 이 문서 | **영구** | | @@ -520,7 +520,7 @@ joined 가 더 높은 짝 26/198 ← 172짝에서 내려간다 - **초점 집합과 전량이 어긋나는 것을 어떻게 다룰지** — 「사용자가 무엇을 치는가」를 재는 방법이 없으면 이 어긋남은 다음 측정에서도 반복된다. - **전각 공백 대역을 넓혀서 재기** — 3건으로는 크기를 모른다. `boundary_matrix.py` 에 - `fullwidth` 형태를 세 번째로 넣으면 176종 전량에서 잴 수 있다(GMS 배치 2회 추가). + `fullwidth` 형태를 세 번째로 넣으면 176종 전량에서 잴 수 있다(AI API 배치 2회 추가). - **`신한 부캠` 의 `r` 탈락** — `-266` 이 「`r` 은 참여하지 않는다」로 가르지 않았는데 이 건은 `r` 에만 걸린다. `word_sweep.py` 의 `r` 축을 이 질의로 다시 봐야 한다. diff --git a/docs/implements/2026-08-06-rerank-adoption.md b/docs/implements/2026-08-06-rerank-adoption.md index 0e405a6..719f8db 100644 --- a/docs/implements/2026-08-06-rerank-adoption.md +++ b/docs/implements/2026-08-06-rerank-adoption.md @@ -1,11 +1,11 @@ # 키워드 재정렬 채택값 실측과 런타임 구현 — 재정렬 전용 구조의 binary w=0.05 · floor 0.35 -- **티켓**: `S15P11A705-339` -- **번호**: **I56 (잠정)** — 병렬 브랜치(`S15P11A705-337-rewrite-probe`)의 잠정 I55 와 충돌해, 나중에 통합에 병합된 이쪽이 I56 으로 재확정됐다(색인 검사가 중복을 잡는 설계된 절차). 최종 확정은 dev 병합 직후다. +- **티켓**: Jira 작업 +- **번호**: **I56 (잠정)** — 병렬 재작성 측정 브랜치의 잠정 I55 와 충돌해, 나중에 통합에 병합된 이쪽이 I56 으로 재확정됐다(색인 검사가 중복을 잡는 설계된 절차). 최종 확정은 dev 병합 직후다. - **날짜**: 2026-08-06 - **하네스**: `tools/search_cut/fusion_rerank_sweep.py`(신규) · `fusion.py` 의 `rerank()`(신규) · `rerank_verify_live.py`(실서버 대조, 신규) — 실행 절차는 그 README - **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §4(병합 방법)·§5(무관 결과 차단)·§8(작업 4) · 선행 실측 [I53](2026-08-05-fusion-measurement.md) -- **성격**: 측정과 런타임 구현을 함께 완결한다. 측정은 기존 artifact 만 읽는 완전 오프라인이다(GMS·DB 호출 0회). 런타임 재정렬은 기본 off 플래그 뒤에 있고, 스냅샷 DB 상대의 실서버 on/off 대조까지 이 리포트가 담는다. +- **성격**: 측정과 런타임 구현을 함께 완결한다. 측정은 기존 artifact 만 읽는 완전 오프라인이다(AI API·DB 호출 0회). 런타임 재정렬은 기본 off 플래그 뒤에 있고, 스냅샷 DB 상대의 실서버 on/off 대조까지 이 리포트가 담는다. ## 한눈에 보는 결과 @@ -106,7 +106,7 @@ off 와 on 은 별도 요청이라 임베딩이 흔들릴 수 있어(T68, 최대 **못 한 것** -- **임베딩 API 비결정성 재측정은 하지 않았다.** artifact 재생성(GMS 호출)이 필요해 티켓 범위 밖이다. 대신 경계 민감도 측정으로 그 흔들림이 채택 결론을 바꾸지 않음을 보였고, 실서버 대조에서도 흔들림 분류가 발동하지 않았다. +- **임베딩 API 비결정성 재측정은 하지 않았다.** artifact 재생성(AI API 호출)이 필요해 티켓 범위 밖이다. 대신 경계 민감도 측정으로 그 흔들림이 채택 결론을 바꾸지 않음을 보였고, 실서버 대조에서도 흔들림 분류가 발동하지 않았다. - **통합 검증(P49 §7)은 이 리포트 범위 밖이다.** 이번 실서버 검증은 재정렬 단독의 브랜치 검증이다. 재작성·문자열 검색을 합친 5기준 통합 검증과 시연 리허설은 작업 6 의 몫으로 남는다. - **운영 DB 를 재지 않았다.** 시연 DB 스냅샷 시점의 artifact 와 스냅샷 DB 만 썼다. Record 42건 규모라 절대값을 일반화하지 않고 퇴행 탐지 근거로만 쓴다 — 선행 리포트들과 같은 한계다. - Preset 이 개정되면(P51 거버넌스 루프) preset_version 가드가 실측을 막으므로 keyword artifact 재생성과 재측정이 필요하다. diff --git a/docs/implements/2026-08-06-rewrite-effect.md b/docs/implements/2026-08-06-rewrite-effect.md index 8fc798c..9ab7872 100644 --- a/docs/implements/2026-08-06-rewrite-effect.md +++ b/docs/implements/2026-08-06-rewrite-effect.md @@ -1,6 +1,6 @@ # LLM 질의 재작성의 검색 효과 실측 — 약어 회복 확인, 무관 노출 증가와 길이 게이트 확정 -- **티켓**: S15P11A705-337 (잔여 측정·게이트 확정) +- **티켓**: Jira 작업 (잔여 측정·게이트 확정) - **날짜**: 2026-08-06 - **하네스**: `tools/search_cut/rewrite_probe.py` — 실행 절차는 도구 도입주석 - **기준 문서**: [P49](../proposals/P49-multi-signal-search.md) §3(LLM 질의 재작성)·§5(관련 없는 결과를 막는 방법) diff --git a/docs/implements/2026-08-07-gate-threshold-remeasure.md b/docs/implements/2026-08-07-gate-threshold-remeasure.md index 850b270..84d5647 100644 --- a/docs/implements/2026-08-07-gate-threshold-remeasure.md +++ b/docs/implements/2026-08-07-gate-threshold-remeasure.md @@ -1,10 +1,10 @@ # 결합 신뢰도 게이트 임계값 오프라인 재측정 -- **티켓**: S15P11A705-401 +- **티켓**: Jira 작업 - **날짜**: 2026-08-07 - **하네스**: `tools/search_cut/gate_sweep.py` — 실행 절차·데이터 원본은 그 파일 docstring - **기준 문서**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · [P49](../proposals/P49-multi-signal-search.md) §9(0.35의 원출처) · 선행 실측 [I57](2026-08-06-rerank-adoption.md) -- **성격**: 재기만 한다. 앱 런타임 코드를 바꾸지 않는다. 기존에 커밋된 행렬만 읽었다(DB·GMS 호출 0회). +- **성격**: 재기만 한다. 앱 런타임 코드를 바꾸지 않는다. 기존에 커밋된 행렬만 읽었다(DB·AI API 호출 0회). ## 한눈에 보는 결과 @@ -43,7 +43,7 @@ ## 남은 것 -- 이 측정은 S1/S2/S3 신호를 **오프라인으로 재구성**한 것이다. S15P11A705-400(back 게이트 구현) 이후에는 실서버 on/off 대조로 한 번 더 검증해야 한다(`rerank_verify_live.py`류 절차). +- 이 측정은 S1/S2/S3 신호를 **오프라인으로 재구성**한 것이다. Jira 작업(back 게이트 구현) 이후에는 실서버 on/off 대조로 한 번 더 검증해야 한다(`rerank_verify_live.py`류 절차). - Record 42건·소유자 3명 규모의 결론이다. 절대 채택값이 아니라 방향과 상대 비교로 읽는다. - 유사도 값 자체의 회차 간 흔들림(T68)은 이번에 재측정하지 않고 기존 실측을 인용했다. 0.35~0.36처럼 격자 간격(0.01)보다 좁은 인접 값 비교는 그 흔들림 안에 있으므로 우열을 주장하지 않는다. diff --git a/docs/implements/2026-08-07-rerank-gate-contract-boundary.md b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md index c30e0b1..052536e 100644 --- a/docs/implements/2026-08-07-rerank-gate-contract-boundary.md +++ b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md @@ -1,6 +1,6 @@ # 재정렬 후보 불변 계약과 결합 신뢰도 게이트 계약의 분리 -- **티켓**: S15P11A705-402 +- **티켓**: Jira 작업 - **날짜**: 2026-08-07 - **성격**: 문서·테스트 경계 명시. 런타임 코드는 바꾸지 않는다. @@ -10,7 +10,7 @@ > 기존 계약 테스트(§4.1에서 인용한 "정렬은 후보를 추가·제거하면 안 된다")는 재정렬 로직의 계약이지 이 새 게이트의 계약이 아니라는 것을 별도로 명시해야 한다 — 헷갈리면 안 되는 지점이다. -`tests/test_search_rerank.py`가 고정하는 다섯 계약 중 ②("같은 요청·같은 limit에서 on/off의 후보 Record id 집합이 같다")는 재정렬(`_rerank_by_keyword`)의 것이다. 결합 신뢰도 게이트(S15P11A705-400)는 back 레포에 구현됐고 정확히 그 반대 성질 — S1 신호만 있고 유사도가 낮은 후보를 **의도적으로 제거한다** — 을 가진다. 이 파일만 읽으면 "재정렬은 후보를 안 지운다"는 규칙이 게이트에도 적용돼야 한다는 오독이 생긴다. +`tests/test_search_rerank.py`가 고정하는 다섯 계약 중 ②("같은 요청·같은 limit에서 on/off의 후보 Record id 집합이 같다")는 재정렬(`_rerank_by_keyword`)의 것이다. 결합 신뢰도 게이트(Jira 작업)는 back 레포에 구현됐고 정확히 그 반대 성질 — S1 신호만 있고 유사도가 낮은 후보를 **의도적으로 제거한다** — 을 가진다. 이 파일만 읽으면 "재정렬은 후보를 안 지운다"는 규칙이 게이트에도 적용돼야 한다는 오독이 생긴다. ## 변경 @@ -21,4 +21,4 @@ - `pytest tests/test_search_rerank.py` — 신규 1건 포함 전체 통과. - `ruff check .` · `pytest --cov` 전체 · `tools/check_coverage_gate.py` — line 94.64%·branch 85.00%, 둘 다 통과. -- `pytest tests/test_docs_index.py` — 이 작업 도중 `docs/implements/README.md`의 두 색인 표(개별 리포트·구현·산출 전수) 모두에 S15P11A705-399 리포트 행이 빠져 있던 것을 발견해 함께 고쳤다(원래 이 티켓의 범위는 아니었으나, 색인 검사가 이 티켓의 변경으로 인해 실행되면서 잡았다). +- `pytest tests/test_docs_index.py` — 이 작업 도중 `docs/implements/README.md`의 두 색인 표(개별 리포트·구현·산출 전수) 모두에 Jira 작업 리포트 행이 빠져 있던 것을 발견해 함께 고쳤다(원래 이 티켓의 범위는 아니었으나, 색인 검사가 이 티켓의 변경으로 인해 실행되면서 잡았다). diff --git a/docs/implements/2026-08-07-search-keyword-matched-field.md b/docs/implements/2026-08-07-search-keyword-matched-field.md index 5373716..4187b0d 100644 --- a/docs/implements/2026-08-07-search-keyword-matched-field.md +++ b/docs/implements/2026-08-07-search-keyword-matched-field.md @@ -1,12 +1,12 @@ # 검색 응답에 키워드 매치 여부 필드 추가 -- **티켓**: S15P11A705-399 +- **티켓**: Jira 작업 - **날짜**: 2026-08-07 -- **성격**: 응답 스키마 확장. 재정렬(P49 §4, `S15P11A705-339`)의 순서·계약은 바꾸지 않는다. +- **성격**: 응답 스키마 확장. 재정렬(P49 §4, Jira 작업)의 순서·계약은 바꾸지 않는다. ## 배경 -`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4.2가 지적한 것 — 키워드 재정렬(`_rerank_by_keyword`)은 컷 통과 후보와 Preset 후보가 실제로 match 하는지 이미 계산하지만, 그 결과는 정렬 키로만 쓰이고 버려진다. 결합 신뢰도 게이트(§4, `S15P11A705-400`)가 S3(키워드 매치) 신호를 쓰려면 이 값이 응답까지 살아 있어야 한다. +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4.2가 지적한 것 — 키워드 재정렬(`_rerank_by_keyword`)은 컷 통과 후보와 Preset 후보가 실제로 match 하는지 이미 계산하지만, 그 결과는 정렬 키로만 쓰이고 버려진다. 결합 신뢰도 게이트(§4, Jira 작업)가 S3(키워드 매치) 신호를 쓰려면 이 값이 응답까지 살아 있어야 한다. ## 변경 diff --git a/docs/implements/README.md b/docs/implements/README.md index 4039a4e..fc9a3a5 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -21,47 +21,47 @@ | I14 | [2026-07-23-keyword-matching-eval.md](2026-07-23-keyword-matching-eval.md) | 검증 | Keyword 매칭 평가 A/B/C 요약·포인터 (판정 모델 gemini-2.5-flash 확정) | | I19 | [2026-07-23-fastapi-implementation.md](2026-07-23-fastapi-implementation.md) | 구현 | FastAPI scaffold + /context/process + /search 구현·검증 (ai#5·#6) | | I20 | [2026-07-24-e3-test-harness.md](2026-07-24-e3-test-harness.md) | 구현 | E3 통합 테스트 하네스 + 저수준 27케이스 + 파이프라인 20 + Dockerfile + ai-ci 정비 (ai#14·#16·#18) | -| I21 | [2026-07-27-e2e-verification.md](2026-07-27-e2e-verification.md) | 검증 | E2E 실경로 — 실제 GMS 프리셋 적재·파이프라인·검색 품질·하네스 동등성·권한 경계 | +| I21 | [2026-07-27-e2e-verification.md](2026-07-27-e2e-verification.md) | 검증 | E2E 실경로 — 실제 AI API 프리셋 적재·파이프라인·검색 품질·하네스 동등성·권한 경계 | | I22 | [2026-07-28-s1-implementation-recovery.md](2026-07-28-s1-implementation-recovery.md) | 구현 | S1 세션 구현 판단 맥락 복원 — 설계선택 19·불변식·spec↔구현 불일치(구현결함)·인프라 미복원 | -| I23 | [2026-07-29-dev-deployment-gates.md](2026-07-29-dev-deployment-gates.md) | 구현 | dev 배포 게이트 3종 — `/ready`·`GMS_BASE_URL` fail-fast·GMS 양방향 스모크 (ai#33) | -| I25 | [2026-07-29-demo-seeding.md](2026-07-29-demo-seeding.md) | 구현 | 데모 시딩 — back API 경로 시딩(`tools/demo_seed/`)·GMS 건수 판단·E2E 재확인 (S15P11A705-58) | -| I41 | [2026-07-30-retry-and-error-classification.md](2026-07-30-retry-and-error-classification.md) | 구현 | 외부 API 재시도·오류 분류 정합화 — 429/LLM 4xx 오분류 정정·짧은 재시도·오류 경로 테스트 (S15P11A705-121) | -| I26 | [2026-07-30-coverage-gate.md](2026-07-30-coverage-gate.md) | 구현 | app coverage 게이트 활성화 — line·branch 각각 80% 차단·부트스트랩/기동 계층 신설·§4.2 계층 구분 명문화 (S15P11A705-110) | -| I24 | [2026-07-29-sealed-secret-handoff.md](2026-07-29-sealed-secret-handoff.md) | 구현 | Runtime Secret handoff — Environment 경계 계약·공급망 pin (S15P11A705-154). **상태: 대체** — 봉인 실행은 Infra 공용 action으로 이관, `S15P11A705-96` 판 설계 근거는 같은 문서에 보존 | -| I43 | [2026-07-30-real-data-e2e.md](2026-07-30-real-data-e2e.md) | 검증 | 실사용자 데이터 E2E — 검색 10/12·피드·Keyword PASS, 시딩 15분 8초·37건, GMS 32,912 토큰 (S15P11A705-174) | -| I44 | [2026-07-30-judge-vendor-fallback.md](2026-07-30-judge-vendor-fallback.md) | 구현 | 판정 LLM 벤더 폴백 — 429가 프로바이더 경로별로 걸린다는 실측·어댑터 3종·시도 예산 공유·응답 벤더 기록 (S15P11A705-175) | -| 없음 | [2026-07-31-ticket-audit-96-77.md](2026-07-31-ticket-audit-96-77.md) | 감사 | 티켓 대조 — `-96` 완료 조건 5개·`-77` 정정 요청 8개를 `ai`·`infra`·`docs` 실물과 대조해 해소/미해소/무효 판정. 둘 다 닫을 수 있음 (S15P11A705-96·-77). **번호 없음** — 이 문서 자신이 "구현 기록이 아니라 대조·판정 기록이다. 만든 것이 아니라 확인한 결과"라고 명시한다(전수 표는 산출을 세는 곳이다) | -| I27 | [2026-07-31-embedding-grid.md](2026-07-31-embedding-grid.md) | 검증 | 임베딩 4조건 실경로 측정 — 입력 구성 × 모델 교차. 1위 일치 A 10 · B 9 · C 10 · D 10 / 12, top-3 넷 다 12/12. `-174` 의 8번 진단이 틀렸음을 B·C 대비가 보인다 (S15P11A705-191) | -| I28 | [2026-07-31-gms-call-observability.md](2026-07-31-gms-call-observability.md) | 구현 | GMS 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율, 만료 `PROCESSING` 재선점. httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단 (S15P11A705-197) | -| I39 | [2026-07-31-seed-guard.md](2026-07-31-seed-guard.md) | 구현 | 시연 도구 결함 3건 — 쓰기 컬럼 계약·고아 집계·JWT 키 실검증을 `--reset` 앞에 두는 preflight. 셋 다 어긋내 RED 확인 (S15P11A705-198) | +| I23 | [2026-07-29-dev-deployment-gates.md](2026-07-29-dev-deployment-gates.md) | 구현 | dev 배포 게이트 3종 — `/ready`·`GMS_BASE_URL` fail-fast·AI API 양방향 스모크 (ai#33) | +| I25 | [2026-07-29-demo-seeding.md](2026-07-29-demo-seeding.md) | 구현 | 데모 시딩 — back API 경로 시딩(`tools/demo_seed/`)·AI API 건수 판단·E2E 재확인 (Jira 작업) | +| I41 | [2026-07-30-retry-and-error-classification.md](2026-07-30-retry-and-error-classification.md) | 구현 | 외부 API 재시도·오류 분류 정합화 — 429/LLM 4xx 오분류 정정·짧은 재시도·오류 경로 테스트 (Jira 작업) | +| I26 | [2026-07-30-coverage-gate.md](2026-07-30-coverage-gate.md) | 구현 | app coverage 게이트 활성화 — line·branch 각각 80% 차단·부트스트랩/기동 계층 신설·§4.2 계층 구분 명문화 (Jira 작업) | +| I24 | [2026-07-29-sealed-secret-handoff.md](2026-07-29-sealed-secret-handoff.md) | 구현 | Runtime Secret handoff — Environment 경계 계약·공급망 pin (Jira 작업). **상태: 대체** — 봉인 실행은 Infra 공용 action으로 이관, Jira 작업 판 설계 근거는 같은 문서에 보존 | +| I43 | [2026-07-30-real-data-e2e.md](2026-07-30-real-data-e2e.md) | 검증 | 실사용자 데이터 E2E — 검색 10/12·피드·Keyword PASS, 시딩 15분 8초·37건, AI API 32,912 토큰 (Jira 작업) | +| I44 | [2026-07-30-judge-vendor-fallback.md](2026-07-30-judge-vendor-fallback.md) | 구현 | 판정 LLM 벤더 폴백 — 429가 프로바이더 경로별로 걸린다는 실측·어댑터 3종·시도 예산 공유·응답 벤더 기록 (Jira 작업) | +| 없음 | [2026-07-31-ticket-audit-96-77.md](2026-07-31-ticket-audit-96-77.md) | 감사 | 티켓 대조 — `-96` 완료 조건 5개·`-77` 정정 요청 8개를 `ai`·`infra`·`docs` 실물과 대조해 해소/미해소/무효 판정. 둘 다 닫을 수 있음 (Jira 작업·-77). **번호 없음** — 이 문서 자신이 "구현 기록이 아니라 대조·판정 기록이다. 만든 것이 아니라 확인한 결과"라고 명시한다(전수 표는 산출을 세는 곳이다) | +| I27 | [2026-07-31-embedding-grid.md](2026-07-31-embedding-grid.md) | 검증 | 임베딩 4조건 실경로 측정 — 입력 구성 × 모델 교차. 1위 일치 A 10 · B 9 · C 10 · D 10 / 12, top-3 넷 다 12/12. `-174` 의 8번 진단이 틀렸음을 B·C 대비가 보인다 (Jira 작업) | +| I28 | [2026-07-31-gms-call-observability.md](2026-07-31-gms-call-observability.md) | 구현 | AI API 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율, 만료 `PROCESSING` 재선점. httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단 (Jira 작업) | +| I39 | [2026-07-31-seed-guard.md](2026-07-31-seed-guard.md) | 구현 | 시연 도구 결함 3건 — 쓰기 컬럼 계약·고아 집계·JWT 키 실검증을 `--reset` 앞에 두는 preflight. 셋 다 어긋내 RED 확인 (Jira 작업) | | I30 | [2026-07-31-candidate-threshold.md](2026-07-31-candidate-threshold.md) | 검증 | 후보 유사도 임계값 τ 재검증 — 현행 `0.30` 유지. `fit min 0.3001` 과 `unfit max 0.4225` 가 겹쳐 τ 로는 「붙여도 되는가」를 가를 수 없다 | -| I29 | [2026-07-31-search-cut.md](2026-07-31-search-cut.md) | 검증 | 검색 결과 컷 `τ_abs=0.30 · r=0.60` — 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3% 제거 · 무관 질의 11/15 침묵. **무관 질의를 1건에서 15건으로 늘리자 §6 의 「간격 +0.2120」이 -0.0176 으로 뒤집혀** 컷 미적용 판단을 개정 (S15P11A705-213) | -| I40 | [2026-07-31-judge-prompt-rule.md](2026-07-31-judge-prompt-rule.md) | 검증 | 판정 프롬프트 「본문에 근거 없으면 미선택」 개정안 둘 — **둘 다 채택하지 않는다.** 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했고, 사용자가 보는 손실(`fit 0건 Context` 8.00)은 세 조건이 같다 (S15P11A705-219) | -| I31 | [2026-07-31-search-error-contract.md](2026-07-31-search-error-contract.md) | 구현 | 검색 API 오류 응답 계약 — `TransientError→503` · `PermanentError→502`, 500 을 「우리 코드의 결함」으로 비워 둔다. 운영 버그 `ai#69`(임베딩 502 → 검색 500). **`back` 은 500·503 을 구분하지 않으므로 바뀌는 것은 사용자 화면이 아니라 관측** (S15P11A705-220) | -| I34 | [2026-07-31-gms-error-body-redaction.md](2026-07-31-gms-error-body-redaction.md) | 구현 | 게이트웨이 오류 본문 마스킹 — 응답 본문 200자가 예외 메시지를 타고 **다섯 곳의 로그와 트레이스백**으로 나가던 경로를 원천에서 막는다. 실제 GMS 로 네 경로에 오류 19건을 넣어 실측: **자격 증명은 한 건도 에코되지 않고**, endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다** (S15P11A705-205) | -| I32 | [2026-07-31-db-error-classification.md](2026-07-31-db-error-classification.md) | 구현 | DB 실패의 오류 분류 — `-220` 이 남긴 500 을 메운다. SQLSTATE 군 단위 경계, **미분류(500) 목록이 분류 목록만큼 중요**. `-220` 의 핸들러를 고치지 않고 하위 타입으로 받는다 (S15P11A705-221) | -| I33 | [2026-07-31-judge-vote.md](2026-07-31-judge-vote.md) | 구현 | 판정 n회 다수결 `PINLOG_JUDGE_VOTE_N` — 비결정성 24%→12% · 흔들리던 오분류 13종 완전 제거 · 정상 판정 손실 0. **그런데 오분류 행은 거의 안 준다**(10.13→9.17, p=0.210) — 다수결은 소수의견을 지우는 대신 다수의견을 굳힌다. **n=1 유지** (S15P11A705-223) | +| I29 | [2026-07-31-search-cut.md](2026-07-31-search-cut.md) | 검증 | 검색 결과 컷 `τ_abs=0.30 · r=0.60` — 정답 누락 0/12 · 빈 결과 0/12 · 꼬리 76.3% 제거 · 무관 질의 11/15 침묵. **무관 질의를 1건에서 15건으로 늘리자 §6 의 「간격 +0.2120」이 -0.0176 으로 뒤집혀** 컷 미적용 판단을 개정 (Jira 작업) | +| I40 | [2026-07-31-judge-prompt-rule.md](2026-07-31-judge-prompt-rule.md) | 검증 | 판정 프롬프트 「본문에 근거 없으면 미선택」 개정안 둘 — **둘 다 채택하지 않는다.** 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했고, 사용자가 보는 손실(`fit 0건 Context` 8.00)은 세 조건이 같다 (Jira 작업) | +| I31 | [2026-07-31-search-error-contract.md](2026-07-31-search-error-contract.md) | 구현 | 검색 API 오류 응답 계약 — `TransientError→503` · `PermanentError→502`, 500 을 「우리 코드의 결함」으로 비워 둔다. 운영 버그 `ai#69`(임베딩 502 → 검색 500). **`back` 은 500·503 을 구분하지 않으므로 바뀌는 것은 사용자 화면이 아니라 관측** (Jira 작업) | +| I34 | [2026-07-31-gms-error-body-redaction.md](2026-07-31-gms-error-body-redaction.md) | 구현 | 게이트웨이 오류 본문 마스킹 — 응답 본문 200자가 예외 메시지를 타고 **다섯 곳의 로그와 트레이스백**으로 나가던 경로를 원천에서 막는다. 실제 AI API 로 네 경로에 오류 19건을 넣어 실측: **자격 증명은 한 건도 에코되지 않고**, endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다** (Jira 작업) | +| I32 | [2026-07-31-db-error-classification.md](2026-07-31-db-error-classification.md) | 구현 | DB 실패의 오류 분류 — `-220` 이 남긴 500 을 메운다. SQLSTATE 군 단위 경계, **미분류(500) 목록이 분류 목록만큼 중요**. `-220` 의 핸들러를 고치지 않고 하위 타입으로 받는다 (Jira 작업) | +| I33 | [2026-07-31-judge-vote.md](2026-07-31-judge-vote.md) | 구현 | 판정 n회 다수결 `PINLOG_JUDGE_VOTE_N` — 비결정성 24%→12% · 흔들리던 오분류 13종 완전 제거 · 정상 판정 손실 0. **그런데 오분류 행은 거의 안 준다**(10.13→9.17, p=0.210) — 다수결은 소수의견을 지우는 대신 다수의견을 굳힌다. **n=1 유지** (Jira 작업) | | I35 | [2026-07-31-docs-index-check.md](2026-07-31-docs-index-check.md) | 구현 | 문서 색인 정합을 `ai-ci / check` 로 옮긴다 — 번호 중복 · 고아/누락 둘만. 07-31 사고 1·2·3 을 실제 `docs/` 사본으로 재현해 잡는 것과, **결번·두 표 불일치를 통과시키는 것**을 함께 고정. 착수 시점 `dev` 위반 3건 정리. 표 이중화는 **줄일 수 없다**(두 표의 집합이 다르다) (티켓 없음) | -| I36 | [2026-08-03-gms-vision-probe.md](2026-08-03-gms-vision-probe.md) | 검증 | GMS 게이트웨이 멀티모달(이미지) 지원 재고 — **지원한다.** 1×1 PNG 를 세 벤더 스펙(OpenAI·Gemini·Anthropic) 그대로 실어 각 1회씩 총 3호출, 셋 다 200 이고 이미지 내용에 실제로 답함. `back#138` 이 이미지 분석 흐름의 유일한 선행 조건으로 남긴 것을 해소 (S15P11A705-227) | -| I42 | [2026-08-03-docs-index-oneway.md](2026-08-03-docs-index-oneway.md) | 구현 | 구현 리포트 파일 표에 번호 컬럼을 넣어 반대 방향 누락(⑤)을 잡는다 — `-226` 이 한 방향만 켜고 남긴 구멍. 번호 없던 7건 판정(5건 신규 번호·1건 기존 I30 링크 보완·1건 「없음」 명시), 병합 중 실제로 난 번호 충돌(T56)도 재현·해소 (S15P11A705-230) | -| I37 | [2026-08-03-dead-config-keys.md](2026-08-03-dead-config-keys.md) | 구현 | 죽은 설정 키 전수조사 — `PINLOG_JUDGE_MODEL`(`-175` 가 대체)·`PRESET_CACHE_TTL_SEC`(`ai#24` 가 이미 제거) 둘 다 저장소는 이미 정리돼 있었고 개발자 로컬 `.env` 잔재만 남아 있었음을 런타임 sentinel 주입으로 확인(grep 만으로 끝내지 않음). 배포 Secret(8키) 은 애초에 둘 다 담지 않음. `.env` 13 vs `.env.example` 12 는 단일 차집합이 아니라 양방향(2:1)이었고 둘 다 정상이라 example 변경 불필요. `-197` 계측이 벤더·모델을 이미 로그로 남김을 확인 — 보강 불필요 (S15P11A705-224) | -| I45 | [2026-08-03-search-recall-probe.md](2026-08-03-search-recall-probe.md) | 검증 | 「본문에 있는 말로 검색해도 안 나온다」 원인 판별 — **셋이 같은 원인이 아니다.** `그네`는 컷(풀면 3위·①), `신한`·`부캠`은 컷을 풀어도 6위·8위(③). 가른 것은 무관 기준선이다 — **어느 본문에도 없는 `치과`의 top-1(0.2953)이 본문에 그대로 있는 `스팟`(0.2438)보다 높다.** `#87`의 BPE 가설은 토크나이저 실측으로 **반증**(`그네`↔`그네팟` 접두 토큰 공유) (S15P11A705-255) | -| I38 | [2026-08-03-error-wording-split.md](2026-08-03-error-wording-split.md) | 구현 | 오류 응답 문구 분리 — `-221` 이 상태 코드·로그는 맞혔지만 본문은 `embedding upstream ...` 그대로였다. `DatabaseTransientError`/`DatabasePermanentError` 를 보고 `database unavailable`/`database rejected the request` 로 가르되 원인 값은 여전히 안 싣는다. `static/05` 는 문구를 소유하지 않고 `back` 의 `AiSearchClient.translate` 가 본문을 안 읽어 **반영하지 않음** (S15P11A705-229) | -| I47 | [2026-08-03-word-query-cut.md](2026-08-03-word-query-cut.md) | 구현 | 단어형 질의로 컷 격자 재측정 — **`τ_abs` 를 질의 길이로 가른다**(단어형 0.24 / 문장형 0.30, `r` 은 갈리지 않는다). 두 대역이 겹치지 않아(문장형 정답 하한 0.3642 · 단어형 0.2438) 단일값으로는 한쪽이 반드시 손해를 본다. **계약이 지목한 「문장형 회귀」는 격자 240조합 전량에서 0 이었고**(컷을 푸는 방향이라 구조적) 진짜 손실은 문장형 **무관 질의 침묵**이 11/15→5/15 로 무너지는 것이었다. 채택값을 정한 것은 회복률이 아니라 **「1위 손실」** — 0.26 은 회복 96% 인데 컷 전 **1위**인 정답 3건을 0건으로 만든다(`비건`→플랜트가 1위인데 0건). 0.24 는 「1위 정답을 하나도 안 잃는 가장 높은 값」(0.25 부터 깨진다). `ai#87` 실서버 회복 확인 · 실서버 대조 87/87. **`T68` 대응으로 재현성 회차 3개를 함께 냈다** — 마진 0.0038 이 `T68` 실측 잡음 0.0044 보다 작아 값 자체가 위협받았으나, 이 조건의 흔들림 상한은 **0.000209**(21배 작다)이고 경계점 `스팟` 은 스프레드 **0.000000**, 격자 판정은 τ=0.20~0.30 전 구간 3회 일치. `word_sweep.py --repro` 를 상시 관측으로 남겼다 (S15P11A705-266) | -| I46 | [2026-08-03-preset-description.md](2026-08-03-preset-description.md) | 검증 | 프리셋 `description`·`examples` 개정 3조건 측정 — **`examples` 만 채택(`E`), `description` 개정은 기각.** 네 티켓 만에 처음 채택한다. 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩만, `description` 은 임베딩+판정 프롬프트) 갈라 쟀고, 갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻혔다. `E` 오분류 11.00→6.90행(p=0.0001)·정상 손실 -0.80(p=0.16)·교환비 0.20·**순이득 부호가 양극단 모두 양수**(네 티켓 중 유일). `D` 는 `fit 0건 Context` 8.20→10.00 으로 **악화**(범위 분리)하고 안 고친 22종이 정상 -2.50 를 치른다. 자기충족 방어 셋(라벨 무관 수정 원칙·안 고친 22종 대조·라벨 밖 홀드아웃 30건). T68(임베딩 비결정성)·T69(τ 격자가 rank 로 밀린 행을 안 셈) 발견·우회 (S15P11A705-228) | +| I36 | [2026-08-03-gms-vision-probe.md](2026-08-03-gms-vision-probe.md) | 검증 | AI API 게이트웨이 멀티모달(이미지) 지원 재고 — **지원한다.** 1×1 PNG 를 세 벤더 스펙(OpenAI·Gemini·Anthropic) 그대로 실어 각 1회씩 총 3호출, 셋 다 200 이고 이미지 내용에 실제로 답함. `back#138` 이 이미지 분석 흐름의 유일한 선행 조건으로 남긴 것을 해소 (Jira 작업) | +| I42 | [2026-08-03-docs-index-oneway.md](2026-08-03-docs-index-oneway.md) | 구현 | 구현 리포트 파일 표에 번호 컬럼을 넣어 반대 방향 누락(⑤)을 잡는다 — `-226` 이 한 방향만 켜고 남긴 구멍. 번호 없던 7건 판정(5건 신규 번호·1건 기존 I30 링크 보완·1건 「없음」 명시), 병합 중 실제로 난 번호 충돌(T56)도 재현·해소 (Jira 작업) | +| I37 | [2026-08-03-dead-config-keys.md](2026-08-03-dead-config-keys.md) | 구현 | 죽은 설정 키 전수조사 — `PINLOG_JUDGE_MODEL`(`-175` 가 대체)·`PRESET_CACHE_TTL_SEC`(`ai#24` 가 이미 제거) 둘 다 저장소는 이미 정리돼 있었고 개발자 로컬 `.env` 잔재만 남아 있었음을 런타임 sentinel 주입으로 확인(grep 만으로 끝내지 않음). 배포 Secret(8키) 은 애초에 둘 다 담지 않음. `.env` 13 vs `.env.example` 12 는 단일 차집합이 아니라 양방향(2:1)이었고 둘 다 정상이라 example 변경 불필요. `-197` 계측이 벤더·모델을 이미 로그로 남김을 확인 — 보강 불필요 (Jira 작업) | +| I45 | [2026-08-03-search-recall-probe.md](2026-08-03-search-recall-probe.md) | 검증 | 「본문에 있는 말로 검색해도 안 나온다」 원인 판별 — **셋이 같은 원인이 아니다.** `그네`는 컷(풀면 3위·①), `신한`·`부캠`은 컷을 풀어도 6위·8위(③). 가른 것은 무관 기준선이다 — **어느 본문에도 없는 `치과`의 top-1(0.2953)이 본문에 그대로 있는 `스팟`(0.2438)보다 높다.** `#87`의 BPE 가설은 토크나이저 실측으로 **반증**(`그네`↔`그네팟` 접두 토큰 공유) (Jira 작업) | +| I38 | [2026-08-03-error-wording-split.md](2026-08-03-error-wording-split.md) | 구현 | 오류 응답 문구 분리 — `-221` 이 상태 코드·로그는 맞혔지만 본문은 `embedding upstream ...` 그대로였다. `DatabaseTransientError`/`DatabasePermanentError` 를 보고 `database unavailable`/`database rejected the request` 로 가르되 원인 값은 여전히 안 싣는다. `static/05` 는 문구를 소유하지 않고 `back` 의 `AiSearchClient.translate` 가 본문을 안 읽어 **반영하지 않음** (Jira 작업) | +| I47 | [2026-08-03-word-query-cut.md](2026-08-03-word-query-cut.md) | 구현 | 단어형 질의로 컷 격자 재측정 — **`τ_abs` 를 질의 길이로 가른다**(단어형 0.24 / 문장형 0.30, `r` 은 갈리지 않는다). 두 대역이 겹치지 않아(문장형 정답 하한 0.3642 · 단어형 0.2438) 단일값으로는 한쪽이 반드시 손해를 본다. **계약이 지목한 「문장형 회귀」는 격자 240조합 전량에서 0 이었고**(컷을 푸는 방향이라 구조적) 진짜 손실은 문장형 **무관 질의 침묵**이 11/15→5/15 로 무너지는 것이었다. 채택값을 정한 것은 회복률이 아니라 **「1위 손실」** — 0.26 은 회복 96% 인데 컷 전 **1위**인 정답 3건을 0건으로 만든다(`비건`→플랜트가 1위인데 0건). 0.24 는 「1위 정답을 하나도 안 잃는 가장 높은 값」(0.25 부터 깨진다). `ai#87` 실서버 회복 확인 · 실서버 대조 87/87. **`T68` 대응으로 재현성 회차 3개를 함께 냈다** — 마진 0.0038 이 `T68` 실측 잡음 0.0044 보다 작아 값 자체가 위협받았으나, 이 조건의 흔들림 상한은 **0.000209**(21배 작다)이고 경계점 `스팟` 은 스프레드 **0.000000**, 격자 판정은 τ=0.20~0.30 전 구간 3회 일치. `word_sweep.py --repro` 를 상시 관측으로 남겼다 (Jira 작업) | +| I46 | [2026-08-03-preset-description.md](2026-08-03-preset-description.md) | 검증 | 프리셋 `description`·`examples` 개정 3조건 측정 — **`examples` 만 채택(`E`), `description` 개정은 기각.** 네 티켓 만에 처음 채택한다. 두 필드가 들어가는 곳이 달라(`examples` 는 임베딩만, `description` 은 임베딩+판정 프롬프트) 갈라 쟀고, 갈라 재지 않았으면 `D` 의 해로움이 `DE` 안에 묻혔다. `E` 오분류 11.00→6.90행(p=0.0001)·정상 손실 -0.80(p=0.16)·교환비 0.20·**순이득 부호가 양극단 모두 양수**(네 티켓 중 유일). `D` 는 `fit 0건 Context` 8.20→10.00 으로 **악화**(범위 분리)하고 안 고친 22종이 정상 -2.50 를 치른다. 자기충족 방어 셋(라벨 무관 수정 원칙·안 고친 22종 대조·라벨 밖 홀드아웃 30건). T68(임베딩 비결정성)·T69(τ 격자가 rank 로 밀린 행을 안 셈) 발견·우회 (Jira 작업) | | I48 | [2026-08-03-dev-deploy-gap.md](2026-08-03-dev-deploy-gap.md) | 감사 | `dev` 병합이 배포에 닿지 않는 구간 — **`ai-image / publish` SKIPPED 는 고장이 아니라 설계다.** `ai-ci.yml` 안의 `image-publish` job 이고 `push` × `refs/heads/main` 두 조건이 모두 필요하다(별도 워크플로가 아니라 워크플로 목록에 안 보인다). 근거가 주석·계약 테스트·`infra` 어서션·`CONTRIBUTING` 넷이라 한 곳의 실수일 수 없다. `apps/dev/ai` 가 pin 하는 것은 **`ai` 의 `main` HEAD** 이고 `ai` 의 `dev` 브랜치는 배포 경로에 없다 — 끊긴 것은 파이프라인이 아니라 릴리스 PR 이다. `ai-image-update` 의 success 는 「갱신했다」가 아니라 **「확인했고 갱신할 것이 없었다」**(자동화는 08-03 00:37Z 에 되살아나 `infra#172` 로 한 번 동작했다). 새 봉인 값 **`preset-ab321360b0df`** 산출 — 방식은 파일 전체 SHA-256 앞 12자이며 문서에 적힌 64자와 전량 대조해 확정했다. **`bootstrap.version` 은 어떤 자동화도 갱신하지 않고 틀려도 아무 신호가 없다** (티켓 없음) | -| I49 | [2026-08-03-gms-image-probe.md](2026-08-03-gms-image-probe.md) | 검증 | GMS 이미지 실사용 조건 두 축 — **축 A 생성은 지원한다**(`gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 차단). **축 B 는 가설 C** — 토큰은 **치수**에 붙고 **바이트에는 안 붙는다**(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 의 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이다(텍스트 23 + 2,833 + 5,667×타일, 7개 조건 잔차 0). **그리고 더 급한 것이 나왔다 — 게이트웨이 요청 본문 상한이 96,024 B 통과 · 129,564 B 거부 사이에 있고, 넘으면 「Model not found in request」로 온다**(`-205` T62·`-225` 와 같은 원인). 실사용 대화 캡처(200 KB~2 MB)는 토큰을 쓰기 전에 이 벽에 먼저 막힌다 — `ai#98` 의 전제가 바뀐다. 정책 거절은 벤더마다 층이 달라 OpenAI 는 400, **Gemini 는 200 + `finishReason=PROHIBITED_CONTENT`** 다 (S15P11A705-253) | +| I49 | [2026-08-03-gms-image-probe.md](2026-08-03-gms-image-probe.md) | 검증 | AI API 이미지 실사용 조건 두 축 — **축 A 생성은 지원한다**(`gpt-image-1`·`gemini-2.5-flash-image` 둘 다 1024×1024 PNG, 게이트웨이에 모델 allowlist 가 있어 `dall-e-3`·`imagen-3` 은 차단). **축 B 는 가설 C** — 토큰은 **치수**에 붙고 **바이트에는 안 붙는다**(512×512 고정에서 바이트 36배에 `prompt_tokens` 8,523 불변). `-227` 의 8,524 는 게이트웨이 가산이 아니라 `gpt-4o-mini` 의 타일 요금이다(텍스트 23 + 2,833 + 5,667×타일, 7개 조건 잔차 0). **그리고 더 급한 것이 나왔다 — 게이트웨이 요청 본문 상한이 96,024 B 통과 · 129,564 B 거부 사이에 있고, 넘으면 「Model not found in request」로 온다**(`-205` T62·`-225` 와 같은 원인). 실사용 대화 캡처(200 KB~2 MB)는 토큰을 쓰기 전에 이 벽에 먼저 막힌다 — `ai#98` 의 전제가 바뀐다. 정책 거절은 벤더마다 층이 달라 OpenAI 는 400, **Gemini 는 200 + `finishReason=PROHIBITED_CONTENT`** 다 (Jira 작업) | | I52 | [2026-08-05-search-rank-baseline.md](2026-08-05-search-rank-baseline.md) | 구현 | 검색 **순위** 지표 baseline `tools/search_cut/rank_score.py` — 기존 지표가 전부 컷 기준이라 순위 변화를 못 센다(P48 1단계 이후는 순위를 바꾸는 변경이라 개선이 0으로 보인다). 기존 행렬만 읽어 Hit·Recall·MRR·nDCG@1/3/5 를 **컷 전/후 · 단어형/문장형 · 정답질의/무관질의**로 갈라 낸다. **재구성 일치 두 건** — 무관-문장형 침묵률 11/15 와 `스팟` 0.2438 이 기존 기록과 같다. **`-255` ① 판정이 이미 닫혀 있음을 확인**(`-266` 의 단어형 분기가 `그네` 를 회복시켰다). `신한`·`부캠` 은 `tau_word` 와 `r·top1` **양쪽 모두 아래**라 컷 조절로 닿지 않는다 (티켓 없음 · 런타임 변경 없음) | -| I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (S15P11A705-292) | -| I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (S15P11A705-273) | -| I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (S15P11A705-336) | +| I50 | [2026-08-03-preset-display-name-deploy-chain.md](2026-08-03-preset-display-name-deploy-chain.md) | 검증 | 표시명 개정의 배포 체인 여섯 단계와 소관 — **봉인 값은 판 표기이지 부트스트랩 재실행 조건이 아니다.** 낡은 봉인 값 위에서 새 표시명이 운영 화면까지 온 것이 곧 재실행의 증거다(`hook-delete-policy` 가 같은 이름 Job 을 지우고 다시 만들고, 적재는 멱등이다). 중앙이 「값이 같으면 재실행 안 된다」로 판정했고 독립 검증이 논거를 반증했으나 클러스터 관측 경로가 없어 「확인 불가」로 끝났던 것을 **배포가 닫았다.** 릴리스가 두 번 막힌다 — base 검사(`main` base 는 `release/*`·`hotfix/*` 만)와 strict 상태 검사(BEHIND 거부). 화면 확인이 유일한 검증이라 이르게 보면 「안 됐다」로 오판한다 (Jira 작업) | +| I51 | [2026-08-05-short-query-boundary.md](2026-08-05-short-query-boundary.md) | 검증 | 짧은 질의의 층별 탈락과 단어형 경계 두 정의 — **`-266` 이후 남은 실패는 셋뿐이고 경계 정의로는 하나도 안 풀린다.** `신한`(③ τ_abs·6위)·`부캠`(③ τ_abs·8위)·`신한 부캠`(**④ r**·4위)이고 실서버 대조 22/22 일치로 층을 확정했다(전처리·폴백은 **존재하지 않는다** — `min_length=1` 뿐이고 0건이면 0건이 그대로 나간다). **「2자가 안 걸린다」는 부정확** — 2자 7건 중 5건은 걸리고 못 걸리는 둘은 컷 전 순위가 6·8위다. `-266` 이 「재지 않았다」고 적은 B(공백·짧다)·C(무공백·길다) 대역을 **본문 인접 어절쌍 전량**(쌍 176종 × spaced/joined × 소유자 3명 = 1,128행)으로 처음 쟀다 — 손실은 C 에 몰려 있고(16/122 → 어절 수 정의 3/122) **그런데 초점 집합 36행에서는 어느 정의에서도 누락이 0** 이라 두 표가 어긋난다(회복되는 것이 기능어 쌍이다). 새 관측 둘: **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385 · 172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 τ 탈락 — `-266` 이 「안전 방향」이라 부른 판단이 이 대역에서는 손해다). 계약이 「이것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하므로 **세 번째다** (Jira 작업) | +| I53 | [2026-08-05-fusion-measurement.md](2026-08-05-fusion-measurement.md) | 구현 | Keyword fusion 1단계 실측 — 병합 방식(가중합 5조합·RRF cutoff 5값)×floor 4값 격자. **가중합은 정답과 관련 없는 후보를 함께 끌어올린다** — floor 0.25 에서 `신한` 복구(컷 후 4위)와 무관-문장형 무노출 11/15→9/15 감소가 같은 계산에서 나온다. RRF 는 무노출을 유지하나 문장형 hit@1 0.8333→0.5833 퇴행. **P48 §6.1 네 조건을 전부 통과한 조합은 floor 0.35 · binary w=0.05 하나** — 그 조건에서 단어형·문장형 전 지표 개선·무관 무노출 유지, 대가는 신호 반영 12/66 축소와 `신한` 재제외. `신한`·`부캠`은 이 구조로 안 풀린다(재작성 `-337`·문자열 검색의 대상). 환경 동등성은 baseline 재현 = I52 일치로 확인. 못 잰 축(floor 0.30~0.35·weight 격자·재현성 회차)은 `-339` 몫 (Jira 작업) | | I54 | [2026-08-06-lexical-merge-rule.md](2026-08-06-lexical-merge-rule.md) | 구현 | 문자열 검색 병합 규칙 실측 — 게이트 3단(전체/단어형 한정/어절 경계) × 병합 3종(뒤 추가/RRF/앞 고정) 격자. **무관·타인소유·문장형 세그먼트에서 문자열 매치 0건** — 어떤 조합도 관련 없는 결과 노출을 늘리지 않는다. `신한` 이 전 조합에서 회복(RRF 5위)되고 `부캠` 은 예상대로 미회복(재작성 -337 대상). 경계 게이트는 기대 정답 6건을 제외하는 손해만 관측돼 부분일치 채택 권고. **규칙 확정안: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + 컷·응답 계약 불변 + 실패 시 벡터만** — back 연동 티켓의 판단 재료. 한계: 경계 게이트가 막으려는 유형(어절 중간 시작·부수적 언급·동형어)이 코퍼스에 없어 이득 측정 불가, 운영 코퍼스 재평가 필요 (티켓 없음 · 런타임 변경 없음 · 스냅샷 DB) | -| I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (S15P11A705-337 · 스냅샷 DB) | -| I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (S15P11A705-339 · 스냅샷 DB) | +| I55 | [2026-08-06-rewrite-effect.md](2026-08-06-rewrite-effect.md) | 구현 | LLM 질의 재작성 효과 실측·길이 게이트 확정 — 실제 RewriteClient(운영 체인)로 무관 15건·사례 5건 전/후 비교. **약어 회복 확인**(`부캠`→`부트캠프` 컷 전 8위→1위·`신한 부캠` 4위→3위, 둘 다 반환 회복)·고유명사 유지 규칙 작동(`신한`·`그네`·`스팟` 원문 불변). **전면 적용은 무관 무노출 11/15→9/15 로 채택 기준 미달** — 의미 불변 표현 정규화(`파는 데`→`파는 곳`)가 컷 경계 아래 대역을 밀어 올린다. 게이트 후보 8종 전수 시뮬레이션에서 **형태(길이) 기준만 충족** — 성과 기반(결과 0건·top-1 임계)은 두 집단 대역이 겹쳐 분리 불가에 회복 대상까지 놓치고(원문도 오답 3건 반환), 단어형 판정 재사용은 `신한 부캠` 상실. **게이트 확정: strip 후 6자 이하만 재작성**(`SEARCH_REWRITE_MAX_CHARS`, 사용자 확정) — 게이트 반영 재측정에서 무노출 11/15 유지·회복 2건 유지로 채택 조건 충족. 한계: 6~12자 대역 무관측·모델 1종 1회차 관측 (Jira 작업 · 스냅샷 DB) | +| I56 | [2026-08-06-rerank-adoption.md](2026-08-06-rerank-adoption.md) | 구현 | 키워드 재정렬 채택값 실측·런타임 구현 — P49 §4 재정렬 전용 구조(컷 통과 집합 고정·순서만 조정)에서 BASE·binary(floor 5값×weight 4값)·RRF(k=60) 재측정. **binary·floor 0.35·w 0.05 만 퇴행 없이 개선**(단어형 hit@1 0.8636→0.8788 · hit@3 0.9394→0.9545 · MRR 0.9015→0.9129, 문장형 유지+MRR 개선), RRF 는 문장형 hit@1 0.8333→0.5833~0.6667 퇴행으로 기각. 무관 세그먼트는 전 조합에서 BASE 와 동일(구조적 불변 실측 확인·스크립트 가드). floor 0.35~0.36 지표 동일로 T68 흔들림에 안전. 런타임: 기본 off 플래그·`keyword_status=COMPLETED` 신호·실패 시 벡터 순서 복귀·similarity 원값 유지, on/off 후보 집합 불변 계약 테스트 9건 + 픽스처 8건(총 511 통과). **실서버 on/off 대조 5판정 93건 전부 통과** — 재정렬 발화 7건 전건 오프라인 일치(`만두` 4위→1위), `신한`·`부캠` 미회복 재확인(역할 분담 검증), 흔들림 분류 미발동. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 색인 검사가 잡아 나중에 병합된 이쪽이 I56 으로 재확정(설계된 절차), 최종 확정은 dev 병합 직후 (Jira 작업 · 스냅샷 DB) | | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | -| I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (S15P11A705-401 · 스냅샷 없음 · DB·GMS 호출 0회) | -| I59 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched: bool` 필드 추가 — `_rerank_by_keyword`가 이미 계산하던 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 `(list, matched_ids)` 튜플로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 값 그대로 유지(정렬 점수 비노출 계약 불변), 재정렬이 생략되는 모든 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 (S15P11A705-399) | -| I60 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 S15P11A705-400)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정 (S15P11A705-402) | +| I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (Jira 작업 · 스냅샷 없음 · DB·AI API 호출 0회) | +| I59 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched: bool` 필드 추가 — `_rerank_by_keyword`가 이미 계산하던 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 `(list, matched_ids)` 튜플로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 값 그대로 유지(정렬 점수 비노출 계약 불변), 재정렬이 생략되는 모든 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트가 쓸 S3 신호 준비 (Jira 작업) | +| I60 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 Jira 작업)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정 (Jira 작업) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -89,43 +89,43 @@ | I20 | E3 통합 테스트 하네스 + 저수준 27 + 파이프라인 20 + Dockerfile + ai-ci 정비(Python 3.12·lock·Jira 검증) | [E3 리포트](2026-07-24-e3-test-harness.md), PR ai#14·#16·#18 | | I21 | E2E 실경로 검증 + 검증 드라이버(`tools/e2e/`) — 문서 마찰 F1·F2·F5·F6, 하네스 동등성 실측, 권한 경계 실증(`-61` 근거), 시딩 가능 범위 | [E2E 리포트](2026-07-27-e2e-verification.md), [tools/e2e/](../../tools/e2e/) | | I22 | S1 구현 판단 맥락 복원 — 설계선택 19·불변식·spec↔구현 불일치(구현결함 A·F절)·실행 인프라 미복원 | [S1 복원 리포트](2026-07-28-s1-implementation-recovery.md) | -| I23 | dev 배포 게이트 3종 — `GET /ready`(DB+Preset, GMS 미호출) · `GMS_BASE_URL` `/gmsapi/` fail-fast · `app.smoke.gms_roundtrip`(embedding+judge 실호출, 한쪽 실패 시 exit 1) | [배포 게이트 리포트](2026-07-29-dev-deployment-gates.md), ai#33 ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 요청 | -| I24 | Runtime Secret handoff — 평문 없이 Infra 에 전달. `S15P11A705-96` 판은 Actions Secret 7종을 `kubeseal --raw` 로 직접 봉인(`pinlog-dev/ai-owner-secrets`, scope strict), `S15P11A705-154` 에서 Environment 경계 + Infra 공용 action 으로 이관하고 앱 Secret 3종으로 축소. **설계 근거는 문서에 보존** | [handoff 리포트](2026-07-29-sealed-secret-handoff.md) ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 요청 ① | +| I23 | dev 배포 게이트 3종 — `GET /ready`(DB+Preset, AI API 미호출) · `GMS_BASE_URL` `/gmsapi/` fail-fast · `app.smoke.gms_roundtrip`(embedding+judge 실호출, 한쪽 실패 시 exit 1) | [배포 게이트 리포트](2026-07-29-dev-deployment-gates.md), ai#33 ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 요청 | +| I24 | Runtime Secret handoff — 평문 없이 Infra 에 전달. Jira 작업 판은 Actions Secret 7종을 `kubeseal --raw` 로 직접 봉인(`pinlog-dev/ai-owner-secrets`, scope strict), Jira 작업에서 Environment 경계 + Infra 공용 action 으로 이관하고 앱 Secret 3종으로 축소. **설계 근거는 문서에 보존** | [handoff 리포트](2026-07-29-sealed-secret-handoff.md) ← [ai#32](https://github.com/Team-PinLog/ai/pull/32) 요청 ① | | I26 | app coverage 게이트 `tools/check_coverage_gate.py` — line·branch 를 **따로** 판정(합산 비율은 statement 수에 가려 branch 미달을 통과시킨다), 임계값은 스크립트 상수라 CI 인자로 덮을 수 없음. 부트스트랩·기동 계층 테스트 신설(둘 다 기준선 0%·58%), 146→181 tests, line 88.80→99.74% · branch 82.08→98.11%. RED 4종 실측 | [게이트 리포트](2026-07-30-coverage-gate.md), [tools/check_coverage_gate.py](../../tools/check_coverage_gate.py) | -| I25 | 데모 시딩 도구 `tools/demo_seed/` — back API 경로로 member 5·Context 14·Collection 9 생성, `--reset` 재현, `verify.py` 시연 3종 판정. GMS 429(분당 약 2건) 실측과 그에 맞춘 회수 루프 | [데모 시딩 리포트](2026-07-29-demo-seeding.md), [tools/demo_seed/](../../tools/demo_seed/) | +| I25 | 데모 시딩 도구 `tools/demo_seed/` — back API 경로로 member 5·Context 14·Collection 9 생성, `--reset` 재현, `verify.py` 시연 3종 판정. AI API 429(분당 약 2건) 실측과 그에 맞춘 회수 루프 | [데모 시딩 리포트](2026-07-29-demo-seeding.md), [tools/demo_seed/](../../tools/demo_seed/) | | I27 | 임베딩 4조건 측정 하네스 `tools/emb_grid/` — 조건 정본 하나(`conditions.py`)를 셸에 `eval` 로 넘겨 값이 갈라지지 않게 하고, 조건이 환경과 어긋나면 **재기 전에 멈춘다**(profile·차원·프리셋 적재 여부·FastAPI 도달). `alter_dim.py` 는 로컬 DB 차원을 바꾸고 되돌린다 — Flyway 를 만들지 않는 것이 이 측정의 계약이다 | [4조건 측정](2026-07-31-embedding-grid.md), [tools/emb_grid/](../../tools/emb_grid/) | -| I28 | GMS 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율 집계, 만료 `PROCESSING` 재선점. `_usage.py`(토큰)와 합치지 않은 근거와 `try_start` CTE 재작성. **httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단** | [GMS 호출 관측](2026-07-31-gms-call-observability.md), [failure-recovery §2.4](../spec/failure-recovery.md) | -| I29 | 검색 결과 컷 하네스 `tools/search_cut/` — 질의를 한 번 임베딩해 굳히고 `τ_abs × r` 격자를 오프라인으로 훑는다(GMS 배치 1회). **정답이 없는 무관 질의 15건을 별도 축으로 둔다** — 검증 질의만 재면 `r` 이 무관 질의를 침묵시키지 못한다는 사실이 보이지 않는다. `verify_live.py` 가 실서버와 27/27 정확 일치를 확인(`-210` 과 달리 근사가 아니라 대조군 불필요) | [컷 측정](2026-07-31-search-cut.md), [tools/search_cut/](../../tools/search_cut/) | -| I52 | 검색 순위 지표 하네스 `tools/search_cut/rank_score.py` — 기존 행렬 셋만 읽어(**DB 0회·GMS 0회**) Hit@k·Recall@k·MRR·nDCG@k 를 낸다. **세 가지를 가른다** — 컷 전/후(컷 후만 보면 순위 개선이 가려진다), 단어형/문장형(대역이 겹치지 않아 합산 평균이 어느 쪽도 설명하지 못한다), 정답질의/무관질의(좋은 방향이 반대라 섞으면 상쇄된다). **MRR 만으로 판정하지 않는다** — 단어형 정답이 본문 문자열 포함으로 계산돼 복수라, 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 MRR 이 같다. **재구성이 맞다는 증거 두 건이 독립적으로 나왔다**(무관-문장형 침묵 11/15 = `-213` 기록 · `스팟` 0.2438 = `config.py` 주석의 마진 0.0038). 비상 스위치를 단어형 분기보다 앞에 두는 **순서까지** 원본대로 옮겼다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치·필드 누락·정답질의에 정답 0건·무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로, 입력 추적은 SHA-256 기록으로 확인. **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 **이미 해소**됐고, `신한`·`부캠` 은 `tau_word`·`r·top1` **양쪽 모두 아래**라 한쪽만 풀어도 회복되지 않는다(`r` 은 top1 이 높을수록 세게 자르므로 무관한 1위가 강할수록 정답이 확실히 잘린다) | [순위 baseline](2026-08-05-search-rank-baseline.md), [P48](../proposals/P48-search-signal-expansion.md), [tools/search_cut/rank_score.py](../../tools/search_cut/rank_score.py) | -| I30 | 후보 임계값 τ 측정 하네스 `tools/tau_grid/` — 42×27 유사도 행렬을 한 번 떠서 임의의 τ 를 **GMS 호출 없이 재구성**한다. 라벨 83행(`labels.yaml`)이 채점 기준이고 `unclear` 를 따로 둬 낙관·비관 양 끝을 함께 낸다 | [τ 재검증 리포트](2026-07-31-candidate-threshold.md), [tools/tau_grid/](../../tools/tau_grid/) | -| I41 | 외부 API 재시도·오류 분류 정합화 `app/core/errors.py`(`classify_http_status` 단일화)·`app/client/retry.py`(`RetryPolicy`) — 두 클라이언트가 상태 코드를 정반대로 분류하던 구현 결함(429→Transient, LLM 400/401/403→Permanent)을 고쳤다. `SchemaViolationError` 로 구조화 출력 위반을 하위 타입 처리, `PermanentError → _fail()` 결선 추가 (S15P11A705-121) | [재시도·오류 분류 리포트](2026-07-30-retry-and-error-classification.md) | -| I43 | 실사용자 데이터 E2E 검증 — 가공 14건 + 실사용자 기록 23건 = 37건 통합 경로. 검색 정확도 10/12(83.3%) · 탐색 피드 PASS · Keyword PASS, 시딩 42초 · 토큰 32,912(판정이 임베딩의 17배) (S15P11A705-174) | [실데이터 E2E 리포트](2026-07-30-real-data-e2e.md) | -| I44 | 판정 LLM 벤더 폴백 `app/client/vendors.py`(신설)·`PINLOG_JUDGE_CHAIN` — 429가 프로바이더 경로별로 걸린다는 실측 위에 벤더 어댑터 3종(OpenAI·Gemini·Anthropic)을 두고 시도 예산을 공유, 실제로 답한 벤더를 `model_profile`에 기록 (S15P11A705-175) | [벤더 폴백 리포트](2026-07-30-judge-vendor-fallback.md) | -| I39 | 시연 도구 결함 3건 방어 `tools/demo_seed/preflight.py`(신규) — 쓰기 컬럼 계약 대조·미적용 마이그레이션 차단·JWT 키 실검증을 `--reset` 앞에 두는 preflight. 셋 다 일부러 어긋내 RED 확인 (S15P11A705-198) | [시연 도구 결함 리포트](2026-07-31-seed-guard.md), [tools/demo_seed/preflight.py](../../tools/demo_seed/preflight.py) | -| I40 | 판정 프롬프트 「본문에 근거 없으면 미선택」 개정안 둘 — 둘 다 채택하지 않는다. 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했고, 사용자가 보는 손실(`fit 0건 Context`)은 세 조건이 같다 (S15P11A705-219) | [판정 프롬프트 리포트](2026-07-31-judge-prompt-rule.md) | +| I28 | AI API 호출·재선점 로그 계측 — 호출 1회당 벤더·모델·상태·결과 분류·지연, 60초 창 실패율 집계, 만료 `PROCESSING` 재선점. `_usage.py`(토큰)와 합치지 않은 근거와 `try_start` CTE 재작성. **httpx 가 요청 URL 을 INFO 로 흘리던 것을 함께 차단** | [AI API 호출 관측](2026-07-31-gms-call-observability.md), [failure-recovery §2.4](../spec/failure-recovery.md) | +| I29 | 검색 결과 컷 하네스 `tools/search_cut/` — 질의를 한 번 임베딩해 굳히고 `τ_abs × r` 격자를 오프라인으로 훑는다(AI API 배치 1회). **정답이 없는 무관 질의 15건을 별도 축으로 둔다** — 검증 질의만 재면 `r` 이 무관 질의를 침묵시키지 못한다는 사실이 보이지 않는다. `verify_live.py` 가 실서버와 27/27 정확 일치를 확인(`-210` 과 달리 근사가 아니라 대조군 불필요) | [컷 측정](2026-07-31-search-cut.md), [tools/search_cut/](../../tools/search_cut/) | +| I52 | 검색 순위 지표 하네스 `tools/search_cut/rank_score.py` — 기존 행렬 셋만 읽어(**DB 0회·AI API 0회**) Hit@k·Recall@k·MRR·nDCG@k 를 낸다. **세 가지를 가른다** — 컷 전/후(컷 후만 보면 순위 개선이 가려진다), 단어형/문장형(대역이 겹치지 않아 합산 평균이 어느 쪽도 설명하지 못한다), 정답질의/무관질의(좋은 방향이 반대라 섞으면 상쇄된다). **MRR 만으로 판정하지 않는다** — 단어형 정답이 본문 문자열 포함으로 계산돼 복수라, 정답 3개가 `1·2·3위` 든 `1·9·14위` 든 MRR 이 같다. **재구성이 맞다는 증거 두 건이 독립적으로 나왔다**(무관-문장형 침묵 11/15 = `-213` 기록 · `스팟` 0.2438 = `config.py` 주석의 마진 0.0038). 비상 스위치를 단어형 분기보다 앞에 두는 **순서까지** 원본대로 옮겼다. 가드 4종은 수치를 내지 않고 실패한다(profile 불일치·필드 누락·정답질의에 정답 0건·무관질의에 정답 존재). 결정성은 두 번 실행 바이트 일치로, 입력 추적은 SHA-256 기록으로 확인. **판정 하나가 바뀌었다** — `-255` 의 ①(`그네` 는 컷이 잘랐다)은 `-266` 의 단어형 분기로 **이미 해소**됐고, `신한`·`부캠` 은 `tau_word`·`r·top1` **양쪽 모두 아래**라 한쪽만 풀어도 회복되지 않는다(`r` 은 top1 이 높을수록 세게 자르므로 무관한 1위가 강할수록 정답이 확실히 잘린다) | [순위 baseline](2026-08-05-search-rank-baseline.md), [P48](../proposals/P48-search-signal-expansion.md), [tools/search_cut/rank_score.py](../../tools/search_cut/rank_score.py) | +| I30 | 후보 임계값 τ 측정 하네스 `tools/tau_grid/` — 42×27 유사도 행렬을 한 번 떠서 임의의 τ 를 **AI API 호출 없이 재구성**한다. 라벨 83행(`labels.yaml`)이 채점 기준이고 `unclear` 를 따로 둬 낙관·비관 양 끝을 함께 낸다 | [τ 재검증 리포트](2026-07-31-candidate-threshold.md), [tools/tau_grid/](../../tools/tau_grid/) | +| I41 | 외부 API 재시도·오류 분류 정합화 `app/core/errors.py`(`classify_http_status` 단일화)·`app/client/retry.py`(`RetryPolicy`) — 두 클라이언트가 상태 코드를 정반대로 분류하던 구현 결함(429→Transient, LLM 400/401/403→Permanent)을 고쳤다. `SchemaViolationError` 로 구조화 출력 위반을 하위 타입 처리, `PermanentError → _fail()` 결선 추가 (Jira 작업) | [재시도·오류 분류 리포트](2026-07-30-retry-and-error-classification.md) | +| I43 | 실사용자 데이터 E2E 검증 — 가공 14건 + 실사용자 기록 23건 = 37건 통합 경로. 검색 정확도 10/12(83.3%) · 탐색 피드 PASS · Keyword PASS, 시딩 42초 · 토큰 32,912(판정이 임베딩의 17배) (Jira 작업) | [실데이터 E2E 리포트](2026-07-30-real-data-e2e.md) | +| I44 | 판정 LLM 벤더 폴백 `app/client/vendors.py`(신설)·`PINLOG_JUDGE_CHAIN` — 429가 프로바이더 경로별로 걸린다는 실측 위에 벤더 어댑터 3종(OpenAI·Gemini·Anthropic)을 두고 시도 예산을 공유, 실제로 답한 벤더를 `model_profile`에 기록 (Jira 작업) | [벤더 폴백 리포트](2026-07-30-judge-vendor-fallback.md) | +| I39 | 시연 도구 결함 3건 방어 `tools/demo_seed/preflight.py`(신규) — 쓰기 컬럼 계약 대조·미적용 마이그레이션 차단·JWT 키 실검증을 `--reset` 앞에 두는 preflight. 셋 다 일부러 어긋내 RED 확인 (Jira 작업) | [시연 도구 결함 리포트](2026-07-31-seed-guard.md), [tools/demo_seed/preflight.py](../../tools/demo_seed/preflight.py) | +| I40 | 판정 프롬프트 「본문에 근거 없으면 미선택」 개정안 둘 — 둘 다 채택하지 않는다. 오분류 감소가 같은 프롬프트를 다시 돌렸을 때의 변동을 넘지 못했고, 사용자가 보는 손실(`fit 0건 Context`)은 세 조건이 같다 (Jira 작업) | [판정 프롬프트 리포트](2026-07-31-judge-prompt-rule.md) | | I31 | 검색 API 오류 응답 계약 — `app/main.py` 예외 핸들러 2종과 그 계약 테스트 `tests/test_api_error_contract.py`(MockTransport→실제 client→router→응답을 한 요청으로 관통). 분류·재시도는 `-121` 이 이미 맞혔고 **비어 있던 것은 예외가 응답이 되는 지점**이었다. 로컬 스텁으로 502 를 만들어 `origin/dev` 500 ↔ 이 브랜치 503 을 대조 | [오류 응답 계약](2026-07-31-search-error-contract.md), [failure-recovery §2.5](../spec/failure-recovery.md), [ai#69](https://github.com/Team-PinLog/ai/issues/69) | | I32 | DB 실패의 오류 분류 `app/core/db_errors.py` — `-220` 이 남긴 항목. **접속 실패는 `asyncpg` 예외가 아니라 stdlib `OSError`** 라서 티켓 문구대로 asyncpg 만 분류했으면 본체를 놓쳤다(T53). 경계는 *"서버·연결의 상태 때문인가, 우리가 보낸 질의 때문인가"* 한 줄이고 **미분류(500) 목록이 분류 목록만큼 중요**하다. 분류를 세션 경계(`db.py`)에 걸어 획득·질의를 함께 덮고, 하위 타입(`DatabaseTransientError`)으로 `-220` 의 핸들러를 그대로 재사용. 전용 컨테이너를 `docker stop` 해 `origin/dev` 500 ↔ 이 브랜치 503 을 대조 | [DB 오류 분류](2026-07-31-db-error-classification.md), [failure-recovery §2.5](../spec/failure-recovery.md), [T53~T55](../troubleshooting/2026-07-31-db-error-pitfalls.md) | | I33 | 판정 n회 다수결 `PINLOG_JUDGE_VOTE_N` + 하네스 `tools/judge_vote/` — 엄격 다수결(`votes*2 > n`), **분모는 성공 수가 아니라 n**(낮추면 n 을 켠 채 n=1 이 실행된다), 정족수 미달이면 저장하지 않고 `PROCESSING` 유지, 짝수 n 은 기동 차단(바로 아래 홀수에 지배당한다). 측정은 **새로 부르지 않고 접는다** — 회차 30개(1,260호출)를 n=1/3/5 로 묶어 2,940호출어치를 얻고, 다수결 규칙은 서비스 코드(`judge_vote.combine`)를 그대로 부른다. `run_live.py` 가 실경로 대조. **다수결은 작동했다**(비결정성 24%→12%, 흔들리는 오분류 13종 완전 제거, 정상 판정 손실 0 — 교환비 처음으로 음수) **그런데 오분류 행은 안 준다**(10.13→9.17, 범위 겹침, p=0.210) — 지운 13종이 원래 드물었고 동시에 70~97%이던 7종을 **100%로 굳혔기** 때문. 남은 7종은 `-219` 가 프롬프트로 못 움직인 것과 **같은 목록**이라 판정 층이 아니라 프리셋 `description` 축(`back#136`). `fit 0건 Context` 8.00 — 세 티켓 연속 불변. **n=1 유지** | [다수결 측정](2026-07-31-judge-vote.md), [T57~T60](../troubleshooting/2026-07-31-judge-vote.md), [tools/judge_vote/](../../tools/judge_vote/) | | I35 | 문서 색인 정합 검사 `tools/check_docs_index.py` — 전수 표의 `T##`·`I##` 중복, 파일 표 ↔ 파일 시스템의 고아/누락, **전수 표에만 있고 파일 표에 없는 문서**(사고 5)를 `ai-ci / check` 스텝으로 판정. **연속성은 검사하지 않는다**(T9·T10 은 back 레포에 있고 결번은 정상) **두 표가 일치하는지도 검사하지 않는다 — 누락만 본다**(표 이중화가 07-31 사고 3 의 *원인*이라 문구까지 맞추라고 하면 문제를 규칙으로 승격시킨다. 구조가 해소되면 이 검사는 지운다). **누락 검사는 한 방향뿐이다** — 반대 방향(파일 표에 있는데 전수 표가 안 가리킨다)은 트러블슈팅 전수 표가 설계상 문서를 안 가리켜 정상 문서 22건이 위반으로 나온다. 셸이 아니라 Python 인 것은 `pytest` 가 같은 함수를 불러 CI 와 로컬이 같은 코드를 쓰기 위함. 새 잡이 아니라 `check` 안의 스텝인 것은 필수 상태 검사가 두 이름뿐이라 잡을 늘리면 실패해도 병합을 못 막기 때문. 메시지는 **다음 빈 번호와 붙여 넣을 표 행까지** 준다. 착수 시점 `dev` 위반 3건(고아) + 표 렌더링 6곳 정리. 조사 결과 **표 이중화는 축소 불가** — 트러블슈팅 전수 표는 문서 링크가 0종이고 구현 전수 표는 파일 표 24개 중 17개만 덮으며 13행은 문서가 아닌 산출이다 | [색인 검사 리포트](2026-07-31-docs-index-check.md), [T64·T65](../troubleshooting/2026-07-31-docs-index-check.md), [tools/check_docs_index.py](../../tools/check_docs_index.py) | -| I34 | 게이트웨이 오류 본문 마스킹 `app/core/redact.py` — 응답 본문 200자가 예외 메시지를 타고 **다섯 곳의 로그와 트레이스백**으로 나가던 것을 원천에서 막았다. 실제 GMS 로 네 경로에 오류 19건을 넣어 본문을 실측 — **자격 증명은 한 건도 에코되지 않았고**(401 은 게이트웨이 고정 문구) endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다**(T57). 그래서 규칙은 「관측된 것을 지운다」가 아니라 「되돌아올 수 있는 자리를 막는다」다. 자격 증명이 endpoint 보다, 마스킹이 절단보다 먼저다. 본문은 지우지 않는다 — 실측한 벤더 400 본문에 마스킹 대상이 한 글자도 없었다 | [오류 본문 마스킹](2026-07-31-gms-error-body-redaction.md), [failure-recovery §2.6](../spec/failure-recovery.md), [T57~T59](../troubleshooting/2026-07-31-log-redaction-pitfalls.md) | -| I36 | GMS 게이트웨이 멀티모달 재고 — `back#138` 이 이미지 분석 흐름(`Front → Spring → FastAPI`)의 유일한 선행 조건으로 남긴 것. 1×1 PNG 를 OpenAI·Gemini·Anthropic 세 경로에 벤더 스펙 그대로(각 1회, 총 3호출) 실어 **지원한다**로 확정 — 셋 다 200 이고 이미지 내용에 실제로 답함(빈 응답·거부 아님). `-205` 의 실측 절차(작은 요청으로 크기 요인 배제, 자격 증명 마스킹 안전망)를 그대로 재사용 | [비전 재고](2026-08-03-gms-vision-probe.md), [back#138](https://github.com/Team-PinLog/back/issues/138) | +| I34 | 게이트웨이 오류 본문 마스킹 `app/core/redact.py` — 응답 본문 200자가 예외 메시지를 타고 **다섯 곳의 로그와 트레이스백**으로 나가던 것을 원천에서 막았다. 실제 AI API 로 네 경로에 오류 19건을 넣어 본문을 실측 — **자격 증명은 한 건도 에코되지 않았고**(401 은 게이트웨이 고정 문구) endpoint 는 맨 호스트로 실리며, **OpenAI 는 요청 값을 앞뒤 3자만 남기고 잘라 되돌린다**(T57). 그래서 규칙은 「관측된 것을 지운다」가 아니라 「되돌아올 수 있는 자리를 막는다」다. 자격 증명이 endpoint 보다, 마스킹이 절단보다 먼저다. 본문은 지우지 않는다 — 실측한 벤더 400 본문에 마스킹 대상이 한 글자도 없었다 | [오류 본문 마스킹](2026-07-31-gms-error-body-redaction.md), [failure-recovery §2.6](../spec/failure-recovery.md), [T57~T59](../troubleshooting/2026-07-31-log-redaction-pitfalls.md) | +| I36 | AI API 게이트웨이 멀티모달 재고 — `back#138` 이 이미지 분석 흐름(`Front → Spring → FastAPI`)의 유일한 선행 조건으로 남긴 것. 1×1 PNG 를 OpenAI·Gemini·Anthropic 세 경로에 벤더 스펙 그대로(각 1회, 총 3호출) 실어 **지원한다**로 확정 — 셋 다 200 이고 이미지 내용에 실제로 답함(빈 응답·거부 아님). `-205` 의 실측 절차(작은 요청으로 크기 요인 배제, 자격 증명 마스킹 안전망)를 그대로 재사용 | [비전 재고](2026-08-03-gms-vision-probe.md), [back#138](https://github.com/Team-PinLog/back/issues/138) | | I42 | 구현 리포트 파일 표 번호 컬럼 `docs/implements/README.md` + 단방향 검사 ⑤ `tools/check_docs_index.py` — `-226` 이 "④는 한 방향뿐"이라 명시적으로 남긴 반대 방향(파일 표에 번호가 있는데 전수 표에 없다)을 구현 리포트에서만 켰다. 번호 없던 7건 중 5건 신규 번호(I37~I41)·1건 기존 I30 링크 보완(`candidate-threshold`)·1건 「없음」 명시(`ticket-audit-96-77`, 문서 자신이 "산출이 아니라 확인 결과"라고 규정). 트러블슈팅은 그대로 — 전수 표가 설계상 문서를 안 가리켜 컬럼을 넣어도 대조 출처가 못 된다. 병합 중 병렬 PR(`#81`)과 I36 충돌이 실제로 나 T56 그대로 재현·해소 | [단방향 검사 리포트](2026-08-03-docs-index-oneway.md), [tools/check_docs_index.py](../../tools/check_docs_index.py) | | I37 | 죽은 설정 키 전수조사 — `.env`·`.env.example`·`config.py`·배포 SealedSecret(8키) 대조 + 15개 `Settings` 필드 전부 런타임 sentinel 주입으로 반영 확인. **읽히지 않는 키 2개**(`PINLOG_JUDGE_MODEL`·`PRESET_CACHE_TTL_SEC`) 는 저장소는 이미 정리돼 있었고 로컬 `.env` 잔재만 남아 있었다. `.env` 13 vs `.env.example` 12 전제 재확인 — 단일 차집합이 아니라 양방향(2:1)이고 둘 다 정상이라 example 변경 불필요. `-197` 계측이 벤더·모델을 이미 로그로 남김을 확인 | [죽은 설정 키 리포트](2026-08-03-dead-config-keys.md), [T66·T67](../troubleshooting/2026-08-03-dead-config-key-audit.md) | | I38 | 오류 응답 문구 분리 `app/main.py` — `-221` 이 남긴 한계. 두 핸들러 안에서 `isinstance(exc, DatabaseTransientError\|DatabasePermanentError)` 하나로 `database ...`/`embedding upstream ...` 를 가른다. 새 핸들러도 상태 코드 변경도 없다. `static/05_AI_설계.md` 에는 이 문구 자체가 없고(포인터만 있음) `back` `AiSearchClient.translate` 가 `502`/`503` 본문을 상태 코드만 보고 버리는 것을 직접 확인해 **공용 계약 미반영**으로 판단 | [오류 문구 분리 리포트](2026-08-03-error-wording-split.md), [failure-recovery §2.5 「응답 본문」](../spec/failure-recovery.md) | | I47 | 단어형 질의 컷 격자 `tools/search_cut/word_matrix.py`·`word_sweep.py` + `app/core/config.py`(`SEARCH_SIMILARITY_FLOOR_WORD` 0.24 · `SEARCH_WORD_QUERY_MAX_CHARS` 5)·`app/service/search_service.py`(`_cut(rows, query)` 길이 분기) — `-255` 가 「컷 조정으로 회복된다」로 판정한 `ai#87` 을 닫는다. **`-255` 의 22건으로는 답할 수 없었다** — 무관 통제가 `치과` 하나뿐이고, 컷 값 판단은 「무관 대역이 어디까지 올라오는가」가 전부라 그 대역을 1점으로 재면 `-213` 이 밟은 함정(무관 1건 → 간격 +0.2120, 15건 → -0.0176)을 그대로 밟는다. 무관 통제를 45행으로 늘리자 역전이 -0.0515 → **-0.1687** 로 깊어졌다. **기대 정답을 손으로 짝짓지 않고 본문 문자열 포함으로 계산한다** — 단어형은 정답이 여럿이라(`신한`→6건) 손으로 정하면 판정자 재량이 결과를 만들고, 이 기준이 `ai#87` 의 요구(「본문에 있는 말로 검색하면 나와야 한다」)와 정확히 일치한다. 부작용으로 **같은 질의를 소유자 셋에게 던지면 통제가 공짜로 는다**(정답 66행 · 교차 96행 · 무관 45행). 통제를 두 층으로 갈랐다(`cross` 는 같은 생활권 어휘라 무르다 — 합치면 「무관 통과」가 물러진다). 라벨을 요구하지 않는 대신 꼬리 제거율을 포기했다. **계약의 전제 정정** — 「문장형 회귀가 주 리스크」는 틀렸다(240/240 조합에서 누락 0, 컷을 푸는 방향이라 구조적). 진짜 손실은 무관 침묵이고 **가르면 그것이 0 이 된다**. 채택을 정한 지표는 **「1위 손실」**(회복률 96% 뒤에 「1위인데 0건」이 숨는다). `r` 은 무관 통과를 한 건도 못 줄이고(`-213` 의 구조적 성질 재현) 단어형 손실도 `r=0.75` 까지 0 이라 **가르지 않았다** — 가르면 재지 않은 축을 코드가 만드는 셈. `verify_live.py` 에 분기를 **다시 적어**(구현 import 금지) 두 하한에서 갈리는 60행 + 문장형 27건 = **87/87 PASS**, `ai#87` 실서버 회복 직접 확인(「그네」 0→6건·「비건」 0→1건 1위). **`T68`(임베딩 비결정성 0.0044)이 이 티켓의 격자 간격·채택 마진과 같은 자릿수라 두 층을 갈라 답했다** — 격자 내부는 임베딩을 굳혀 훑으므로 애초에 노출되지 않고, 노출되는 것은 채택값의 절대 마진(0.0038)이다. 회차 3개 실측으로 이 조건 상한 **0.000209** · 경계점 스프레드 **0**(순위 변동 0건) 확인, 격자 판정 전 구간 3회 일치. `T68` 의 처방을 그대로 따라 `--repro` 를 상시 관측으로 남겼다(한 번 재고 문서에만 적으면 다음 사람은 조건이 유효한지 알 방법이 없다). **왜 `T68` 보다 21배 작은지는 단정하지 않는다** — 그쪽은 프리셋 27개 중 3개만 갈렸고 이쪽 질의는 전부 짧아 텍스트 길이가 관련 있어 보이나 통제한 측정이 없다 | [단어형 컷 리포트](2026-08-03-word-query-cut.md), [personal-search §6.1](../spec/personal-search.md), [tools/search_cut/](../../tools/search_cut/), [ai#87](https://github.com/Team-PinLog/ai/issues/87) | | I46 | 프리셋 `description`·`examples` 개정 측정 `tools/preset_desc/`(6스크립트) + `data/keyword_preset.yaml` 5종 `examples` 개정 — **`E`(examples 만) 채택 · `D`(description 만) 기각 · `DE` 보류.** `-210`(τ)·`-219`(프롬프트)·`-223`(다수결)이 셋 다 같은 오분류 7행을 가리키고 끝난 뒤 **네 번째 층(프리셋 텍스트)** 을 잰 것이고, 네 티켓 만에 처음으로 채택이 나왔다. **설계의 핵심은 두 필드를 갈라 잰 것** — `examples` 는 임베딩 입력에만, `description` 은 임베딩+판정 프롬프트 양쪽에 들어가므로 합쳐 재면 어느 층이 움직였는지 모른다. 실제로 `D` 는 **해로웠다**(`fit 0건 Context` 8.20→10.00, 범위 분리 — 세 티켓 동안 σ=0 이던 지표가 처음 움직였는데 나쁜 쪽) 이고 그 해로움이 `DE` 안에서는 `E` 의 이득에 묻혀 안 보인다. `E` 는 오분류 11.00→6.90행(Δ-4.10·p=0.0001)·정상 손실 -0.80(p=0.16, 겹침)·교환비 0.20(`-210` 이 기각한 1.22 의 1/6)·**순이득 부호가 양극단 모두 양수**(+1.30/+5.30 — `-219`·`-223` 이 한 번도 못 넘은 줄). 사전 기준(범위 비중첩)은 `base` 최솟값 9 = `E` 최댓값 9 로 **못 넘었고 그 자를 무르지 않은 채** 독립 증거 넷(후보 층 정상 손실 0·홀드아웃 30건·부호 안정·5→10회 확장에서 효과 증가)으로 판단. 자기충족 방어 셋 — 수정 내용을 라벨과 무관한 원칙으로 정함(`variants.py`)·안 고친 22종 대조(`split_score.py`)·라벨 42건 밖 홀드아웃(`probe.py`). **T68**(임베딩 API 가 같은 배치로도 비결정적 — `base2` 바닥 조건으로 상시 관측, 후보 집합은 안 흔들림 확인)·**T69**(`tau_grid/score.py` 가 `rank>K` 로 밀린 행을 어느 칸에도 안 셈 — `score.py` 는 `-210` 기준점이라 안 고치고 `rank_loss.py` 로 갈라 셈) 발견. 라벨은 한 줄도 안 더함 | [프리셋 개정 리포트](2026-08-03-preset-description.md), [T68·T69](../troubleshooting/2026-08-03-preset-description.md), [tools/preset_desc/](../../tools/preset_desc/) | -| I45 | 검색 재현율 프로브 `tools/search_cut/recall_probe.py` — 대상 질의 4건에 비교군 18건을 짝지어 「짧아서인가·약어라서인가·부분어라서인가」를 가른다(GMS 배치 1회). **본문을 고정하고 질의 길이만 바꾸는 축**과 **어느 본문에도 없는 짧은 질의(`치과`)** 를 계약의 최소 비교군에 더한 것이 판정을 갈랐다 — 후자가 없으면 「2자의 0.24 가 낮은 값인가」를 판단할 기준이 없다. 판정을 두 층(`cut_verdict` 무엇이 잘랐나 / `cause` 컷을 풀면 회복되는가)으로 나눈 이유는 **같은 「τ_abs 가 잘랐다」가 풀면 1위인 `스팟`과 풀어도 8위인 `부캠`에 함께 붙기** 때문. `--replay` 는 굳힌 행렬로 판정만 다시 내고(GMS·DB 미호출) `--lengths` 는 본문 길이 상관을 낸다 | [재현율 판별](2026-08-03-search-recall-probe.md), [tools/search_cut/](../../tools/search_cut/) | +| I45 | 검색 재현율 프로브 `tools/search_cut/recall_probe.py` — 대상 질의 4건에 비교군 18건을 짝지어 「짧아서인가·약어라서인가·부분어라서인가」를 가른다(AI API 배치 1회). **본문을 고정하고 질의 길이만 바꾸는 축**과 **어느 본문에도 없는 짧은 질의(`치과`)** 를 계약의 최소 비교군에 더한 것이 판정을 갈랐다 — 후자가 없으면 「2자의 0.24 가 낮은 값인가」를 판단할 기준이 없다. 판정을 두 층(`cut_verdict` 무엇이 잘랐나 / `cause` 컷을 풀면 회복되는가)으로 나눈 이유는 **같은 「τ_abs 가 잘랐다」가 풀면 1위인 `스팟`과 풀어도 8위인 `부캠`에 함께 붙기** 때문. `--replay` 는 굳힌 행렬로 판정만 다시 내고(AI API·DB 미호출) `--lengths` 는 본문 길이 상관을 낸다 | [재현율 판별](2026-08-03-search-recall-probe.md), [tools/search_cut/](../../tools/search_cut/) | | I48 | dev 배포 미반영 구간 판정 + 새 프리셋 봉인 값 `preset-ab321360b0df` — **산출물은 값 하나이고 나머지는 판정이다.** ① `ai-image / publish` 는 `ai-ci.yml` 의 `image-publish` job(별도 워크플로가 아니라 `gh api .../workflows` 에 안 보인다)이고 `if: push && refs/heads/main` 이라 `dev` push·PR 에서 구조적으로 SKIPPED — 설계 근거 넷(`ai-ci.yml:154-156` 주석 · `test_ci_image_publish_contract.py:48` 이 두 조건을 문자열로 잠금 · `infra ai-image-update.yaml:44` 의 `test "$SOURCE_BRANCH" = main` · `CONTRIBUTING.md:48-50`)이라 **`ai` 레포 안에 고칠 것이 없다.** ② `apps/dev/` 의 dev 는 클러스터 이름이고 pin 대상은 `ai` 의 `main` HEAD — 두 「dev」가 겹쳐 오해를 만든다. ③ `ai-image-update` 04:53Z success 는 `create-infra-pr` 의 RED 스텝이 통과해 `changed=false` 로 끝난 것(= 갱신할 것이 없음)이며, 자동화 자체는 00:37Z 에 되살아나 `infra#172`(00:39Z 병합)로 tag 를 `d317f563`→`4d667c3` 로 이미 한 번 갱신했다. ④ 봉인 값 산출 방식을 실측으로 확정 — `infra docs/ai-dev-prerequisites.md:137-139` 의 64자와 `de6e995` 파일 해시가 **전량 일치**하므로 정규화·항목 단위가 아니라 **파일 전체 바이트 SHA-256 의 앞 12자**다. `.gitattributes` LF 고정이라 OS 무관하고 chart 제약 셋(비어있지 않음·DNS label·Job 이름 63자)을 통과한다(옛 값과 길이가 19자로 같다). ⑤ **`bootstrap.version` 은 `update_ai_image.py` 의 갱신 범위 밖**(`FIELD_RE` 가 `repository\|tag\|digest` 한정)이라 사람이 갱신해야 하고 틀려도 실패하는 검사가 없다. ⑥ 미반영 커밋 10개 중 **`-205`(`ai#78` 로그 유출 차단)가 최초 목록에서 누락**돼 있었음을 정정 | [dev 배포 구간 리포트](2026-08-03-dev-deploy-gap.md), [ai-ci.yml](../../.github/workflows/ai-ci.yml), [tests/test_ci_image_publish_contract.py](../../tests/test_ci_image_publish_contract.py) | -| I49 | GMS 이미지 측정 하네스 `tools/gms_image/`(5스크립트) + 회차 기록 `.gms_image/axis-{a,b}.jsonl` — **설계의 핵심은 치수와 바이트를 따로 움직인 것이다.** 실제 사진은 치수가 크면 바이트도 커서 `-253` 의 세 가설(A 게이트웨이 가산 · B base64 를 텍스트로 셈 · C 벤더 자체 규칙)이 전부 같은 곡선을 낸다 — 합성 PNG 의 노이즈 줄 수로 **512×512 를 유지한 채 2 KB↔72 KB** 를 만들어 얽힘을 풀었다. 판정은 두 기계적 검사(바이트 불변 · 치수 반응)로만 하고 **벤더 공개 공식은 판정에서 분리**했다(넣으면 「공식대로니 정상」이라는 순환). **가설 A 기각의 근거는 경로를 가로지른 대조** — Gemini 만 보면 272 로 평탄해 A 처럼 보이지만 같은 게이트웨이의 OpenAI(8,523→25,524)·Anthropic(39→1,407)이 치수에 반응하므로 게이트웨이 상수가 아니다. **비용 손잡이는 벤더 선택이 전부** — 같은 1024×1024 가 `gpt-4o-mini` 25,524 · `haiku-4-5` 1,407 · `gemini-2.5-flash-lite` 272 로 **94배** 차이다. 축 A 는 404·401·403 이 **하나도 안 나왔다** — GMS 는 모든 거부를 400 + 자체 봉투로 내고, 대조군으로 넣은 `GET .../models` 가 세 벤더 모두 「Model not found in request」를 내면서 **게이트웨이가 경로가 아니라 본문의 `model` 로 라우팅한다**는 구조가 드러났다(그래서 모델 목록 조회 자체가 불가능하고, 본문 상한 오진도 같은 뿌리다). 호출 상한을 코드에 박고(축 A 20 · 축 B 30, 양쪽 소진) 응답을 한 건씩 append 한다 — 세션이 죽어도 쓴 쿼터가 남는다. `report.py --replay` 는 GMS 를 안 부른다(판정 규칙 변경과 T27 쿼터 변동이 섞이면 결론이 왜 바뀌었는지 알 수 없다). **못 잰 것** — `detail:"low"`(계획 조건이 전부 본문 상한에 걸렸다) · 상한의 정확한 값(구간까지) · 분당/일일 한도(429 가 0건) · p95(n=5) | [이미지 측정 리포트](2026-08-03-gms-image-probe.md), [tools/gms_image/](../../tools/gms_image/), [비전 재고 I36](2026-08-03-gms-vision-probe.md) | +| I49 | AI API 이미지 측정 하네스 `tools/gms_image/`(5스크립트) + 회차 기록 `.gms_image/axis-{a,b}.jsonl` — **설계의 핵심은 치수와 바이트를 따로 움직인 것이다.** 실제 사진은 치수가 크면 바이트도 커서 `-253` 의 세 가설(A 게이트웨이 가산 · B base64 를 텍스트로 셈 · C 벤더 자체 규칙)이 전부 같은 곡선을 낸다 — 합성 PNG 의 노이즈 줄 수로 **512×512 를 유지한 채 2 KB↔72 KB** 를 만들어 얽힘을 풀었다. 판정은 두 기계적 검사(바이트 불변 · 치수 반응)로만 하고 **벤더 공개 공식은 판정에서 분리**했다(넣으면 「공식대로니 정상」이라는 순환). **가설 A 기각의 근거는 경로를 가로지른 대조** — Gemini 만 보면 272 로 평탄해 A 처럼 보이지만 같은 게이트웨이의 OpenAI(8,523→25,524)·Anthropic(39→1,407)이 치수에 반응하므로 게이트웨이 상수가 아니다. **비용 손잡이는 벤더 선택이 전부** — 같은 1024×1024 가 `gpt-4o-mini` 25,524 · `haiku-4-5` 1,407 · `gemini-2.5-flash-lite` 272 로 **94배** 차이다. 축 A 는 404·401·403 이 **하나도 안 나왔다** — AI API 는 모든 거부를 400 + 자체 봉투로 내고, 대조군으로 넣은 `GET .../models` 가 세 벤더 모두 「Model not found in request」를 내면서 **게이트웨이가 경로가 아니라 본문의 `model` 로 라우팅한다**는 구조가 드러났다(그래서 모델 목록 조회 자체가 불가능하고, 본문 상한 오진도 같은 뿌리다). 호출 상한을 코드에 박고(축 A 20 · 축 B 30, 양쪽 소진) 응답을 한 건씩 append 한다 — 세션이 죽어도 쓴 쿼터가 남는다. `report.py --replay` 는 AI API 를 안 부른다(판정 규칙 변경과 T27 쿼터 변동이 섞이면 결론이 왜 바뀌었는지 알 수 없다). **못 잰 것** — `detail:"low"`(계획 조건이 전부 본문 상한에 걸렸다) · 상한의 정확한 값(구간까지) · 분당/일일 한도(429 가 0건) · p95(n=5) | [이미지 측정 리포트](2026-08-03-gms-image-probe.md), [tools/gms_image/](../../tools/gms_image/), [비전 재고 I36](2026-08-03-gms-vision-probe.md) | | I50 | 표시명 개정 배포 체인 기록 — **산출물은 문서 하나이고 내용은 판정이다.** ① 체인 여섯 단계의 소관을 갈랐다(우리 `ai#102`·`ai#104` / 자동화 `image-publish` / 인프라 `infra#185` / 클러스터 sync / 화면). `image-publish` 가 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**(전수 근거는 `I48`). ② **미결 판정 하나를 실측으로 닫았다** — 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 반대로 판정했고 독립 검증이 논거만 반증한 채 **클러스터 관측 경로가 없어 「확인 불가」**로 끝나 있었다. **다만 표기가 낡은 것은 기록 정합성 문제로 남는다** — 배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다. ③ 릴리스가 막히는 두 곳을 절차로 적었다 — base 검사는 `release/*`·`hotfix/*` 만 허용하므로 `dev` 를 그대로 열지 않고(`ai#103` 이 그렇게 막혀 닫혔다), strict 는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(그 병합 커밋 `032e391f` 가 `ai#104` 에 남아 있다). **둘 다 CI 를 다시 기다리게 한다.** ④ 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 헛돌았고 갱신을 만든 것은 수동 실행이다 — `success` 두 번의 뜻이 다르다(`T71` 과 같은 성질). ⑤ 확인 수단은 화면뿐이라 **「아직인가 실패인가」가 갈리지 않는다** — 실제로 한 번 오판했다가 재확인에서 뒤집혔다 | [배포 체인 리포트](2026-08-03-preset-display-name-deploy-chain.md), [I48](2026-08-03-dev-deploy-gap.md), [T70~T72](../troubleshooting/2026-08-03-repeat-incidents.md) | -| I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, GMS 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | +| I51 | 짧은 질의 층 프로브 `tools/search_cut/layer_probe.py` + 경계 행렬 `boundary_matrix.py`·`boundary_sweep.py` + `.search/boundary_grid.json`(1,128행)·`layer_probe.json` — **산출물은 하네스 셋과 판정이고 코드는 고치지 않았다.** ① 층을 코드로 확정했다 — 계약이 후보로 준 「질의 전처리」와 「후보 0건 폴백」은 **존재하지 않는다**(`schema/search.py:9` 는 `min_length=1` 뿐이고 정규화가 없어 원문이 그대로 도달하며, `_cut` 이 0건이면 0건이 그대로 응답이다). 자르는 것은 `search_service.py:111-115,122` 둘뿐이고 `LIMIT` 은 이 데이터에서 한 건도 안 자른다(후보 17·11·6건). **실서버 대조 22/22 일치** — 재구성에 `_cut`·`_is_word_query` 를 다시 적었다(구현 import 금지, `-213` 규칙). ② `-266` 이후 남은 실패는 셋뿐이고 **서로 다른 층이다** — `신한`(③ τ_abs 0.2301<0.24·6위)·`부캠`(③ 0.2105·8위)·`신한 부캠`(**④ r** 0.3187<0.3520·4위). 셋째는 τ 를 넘고 `r` 에만 걸리므로 **경계 정의를 어떻게 바꿔도 안 풀린다**(`-266` 이 `r` 을 「참여하지 않는다」로 가르지 않았다). ③ 「2자가 안 걸린다」는 부정확 — 2자 7건 중 5건은 걸리고(`우주` 0.4200 1위·`공원` 0.3672 1위) 못 걸리는 둘은 **컷 전 순위가 6·8위**다. ④ `-266` 이 「둘이 갈리는 질의가 행렬에 없다」고 적은 대역을 만들었다 — 본문 **문장 내 인접 어절쌍 전량**을 `spaced`/`joined` 짝으로 내면 기대 정답이 같아 차이가 전부 「공백 하나」에 귀속된다(쌍 176종 × 2형태 × 소유자 3명 = 1,128행, AI API 376건). 질의를 고르지 않아 `-266` 이 한계로 적은 선정 재량이 없다(남는 재량은 「두 어절 모두 2자 이상」 하나). ⑤ 손실은 **C 대역(무공백·길다)** 에 몰려 있다 — 현행 16/122·1위 손실 4 가 어절 수 정의에서 3/122·1 이 되고 A·B·D 는 안 움직인다. **그런데 내용어 초점 집합 36행에서는 여섯 정의 전부 누락 0** 이라 두 표가 어긋난다 — 회복되는 것이 기능어 쌍(`당시자주` 0.1909·`같은분위기가` 0.2170)이라 사용자 이득이 관측되지 않았다. ⑥ 새 관측 둘 — **공백을 떼면 유사도가 내려간다**(198짝 평균 -0.0385·중앙값 -0.0279·172/198 하락)는 것과 **전각 공백은 더 내려간다**(`신한 부캠` 0.3187→0.2753 으로 ④ r 이 아니라 ③ τ 에서 잘린다). 후자는 `-266` 이 「측정이 아니라 안전 방향으로의 판단」이라 적은 대역이고 **그 판단이 여기서는 손해로 나타난다**(3건 표본). ⑦ 처방 후보 여섯을 비용·부작용과 함께 냈고 **「안 바꾸는 안」을 포함**했다 — 고르는 것은 이 티켓의 일이 아니다. ⑧ 계약이 「네가 재는 것이 첫 측정」이라 못박았으나 `-255`·`-266` 이 선행하고 후자는 코드까지 병합돼 dev 배포 tag(`7e72826`)에 들어 있으므로 **세 번째다** | [경계 리포트](2026-08-05-short-query-boundary.md), [I45](2026-08-03-search-recall-probe.md), [I47](2026-08-03-word-query-cut.md), [tools/search_cut/](../../tools/search_cut/) | | I53 | Keyword fusion 1단계 실측 `tools/search_cut/fusion_sweep.py` 격자 — 방식(binary·conf·idf·RRF)×weight×floor(0.25/0.30/0.35/0.40)×RRF cutoff(0~0.033). **`신한` 복구와 무관 노출 증가가 같은 계산에서 나온다**(가중합은 컷을 부스트 점수에 걸어 경계 아래 정답과 무관 후보를 똑같이 끌어올린다). RRF 는 cosine eligibility gate 로 무노출을 지키지만 벡터 1위가 키워드 순위 합산에 밀려 문장형이 퇴행한다. floor 0.35 에서 무관 무노출이 baseline 복원되고 binary w=0.05 만 P48 §6.1 전 조건 통과 — Preset 후보가 floor 를 넘는 단어형 질의가 12/66 뿐이라는 대가(미반영 54건 = `-338` 설계 입력). 사례 4건 개별 보고(신한·부캠 미회복 확정). 픽스처 27종·가드 무오발동·baseline 재현 일치 | [실측 리포트](2026-08-05-fusion-measurement.md), [T73~T78](../troubleshooting/2026-08-05-multi-signal-investigation.md), [P48](../proposals/P48-search-signal-expansion.md), [P49](../proposals/P49-multi-signal-search.md) | | I54 | 문자열 병합 규칙 하네스 `tools/search_cut/lexical_matrix.py`·`lexical_sweep.py` — 본문 매치를 (질의 × 소유자 × Record) 로 굳히고(본문 미저장·매치 여부만) 게이트×병합 격자를 오프라인으로 훑는다. 스냅샷 DB(:25432) 가드. 측정 결과: 문자열 매치 94건이 전부 정답 세그먼트에만 발생, 단어형 정답 라벨이 부분일치 정의라 재현율은 구조적으로 유리(순환성 명시), 병합 방식 차이는 RRF·앞고정 > 뒤추가. 단독 추가 7건 · 빈 결과 1건 해소 | [규칙 리포트](2026-08-06-lexical-merge-rule.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md) | -| I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | +| I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 AI API 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | -| I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·GMS 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | -| I59 | 검색 응답에 `keywordMatched` 필드 추가(S15P11A705-399) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | -| I60 | 재정렬 후보 불변 계약과 게이트 계약 분리(S15P11A705-402) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 S15P11A705-400, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I59](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | +| I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·AI API 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | +| I59 | 검색 응답에 `keywordMatched` 필드 추가(Jira 작업) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 Jira 작업)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | +| I60 | 재정렬 후보 불변 계약과 게이트 계약 분리(Jira 작업) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 Jira 작업, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I59](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/docs/proposals/P43-s1-judgment-recovery.md b/docs/proposals/P43-s1-judgment-recovery.md index 7a76135..2ceddcc 100644 --- a/docs/proposals/P43-s1-judgment-recovery.md +++ b/docs/proposals/P43-s1-judgment-recovery.md @@ -45,7 +45,7 @@ contextId 는 본인 데이터의 식별자이고, 수신자는 내부 Spring ### 9. "pgvector 0.8.1 = back 과 일치" 판단을 무효화하고 0.8.5+digest 를 권고 -back#31 이 compose 의 pgvector 버전을 올려서 기존 판단의 전제가 깨졌다. 조사 끝에 digest 까지 동일하게 고정하라고 권고했다. 다만 **값 변경은 직접 하지 않고 세션을 종료**했고, 권고는 `S15P11A705-122`에서 반영됐다(`0.8.5-pg16`+digest). +back#31 이 compose 의 pgvector 버전을 올려서 기존 판단의 전제가 깨졌다. 조사 끝에 digest 까지 동일하게 고정하라고 권고했다. 다만 **값 변경은 직접 하지 않고 세션을 종료**했고, 권고는 Jira 작업에서 반영됐다(`0.8.5-pg16`+digest). ### 10. 공유 워킹트리 커밋을 격리 worktree 로 변경 diff --git a/docs/proposals/P44-ai-repository-governance.md b/docs/proposals/P44-ai-repository-governance.md index da69c2d..cb93104 100644 --- a/docs/proposals/P44-ai-repository-governance.md +++ b/docs/proposals/P44-ai-repository-governance.md @@ -3,8 +3,8 @@ - **상태**: Accepted - **날짜**: 2026-07-28 - **주도(Driver)**: AI -- **관련 Jira**: S15P11A705-108 -- **관련 PR/커밋**: S15P11A705-108 PR +- **관련 Jira**: Jira 작업 +- **관련 PR/커밋**: Jira 작업 PR ## 결정 @@ -53,22 +53,22 @@ Jira, GitHub, 레포 영구 문서만 공동 진실 원천으로 사용한다. | 문서와 실제 GitHub 설정 불일치 | 설정을 바꾼 사람이 **같은 작업에서** `CONTRIBUTING.md` 병합 조건 절을 갱신하고, 변경 직후 API로 재조회해 문서와 대조한다. | 이 문서를 작성한 시점(2026-07-28)의 브랜치는 `main` 단일 구성이었다. -`dev` 통합 / `main` 배포의 2단 구성 전환은 `S15P11A705-154`·`S15P11A705-158`에서 +`dev` 통합 / `main` 배포의 2단 구성 전환은 관련 Jira 작업에서 이뤄졌고, 위 표의 브랜치 서술은 그 전환이 끝난 뒤의 상태다. 브랜치 규칙의 정본은 [`CONTRIBUTING.md`](../../CONTRIBUTING.md)다. **"문서와 실제 GitHub 설정 불일치" 리스크는 실제로 발생했다.** `ai#42` 병합 후 required status checks 에 `ai-ci / embedding profile parity`가 추가됐는데 정본이 갱신되지 않았다. 그 결과 낡은 값이 `CONTRIBUTING.md`·`workflow.md`·이 문서로 퍼졌다 -(`S15P11A705-158`에서 정정). 원래의 완화 방안("API로 재조회")은 **설정을 읽는 것까지만** +(Jira 작업에서 정정). 원래의 완화 방안("API로 재조회")은 **설정을 읽는 것까지만** 다루고, 읽은 결과로 문서를 고치는 주체를 정하지 않았다. 그 구멍 때문에 불일치가 전파됐으므로, 위 표의 완화 문구를 문서 갱신 주체까지 포함하도록 고쳤다. ## 단계적 coverage 기준 -S15P11A705-108의 최초 기준선은 Python 3.12, 52 tests 기준으로 line 76.95% +Jira 작업의 최초 기준선은 Python 3.12, 52 tests 기준으로 line 76.95% (측정 대상 616줄 중 474줄 실행), branch 62.5% (분기 88개 중 55개 통과)다. 이 단계에서는 보고서만 CI artifact 로 보존하고, 수치가 낮다는 이유로 CI 를 실패시키지 않는다. -테스트 보강과 line·branch 80% 게이트 활성화는 후속 S15P11A705-110에서 수행한다. +테스트 보강과 line·branch 80% 게이트 활성화는 후속 Jira 작업에서 수행한다. diff --git a/docs/proposals/P45-public-config-in-code.md b/docs/proposals/P45-public-config-in-code.md index 4bdff33..853138c 100644 --- a/docs/proposals/P45-public-config-in-code.md +++ b/docs/proposals/P45-public-config-in-code.md @@ -2,7 +2,7 @@ - 상태: Accepted - 날짜: 2026-07-29 -- 관련: `S15P11A705-96` · [P32](README.md) · [model-profile.md §2.1](../spec/model-profile.md) · [ai#32](https://github.com/Team-PinLog/ai/pull/32) · [ai#34](https://github.com/Team-PinLog/ai/pull/34) +- 관련: Jira 작업 · [P32](README.md) · [model-profile.md §2.1](../spec/model-profile.md) · [ai#32](https://github.com/Team-PinLog/ai/pull/32) · [ai#34](https://github.com/Team-PinLog/ai/pull/34) ## 무엇을 정하는가 @@ -85,6 +85,6 @@ Infra 가 주입 경로를 하나로 요구했으므로 그 요구를 따르는 **Spring 과의 이중화가 남는다.** [§2](../spec/model-profile.md)의 "단일 정본"은 Spring 과 FastAPI 가 같은 값을 갖게 하려는 것인데, 코드 기본값을 두면 나중에 Spring 쪽에도 같은 값의 리터럴이 생길 수 있다. -현재는 문제가 되지 않는다. **Spring 은 아직 `embeddingProfile`을 다루지 않는다**(레포 전수 검색 0건). 검색 연동(`S15P11A705-135`)에서 Spring 이 이 값을 다루게 되면 §2.2 의 런타임 대조가 불일치를 잡는 역할을 하지만, **어느 쪽이 정본인지는 그때 정해야 한다.** 지금 정하지 않는 이유는, 소비자가 없는 상태에서 정한 규칙은 실제로 붙일 때 다시 뒤집히기 때문이다. +현재는 문제가 되지 않는다. **Spring 은 아직 `embeddingProfile`을 다루지 않는다**(레포 전수 검색 0건). 검색 연동(Jira 작업)에서 Spring 이 이 값을 다루게 되면 §2.2 의 런타임 대조가 불일치를 잡는 역할을 하지만, **어느 쪽이 정본인지는 그때 정해야 한다.** 지금 정하지 않는 이유는, 소비자가 없는 상태에서 정한 규칙은 실제로 붙일 때 다시 뒤집히기 때문이다. **기본값이 낡을 수 있다.** 배포 환경이 실제로 다른 모델을 쓰는데 코드 기본값을 고치지 않으면, 주입을 잊었을 때 낡은 profile 로 기동한다. 이 상태는 §3.1 의 런타임 대조가 잡지만 그 전까지는 드러나지 않는다. **덮어쓰기를 상시 운영 수단으로 쓰지 않는 것**이 이 결정의 전제다. 상시 덮어쓰기가 필요해지면 그 값은 공개 설정이 아니라 환경 종속 설정이므로 분류를 다시 봐야 한다. diff --git a/docs/proposals/P47-keyword-preset-label-axis.md b/docs/proposals/P47-keyword-preset-label-axis.md index f43e569..8eb9a17 100644 --- a/docs/proposals/P47-keyword-preset-label-axis.md +++ b/docs/proposals/P47-keyword-preset-label-axis.md @@ -4,10 +4,10 @@ - **날짜**: 2026-08-03 - **주도(Driver)**: AI 파트 - **관련 PR/커밋**: [ai#90](https://github.com/Team-PinLog/ai/pull/90) — `examples` 개정 채택·`description` 개정 기각 · [ai#102](https://github.com/Team-PinLog/ai/pull/102) — §6 표시 라벨 반영(`dev`) · [ai#104](https://github.com/Team-PinLog/ai/pull/104) — 그 릴리스(`main`) -- **관련 티켓**: `S15P11A705-228` · `S15P11A705-269` · `S15P11A705-270` · `S15P11A705-271` · `S15P11A705-292` +- **관련 티켓**: 관련 Jira 작업 - **근거 리포트**: [implements/2026-08-03-preset-description.md](../implements/2026-08-03-preset-description.md) · [troubleshooting/2026-08-03-preset-description.md](../troubleshooting/2026-08-03-preset-description.md)(T68·T69) -> **§6(표시 라벨)은 반영이 끝났고, 나머지는 여전히 제안이다.** 표시명 명사화는 `S15P11A705-292`로 실행돼 `dev`와 `main`에 들어갔다. 따라서 그 절은 제안이 아니라 반영된 결과의 기록이다. 문서 전체를 `Accepted`로 올리려면 §7 이후가 실행되고 §11의 실측이 있어야 한다. +> **§6(표시 라벨)은 반영이 끝났고, 나머지는 여전히 제안이다.** 표시명 명사화는 Jira 작업으로 실행돼 `dev`와 `main`에 들어갔다. 따라서 그 절은 제안이 아니라 반영된 결과의 기록이다. 문서 전체를 `Accepted`로 올리려면 §7 이후가 실행되고 §11의 실측이 있어야 한다. > > §4의 선결 조건은 「개정 전후의 프리셋 정의를 구분할 경로」에서 **「재처리할 수단」**으로 바뀌었다. 개정 때마다 기존 판정을 전부 재처리하기로 정해졌기 때문이다(§12.1). @@ -94,7 +94,7 @@ 행 단위 `version`과 세트 단위 release가 구분되지 않는다. 판정 결과에 남는 값만으로는 **어떤 YAML 내용과 어떤 임베딩 소스로 판정했는지** 되짚기 어렵다. -**행 `version`을 올릴 경로 자체가 없다.** DB에 컬럼이 있고 판정 결과가 그 값을 참조하지만, 적재 코드는 그 값을 **YAML에서 읽는다.** 그런데 YAML 헤더 주석은 `version`을 「여기서 다루지 않는다」고 스스로 밝히고 있고, 실제로 어느 항목도 그 필드를 갖고 있지 않다. 문서와 코드가 서로를 가리키기만 할 뿐, 어느 쪽도 그 필드를 올리지 않는다. 관련 티켓은 `S15P11A705-269`다. +**행 `version`을 올릴 경로 자체가 없다.** DB에 컬럼이 있고 판정 결과가 그 값을 참조하지만, 적재 코드는 그 값을 **YAML에서 읽는다.** 그런데 YAML 헤더 주석은 `version`을 「여기서 다루지 않는다」고 스스로 밝히고 있고, 실제로 어느 항목도 그 필드를 갖고 있지 않다. 문서와 코드가 서로를 가리키기만 할 뿐, 어느 쪽도 그 필드를 올리지 않는다. 관련 티켓은 Jira 작업다. **다만 이 축의 우선순위는 낮다.** 개정 때마다 기존 판정을 전부 재처리하기로 정해졌으므로(§12.1), 「어느 판정이 옛 정의로 남았는가」를 운영에서 골라낼 일이 줄어든다. 남는 용도는 평가 재현이다. 즉 특정 측정이 어떤 YAML과 어떤 임베딩 프로필 위에서 나왔는지 되짚는 것이다. @@ -142,7 +142,7 @@ enum 값은 즉시 바꾸지 않고 **문서상의 의미만** 재정의한다. ## 6. 표시 라벨 개정 — 반영 완료 -**이 절은 제안이 아니라 반영된 결과의 기록이다.** 표시명 명사화는 `S15P11A705-292`로 실행됐고, [ai#102](https://github.com/Team-PinLog/ai/pull/102)가 `dev`에 [ai#104](https://github.com/Team-PinLog/ai/pull/104)가 `main`에 들어갔다. 운영 화면의 표시명이 아래 세트로 바뀌어 있다. +**이 절은 제안이 아니라 반영된 결과의 기록이다.** 표시명 명사화는 Jira 작업으로 실행됐고, [ai#102](https://github.com/Team-PinLog/ai/pull/102)가 `dev`에 [ai#104](https://github.com/Team-PinLog/ai/pull/104)가 `main`에 들어갔다. 운영 화면의 표시명이 아래 세트로 바뀌어 있다. **정본은 `ai/data/keyword_preset.yaml`이다.** 아래 표는 그 값을 다시 적은 것이 아니라 무엇을 왜 그렇게 정했는지를 남긴 것이며, 표와 YAML이 어긋나면 YAML이 옳다. diff --git a/docs/proposals/P48-search-signal-expansion.md b/docs/proposals/P48-search-signal-expansion.md index 6dc36c1..3362de6 100644 --- a/docs/proposals/P48-search-signal-expansion.md +++ b/docs/proposals/P48-search-signal-expansion.md @@ -4,14 +4,14 @@ - **날짜**: 2026-08-05 - **주도(Driver)**: AI 파트 - **관련 PR/커밋**: ai#114(하네스·I52·I53 인수) · `39dba0d`(2단계 대체 구현 — P49 §3) · `8a79628`(3단계 규칙 확정 — I54) -- **관련 티켓**: S15P11A705-336(인수) · -337(2단계 대체) · -339(1단계 채택값·재정렬 기준) · back 문자열 구현 티켓(발급 예정) -- **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I52 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(`S15P11A705-213`) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(`S15P11A705-255`) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(`S15P11A705-266`) +- **관련 티켓**: Jira 작업(인수) · -337(2단계 대체) · -339(1단계 채택값·재정렬 기준) · back 문자열 구현 티켓(발급 예정) +- **근거 리포트**: [implements/2026-08-05-search-rank-baseline.md](../implements/2026-08-05-search-rank-baseline.md)(I52 · 0단계 산출) · [implements/2026-07-31-search-cut.md](../implements/2026-07-31-search-cut.md)(Jira 작업) · [implements/2026-08-03-search-recall-probe.md](../implements/2026-08-03-search-recall-probe.md)(Jira 작업) · [implements/2026-08-03-word-query-cut.md](../implements/2026-08-03-word-query-cut.md)(Jira 작업) > **[승계 공지 — 2026-08-06]** 이 문서는 검색 신호 확장의 원안과 실험 기반을 보존하는 > 선행 문서다. 현재 실행 상태: **0단계 완료**(I52) · **1단계 오프라인 구현·실측 완료** > (ai#114 로 인수, 통합 브랜치 병합 대기 — 런타임 병합 정책은 > [P49](P49-multi-signal-search.md) §4 가 컷 이후 재정렬 전용으로 변경) · -> **2단계는 P49 §3 과 S15P11A705-337 로 대체**(프리셋 제한 출력과 단어형 한정은 현행 +> **2단계는 P49 §3 과 Jira 작업 로 대체**(프리셋 제한 출력과 단어형 한정은 현행 > 런타임 설계가 아니다 — 약어를 프리셋 밖 표현으로 풀어야 해서다) · > **3단계는 P49 와 I54 로 승계**(규칙 확정, back 구현 대기). > @@ -60,9 +60,9 @@ 근거 리포트 셋이 이미 이 구조의 한계를 각각 다른 각도에서 기록했다. ``` -S15P11A705-213 컷 두 겹(τ_abs · r)이 서로를 대체하지 못한다 -S15P11A705-255 세 이슈의 원인이 서로 다르다 — 컷 · 약어 · 짧은 질의 -S15P11A705-266 τ_abs 단일값으로는 단어형과 문장형 중 한쪽이 반드시 손해를 본다 +Jira 작업 컷 두 겹(τ_abs · r)이 서로를 대체하지 못한다 +Jira 작업 세 이슈의 원인이 서로 다르다 — 컷 · 약어 · 짧은 질의 +Jira 작업 τ_abs 단일값으로는 단어형과 문장형 중 한쪽이 반드시 손해를 본다 ``` **세 리포트가 같은 것을 말하고 있다.** 원인이 여러 개인데 대응 수단이 하나뿐이라, @@ -190,7 +190,7 @@ keyword 신호가 있는데 코사인이 없는 Record 가 나오면 그것은 * ``` tools/search_cut/rank_score.py (신규) - 입력 기존 행렬 셋 — 새 데이터도 GMS 호출도 필요 없다 + 입력 기존 행렬 셋 — 새 데이터도 AI API 호출도 필요 없다 출력 recall@k · MRR · 단어형 / 문장형 분리 두 대역이 겹치지 않으므로(-266) 합산은 무의미 · 컷 적용 전 / 후 각각 컷 후만 보면 순위 개선이 가려진다 @@ -333,7 +333,7 @@ P9([`spec/architecture.md`](../spec/architecture.md) §1)가 금지한다. > 검색 경로에는 LLM이 없고 임베딩은 결정적이라 이쪽 재구성은 정확하다 — > `verify_live.py`가 실서버와 일치를 확인한다. -`sweep`류가 DB도 GMS도 부르지 않는 것은 행렬에 (질의 × Record) 코사인이 **전량** 굳어 +`sweep`류가 DB도 AI API도 부르지 않는 것은 행렬에 (질의 × Record) 코사인이 **전량** 굳어 있기 때문이다. 점수가 코사인만의 함수가 아니게 되면 **그 성질이 깨진다.** **현행 artifact로는 1단계를 재구성할 수 없다.** 2026-08-05 확인 결과다. @@ -392,11 +392,11 @@ top-k 와 하한은 그 목록을 자르는 연산이므로 sweep에서 자유 #### 4.3 호출 경계 ``` -artifact 생성 GMS 임베딩 배치 1회 + DB 읽기 허용 (현행 matrix.py 와 같다) -rank·fusion sweep GMS 0회 · DB 0회 필수 +artifact 생성 AI API 임베딩 배치 1회 + DB 읽기 허용 (현행 matrix.py 와 같다) +rank·fusion sweep AI API 0회 · DB 0회 필수 ``` -**기능보다 하네스를 먼저 확장한다.** 순서가 뒤집히면 가중치 하나 바꿀 때마다 GMS를 +**기능보다 하네스를 먼저 확장한다.** 순서가 뒤집히면 가중치 하나 바꿀 때마다 AI API를 부르게 되고, 이 레포의 실측 문화가 그 비용을 못 견딘다. `profile` · `preset_version` · 생성 조건을 artifact에 함께 저장해 **낡은 artifact로 @@ -442,7 +442,7 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 ``` ① baseline 계산의 결정성 같은 입력에 같은 출력 -② 확장 artifact 재구성 = 원본 계산 DB/GMS 실계산과 정확 일치 (verify_live.py 와 같은 요구) +② 확장 artifact 재구성 = 원본 계산 DB/AI API 실계산과 정확 일치 (verify_live.py 와 같은 요구) ③ sweep 반복 실행 결과 일치 artifact 만 읽으므로 흔들릴 이유가 없다 ④ 잘못된 profile·version·context 매핑이면 계산하지 않고 실패 ``` @@ -469,7 +469,7 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 **선결 조건** 1. ~~0단계 완료와 baseline 보존~~ — **완료**(I52). -2. artifact 확장. 데모 DB와 GMS 키 환경이 필요하다. — **해소**(ai#114: `word_grid`·`recall_probe` 에 `context_id` 추가, `keyword_matrix.json` 신규) +2. artifact 확장. 데모 DB와 AI API 키 환경이 필요하다. — **해소**(ai#114: `word_grid`·`recall_probe` 에 `context_id` 추가, `keyword_matrix.json` 신규) 3. **티켓 발급.** 단계별로 분리하며, 3단계는 back 티켓과 연결한다. — **해소**(-336·-337·-339 발급, back 티켓 발급 예정) **Jira 키 없이 런타임 구현을 시작하지 않는다**(`CONTRIBUTING.md`). @@ -477,7 +477,7 @@ rank·fusion sweep GMS 0회 · DB 0회 필수 - 0단계 하네스와 이 문서 개정까지만 진행한다. - **artifact 값을 추정하거나 임의 생성하지 않는다.** -- DB/GMS 미실행으로 막힌 항목과 필요한 실행 명령을 결과에 남긴다. +- DB/AI API 미실행으로 막힌 항목과 필요한 실행 명령을 결과에 남긴다. **미결** diff --git a/docs/proposals/P49-multi-signal-search.md b/docs/proposals/P49-multi-signal-search.md index 4d8c486..8909ebc 100644 --- a/docs/proposals/P49-multi-signal-search.md +++ b/docs/proposals/P49-multi-signal-search.md @@ -4,7 +4,7 @@ - **날짜**: 2026-08-05 - **주도(Driver)**: AI - **관련 PR/커밋**: ai#114(P48 하네스·I52·I53 인수, base `search-upgrade`, 병합 대기) · `39dba0d`(-337 질의 재작성 런타임 구현) · `8a79628`(문자열 병합 규칙 오프라인 확정 — I54. back 런타임은 미구현) -- **관련 문서**: [P48](P48-search-signal-expansion.md)(`origin/leo`, 인수 예정) · 근거 실측 기록 [2026-08-05-multi-signal-investigation.md](../troubleshooting/2026-08-05-multi-signal-investigation.md)(T73~T78 잠정) · 관련 트랙 `S15P11A705-336` +- **관련 문서**: [P48](P48-search-signal-expansion.md)(`origin/leo`, 인수 예정) · 근거 실측 기록 [2026-08-05-multi-signal-investigation.md](../troubleshooting/2026-08-05-multi-signal-investigation.md)(T73~T78 잠정) · 관련 트랙 Jira 작업 - **번호는 잠정이다.** P48 은 `origin/leo` 브랜치가 사용 중이다. 병합 직전에 번호를 재확인해 확정한다(T48·T60 규칙). > 이 문서의 실측 수치는 2026-08-05 측정 시점의 관측값이다. 현행 상수의 정본은 `app/core/config.py` 이고, 측정 기록의 정본은 `-336` 실측 리포트로 정식화할 예정이다. 부록 D 에 근거 문서의 좌표를 모아 두었다. @@ -148,7 +148,7 @@ CI 는 확인이 필요하다. 대상 브랜치가 `search-upgrade` 인 PR 에 - **작업 0. leo 인수.** leo 브랜치를 rebase 해 하네스·테스트·문서를 통합 브랜치에 들인다. 발견된 버그 2건(T76·T77) 수정, 리포트 번호 충돌(I51→I52) 해소, artifact 커밋, 실측 리포트 작성, 통합 브랜치 생성과 CI 확인, DB 스냅샷 준비를 포함한다. 모든 후속 작업의 기반이므로 유일한 전면 선행 작업이다. - **작업 1. LLM 재작성 (병렬).** Preset 과 무관하므로 기다릴 이유가 없다. 실패 사례 3건 중 2건을 해소하는, 시연 가치 대비 위험이 가장 낮은 작업이다. 관련 없는 질의 15건의 재측정을 채택 조건으로 포함한다. - **작업 3. 문자열 병합 규칙 실측 (병렬).** 측정 artifact 에 본문 매치 정보를 추가해, 병합 방식과 경계 검사 조합을 오프라인으로 훑는다. back 이 구현을 시작하기 전에 규칙을 확정해서 넘기기 위한 작업이다. 파트 간 재작업을 줄이는 것이 목적이다. -- **작업 4. 키워드 재정렬 구현·채택값 확정.** 두 부분이다. **측정** — 현행 Preset·현행 측정 자료로, §4 의 재정렬 전용 규칙에서 BASE·binary 재정렬·RRF 재정렬을 비교하고 floor·weight 를 확정한다(GMS·DB 호출 없음). 오프라인 결정성 회차(같은 자료·같은 인자에서 출력 일치)와 artifact 신선도 가드(profile·preset_version 불일치 시 실패)를 포함한다. **런타임 구현** — 컷 통과 후보의 `context_keyword` 조회, `keyword_status=COMPLETED` 만 신호 사용, PUBLIC+PRIVATE_ONLY 사용·BLOCKED 제외(P48 §1-b·1-c 그대로), 채택값으로 순서만 변경, 기본 off 플래그, 오류 시 벡터 순서 복귀, on/off 후보 집합 불변 계약 테스트, 채택값의 `app/core/config.py` 반영. 이 구현이 없으면 채택값이 있어도 세 번째 검색 신호가 런타임에 존재하지 않는다. +- **작업 4. 키워드 재정렬 구현·채택값 확정.** 두 부분이다. **측정** — 현행 Preset·현행 측정 자료로, §4 의 재정렬 전용 규칙에서 BASE·binary 재정렬·RRF 재정렬을 비교하고 floor·weight 를 확정한다(AI API·DB 호출 없음). 오프라인 결정성 회차(같은 자료·같은 인자에서 출력 일치)와 artifact 신선도 가드(profile·preset_version 불일치 시 실패)를 포함한다. **런타임 구현** — 컷 통과 후보의 `context_keyword` 조회, `keyword_status=COMPLETED` 만 신호 사용, PUBLIC+PRIVATE_ONLY 사용·BLOCKED 제외(P48 §1-b·1-c 그대로), 채택값으로 순서만 변경, 기본 off 플래그, 오류 시 벡터 순서 복귀, on/off 후보 집합 불변 계약 테스트, 채택값의 `app/core/config.py` 반영. 이 구현이 없으면 채택값이 있어도 세 번째 검색 신호가 런타임에 존재하지 않는다. - **작업 5. back 구현.** 문자열 검색과 병합을 작업 3 의 확정 규칙대로 구현한다. 유일한 타 파트 의존 작업이라 일정 위험이 가장 크다. - **작업 6. 검증.** §7 기준 전부 통과 후 dev 병합, 시연 DB 반영, 리허설. diff --git a/docs/proposals/P50-three-layer-place-metadata.md b/docs/proposals/P50-three-layer-place-metadata.md index 96499a7..b3658c2 100644 --- a/docs/proposals/P50-three-layer-place-metadata.md +++ b/docs/proposals/P50-three-layer-place-metadata.md @@ -132,7 +132,7 @@ WHERE place.region_2depth = '마포구' AND place.category_group_code = 'CE7' ## 6. 검색 고도화 트랙과의 접점 -- **재작성 클라이언트가 필터 추출의 확장 지점이다.** 검색 질의 재작성(S15P11A705-337)의 출력을 「재작성문 하나」에서 「재작성문 + 구조 필터」로 확장하면 §5.3의 자연어 조건 분해를 담을 수 있다. 현행 구현은 이 확장을 막지 않는 형태다. +- **재작성 클라이언트가 필터 추출의 확장 지점이다.** 검색 질의 재작성(Jira 작업)의 출력을 「재작성문 하나」에서 「재작성문 + 구조 필터」로 확장하면 §5.3의 자연어 조건 분해를 담을 수 있다. 현행 구현은 이 확장을 막지 않는 형태다. - **문자열 검색 규칙([I54](../implements/2026-08-06-lexical-merge-rule.md))과 병립한다.** 문자열 검색은 본문 매치, 구조 필터는 Place 속성 매치로 서로 다른 신호다. - **알려진 긴장 1건.** 프리셋 정의문에 음식 명사를 보강하는 안(폐기 경위는 [P51 §10](P51-keyword-preset-governance.md))은 검색 매칭용이었더라도 Place 어휘를 Context 임베딩에 넣는 부분 위반이었다. 장기 해소책은 표시·판정·검색 임베딩 입력의 분리(aliases — 전수 검토 문서의 스키마 v2 항목)다. diff --git a/docs/proposals/P51-keyword-preset-governance.md b/docs/proposals/P51-keyword-preset-governance.md index 3d9f553..1801875 100644 --- a/docs/proposals/P51-keyword-preset-governance.md +++ b/docs/proposals/P51-keyword-preset-governance.md @@ -25,7 +25,7 @@ **빈 배열의 사용자 체감.** 적합한 키워드가 없을 때 빈 배열을 반환하는 현행 실패는 데이터로는 안전하지만, 사용자에게는 「AI가 내 기록을 이해하지 못했다」로 느껴지기 쉽다. -**직전 이력.** 검색 미반영 질의를 줄이려고 기존 프리셋의 정의문·예문에 음식 명사를 보강하는 안(S15P11A705-338)이 검토됐다. 이 안은 두 근거로 폐기가 제안된 상태다 — ① 장소 어휘를 Context 축에 넣는 계층 위반([P50 §2](P50-three-layer-place-metadata.md)), ② 해당 질의는 문자열 검색이 회복함이 실측됨([I54](../implements/2026-08-06-lexical-merge-rule.md)). 폐기 확정은 사용자 승인 사안이다(§12). +**직전 이력.** 검색 미반영 질의를 줄이려고 기존 프리셋의 정의문·예문에 음식 명사를 보강하는 안(Jira 작업)이 검토됐다. 이 안은 두 근거로 폐기가 제안된 상태다 — ① 장소 어휘를 Context 축에 넣는 계층 위반([P50 §2](P50-three-layer-place-metadata.md)), ② 해당 질의는 문자열 검색이 회복함이 실측됨([I54](../implements/2026-08-06-lexical-merge-rule.md)). 폐기 확정은 사용자 승인 사안이다(§12). ## 3. 대안 3방식의 비교와 판정 @@ -158,7 +158,7 @@ front·back의 응답 계약과 화면 변경이 필요하므로 후순위이며 | 검색 실패 유형 | 담당 | 근거 | |---|---|---| -| 약어·줄임말 (`부캠`) | 질의 LLM 재작성 (S15P11A705-337) | [P49 §3](P49-multi-signal-search.md) | +| 약어·줄임말 (`부캠`) | 질의 LLM 재작성 (Jira 작업) | [P49 §3](P49-multi-signal-search.md) | | 본문 문자열 존재 (`신한`) | 문자열 검색 병합 | [I54](../implements/2026-08-06-lexical-merge-rule.md) | | 프리셋 축 질의 순위 | 키워드 신호 융합 | [I53](../implements/2026-08-05-fusion-measurement.md) | | 프리셋 미표현 개념 | **이 문서의 승격 루프** (검색 신호 아님) | §4 | @@ -169,7 +169,7 @@ front·back의 응답 계약과 화면 변경이 필요하므로 후순위이며 |---|---| | 자유 생성 키워드의 정식 사용 (타인 공개·피드 추천·공통 프로필·컬렉션 특징값) | §3.1의 4문제 — code 기반 추천·visibility·재현성이라는 핵심 이점을 모두 잃는다 | | 무작정 대규모 확장 (3~4배) | §3.2 — 후보 도달률 하락·오분류 전환·릴리스 비용. 표적 확장(§7)만 허용 | -| 기존 프리셋에 음식·장소 명사 보강 (S15P11A705-338 원안) | 계층 위반([P50](P50-three-layer-place-metadata.md)) + 해당 질의는 문자열 검색이 회복(I54). 폐기 확정은 사용자 승인 대기 | +| 기존 프리셋에 음식·장소 명사 보강 (Jira 작업 원안) | 계층 위반([P50](P50-three-layer-place-metadata.md)) + 해당 질의는 문자열 검색이 회복(I54). 폐기 확정은 사용자 승인 대기 | | 유형 1(장소 카테고리) 개념의 프리셋 승격 | Place 계층과의 중복 생성 — 프리셋 증식의 주 원인 차단 | ## 11. 적용 로드맵 @@ -188,4 +188,4 @@ front·back의 응답 계약과 화면 변경이 필요하므로 후순위이며 - AI 임시 태그 도입 여부 (§8). - 파일럿 리허설의 실행 시점. - 표시·판정·검색 임베딩 입력 분리(aliases)의 실험 — 정의문 개정이 실험에서 기각된 전례가 있으므로 입력 구성 변경은 반드시 기존 평가 하네스 비교 후 채택. -- S15P11A705-338 폐기의 최종 확정 (사용자 승인). +- Jira 작업 폐기의 최종 확정 (사용자 승인). diff --git a/docs/proposals/P52-keyword-taxonomy-redesign.md b/docs/proposals/P52-keyword-taxonomy-redesign.md index 03ae369..7b43531 100644 --- a/docs/proposals/P52-keyword-taxonomy-redesign.md +++ b/docs/proposals/P52-keyword-taxonomy-redesign.md @@ -2,7 +2,7 @@ - **상태**: 제안 — 2026-08-07 사용자 채택 (실행은 별도 결정) - **날짜**: 2026-08-07 -- **관련**: P26(현행 27종 확정 — **이 제안 채택 시 supersede 대상**) · P47(라벨·축·불변 계약 — 채택 시 부분 개정 대상) · P51(프리셋 거버넌스 — 채택 시 §7 확장 전제 개정 대상) · P50(3계층 분리 — 정합) · 공용 계약 05 §8/07 ERD(채택 시 후속 개정 대상) · S15P11A705-292(표시명 명사형 통일 — 라벨 정본) +- **관련**: P26(현행 27종 확정 — **이 제안 채택 시 supersede 대상**) · P47(라벨·축·불변 계약 — 채택 시 부분 개정 대상) · P51(프리셋 거버넌스 — 채택 시 §7 확장 전제 개정 대상) · P50(3계층 분리 — 정합) · 공용 계약 05 §8/07 ERD(채택 시 후속 개정 대상) · Jira 작업(표시명 명사형 통일 — 라벨 정본) - **번호는 잠정 P52다.** 커밋 시점의 색인 기준으로 재확정한다. ## 1. 제안 요약 @@ -201,7 +201,7 @@ PLACE_PROPERTY / PERSONAL_CONTEXT는 **coverage 분석용 meta-domain**이다(§ ### 실행의 전제 조건 — 선결 결함 3건 (전부 미해결) -1. **preset_version 증가 경로 부재** — 신선도 가드 전체 불발(위 defect가 실증). 기존 티켓 S15P11A705-269. +1. **preset_version 증가 경로 부재** — 신선도 가드 전체 불발(위 defect가 실증). 기존 티켓 Jira 작업. 2. **YAML 삭제 항목 미처리** — 시딩이 UPSERT만 수행. DEACTIVATE(REPLACE 포함)를 실행하려면 is_active 동기화 로직이 선행돼야 한다. 3. **재판정 수단 부재**(P47 §4) — 상태 되돌림·재스캔 수집·속도 조절 전부 없음. REPLACE(DATE_COURSE→DATING)의 「재판정 시 신규 code 수렴」도 이 수단에 의존한다. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index a450f98..8956eeb 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -65,7 +65,7 @@ | P37 | 분류 3범주 proposals/implements/troubleshooting + spec | 공용(AI 주도) | 이 트리 | | P38 | rebase Option B(MINYONG 독립작업 위 재정리) | AI | [troubleshooting](../troubleshooting/) | | P40 | `/search` 응답 `contextId` 추가(DISTINCT ON, Spring matchedContext 조립용) | AI | ai#11·docs#10, [spec/personal-search.md](../spec/personal-search.md) | -| P41 | 툴체인 — Python 3.12 통일·pgvector `0.8.5-pg16`+digest 고정(최초 `0.8.1-pg16` → `S15P11A705-122`에서 운영·back 실제 버전에 정합)·requirements lock | AI | [implements](../implements/2026-07-24-e3-test-harness.md) (ai#14·#16) | +| P41 | 툴체인 — Python 3.12 통일·pgvector `0.8.5-pg16`+digest 고정(최초 `0.8.1-pg16` → Jira 작업에서 운영·back 실제 버전에 정합)·requirements lock | AI | [implements](../implements/2026-07-24-e3-test-harness.md) (ai#14·#16) | | P43 | S1 판단 변경 10·기각 대안 9 복원(search_path 캐스트 원복·pgvector digest 권고·Python 3.12 상한(GraphRAG)·브랜치보호 CI 후 적용 등) | AI | [P43](P43-s1-judgment-recovery.md) | | P44 | Jira-first·TDD 증거·리뷰 대화 해결·영구 문서 기반 AI 협업 운영 | AI | [P44](P44-ai-repository-governance.md) | | P45 | 공개 설정은 코드가 정본, 주입 필수는 비밀뿐 — Embedding Profile 넷에 기본값 부여 | AI | [P45](P45-public-config-in-code.md), docs#27(05 §7.1 개정) | diff --git a/docs/spec/cost-estimate.md b/docs/spec/cost-estimate.md index c4bad09..118463c 100644 --- a/docs/spec/cost-estimate.md +++ b/docs/spec/cost-estimate.md @@ -2,11 +2,11 @@ > (`tools/keyword_eval/REPORT.md`), 이 문서는 **재선정이 아니라 그 모델의 운영 비용을 > 추정**합니다. > -> ⚠️ **2026-07-30 이후 판정은 벤더 폴백 체인으로 호출됩니다**(`S15P11A705-175`, +> ⚠️ **2026-07-30 이후 판정은 벤더 폴백 체인으로 호출됩니다**(Jira 작업, > [failure-recovery.md](failure-recovery.md) §3.4). 1순위가 `gpt-4o-mini`이고 > `gemini-2.5-flash`는 2순위입니다. 따라서 아래 §3의 단일 모델 추정은 **하한**이며 실제 > 구성은 호출마다 다를 수 있습니다. Anthropic 경로(3순위)는 prompt 토큰이 2.4배입니다 -> (1973 대 732). 그럼에도 재계산하지 않은 이유는 §4 주석과 같습니다. GMS의 프로바이더별 +> (1973 대 732). 그럼에도 재계산하지 않은 이유는 §4 주석과 같습니다. AI API의 프로바이더별 > 크레딧 환산율표를 확보하지 못했고, 환산율표 없이 하는 모델 간 비용 비교는 추정에 > 추정을 쌓는 일이 되기 때문입니다. > 실제 구성은 토큰 로그(`PINLOG_TOKEN_LOG`)의 `vendor`·`model`로 사후 집계할 수 있습니다. @@ -15,7 +15,7 @@ 근거 데이터: 테스트 C-2 토큰 측정(`tools/keyword_eval/REPORT.md`, 35샘플, K=10, floor 0.30). -> ⚠️ **단가는 공개가 근사치입니다.** 실제 청구는 GMS 게이트웨이의 크레딧 단가로 +> ⚠️ **단가는 공개가 근사치입니다.** 실제 청구는 AI API 게이트웨이의 크레딧 단가로 > 이루어지며, 모델별 크레딧 환산율표는 아직 확보하지 못했습니다. 환산율표를 > 받으면 아래 §4 공식에 대입해 재계산합니다. 현재 수치는 규모 감을 잡기 위한 > 근사이며 청구 금액이 아닙니다. @@ -75,7 +75,7 @@ Embedding : 50 × 0.02/1e6 = $0.0000010 $0.00028 부근입니다. 후보가 0건이어서 판정을 건너뛰는 비율만큼 실제 비용은 위 상한보다 내려갑니다. -## 4. GMS 크레딧 환산 공식 (단가표 확보 시 대입) +## 4. AI API 크레딧 환산 공식 (단가표 확보 시 대입) ``` Context당 비용 = Σ(단계별 tok × 해당 모델·방향 크레딧단가) @@ -84,7 +84,7 @@ Context당 비용 = Σ(단계별 tok × 해당 모델·방향 크레딧단가) + judge_out × rate(judge, output) ``` -`rate(...)`에 GMS 크레딧 단가를 넣으면 청구 기준 비용이 됩니다. tok 프로파일 +`rate(...)`에 AI API 크레딧 단가를 넣으면 청구 기준 비용이 됩니다. tok 프로파일 (§2)은 모델 고정이므로 단가만 갈아끼우면 됩니다. ## 5. 모델 간 상대 비교 (토큰 총량 기준) @@ -105,5 +105,5 @@ C-2 총 토큰(35샘플 34호출)으로 본 상대 순위입니다. gemini가 ## 6. 남은 것 -- GMS 모델별 크레딧 단가표를 확보하면 §4 공식에 대입해 청구 기준 비용을 확정합니다. +- AI API 모델별 크레딧 단가표를 확보하면 §4 공식에 대입해 청구 기준 비용을 확정합니다. - 실사용 로그로 판정률(후보 0건 비율)과 평균 본문 토큰을 측정하면, 상한이 아닌 기대 비용을 산정할 수 있습니다. diff --git a/docs/spec/failure-recovery.md b/docs/spec/failure-recovery.md index 706afa6..f2da8f1 100644 --- a/docs/spec/failure-recovery.md +++ b/docs/spec/failure-recovery.md @@ -121,16 +121,16 @@ Circuit Breaker를 열어 이후 요청을 즉시 중단시키고, 그 구간의 ### 2.4 관측 — 무엇을 어느 레벨로 남기는가 -`S15P11A705-197`에서 정했습니다. 위 세 절이 단계별 로그 레벨을 정하지만, **호출 자체가 -성공했는지**는 어디에도 남지 않아 실패율을 계산할 분모가 없었습니다. GMS는 공용 +Jira 작업에서 정했습니다. 위 세 절이 단계별 로그 레벨을 정하지만, **호출 자체가 +성공했는지**는 어디에도 남지 않아 실패율을 계산할 분모가 없었습니다. AI API는 공용 게이트웨이라 쿼터가 시점·프로바이더 경로별로 다르고(§3.4), 실패율을 못 보면 느려졌을 때 우리 문제인지 게이트웨이 문제인지 가릴 수 없습니다. | 사건 | 레벨 | 로거 | |---|---|---| -| GMS 호출 성공 | `DEBUG` | `app.client.gms` | -| GMS 호출 실패 — 일시·영구·스키마 위반 | `WARNING` | `app.client.gms` | -| GMS 호출이 두 분류 밖 예외로 끝남 | `ERROR` | `app.client.gms` | +| AI API 호출 성공 | `DEBUG` | `app.client.gms` | +| AI API 호출 실패 — 일시·영구·스키마 위반 | `WARNING` | `app.client.gms` | +| AI API 호출이 두 분류 밖 예외로 끝남 | `ERROR` | `app.client.gms` | | 호출 집계 — 창(60초) 단위 실패율·지연 | `INFO` | `app.client.gms` | | 만료된 `PROCESSING` 재선점 | `WARNING` | `app.service.stage` | | 단계 일시 오류 (§2.1) | `WARNING` | `app.service.*` | @@ -145,7 +145,7 @@ Circuit Breaker를 열어 이후 요청을 즉시 중단시키고, 그 구간의 않고, 대신 종료 시 마지막 창을 `flush`합니다. 3. **재선점은 `WARNING`입니다.** §2.3의 "영향 행 수 0"과 달리 정상 경로가 아닙니다. 재선점은 앞선 처리가 만료 안에 끝나지 못했다는 뜻이고, 원인은 프로세스 종료 아니면 - GMS 지연 둘뿐입니다. 신규 시작은 남기지 않습니다. + AI API 지연 둘뿐입니다. 신규 시작은 남기지 않습니다. 4. **credential·endpoint·요청 본문은 어느 레벨에서도 남기지 않습니다.** [probe.py](../../app/api/probe.py)가 세운 기준을 로그에 그대로 적용합니다. 요청 본문에는 사용자가 쓴 Context 원문이 들어 있습니다. **벤더·모델 이름은 남깁니다.** 공개 설정이고 @@ -157,7 +157,7 @@ Circuit Breaker를 열어 이후 요청을 즉시 중단시키고, 그 구간의 ### 2.6 예외 메시지 — 값이 로그에 닿는 경로 -`S15P11A705-205`에서 정했습니다. §2.4 원칙 4는 처음에 응답 본문까지 *"어느 레벨에서도 +Jira 작업에서 정했습니다. §2.4 원칙 4는 처음에 응답 본문까지 *"어느 레벨에서도 남기지 않습니다"*로 적혀 있었지만 **코드는 그때도 남기고 있었습니다.** 이 절은 그 어긋남을 없앱니다. @@ -195,7 +195,7 @@ resp.text[:200] → TransientError/PermanentError 메시지 → 다섯 곳의 ### 2.5 API 층 — 분류를 HTTP 상태로 -`S15P11A705-220`에서 정했습니다. §2.1·§2.2는 **Context 처리 경로**(백그라운드)의 상태 +Jira 작업에서 정했습니다. §2.1·§2.2는 **Context 처리 경로**(백그라운드)의 상태 반영을 정하지만, 검색은 사용자 요청 경로라 바꿀 상태가 없고 **분류가 곧 응답**입니다. 그 변환 규칙이 없어서 분류된 실패가 전부 `500`으로 나갔습니다. 임베딩 `502`가 검색 `500`이 된 `ai#69`가 그 사례입니다. 분류(`errors.py`)도 재시도(`retry.py`)도 맞았고, @@ -210,7 +210,7 @@ resp.text[:200] → TransientError/PermanentError 메시지 → 다섯 곳의 #### DB 축 -`S15P11A705-221`에서 채웠습니다. 위 표는 **분류된 예외**를 상태 코드로 바꾸지만, +Jira 작업에서 채웠습니다. 위 표는 **분류된 예외**를 상태 코드로 바꾸지만, 검색 경로의 DB 실패는 아무도 분류하지 않아 그대로 `500`으로 나갔습니다. §2.1이 `DB 연결 실패, 잠금 타임아웃, 직렬화 실패`를 일시적 오류로 이미 규정하고 있었으므로 **명세와 코드가 어긋난 상태**였고, 그동안 커넥션 풀 고갈이나 DB 재기동 같은 회복 @@ -269,14 +269,14 @@ SQLSTATE만** 담습니다. SQLSTATE를 남기는 것은 공개 어휘이면서 *"DB가 재기동 중이다"*를 가르는 유일한 값이기 때문이고, 그 한 줄은 분류가 붙는 순간 `app.core.db` 로거가 남깁니다(일시 `WARNING`, 영구 `ERROR`, §2.4). -**`S15P11A705-229`에서 이 한계를 메웠습니다.** 그때까지 두 핸들러의 응답 본문은 +**Jira 작업에서 이 한계를 메웠습니다.** 그때까지 두 핸들러의 응답 본문은 `embedding upstream ...` 고정 문구 하나뿐이라, DB에서 비롯한 `503`·`502`도 임베딩을 가리키는 문구를 답했습니다. 상태 코드와 로그는 정확했지만 본문만 사실과 달랐습니다. 아래 「응답 본문」 절이 그 개정입니다. 변환은 `app/main.py`의 예외 핸들러 **한 곳**에서만 합니다. 라우터가 개별적으로 잡으면 경로마다 다른 코드가 나가고, 그것이 §2.1·§2.2의 분류표를 두 클라이언트가 각자 들고 -있어 갈라졌던 `S15P11A705-121`과 같은 형태입니다. +있어 갈라졌던 Jira 작업과 같은 형태입니다. **500을 비워 두는 것이 이 표의 목적입니다.** 분류된 실패까지 500이면 "AI가 깨졌는지 게이트웨이가 깨졌는지"를 로그 없이 가릴 수 없습니다. 두 코드를 갈라낸 뒤 남는 500은 @@ -289,7 +289,7 @@ SQLSTATE만** 담습니다. SQLSTATE를 남기는 것은 공개 어휘이면서 #### 응답 본문 -`S15P11A705-220`은 고정 문구 한 줄을 정했고, `S15P11A705-229`가 그 한 줄을 **층 +한 Jira 작업은 고정 문구 한 줄을 정했고, 후속 Jira 작업이 그 한 줄을 **층 이름별로 둘로 갈랐습니다.** DB 축이 §2.1이 이미 규정한 대로 `503`/`502`를 정확히 내면서도(위 `DB 축` 절), 본문만 `embedding upstream ...`를 답해 DB 실패가 게이트웨이 실패로 읽혔기 때문입니다. 상태 코드와 로그는 원인을 가리키는데 본문만 어긋나 있었습니다. @@ -369,14 +369,14 @@ FastAPI에는 이를 복구할 장치가 없고, 있을 필요도 없습니다. ### 3.4 판정 LLM 벤더 폴백 -`S15P11A705-175`에서 추가했습니다. 판정 LLM은 **우선순위가 있는 벤더 체인**으로 호출하며, +Jira 작업에서 추가했습니다. 판정 LLM은 **우선순위가 있는 벤더 체인**으로 호출하며, 일시적 오류일 때 다음 벤더로 넘어갑니다. Embedding은 폴백 대상이 아닙니다. 프로바이더가 바뀌면 벡터 공간이 달라져 기존 데이터와 비교가 불가능하고, 그것을 막는 장치가 `embedding_profile`이기 때문입니다([model-profile.md](model-profile.md) §3.2). -근거는 2026-07-30 실측입니다. 같은 시각·같은 GMS 키로 같은 판정 작업을 던졌을 때 +근거는 2026-07-30 실측입니다. 같은 시각·같은 AI API 키로 같은 판정 작업을 던졌을 때 Gemini 경로만 `429`를 냈고 OpenAI·Anthropic 경로는 한 번도 막히지 않았습니다. -**GMS 쿼터가 게이트웨이 전역이 아니라 프로바이더 경로별로 걸립니다.** +**AI API 쿼터가 게이트웨이 전역이 아니라 프로바이더 경로별로 걸립니다.** | 규칙 | 내용 | |---|---| diff --git a/docs/spec/integration-tests.md b/docs/spec/integration-tests.md index 8614eac..7bc8ba8 100644 --- a/docs/spec/integration-tests.md +++ b/docs/spec/integration-tests.md @@ -1,4 +1,4 @@ -> 구현 완료. 하네스·저수준 계층(단위·저장소·API, ai#14·#16)과 파이프라인 계층(§3 시나리오, `test_pipeline.py`, ai#18)이 모두 구현됨([../../tests/README.md](../../tests/README.md), `pytest tests/` **181 passed**). 이후 합류분: 배포 게이트 14건(2026-07-29), client 재시도·오류 분류 `S15P11A705-121`, 부트스트랩·기동 계층과 coverage 게이트 `S15P11A705-110`(2026-07-30). 리포트: [implements/2026-07-24-e3-test-harness.md](../implements/2026-07-24-e3-test-harness.md), [implements/2026-07-30-coverage-gate.md](../implements/2026-07-30-coverage-gate.md). +> 구현 완료. 하네스·저수준 계층(단위·저장소·API, ai#14·#16)과 파이프라인 계층(§3 시나리오, `test_pipeline.py`, ai#18)이 모두 구현됨([../../tests/README.md](../../tests/README.md), `pytest tests/` **181 passed**). 이후 합류분: 배포 게이트 14건(2026-07-29), client 재시도·오류 분류와 부트스트랩·기동 계층, coverage 게이트 관련 Jira 작업(2026-07-30). 리포트: [implements/2026-07-24-e3-test-harness.md](../implements/2026-07-24-e3-test-harness.md), [implements/2026-07-30-coverage-gate.md](../implements/2026-07-30-coverage-gate.md). > 공용 계약은 Team-PinLog/docs의 `static/05_AI_설계.md`를 따릅니다. # AI 파트 통합 테스트 @@ -262,10 +262,10 @@ client 단위 테스트가 묻는 것은 *"상태 코드가 어떤 오류 타입 정의상 인터페이스 레벨 Fake로 검증할 수 없습니다. Fake는 이미 `TransientError`/`PermanentError`를 받아서 던지므로, 상태 코드에서 오류 타입으로 가는 매핑 자체가 Fake의 입력에 숨어 버립니다. 바로 그 공백이 **429를 영구 오류로, LLM 401을 일시 오류로** 둔 채 남긴 원인이었습니다 -(`S15P11A705-121`). +(Jira 작업). 이 구분을 명시적으로 적어 둡니다. 이 절이 *"HTTP 레벨 목이 아니라 인터페이스 레벨 Fake를 -씁니다"*라고만 말해서, client 단위 테스트의 HTTP 목이 명세 위반인지가 `S15P11A705-121` +씁니다"*라고만 말해서, client 단위 테스트의 HTTP 목이 명세 위반인지가 Jira 작업 작업 중 두 번 질문으로 올라왔습니다. 판정은 **충돌이 아니라 층이 다름**이었고, 명세에 없었다는 것이 같은 질문이 반복된 이유입니다. @@ -342,4 +342,4 @@ preset = make_preset(code="WITH_FRIEND", visibility="PUBLIC") 부트스트랩·기동 계층은 시나리오가 아니라 **전제**를 지킵니다. 둘 다 요청 경로 밖이라 파이프라인 테스트로는 한 줄도 실행되지 않고(`test_api.py`는 lifespan을 우회합니다), 여기가 비면 서버는 Preset 없이 또는 잘못된 Profile로 떠서 그 사실을 판정 단계에서야 -드러냅니다. `S15P11A705-110` 기준선에서 두 계층 모두 커버리지 0%였습니다. +드러냅니다. Jira 작업 기준선에서 두 계층 모두 커버리지 0%였습니다. diff --git a/docs/spec/personal-search.md b/docs/spec/personal-search.md index 902187b..f7814f3 100644 --- a/docs/spec/personal-search.md +++ b/docs/spec/personal-search.md @@ -170,8 +170,8 @@ Context 수정은 구 Context 삭제와 신 Context 생성의 조합입니다( 그 두 키가 0이면 단어형을 포함해 컷 전체가 꺼집니다. 비상 스위치 검사가 문장형·단어형 분기보다 앞에 있기 때문입니다. 단어형 하한만 0으로 두면 `r` 컷이 남아 계속 자릅니다. -**`τ_abs` 는 질의 길이에 따라 다른 값을 적용합니다**(S15P11A705-266, -[§단어형 하한의 근거](#단어형-하한의-근거-s15p11a705-266)). 아래 표에서 `τ_abs` 의 한계로 +**`τ_abs` 는 질의 길이에 따라 다른 값을 적용합니다**(Jira 작업, +[§단어형 하한의 근거](#단어형-하한의-근거)). 아래 표에서 `τ_abs` 의 한계로 적은 「질의마다 다른 유사도 대역을 따라가지 못한다」가 단어형 질의에서 실제 손실로 드러났기 때문입니다. `r` 은 질의 유형에 따라 갈리지 않습니다. 1위 대비 상대 컷이므로 질의별 유사도 대역의 차이를 자동으로 흡수합니다. @@ -194,7 +194,7 @@ Query를 건드리지 않는 쪽이 낫습니다. 정확 검색이라 어느 쪽 살아남은 결과의 1위로 옮겨 가고, 그 기준으로는 아무것도 더 잘리지 않습니다. 컷이 자기 기준을 스스로 무력화하는 구조가 되므로 컷 전 1위로 고정합니다. -### 값의 근거 (S15P11A705-213) +### 값의 근거 검증 질의 12건(`tools/demo_seed/demo_data.yaml`, 기대 정답 부착)과 **정답이 없는 무관 질의 15건**(질의 5종 × 소유자 3명)으로 `τ_abs × r` 격자를 훑었습니다. 하네스는 @@ -210,7 +210,7 @@ Query를 건드리지 않는 쪽이 낫습니다. 정확 검색이라 어느 쪽 질의 「친구들이랑 피자에 맥주 마신 곳」의 정답이 3위 `sim=0.3642`·`r=0.807`입니다. 그 한 점이 흔들리면 두 축이 동시에 무너지므로 각각 마진을 뒀습니다(τ_abs 17% · r 25%). -### 단어형 하한의 근거 (S15P11A705-266) +### 단어형 하한의 근거 위 값은 **문장형 질의 12건으로 정했습니다.** 단어형 질의(`그네`·`비건`)로 다시 재자 0.30 이 **컷 전 1위인 정답**까지 잘라내고 있었습니다(`ai#87`). 그래서 단어형 54건 × @@ -250,10 +250,10 @@ Query를 건드리지 않는 쪽이 낫습니다. 정확 검색이라 어느 쪽 |---|---| | 시연 DB 재시딩 | Context 본문이 같아도 배치가 달라지면 유사도가 움직입니다 | | Context 수의 유의미한 증가 | `top-1` 유사도가 올라가 `r` 컷이 더 세게 자르고, 무관 결과의 통과도 함께 늘어납니다(`-213` 이 남긴 것과 같은 조건) | -| 임베딩 모델 교체 (`S15P11A705-199`) | 벡터 공간이 바뀌므로 두 하한 **모두** 무효입니다 | +| 임베딩 모델 교체 (Jira 작업) | 벡터 공간이 바뀌므로 두 하한 **모두** 무효입니다 | ```bash -# 행렬을 다시 뜨고 (GMS 배치 1회), 회차를 하나 더 떠서 흔들림부터 본다 +# 행렬을 다시 뜨고 (AI API 배치 1회), 회차를 하나 더 떠서 흔들림부터 본다 python tools/search_cut/word_matrix.py python tools/search_cut/word_matrix.py --out .search/word_grid_run2.json python tools/search_cut/word_sweep.py --repro .search/word_grid.json,.search/word_grid_run2.json diff --git a/docs/spec/state-machine.md b/docs/spec/state-machine.md index e2170f6..64993fe 100644 --- a/docs/spec/state-machine.md +++ b/docs/spec/state-machine.md @@ -150,7 +150,7 @@ if not start.started: service가 판단합니다. 직전 상태는 신규 시작(`PENDING`)과 만료 재선점(`PROCESSING`)을 가르기 위한 것입니다. 두 경우가 같은 UPDATE를 타고 같은 `1`을 돌려주므로 `rowcount` 만으로는 구분되지 않습니다. 그런데 재선점은 **앞선 처리가 만료 안에 끝나지 못했다**는, - 관측 가치가 있는 사건입니다(`S15P11A705-197`). + 관측 가치가 있는 사건입니다(Jira 작업). - 0인 이유를 알기 위해 다시 SELECT하지 않습니다. 이유를 알아도 할 일이 달라지지 않습니다. 위의 직전 상태는 추가 조회가 아니라 **같은 UPDATE 문장의 CTE**이며, `1`이 무엇이었는지를 알기 위한 것입니다. 이 규칙은 그대로입니다. diff --git a/docs/troubleshooting/2026-07-23-fastapi-local-verification.md b/docs/troubleshooting/2026-07-23-fastapi-local-verification.md index c550bb6..cbc567f 100644 --- a/docs/troubleshooting/2026-07-23-fastapi-local-verification.md +++ b/docs/troubleshooting/2026-07-23-fastapi-local-verification.md @@ -2,7 +2,7 @@ - **상태**: 해결됨 - **날짜**: 2026-07-23 -- **맥락**: FastAPI 구현(ai#5·#6)을 로컬 pgvector + 실제 GMS로 end-to-end 검증하는 과정 +- **맥락**: FastAPI 구현(ai#5·#6)을 로컬 pgvector + 실제 AI API로 end-to-end 검증하는 과정 - **관련**: [implements/2026-07-23-fastapi-implementation.md](../implements/2026-07-23-fastapi-implementation.md) ## T16 — `.env` UTF-8 BOM으로 첫 키 파싱 실패 diff --git a/docs/troubleshooting/2026-07-27-e2e-env-issues.md b/docs/troubleshooting/2026-07-27-e2e-env-issues.md index 2054808..12f3529 100644 --- a/docs/troubleshooting/2026-07-27-e2e-env-issues.md +++ b/docs/troubleshooting/2026-07-27-e2e-env-issues.md @@ -2,7 +2,7 @@ - **날짜**: 2026-07-27 - **상태**: 해결됨 -- **맥락**: E2E 실경로 검증(S15P11A705-58) 중 로컬 환경·스크립트 작성에서 겪은 문제 +- **맥락**: E2E 실경로 검증(Jira 작업) 중 로컬 환경·스크립트 작성에서 겪은 문제 - **관련**: [implements/2026-07-27-e2e-verification.md](../implements/2026-07-27-e2e-verification.md), [tools/e2e/](../../tools/e2e/) 세 건 모두 **시딩 작업에서 다시 만난다.** 시딩 스크립트를 새로 쓰는 사람이 같은 곳에서 멈추지 않도록 증상·원인·해결로 남긴다. diff --git a/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md b/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md index 1c7d2d3..43b093a 100644 --- a/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md +++ b/docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md @@ -1,15 +1,15 @@ -# GMS 판정 쿼터 · 콘솔 인코딩으로 인한 시딩 중단 +# AI API 판정 쿼터 · 콘솔 인코딩으로 인한 시딩 중단 - **상태**: 해결됨 (T27은 회피 불가라 설계로 흡수, T28은 코드 수정) - **날짜**: 2026-07-30 (T27은 2026-07-29 실측을 등재) -- **관련**: [데모 시딩 구현](../implements/2026-07-29-demo-seeding.md), `S15P11A705-58`, `S15P11A705-174` +- **관련**: [데모 시딩 구현](../implements/2026-07-29-demo-seeding.md), 관련 Jira 작업 - **레이어**: 외부 API 쿼터 · 도구 실행 환경 > T27은 `2026-07-29-demo-seeding.md`가 이미 참조하고 있었으나 인덱스에 등재되지 > 않아 **빈 링크로 남아 있던 항목**이다. 실측 근거가 그 문서에 있으므로 여기서 > 정식 번호를 부여하고 요약한다. -## T27 — GMS 판정 쿼터는 상수가 아니다 (시점·경로 의존) +## T27 — AI API 판정 쿼터는 상수가 아니다 (시점·경로 의존) > **2026-07-30 정정.** 초판이 "지속적으로 분당 2건 안팎만 통과한다"로 단정했다. > **틀린 서술이다.** 같은 코드가 다음 날 분당 30건 이상을 통과시켰다. @@ -42,10 +42,10 @@ | 동시 10건 | 10/10 성공 · 1.7초 | | 실제 시딩 37건 `--pace 1` | **42초** · 429 0건 | -**근본 원인**: GMS 는 SSAFY **공용** 게이트웨이다. 쿼터가 우리 전용 할당이 아니라 +**근본 원인**: AI API 는 **공용** 게이트웨이다. 쿼터가 우리 전용 할당이 아니라 타 팀 사용량에 좌우되고, **프로바이더 경로별로 따로 걸린다.** 같은 날 같은 시각에 OpenAI·Anthropic 경로는 12/12로 한 번도 막히지 않은 반면 Gemini 만 막혔다 -(`S15P11A705-175` 실측표). +(Jira 작업 실측표). **사전에 발견하지 못한 이유**: 한 시점의 측정을 상수로 읽었다. 공용 자원인 줄 알면서도 "우리가 재면 그 값"이라고 적었다. @@ -55,9 +55,9 @@ OpenAI·Anthropic 경로는 12/12로 한 번도 막히지 않은 반면 Gemini - `--pace` 기본값을 **1초**로 둔다. 선제적으로 속도를 늦추는 것은 게이트웨이가 한산한 날의 시간만 버린다. - 혼잡할 때의 방어는 `retry.py` 지수 백오프와 회수 루프의 두 겹으로 남긴다. - **`--pace`는 429를 실제로 본 뒤에 올린다.** -- 근본 대책은 벤더 폴백이며, `S15P11A705-175`에서 다룬다. +- 근본 대책은 벤더 폴백이며, Jira 작업에서 다룬다. -운영에는 `S15P11A705-159`의 재스캔 Scheduler 가 같은 회수 역할을 한다(5분 주기). +운영에는 Jira 작업의 재스캔 Scheduler 가 같은 회수 역할을 한다(5분 주기). `back#104` 병합 이후로는 로컬에서도 그것이 돈다. **이 항목에서 배울 것은 쿼터 값이 아니라, 공용 게이트웨이의 쿼터를 상수로 적지 말라는 것이다.** @@ -72,7 +72,7 @@ UnicodeEncodeError: 'cp949' codec can't encode character '—' in position 27 **손실이 큰 이유는 중단된 위치다.** `--reset`이 기존 데이터를 **이미 지운 뒤** 첫 로그 출력에서 예외가 난다. DB 에는 member 만 남고 Context 가 0건인 상태가 되고, -복구하려면 전량 재시딩해야 한다. 이번에는 GMS 호출 전이라 API 비용 손실이 없었을 뿐이다. +복구하려면 전량 재시딩해야 한다. 이번에는 AI API 호출 전이라 API 비용 손실이 없었을 뿐이다. **근본 원인**: Windows 에서 파이썬 stdout 이 콘솔 코드페이지(여기서는 cp949)를 따른다. `log()`가 `print`를 그대로 부르므로 `—`·`←` 같은 글자에서 예외가 난다. diff --git a/docs/troubleshooting/2026-07-31-db-error-pitfalls.md b/docs/troubleshooting/2026-07-31-db-error-pitfalls.md index 88d64cc..0e3327d 100644 --- a/docs/troubleshooting/2026-07-31-db-error-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-db-error-pitfalls.md @@ -1,6 +1,6 @@ # DB 오류 분류의 경계를 그으며 만난 함정 (2026-07-31) -`S15P11A705-221` — DB 실패를 `TransientError`/`PermanentError` 로 분류하고, 그것을 +Jira 작업 — DB 실패를 `TransientError`/`PermanentError` 로 분류하고, 그것을 **로컬에서 실제로 DB 를 멈춰** 확인하는 과정에서 만난 세 문제다. 셋 다 **증상이 원인을 가리키지 않는다.** 첫째는 테스트가 전부 초록인데 티켓이 해결되지 diff --git a/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md b/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md index a4db01c..09f320e 100644 --- a/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-error-contract-pitfalls.md @@ -1,6 +1,6 @@ # 오류 응답 계약을 검증하며 만난 함정 (2026-07-31) -`S15P11A705-220` — 업스트림 실패를 HTTP 상태로 바꾸는 계약을 넣고, 그것을 **로컬에서 실제로 +Jira 작업 — 업스트림 실패를 HTTP 상태로 바꾸는 계약을 넣고, 그것을 **로컬에서 실제로 502 를 만들어** 확인하는 과정에서 만난 문제들이다. 앞의 세 건은 「검증 자체」의 함정이지 대상 코드의 결함이 아니다. **그래서 더 오래 걸린다.** @@ -32,7 +32,7 @@ httpx.ASGITransport(app=app, raise_app_exceptions=False) # 500 을 단언하 > 「500 이 나가는가」를 보려면 `False`, 「무엇이 새는가」를 보려면 `True`. -## T51. 로컬 GMS 스텁도 URL 에 `/gmsapi/` 가 있어야 앱이 뜬다 +## T51. 로컬 AI API 스텁도 URL 에 `/gmsapi/` 가 있어야 앱이 뜬다 게이트웨이를 대신할 스텁을 `http://127.0.0.1:8099/v1` 로 띄우고 `GMS_BASE_URL` 을 그리로 돌렸더니 **서버가 기동에 실패했다.** diff --git a/docs/troubleshooting/2026-07-31-judge-prompt-ab.md b/docs/troubleshooting/2026-07-31-judge-prompt-ab.md index ad810a3..81fbd11 100644 --- a/docs/troubleshooting/2026-07-31-judge-prompt-ab.md +++ b/docs/troubleshooting/2026-07-31-judge-prompt-ab.md @@ -1,6 +1,6 @@ # 판정 프롬프트 A/B 측정 — 읽히지 않는 설정 키·라벨 커버리지·라벨 작업의 조건 노출 -`S15P11A705-219`. 구현 리포트는 +Jira 작업. 구현 리포트는 [판정 프롬프트 규칙](../implements/2026-07-31-judge-prompt-rule.md)에 있다. 이 문서는 측정 중 무엇에 걸렸는지만 적는다. @@ -27,7 +27,7 @@ PINLOG_JUDGE_MODEL=gemini-2.5-flash ``` -**이 키는 읽히지 않는다.** `S15P11A705-175`(벤더 폴백, 2026-07-30)가 +**이 키는 읽히지 않는다.** Jira 작업(벤더 폴백, 2026-07-30)가 `PINLOG_JUDGE_CHAIN` 으로 대체했고 `.env.example:53` 이 "옛 키는 이제 무시된다"라고 명시한다. `.env` 에 `PINLOG_JUDGE_CHAIN` 이 없으므로 `config.py` 의 기본 체인이 쓰이고, 그 1순위가 `openai:gpt-4o-mini` 다(`-176` 실측으로 정한 순서 — 100%·0.91s). @@ -213,7 +213,7 @@ unfit n=8 min 0.60 선택뿐 아니라 confidence 도 비결정적이다.** T39 는 「어떤 키워드를 골랐나」가 흔들리는 것을 봤고, 이것은 「얼마나 확신하나」도 같이 흔들린다는 것이다. -DB 조회는 GMS 를 부르지 않아 비용이 0 이다. 그래서 **여러 번 볼 생각을 안 하게 된다.** +DB 조회는 AI API 를 부르지 않아 비용이 0 이다. 그래서 **여러 번 볼 생각을 안 하게 된다.** 비용이 들지 않는 조회라는 점이 오히려 함정이 된다. ### 처방 @@ -244,8 +244,8 @@ python tools/prompt_ab/run.py --variant A --reps 1 --start-rep 11 --outdir .prom 두 벌** 들어갔다. ``` -T40 정답이 있는 질의만 재면 … S15P11A705-213 (#70) -T40 .env 의 PINLOG_JUDGE_MODEL 은 … S15P11A705-219 (이 티켓) +T40 정답이 있는 질의만 재면 … Jira 작업 (#70) +T40 .env 의 PINLOG_JUDGE_MODEL 은 … Jira 작업 (이 티켓) ``` ### 원인 diff --git a/docs/troubleshooting/2026-07-31-judge-vote.md b/docs/troubleshooting/2026-07-31-judge-vote.md index d973bec..70e28f3 100644 --- a/docs/troubleshooting/2026-07-31-judge-vote.md +++ b/docs/troubleshooting/2026-07-31-judge-vote.md @@ -1,6 +1,6 @@ # 판정 n회 다수결 측정 — 선행 산출물이 남아 있지 않았고, 실패와 빈 선택은 구분해야 하며, 순열검정은 전수 계산이고, 번호는 병합 뒤에 붙인다 -`S15P11A705-223`. 구현 리포트는 +Jira 작업. 구현 리포트는 [판정 다수결](../implements/2026-07-31-judge-vote.md)에 있다. 이 문서는 측정 중 무엇에 걸렸는지만 적는다. @@ -44,7 +44,7 @@ `run.py` 를 다시 돌려야 한다」로 적는다. 재측정 비용은 42호출 × 30회 = 1,260호출이었다. `matrix.json` 은 DB 만 읽으므로 추가 -비용 없이 복원됐다(GMS 호출 0). +비용 없이 복원됐다(AI API 호출 0). --- @@ -55,7 +55,7 @@ ### 증상 `-219` 의 `run.py` 는 회차 중 한 건이 실패해도 회차를 버리지 않는다. 그 자체는 옳다. -42건 중 1건 때문에 41건의 GMS 호출을 버리는 것이 더 큰 손실이다. 계약도 이것을 미리 +42건 중 1건 때문에 41건의 AI API 호출을 버리는 것이 더 큰 손실이다. 계약도 이것을 미리 지목했다("실패해도 평균에 그대로 넣는다 — 다수결에서는 문제가 될 수 있다"). 문제는 **실패가 어떤 모양으로 남는가**였다. diff --git a/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md b/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md index a9053b7..8569c2c 100644 --- a/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-local-e2e-and-ci-pitfalls.md @@ -1,6 +1,6 @@ # 로컬 전 스택 E2E 와 CI 에서 만난 함정 (2026-07-31) -`front` → `back` → FastAPI → GMS → pgvector 전 경로를 브라우저로 처음 돌리면서, 그리고 +`front` → `back` → FastAPI → AI API → pgvector 전 경로를 브라우저로 처음 돌리면서, 그리고 `dev` → `main` 릴리스와 CI 검사를 넣으면서 만난 것들이다. **여덟 개 중 넷은 「기대한 출력이 없다」가 증상이었고 원인은 제각각이었다.** 그것이 @@ -79,7 +79,7 @@ DB 에는 `V6` 가 적용돼 있는데 jar 안에 그 파일이 없어서다. ** cd back && ./gradlew bootJar ``` -`git pull` 뒤에는 항상 재빌드한다. 이 날 `S15P11A705-198` 작업 중 실제로 겪었고, +`git pull` 뒤에는 항상 재빌드한다. 이 날 Jira 작업 작업 중 실제로 겪었고, 진단은 우리 도구가 아니라 back 스택트레이스가 했다. ## T33. `ai/.env` 의 `DATABASE_URL` 이 시연 DB 를 가리키지 않는다 diff --git a/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md b/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md index a5871b3..b01726f 100644 --- a/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md +++ b/docs/troubleshooting/2026-07-31-log-redaction-pitfalls.md @@ -1,6 +1,6 @@ # 로그 마스킹 — 측정과 구현에서 걸린 것 (T61~T63) -`S15P11A705-205`. 구현 리포트는 +Jira 작업. 구현 리포트는 [게이트웨이 오류 본문 마스킹](../implements/2026-07-31-gms-error-body-redaction.md). --- @@ -29,7 +29,7 @@ OpenAI 가 **앞 3자와 뒤 3자만 남기고 잘라서** 되돌린다. 원래 --- -## T62 — 요청 본문이 크면 GMS가 「모델을 못 찾겠다」는 400을 낸다 +## T62 — 요청 본문이 크면 AI API가 「모델을 못 찾겠다」는 400을 낸다 **증상.** 컨텍스트 길이 초과를 만들려고 아주 긴 텍스트(약 20만 자)를 보냈더니 벤더의 context-length 오류가 아니라 이것이 왔다. diff --git a/docs/troubleshooting/2026-07-31-search-cut-measurement.md b/docs/troubleshooting/2026-07-31-search-cut-measurement.md index ec28aca..c8241d0 100644 --- a/docs/troubleshooting/2026-07-31-search-cut-measurement.md +++ b/docs/troubleshooting/2026-07-31-search-cut-measurement.md @@ -1,6 +1,6 @@ # 검색 결과 컷 측정에서 만난 것들 (2026-07-31) -`S15P11A705-213`. 구현 기록: [search-cut](../implements/2026-07-31-search-cut.md). +Jira 작업. 구현 기록: [search-cut](../implements/2026-07-31-search-cut.md). **셋 다 「측정이 통과했는데 결론이 틀렸을 뻔한」 종류다.** 코드가 중단되지 않고 숫자가 나왔기 때문에 알아차리기 어려웠다. diff --git a/docs/troubleshooting/2026-07-31-tau-measurement.md b/docs/troubleshooting/2026-07-31-tau-measurement.md index 3de8128..e945f00 100644 --- a/docs/troubleshooting/2026-07-31-tau-measurement.md +++ b/docs/troubleshooting/2026-07-31-tau-measurement.md @@ -1,6 +1,6 @@ # τ 측정에서 만난 문제 (T37~T39) -`S15P11A705-210` 후보 유사도 임계값 측정 중 겪은 것. 결과 리포트는 +Jira 작업 후보 유사도 임계값 측정 중 겪은 것. 결과 리포트는 [구현 리포트](../implements/2026-07-31-candidate-threshold.md)에 있다. --- diff --git a/docs/troubleshooting/2026-08-03-dead-config-key-audit.md b/docs/troubleshooting/2026-08-03-dead-config-key-audit.md index 230972d..55e8404 100644 --- a/docs/troubleshooting/2026-08-03-dead-config-key-audit.md +++ b/docs/troubleshooting/2026-08-03-dead-config-key-audit.md @@ -1,6 +1,6 @@ # 죽은 설정 키 전수조사 — 함정 (T66·T67) -`S15P11A705-224` 작업 중 겪은 두 함정. 구현 리포트는 +Jira 작업 작업 중 겪은 두 함정. 구현 리포트는 [dead-config-keys.md](../implements/2026-08-03-dead-config-keys.md). ## T66 — alias 매칭은 grep으로 못 잡는다 diff --git a/docs/troubleshooting/2026-08-03-preset-description.md b/docs/troubleshooting/2026-08-03-preset-description.md index 04dc872..4e9f364 100644 --- a/docs/troubleshooting/2026-08-03-preset-description.md +++ b/docs/troubleshooting/2026-08-03-preset-description.md @@ -1,6 +1,6 @@ # 프리셋 개정 측정 — 임베딩 API 는 같은 입력에도 다른 벡터를 반환하고, τ 격자는 rank 로 밀려난 행을 세지 않는다 -`S15P11A705-228`. 구현 리포트는 +Jira 작업. 구현 리포트는 [프리셋 description 개정](../implements/2026-08-03-preset-description.md)에 있다. 이 문서는 측정 중에 무엇에 걸렸는지만 적는다. diff --git a/docs/troubleshooting/2026-08-03-repeat-incidents.md b/docs/troubleshooting/2026-08-03-repeat-incidents.md index 276c6a0..af24a26 100644 --- a/docs/troubleshooting/2026-08-03-repeat-incidents.md +++ b/docs/troubleshooting/2026-08-03-repeat-incidents.md @@ -1,6 +1,6 @@ # 하루에 유형마다 서너 번 반복된 사고 — 사본에서 꺼낸 값 · 반만 덮는 자동화 · 공개 저장소의 실사용자 기록 -`S15P11A705-293`. 이 문서는 **개별 사고의 해결이 아니라 반복 유형**을 적는다. 각 건의 처리는 +Jira 작업. 이 문서는 **개별 사고의 해결이 아니라 반복 유형**을 적는다. 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 **무엇이 반복됐는가**만 남긴다. 세 유형의 사고가 2026-08-03 하루에 각각 서너 번씩 났다. **유형마다 마지막 사고는 앞선 사고의 @@ -53,7 +53,7 @@ > 좌표 — `tools/preset_desc/variants.py` 머리말과 `tools/preset_desc/README.md`, > `app/service/keyword_service.py` 의 `cand_dicts`, 구현 리포트 -> `docs/implements/2026-08-03-preset-description.md`, `S15P11A705-228` 과 그 PR. +> `docs/implements/2026-08-03-preset-description.md`, Jira 작업 과 그 PR. **③ 정정을 적어 두고 원래 문구를 다시 꺼낸다.** 중앙이 원장에 정정을 적어 두고, 나중에 워커 계약을 쓰면서 **원래 티켓 문구를 다시 꺼냈다.** 그 문구는 이미 실측으로 반증된 @@ -160,7 +160,7 @@ **틀려도 실패하는 검사가 없다.** 오늘 실측으로 그 값이 배포를 막지 않는다는 것이 확인됐으나, **「검사가 없다」는 사실 자체는 그대로다.** -> 좌표 — `infra` 의 이미지 갱신 도구와 배포 값 파일, `S15P11A705-61` 과 워커 리포트 +> 좌표 — `infra` 의 이미지 갱신 도구와 배포 값 파일, Jira 작업 과 워커 리포트 > `docs/implements/2026-08-03-dev-deploy-gap.md`. **④ 감시 스크립트가 세션보다 오래 살아 고아가 된다.** 세션 프로세스가 종료돼도 감시 diff --git a/docs/troubleshooting/2026-08-05-multi-signal-investigation.md b/docs/troubleshooting/2026-08-05-multi-signal-investigation.md index 1072d37..8fbedca 100644 --- a/docs/troubleshooting/2026-08-05-multi-signal-investigation.md +++ b/docs/troubleshooting/2026-08-05-multi-signal-investigation.md @@ -1,9 +1,9 @@ # 다신호 검색 조사 및 실측 결과 -- **티켓**: 미발급. `S15P11A705-336` 인수 트랙과 [P49](../proposals/P49-multi-signal-search.md) 제안의 근거 기록이다. +- **티켓**: 미발급. Jira 작업 인수 트랙과 [P49](../proposals/P49-multi-signal-search.md) 제안의 근거 기록이다. - **날짜**: 2026-08-05 - **대상 코드**: `origin/leo` `fcb397c`. 대상 파일은 `tools/search_cut/{rank_score,fusion,fusion_sweep,keyword_matrix}.py` 와 `tests/test_search_fusion.py` 다. -- **실측 환경**: 시연 DB `:15432`(`pinlog-demo-postgres-1`) · profile `openai-text-embedding-3-small-1536-cosine-v1` · Preset 27건 v1(PUBLIC 25 · PRIVATE_ONLY 2) · Context 42건 · keyword 판정 83건 · GMS 임베딩 3배치 +- **실측 환경**: 시연 DB `:15432`(`pinlog-demo-postgres-1`) · profile `openai-text-embedding-3-small-1536-cosine-v1` · Preset 27건 v1(PUBLIC 25 · PRIVATE_ONLY 2) · Context 42건 · keyword 판정 83건 · AI API 임베딩 3배치 - **선행 기록**: [검색 실패 원인 판별](2026-08-03-search-recall-probe.md)(`-255`) · [단어형 컷 격자](2026-08-03-word-query-cut.md)(`-266`) · [짧은 질의 층 관측](2026-08-05-short-query-boundary.md)(`-273`) · leo 의 순위 지표 baseline 리포트 I52(`origin/leo`) - **번호는 잠정이다.** T73~T78 은 병합 직전 `origin/dev` 로 rebase 한 뒤 확정한다(T48·T60 규칙). - **수치의 성격**: 이 문서의 수치는 2026-08-05 실측 시점의 관측값이다. 산출물 JSON 5종은 실측 worktree 에 있고 아직 커밋되지 않았다. 측정 기록의 정본은 `-336` 실측 리포트로 정식화할 예정이다. 값이 앞으로도 같다고 주장하지 않는다(T70/R-18). @@ -224,9 +224,9 @@ UnicodeEncodeError: 'cp949' codec can't encode character '—' **실행한 것** [측정] -- leo 픽스처 테스트 24종 통과 (`tests/test_search_fusion.py`, DB·GMS 호출 없음) +- leo 픽스처 테스트 24종 통과 (`tests/test_search_fusion.py`, DB·AI API 호출 없음) - `rank_score.py` 순위 지표 재현 결과가 leo I52 기록과 일치 (환경 동등성 확인) -- `word_matrix.py`·`recall_probe.py` 재생성(`context_id` 포함) 과 `keyword_matrix.py` 신규 artifact 생성 (GMS 3배치) +- `word_matrix.py`·`recall_probe.py` 재생성(`context_id` 포함) 과 `keyword_matrix.py` 신규 artifact 생성 (AI API 3배치) - `fusion_sweep.py` 실행 — 기본 축, RRF cutoff 격자(0/0.004/0.008/0.016/0.033), floor 축(0.25/0.30/0.35/0.40) - back 의 검색 응답 소비 경로 확인 — `RecordSearchService.java` 는 FastAPI 가 준 순서를 재정렬하지 않고, similarity 값으로 필터하지 않으며, 값이 null 인 항목만 버린다 diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md index a3b51cf..2aad628 100644 --- a/docs/troubleshooting/README.md +++ b/docs/troubleshooting/README.md @@ -21,11 +21,11 @@ | [2026-07-24-e3-ci-and-search-path.md](2026-07-24-e3-ci-and-search-path.md) | E3 CI·런타임 이슈 — lock 플랫폼 종속·pytest pythonpath·search_path (T19~T21) | | [2026-07-27-e2e-env-issues.md](2026-07-27-e2e-env-issues.md) | E2E 검증 환경 이슈 — `.env` CRLF·register_vector 미등록·한글 인코딩 (T22~T24) | | [2026-07-28-shared-worktree-and-env-cache.md](2026-07-28-shared-worktree-and-env-cache.md) | 멀티세션 워킹트리 오염 · import 시점 `.env` 캐시 (T25·T26) | -| [2026-07-30-seeding-quota-and-encoding.md](2026-07-30-seeding-quota-and-encoding.md) | GMS 판정 쿼터·콘솔 인코딩으로 인한 시딩 중단 (T27·T28) | +| [2026-07-30-seeding-quota-and-encoding.md](2026-07-30-seeding-quota-and-encoding.md) | AI API 판정 쿼터·콘솔 인코딩으로 인한 시딩 중단 (T27·T28) | | [2026-07-31-local-e2e-and-ci-pitfalls.md](2026-07-31-local-e2e-and-ci-pitfalls.md) | 로컬 E2E·CI 함정 — venv·로그 버퍼·jar 낙후·포트·로그인 쿠키 (T29~T36) | | [2026-07-31-tau-measurement.md](2026-07-31-tau-measurement.md) | 후보 임계값 τ 측정 — 틀린 진단·인코딩 재발·대조군 부재 (T37~T39) | | [2026-07-31-search-cut-measurement.md](2026-07-31-search-cut-measurement.md) | 검색 결과 컷 측정 — 반대 방향 질의 부재·worktree `.env`·배치 구성과 임베딩 재현성 (T40~T42) | -| [2026-07-31-error-contract-pitfalls.md](2026-07-31-error-contract-pitfalls.md) | 오류 응답 계약 검증 — ASGITransport 예외 전파·GMS 스텁 URL 형식·시연 DB 자격증명·색인 갱신 중복 (T50~T52 · T56) | +| [2026-07-31-error-contract-pitfalls.md](2026-07-31-error-contract-pitfalls.md) | 오류 응답 계약 검증 — ASGITransport 예외 전파·AI API 스텁 URL 형식·시연 DB 자격증명·색인 갱신 중복 (T50~T52 · T56) | | [2026-07-31-judge-prompt-ab.md](2026-07-31-judge-prompt-ab.md) | 판정 프롬프트 A/B — 죽은 설정 키·라벨 커버리지·조건 노출·사전 기준·1회 분포·번호 충돌 (T43~T49) | | [2026-07-31-db-error-pitfalls.md](2026-07-31-db-error-pitfalls.md) | DB 오류 분류 — 접속 실패는 asyncpg 예외가 아니다·`min_size=1` 재현 불가·curl 한글 본문 (T53~T55) | | [2026-07-31-log-redaction-pitfalls.md](2026-07-31-log-redaction-pitfalls.md) | 로그 마스킹 — 잘려서 에코되는 요청 값·거대 본문의 오해를 부르는 400·docstring을 잡는 소스 규약 검사 (T61~T63) | @@ -64,7 +64,7 @@ | T24 | Git Bash + `curl`에서 한글 본문 인코딩 깨짐(ASCII 본문은 통과 → T22와 증상 동일) | 한글 요청은 Python `httpx`로 전송, `curl`은 ASCII 경로에만 | | T25 | 멀티세션이 단일 git 워킹트리·인덱스·HEAD 공유 → 3파일 커밋에 타 세션 15파일 섞여 push | 격리 `git worktree` 기본, `git add` 개별(`-A` 금지)·커밋 전 브랜치 확인 | | T26 | `main.py` 모듈 레벨 `create_app()` import 시점 `.env` 캐시 → API 3건만 401(로컬 `.env` 우연 일치로 은폐) | `settings` fixture에서 `get_settings()` 캐시 재설정 + conftest placeholder env 선주입 | -| T27 | **GMS 판정 쿼터는 상수가 아니다** — 공용 게이트웨이라 시점·프로바이더 경로별로 다르다. 07-29 분당 2건 → 07-30 분당 30건 이상 | `--pace` 기본값 1. 방어는 `retry.py` 백오프 + 회수 루프. 근본 대책은 벤더 폴백(`-175`) | +| T27 | **AI API 판정 쿼터는 상수가 아니다** — 공용 게이트웨이라 시점·프로바이더 경로별로 다르다. 07-29 분당 2건 → 07-30 분당 30건 이상 | `--pace` 기본값 1. 방어는 `retry.py` 백오프 + 회수 루프. 근본 대책은 벤더 폴백(`-175`) | | T28 | 콘솔이 cp949면 `—` 한 글자에 `UnicodeEncodeError` → **`--reset` 직후 죽어 데이터만 지워진 상태**가 됨 | `sys.stdout.reconfigure(utf-8)` + `log()` 최후 방어. 호출자가 `PYTHONIOENCODING`을 기억하지 않게 (T22·T24 계열) | | T29 | `python -m uvicorn` 이 시스템 Python 을 타서 `No module named uvicorn` — **exit 0 이라 「완료」로 보인다** | `.venv/Scripts/python.exe -m uvicorn` | | T30 | 백그라운드 파이프(`\| tail`)가 서버 로그를 버퍼에 가둔다 — 살아 있는 동안 0바이트 | 파이프 대신 `> file 2>&1` | @@ -81,7 +81,7 @@ | T41 | worktree 에 `.env` 가 없어 `get_settings()` 가 `GMS_API_KEY` 부터 죽는다(`env_file` 은 CWD 기준·gitignore) | `.env` 를 worktree 에 복사하고 `DATABASE_URL` 만 환경변수로 덮는다. `.demo/` 키 분기(`-198`)와 같은 원인 | | T42 | **임베딩 배치 구성이 바뀌면** 같은 텍스트의 유사도가 `10⁻⁴` 규모로 흔들린다(0.5264→0.5258). 「임베딩은 결정적」은 같은 배치일 때의 이야기다 | 그 규모 차이가 결론을 가르는 값을 채택하지 않는다. 재현용으로 유사도 행렬을 커밋한다 | | T50 | `httpx.ASGITransport` 는 앱 예외를 **응답으로 바꾸지 않는다**(`raise_app_exceptions=True` 기본) — 「500 이 나간다」를 단언하려는데 예외가 테스트로 튄다 | 500 을 보려면 `raise_app_exceptions=False`. 무엇이 새는지 보려면 기본값 그대로 | -| T51 | 로컬 GMS 스텁 URL 에 `/gmsapi/` 가 없으면 **앱이 기동에서 죽는다**(`_gms_base_url_shape`) — 증상이 「스텁이 안 불린다」가 아니라 「안 뜬다」다 | 스텁도 `/gmsapi/api.openai.com/v1/embeddings` 경로를 흉내낸다 | +| T51 | 로컬 AI API 스텁 URL 에 `/gmsapi/` 가 없으면 **앱이 기동에서 죽는다**(`_gms_base_url_shape`) — 증상이 「스텁이 안 불린다」가 아니라 「안 뜬다」다 | 스텁도 `/gmsapi/api.openai.com/v1/embeddings` 경로를 흉내낸다 | | T52 | 시연 DB 는 포트뿐 아니라 **비밀번호도 `.env` 와 다르다**(`pinlog-local`). T33 대로 `:15432` 만 고치면 `InvalidPasswordError` | DSN 일부만 고치지 않는다. `docker inspect` 로 컨테이너 env 를 직접 읽는다 | | T43 | `.env` 의 `PINLOG_JUDGE_MODEL` 은 `-175` 가 대체해 **읽히지 않는데** 값이 그럴듯해(체인 2순위) 리포트의 판정 모델을 잘못 적게 한다. 실제 응답은 체인 1순위 `gpt-4o-mini` | 측정 도구는 벤더를 인자로 받고, 읽은 값이 아니라 **답한 값**(`JudgeResult.model`)을 남긴다 | | T44 | `labels.yaml` 은 **현행 판정 83행만** 덮는다 — τ 스윕과 달리 재판정은 없던 행을 만들어 표 밖으로 나간다(24종). 빼고 세면 조건 비교가 기운다 | 원본은 고치지 않고 `labels_extra.yaml` 로 넓힌다. 남는 것은 `unlabeled` 로 세어 양극단으로 돌린다 | @@ -101,7 +101,7 @@ | T64 | `merge=union` 이 표 안에 남긴 **빈 줄이 GFM 표를 끊는다** — 그 뒤 행이 파이프 문자 문단으로 렌더된다. 에디터·diff 에서는 표로 보여 아무 증상이 없다. union 은 「추가만 하면 안전」으로 통하지만(T56 은 *갱신*을 겨눴다) 추가하는 줄에 빈 줄이 딸리면 추가만으로 깨진다 | 새 행은 빈 줄 없이 마지막 행 바로 아래 붙인다. 색인을 고쳤으면 GitHub 의 **렌더된 화면**을 한 번 본다 — 색인은 고치려고 여는 파일이라 읽는 사람이 없다 | | T65 | 「색인에 있는가」를 README **전체 링크**로 재면 전수 표(`I##`)의 링크가 파일 표의 누락을 가린다 — 착수 시점 `dev` 위반 3건 중 **2건이 그 상태**라 검사가 자기가 겨눈 사고(07-31 사고 3)를 못 잡는다 | 판정 범위를 파일 표 섹션으로 좁힌다. **어느 표가 색인인지**를 먼저 정하지 않으면 검사는 가장 느슨한 해석을 택하고, 느슨한 해석은 원래 잡으려던 것을 통과시킨다 | | T61 | 오류 본문이 요청 값을 에코하는지 **완전 일치**로 재면 「안 샌다」가 나온다 — OpenAI 는 앞뒤 3자만 남기고 잘라서 되돌린다(`Invalid value: 'PIN...def'`). 이 사실이 마스킹 규칙의 근거 전체였다 | 완전 일치로 끝내지 않고 **본문 원문을 본다**. 자동화하려면 마커의 앞/뒤 n자도 함께 찾는다 | -| T62 | 요청 본문이 아주 크면 GMS 가 **`Model not found in request for domain ...` 400** 을 낸다 — `model` 은 멀쩡히 들어 있다. `PermanentError` 로 분류돼 재시도 없이 `FAILED` 가 되는데 문구는 모델명을 가리킨다 | 「모델이 없다」400 은 설정을 고치기 전에 **본문 크기부터** 본다. 같은 모델명으로 짧은 요청이 통과하면 원인은 모델명이 아니다 | +| T62 | 요청 본문이 아주 크면 AI API 가 **`Model not found in request for domain ...` 400** 을 낸다 — `model` 은 멀쩡히 들어 있다. `PermanentError` 로 분류돼 재시도 없이 `FAILED` 가 되는데 문구는 모델명을 가리킨다 | 「모델이 없다」400 은 설정을 고치기 전에 **본문 크기부터** 본다. 같은 모델명으로 짧은 요청이 통과하면 원인은 모델명이 아니다 | | T63 | 소스 규약(`resp.text` 를 마스킹 없이 쓰지 않는다)을 정규식으로 훑으면 그것을 **설명하는 docstring** 이 위반으로 잡힌다 — 예외 목록을 손으로 유지하면 검사가 규약보다 약해지는 방향으로만 고쳐진다 | `ast.parse` 로 본다. 문자열 리터럴은 후보에 들어오지 않으므로 예외 목록이 필요 없다 | | T66 | pydantic Settings 는 필드명을 소문자로 선언해도 `alias=` 로 지정한 대문자 env 를 읽는다 — `.env`·문서에 키 이름이 리터럴로 남아 있어도(전부 "이제 안 읽는다"는 설명 맥락) 그 문자열 존재가 "읽힌다"의 증거가 아니다. `-210`(threshold 가 `config.py:114`에 있었던 사고)과 정반대 모양의 오판 | 후보 키마다 sentinel 값을 주입해 실제로 `Settings()` 를 생성하고 필드 값에 반영되는지 확인한다. `os.environ` 을 통째로 비우면 Windows `asyncio.windows_events` 가 `SYSTEMROOT` 를 못 찾아 죽으므로 필요한 키만 얹었다 뗀다 | | T67 | "`.env` 13 대 `.env.example` 12" 를 단일 방향 차집합(13-12=1)으로 예단하면 틀린다 — 실제로는 `.env` 만의 키가 2개(둘 다 죽음), `.env.example` 만의 키가 1개(살아 있음)였다. 공통 11 + 2 = 13, 공통 11 + 1 = 12 로 총계는 맞지만 "하나만 다르다"는 전제와 실제 구성이 다르다 | 두 파일의 키 이름 집합을 대칭차(symmetric difference)로 비교한다 — 숫자 차이만으로 "어느 하나를 채우면 끝"이라 판단하지 않는다 | diff --git a/tests/README.md b/tests/README.md index fef79b0..34fc862 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,10 +9,10 @@ AI 서버 통합 테스트 규칙. 계약 근거는 [`docs/spec/integration-test - **외부 API는 인터페이스 레벨 Fake**([fakes.py](fakes.py)), HTTP mock 아님. **호출 횟수 기록 필수** — "호출 안 함"/"정확히 한 번"이 여러 시나리오의 핵심 단언. 이 규칙은 **파이프라인이 client를 무엇으로 대체하는가**에 대한 것이다. 두 계층의 구분은 - [integration-tests.md §4.2](../docs/spec/integration-tests.md) 가 정본이다(`S15P11A705-110`에서 명문화). + [integration-tests.md §4.2](../docs/spec/integration-tests.md) 가 정본이다. **client 자신의 HTTP 계층은 §4.2 범위 밖**이며 [test_client_retry.py](test_client_retry.py)가 `httpx.MockTransport`로 검증한다 — 상태 코드→오류 타입 매핑은 인터페이스 Fake로 볼 수 없고, - 그 공백이 429를 영구 오류로·LLM 401을 일시 오류로 둔 채 남긴 원인이었다(`S15P11A705-121`). + 그 공백이 429를 영구 오류로·LLM 401을 일시 오류로 둔 채 남긴 원인이었다. - **오류 경로도 Fake로 주입한다** — `raise_exc`로 `TransientError`/`PermanentError`를 넣어 상태가 PROCESSING으로 남는지·해당 단계만 FAILED가 되는지 단언한다. 주입 파라미터를 두고 쓰지 않으면 그 경로는 한 번도 실행되지 않는다. @@ -25,7 +25,7 @@ AI 서버 통합 테스트 규칙. 계약 근거는 [`docs/spec/integration-test - **외부 실호출을 CI에 넣지 않는다.** `app.smoke.gms_roundtrip`은 `_CHECKS`를 스텁으로 교체해 집계·종료 코드·값 미노출 규약만 검증하고, 스크립트 실행 경로는 **클라이언트 클래스**를 스텁으로 갈아 끼워 검증한다. 실제 GMS 왕복은 배포 절차에서 수동 실행한다 — - 실호출을 CI에 넣으면 GMS 가용성이 CI 성패에 들어온다. + 실호출을 CI에 넣으면 AI API 가용성이 CI 성패에 들어온다. - **`if __name__ == "__main__"` 아래는 `runpy`로 검증한다.** import로는 한 줄도 실행되지 않는다. `runpy.run_module(..., run_name="__main__")`은 **새 네임스페이스**에서 모듈을 다시 실행하므로 캐시된 모듈에 건 패치가 보이지 않는다 — `monkeypatch.setattr("app.client.embedding_client.EmbeddingClient", ...)` @@ -46,7 +46,7 @@ AI 서버 통합 테스트 규칙. 계약 근거는 [`docs/spec/integration-test > `test_bootstrap.py`·`test_lifespan.py`는 요청 경로 **밖**이라 파이프라인·API 테스트로는 > 실행되지 않는다(`test_api.py`는 lifespan을 우회하고 `app.state`에 Fake를 직접 꽂는다). -> `S15P11A705-110` 기준선에서 두 파일이 덮는 영역이 각각 0%·58%였다. +> 초기 기준선에서 두 파일이 덮는 영역이 각각 0%·58%였다. > lifespan 테스트는 **진짜 클라이언트를 조립하는지**를 단언하므로 Fake로 바꾸지 않는다 — > 생성자는 IO를 하지 않으므로 실호출 금지 규칙과 충돌하지 않는다. diff --git a/tools/demo_seed/README.md b/tools/demo_seed/README.md index 8ba8fdc..6a05bda 100644 --- a/tools/demo_seed/README.md +++ b/tools/demo_seed/README.md @@ -11,10 +11,10 @@ |---|---|---| | member | 5 | 주인공 1 + 피드 후보 소유자 4 | | Record·Context | 14 | 주인공 6 + 소유자별 2 | -| Collection | 9 | Record를 재사용하므로 GMS 호출이 늘지 않는다 | +| Collection | 9 | Record를 재사용하므로 AI API 호출이 늘지 않는다 | | Follow | 2 | 주인공 → walker·dessert | -**GMS 실호출 29회** — 임베딩 14 + 판정 14 + 프리셋 1배치. 규모의 근거는 +**AI API 실호출 29회** — 임베딩 14 + 판정 14 + 프리셋 1배치. 규모의 근거는 [`demo_data.yaml`](demo_data.yaml) 머리말에 있다. 시연 3종이 이 데이터로 성립한다. @@ -51,7 +51,7 @@ back과 시딩 스크립트가 **같은 RSA 개인키**를 써야 한다. 키가 경로는 `git worktree` 안에서 실행해도 **메인 워킹트리의 `.demo/`** 하나로 고정된다 (`_client.shared_root()`). worktree 기준으로 잡으면 `.demo/`가 거기 없어 새 키가 생기고, back에 주입된 키와 갈라져 **전 요청이 401인데 back 로그에는 아무것도 남지 -않는다**(`S15P11A705-198` 결함 3). 다른 키를 쓰려면 `PINLOG_DEMO_JWT_KEY`로 명시한다. +않는다**(Jira 작업 결함 3). 다른 키를 쓰려면 `PINLOG_DEMO_JWT_KEY`로 명시한다. ```bash python -c "import sys; sys.path.insert(0,'tools/demo_seed'); import _client; _client.ensure_key()" @@ -117,7 +117,7 @@ python tools/demo_seed/preflight.py **왜 시작 전인가**: 2·4는 시딩이 진행된 뒤에 알아도 소용이 없다. `--reset`이 이미 지운 뒤이기 때문이다. 실제로 그 순서로 두 번 데이터를 잃었다 -([T28](../../docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md)·`S15P11A705-198` 결함 3). +([T28](../../docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md)·Jira 작업 결함 3). **2가 겨누는 것은 NOT NULL 제약이 아니라 우리가 값을 주지 않는 컬럼이다.** `email`은 `V4`에서 nullable로 태어났고 우리는 그 존재를 모른 채 NULL로 두었다. @@ -135,9 +135,9 @@ python tools/demo_seed/preflight.py 둘 다 참이므로 도구가 고르지 않는다. preflight가 세어서 보여주고, 지울지는 `--prune-orphans`로 사람이 정한다. -## 얼마나 걸리나 — 그날 GMS 상태에 달렸다 +## 얼마나 걸리나 — 그날 AI API 상태에 달렸다 -**쿼터가 상수가 아니다.** GMS는 SSAFY 공용 게이트웨이라 우리 전용 할당이 아니고, +**쿼터가 상수가 아니다.** AI API는 공용 게이트웨이라 우리 전용 할당이 아니고, 시점과 프로바이더 경로에 따라 달라진다([T27](../../docs/troubleshooting/2026-07-30-seeding-quota-and-encoding.md)). ``` diff --git a/tools/e2e/README.md b/tools/e2e/README.md index 44f947a..0c85204 100644 --- a/tools/e2e/README.md +++ b/tools/e2e/README.md @@ -1,6 +1,6 @@ # E2E 검증 드라이버 -실제 GMS를 호출하는 **실경로 검증** 도구입니다. Fake 기반 `tests/`(46 케이스, Docker만 필요)와 달리 +실제 AI API를 호출하는 **실경로 검증** 도구입니다. Fake 기반 `tests/`(46 케이스, Docker만 필요)와 달리 **실제 DB·실제 API 키·기동 중인 서버**가 필요합니다. 검증 결과와 판단 근거: [docs/implements/2026-07-27-e2e-verification.md](../../docs/implements/2026-07-27-e2e-verification.md) @@ -52,7 +52,7 @@ python tools/e2e/run_search.py --base http://localhost:8001 ## 주의 -- **실제 GMS를 호출합니다.** 임베딩·판정 비용이 발생합니다. +- **실제 AI API를 호출합니다.** 임베딩·판정 비용이 발생합니다. - **`e2e_contexts.yaml`의 id는 검증 전용 대역**(user 9001·9002 / record 5xxx / context 1xxx)입니다. 실제 데이터가 있는 DB에 그대로 쓰지 마세요. - **벡터 컬럼을 읽는 스크립트는 반드시 `app.core.db.Database`를 씁니다.** raw `asyncpg`로 붙으면 diff --git a/tools/emb_grid/README.md b/tools/emb_grid/README.md index be99ee3..e7aacaa 100644 --- a/tools/emb_grid/README.md +++ b/tools/emb_grid/README.md @@ -1,6 +1,6 @@ # 임베딩 4조건 실경로 측정 -`S15P11A705-174` 의 검색 정확도 10/12 에서 실패 2건이 나왔고, 그중 8번 +Jira 작업 의 검색 정확도 10/12 에서 실패 2건이 나왔고, 그중 8번 (「밥 먹고 산책하면서 쉬어가는 공원」)의 원인이 **질의의 「공원」이 본문에 없고 장소명에만 있다**는 것이었다. 임베딩 입력이 Context 본문 하나이므로 장소명은 벡터에 들어갈 경로가 없다. diff --git a/tools/gms_image/README.md b/tools/gms_image/README.md index df6567f..e20fa96 100644 --- a/tools/gms_image/README.md +++ b/tools/gms_image/README.md @@ -1,6 +1,6 @@ -# GMS 이미지 측정 — 생성(축 A) · 분석(축 B) +# AI API 이미지 측정 — 생성(축 A) · 분석(축 B) -`S15P11A705-253`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-08-03-gms-image-probe.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. @@ -8,11 +8,11 @@ | | | |---|---| -| `synth.py` | 합성 PNG. **치수와 바이트를 따로 움직인다.** GMS 를 부르지 않는다 | +| `synth.py` | 합성 PNG. **치수와 바이트를 따로 움직인다.** AI API 를 부르지 않는다 | | `gateway.py` | 자격 증명 로드 · 마스킹 · 호출 상한 · 한 건씩 append | -| `probe_gen.py` | 축 A — 이미지 생성 경로 탐색·프로파일. **GMS 호출** | -| `probe_vision.py` | 축 B — 크기별 토큰·지연·거부 임계. **GMS 호출** | -| `report.py` | 표와 가설 판정. **GMS 를 부르지 않는다** | +| `probe_gen.py` | 축 A — 이미지 생성 경로 탐색·프로파일. **AI API 호출** | +| `probe_vision.py` | 축 B — 크기별 토큰·지연·거부 임계. **AI API 호출** | +| `report.py` | 표와 가설 판정. **AI API 를 부르지 않는다** | `.gms_image/axis-a.jsonl` · `axis-b.jsonl` 은 **커밋한다.** 다시 뜨려면 공용 게이트웨이 쿼터를 또 쓰고, 담는 것은 합성 이미지의 지문·usage·상태 코드뿐이다. @@ -38,7 +38,7 @@ 기록 파일은 **덮어쓰지 않고 이어 붙인다.** 같은 이름으로 다시 돌리면 회차가 쌓인다 — 지우고 싶으면 파일을 직접 지운다. 쌓이는 쪽을 기본으로 둔 것은 **잃는 쪽이 더 비싸기** -때문이다(GMS 호출은 다시 뜨면 쿼터를 또 쓴다). +때문이다(AI API 호출은 다시 뜨면 쿼터를 또 쓴다). ## 실제 이미지를 쓰지 않는다 @@ -48,7 +48,7 @@ ## 다음 사람에게 -**수치를 상수로 읽지 마라.** GMS 쿼터·상한은 시점과 프로바이더 경로별로 다르다(T27). +**수치를 상수로 읽지 마라.** AI API 쿼터·상한은 시점과 프로바이더 경로별로 다르다(T27). 리포트의 수치에는 측정 시각(KST)과 경로가 붙어 있다 — 다시 재려면 그것부터 보라. 판정 규칙만 고칠 때는 `report.py --replay` 로 끝난다. **다시 부르지 마라** — 같은 규칙 diff --git a/tools/judge_vote/README.md b/tools/judge_vote/README.md index 7181d2d..ca29ca2 100644 --- a/tools/judge_vote/README.md +++ b/tools/judge_vote/README.md @@ -1,6 +1,6 @@ # 판정 n회 다수결 측정 -`S15P11A705-223`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-07-31-judge-vote.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. @@ -32,7 +32,7 @@ | | | |---|---| -| `compose.py` | 단일 회차들을 n회 다수결 회차로 접는다. **GMS 를 부르지 않는다** | +| `compose.py` | 단일 회차들을 n회 다수결 회차로 접는다. **AI API 를 부르지 않는다** | | `run_live.py` | 실제 `KeywordService._judge_n` 을 n회 호출로 돌린다. 접은 값의 대조군 | 채점은 `-219` 의 `tools/prompt_ab/score_ab.py` 를 그대로 쓴다. 두 도구의 출력 형식이 diff --git a/tools/keyword_eval/README.md b/tools/keyword_eval/README.md index ce5732b..acdc279 100644 --- a/tools/keyword_eval/README.md +++ b/tools/keyword_eval/README.md @@ -31,12 +31,12 @@ python test_b_coverage.py --k 10 --floor 0.30 # 커버리지 (샘플 임베딩 # 테스트 C — 2단계 python test_c_judge.py --provider anthropic --model claude-haiku-4-5 # C-1 프롬프트 안정화 -python test_c_judge.py --compare # C-2 모델 비교(GMS, MODELS 목록) +python test_c_judge.py --compare # C-2 모델 비교(AI API, MODELS 목록) ``` 판정 모델은 계약상 미확정입니다. C-1은 접근 편한 모델 1개로 프롬프트를 잡고(세션 Claude 무방), -C-2는 확정 프롬프트로 GMS 후보 모델을 비교해 판정 모델을 정합니다. `test_c_judge.py`의 `MODELS` -목록을 GMS 실제 모델명으로 맞추세요. +C-2는 확정 프롬프트로 AI API 후보 모델을 비교해 판정 모델을 정합니다. `test_c_judge.py`의 `MODELS` +목록을 AI API 실제 모델명으로 맞추세요. 임베딩 결과는 `.cache/`에 캐시되어 재호출을 막습니다. diff --git a/tools/keyword_eval/REPORT.md b/tools/keyword_eval/REPORT.md index 4645b66..1a14c69 100644 --- a/tools/keyword_eval/REPORT.md +++ b/tools/keyword_eval/REPORT.md @@ -54,7 +54,7 @@ - **gemini-2.5-flash 최우수**: 최속(1.12s) + 최소 토큰(25314). thinking off로 비추론 경량 동작. - **haiku**: 빠르나(1.52s) 입력 토큰 2배(66222)로 총 토큰 최다. - **confidence는 전 모델 변별력 낮음**(std 0.03~0.10) → 랭킹 신호로 쓰지 않음(MVP). -- 크레딧(원가)은 GMS 모델별 단가 × 토큰. 단가표는 별도지만 토큰 총량 기준 **gemini < gpt-5-mini < gpt-5-nano ≈ haiku**. +- 크레딧(원가)은 AI API 모델별 단가 × 토큰. 단가표는 별도지만 토큰 총량 기준 **gemini < gpt-5-mini < gpt-5-nano ≈ haiku**. **판정 모델 권고: `gemini-2.5-flash` (thinkingBudget=0)** — 정확도 동급에 지연·토큰 모두 최소. 차선은 `gpt-5-mini`(reasoning으로 느리나 안정), `claude-haiku`(빠르나 입력 토큰 큼). @@ -70,10 +70,10 @@ ## 남은 것 - ~~팀 실제 샘플(프리셋 안 보고 작성)로 B/C 재측정~~ → **부분 수행**. 실사용자 기록을 포함한 - 42건으로 하한 0.30 을 재검증했고 **0.30 유지가 확인됐다**(`S15P11A705-210`, - [리포트](../../docs/implements/2026-07-31-candidate-threshold.md)). 이 문서의 C-1 이 + 42건으로 하한 0.30 을 재검증했고 **0.30 유지가 확인됐다** + ([리포트](../../docs/implements/2026-07-31-candidate-threshold.md)). 이 문서의 C-1 이 "floor 0.35 로 올리면 STUDY_WORK 유실"로 관측한 것이 실데이터에서 더 크게 재현된다 — 0.34~0.35 에서 정상 판정 11건이 사라지고 교환비가 1.22 다. Recall·트리키 케이스의 유효 검증은 여전히 남아 있다. -- GMS 모델별 단가표로 토큰→크레딧 환산(운영 비용 확정). +- AI API 모델별 단가표로 토큰→크레딧 환산(운영 비용 확정). - Gemini responseSchema 경로가 확정 모델이므로 E 구현 시 이 호출 방식(thinking off) 사용. diff --git a/tools/preset_desc/README.md b/tools/preset_desc/README.md index daab660..93d747c 100644 --- a/tools/preset_desc/README.md +++ b/tools/preset_desc/README.md @@ -1,6 +1,6 @@ # 프리셋 `description`·`examples` 개정 측정 -`S15P11A705-228`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-08-03-preset-description.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. @@ -36,9 +36,9 @@ | | | |---|---| | `variants.py` | 조건 정본(`base`·`base2`·`D`·`E`·`DE`). 개정 내용과 **무엇을 왜 걷었는가** | -| `matrix.py` | 조건별 프리셋 27건 재임베딩 + 유사도 행렬. **GMS 임베딩을 부르는 유일한 파일** | +| `matrix.py` | 조건별 프리셋 27건 재임베딩 + 유사도 행렬. **AI API 임베딩을 부르는 유일한 파일** | | `cands.py` | 조건별 후보 집합 이동. 파일만 읽는다 | -| `run.py` | 조건 하나를 N회 판정. **GMS 판정을 부르는 유일한 파일** | +| `run.py` | 조건 하나를 N회 판정. **AI API 판정을 부르는 유일한 파일** | | `split_score.py` | 고친 5종 / 안 고친 22종을 갈라 센다. 자기충족 진단 | ## 실행 diff --git a/tools/prompt_ab/README.md b/tools/prompt_ab/README.md index 5449e64..55d89fe 100644 --- a/tools/prompt_ab/README.md +++ b/tools/prompt_ab/README.md @@ -1,22 +1,22 @@ # 판정 프롬프트 A/B 측정 -`S15P11A705-219`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-07-31-judge-prompt-rule.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. -선행은 `S15P11A705-210`([τ 리포트](../../docs/implements/2026-07-31-candidate-threshold.md))이다. +선행은 Jira 작업([τ 리포트](../../docs/implements/2026-07-31-candidate-threshold.md))이다. 후보 선정 층(유사도 임계값)으로는 오분류를 못 푼다는 것을 실측하고 끝났고, 그 §6 이 「판정 프롬프트로 옮긴다」를 후속으로 남겼다. **라벨과 데이터를 그대로 물려받는다** — 기준이 바뀌면 `-210` 과 비교가 불가능해지기 때문이다. ## 왜 τ 하네스를 그대로 못 쓰나 -`tau_grid` 는 유사도 행렬을 한 번 떠 두고 **임의의 τ 를 GMS 호출 없이 재구성**한다. +`tau_grid` 는 유사도 행렬을 한 번 떠 두고 **임의의 τ 를 AI API 호출 없이 재구성**한다. 프롬프트는 그렇게 못 한다 — LLM 을 실제로 불러야 답이 나온다. 그래서 이 하네스는 호출을 전제로 짰고, 그 전제가 설계 전부를 규정한다. ``` -회차마다 즉시 파일로 중단되면 그 회차까지는 남는다. GMS 호출은 되돌릴 수 없다 +회차마다 즉시 파일로 중단되면 그 회차까지는 남는다. AI API 호출은 되돌릴 수 없다 이미 있는 회차는 건너뜀 재개가 곧 이어달리기다 후보는 matrix.json 고정 재임베딩·재시딩 없음. A·B 의 후보 집합이 완전히 같아야 한다 벤더 단일 고정 폴백이 살면 회차마다 다른 모델이 섞인다 @@ -27,7 +27,7 @@ | | | |---|---| | `variants.py` | 조건 정본. A(현행)·B(개정) 시스템 프롬프트 **전문**과 무엇을 왜 더했는지 | -| `run.py` | 조건 하나를 N회 돌린다. **GMS 를 부르는 유일한 파일** | +| `run.py` | 조건 하나를 N회 돌린다. **AI API 를 부르는 유일한 파일** | | `score_ab.py` | 회차들을 라벨에 붙여 집계. 비결정성과 조건 효과를 같은 자로 낸다 | | `labels_extra.yaml` | 재판정에서 새로 나온 행의 라벨. `tau_grid/labels.yaml` 은 건드리지 않는다 | @@ -39,7 +39,7 @@ cd ai export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" -.venv/Scripts/python.exe tools/tau_grid/matrix.py # GMS 호출 없음. DB 만 읽는다 +.venv/Scripts/python.exe tools/tau_grid/matrix.py # AI API 호출 없음. DB 만 읽는다 .venv/Scripts/python.exe tools/prompt_ab/run.py --variant A --reps 5 # LLM 210회 .venv/Scripts/python.exe tools/prompt_ab/run.py --variant B --reps 5 # LLM 210회 .venv/Scripts/python.exe tools/prompt_ab/score_ab.py # 파일만 읽는다 @@ -65,7 +65,7 @@ Context 42건 전부가 τ=0.30 에서 후보를 갖는다(`-210` §2). 그러 5회는 회차 5개씩 열 관측을 만든다. 그러면 **두 조건의 관측 범위가 아예 겹치지 않는가**를 볼 수 있고, 효과가 없는데 그렇게 갈릴 확률은 1/C(10,5) ≈ 0.4% 다. 3회면 그 확률이 5% 로 -올라 흔들림과 구분이 안 되고, 10회면 GMS 840회를 쓰면서 판정 기준은 그대로다. +올라 흔들림과 구분이 안 되고, 10회면 AI API 840회를 쓰면서 판정 기준은 그대로다. ## 라벨을 넓힐 때 diff --git a/tools/search_cut/README.md b/tools/search_cut/README.md index 47da1e8..a59715c 100644 --- a/tools/search_cut/README.md +++ b/tools/search_cut/README.md @@ -1,6 +1,6 @@ # 검색 결과 컷 측정 — `τ_abs` × `r` -`S15P11A705-213`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-07-31-search-cut.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. @@ -19,30 +19,30 @@ | | | |---|---| -| `matrix.py` | 질의를 임베딩하고 질의별 Record 전량의 유사도를 `.search/matrix.json` 으로 굳힌다. **GMS 임베딩 배치 1회** | +| `matrix.py` | 질의를 임베딩하고 질의별 Record 전량의 유사도를 `.search/matrix.json` 으로 굳힌다. **AI API 임베딩 배치 1회** | | `labels.yaml` | 정답 아닌 141행 중 `plausible` 인 것. 판정 기준이 머리말에 있다 | -| `sweep.py` | 분포와 `τ_abs × r` 격자. **DB 도 GMS 도 부르지 않는다** | +| `sweep.py` | 분포와 `τ_abs × r` 격자. **DB 도 AI API 도 부르지 않는다** | | `label_sheet.py` | 라벨을 손으로 채우기 위한 시트(본문 포함). 출력물은 커밋하지 않는다 | | `verify_live.py` | 오프라인 재구성이 실서버 응답과 같은지. **정확 일치를 요구한다** | -| `recall_probe.py` | 「본문에 있는 말로 검색해도 안 나온다」의 원인 판별(`S15P11A705-255`). 질의 22건 × Record 전량. **GMS 임베딩 배치 1회** | -| `word_matrix.py` | **단어형** 질의 54건 × 소유자 3명의 행렬(`S15P11A705-266`). 기대 정답을 손으로 짝짓지 않고 **본문 문자열 포함으로 계산**한다. **GMS 임베딩 배치 1회** | -| `word_sweep.py` | 단어형·문장형을 **한 표에** 놓고 격자를 훑는다. **DB 도 GMS 도 부르지 않는다** | -| `boundary_matrix.py` | 단어형 **경계 정의** 두 가지를 가르는 행렬(`S15P11A705-273`). 본문 인접 어절쌍을 `spaced`/`joined` 짝으로 낸다. **GMS 임베딩 배치 3회** | -| `boundary_sweep.py` | `_is_word_query` 정의 6종을 같은 행렬에 걸어 비교한다. **DB 도 GMS 도 부르지 않는다** | +| `recall_probe.py` | 「본문에 있는 말로 검색해도 안 나온다」의 원인 판별(Jira 작업). 질의 22건 × Record 전량. **AI API 임베딩 배치 1회** | +| `word_matrix.py` | **단어형** 질의 54건 × 소유자 3명의 행렬(Jira 작업). 기대 정답을 손으로 짝짓지 않고 **본문 문자열 포함으로 계산**한다. **AI API 임베딩 배치 1회** | +| `word_sweep.py` | 단어형·문장형을 **한 표에** 놓고 격자를 훑는다. **DB 도 AI API 도 부르지 않는다** | +| `boundary_matrix.py` | 단어형 **경계 정의** 두 가지를 가르는 행렬(Jira 작업). 본문 인접 어절쌍을 `spaced`/`joined` 짝으로 낸다. **AI API 임베딩 배치 3회** | +| `boundary_sweep.py` | `_is_word_query` 정의 6종을 같은 행렬에 걸어 비교한다. **DB 도 AI API 도 부르지 않는다** | | `layer_probe.py` | 질의가 **어느 층에서** 몇 건을 잃는지. 후보·LIMIT·τ·r·**실서버**를 한 줄에 놓는다 | -| `rank_score.py` | 검색 **순위** 지표 baseline(P48 0단계, I52). 컷 기준 지표로는 순위 변화가 안 보여서 따로 둔다. Hit·Recall·MRR·nDCG 를 컷 전/후 · 단어형/문장형 · 정답/무관으로 갈라 낸다. **DB 도 GMS 도 부르지 않는다** | -| `fusion.py` | keyword 신호 fusion **순수 로직**(P48 1단계). DB·GMS·파일을 읽지 않고 인자만 받는다 — `tests/test_search_fusion.py` 가 픽스처로 검증한다 | -| `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **GMS 임베딩 배치 1회 + DB 읽기** | -| `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자 — **P48 구조**(후보 합집합 후 병합 점수에 컷). `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | -| `fusion_rerank_sweep.py` | keyword 재정렬 전용 병합 격자 — **P49 §4 구조**(컷 통과 집합 고정·순서만 조정). BASE·binary(floor×weight)·RRF 비교. 같은 파일만 읽는다 — **DB 도 GMS 도 부르지 않는다** | -| `rerank_verify_live.py` | keyword 재정렬의 **실서버 on/off 대조** — 플래그만 다른 두 서버의 실응답으로 다섯 계약(후보 불변·재정렬 정확성·무관 무노출·정답 무퇴행·off 현행 동일)을 판정한다. **GMS 임베딩 질의당·서버당 1회** | -| `lexical_matrix.py` | 문자열 매치 artifact — 본문에 질의가 그대로 있는지를 (질의×소유자×Record)로 굳힌다. 본문은 저장하지 않는다. **스냅샷 DB(:25432) 읽기 · GMS 0회** | -| `lexical_sweep.py` | 문자열 병합 규칙 격자 — 게이트 3단×병합 3종. `lexical_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 GMS 도 부르지 않는다** | +| `rank_score.py` | 검색 **순위** 지표 baseline(P48 0단계, I52). 컷 기준 지표로는 순위 변화가 안 보여서 따로 둔다. Hit·Recall·MRR·nDCG 를 컷 전/후 · 단어형/문장형 · 정답/무관으로 갈라 낸다. **DB 도 AI API 도 부르지 않는다** | +| `fusion.py` | keyword 신호 fusion **순수 로직**(P48 1단계). DB·AI API·파일을 읽지 않고 인자만 받는다 — `tests/test_search_fusion.py` 가 픽스처로 검증한다 | +| `keyword_matrix.py` | keyword 신호 artifact 생성 — 질의별 전체 활성 Preset 코사인 + Context 별 keyword·confidence·상태. **AI API 임베딩 배치 1회 + DB 읽기** | +| `fusion_sweep.py` | fusion 방식(binary·confidence·idf·RRF)×가중치×floor×RRF cutoff 격자 — **P48 구조**(후보 합집합 후 병합 점수에 컷). `keyword_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 AI API 도 부르지 않는다** | +| `fusion_rerank_sweep.py` | keyword 재정렬 전용 병합 격자 — **P49 §4 구조**(컷 통과 집합 고정·순서만 조정). BASE·binary(floor×weight)·RRF 비교. 같은 파일만 읽는다 — **DB 도 AI API 도 부르지 않는다** | +| `rerank_verify_live.py` | keyword 재정렬의 **실서버 on/off 대조** — 플래그만 다른 두 서버의 실응답으로 다섯 계약(후보 불변·재정렬 정확성·무관 무노출·정답 무퇴행·off 현행 동일)을 판정한다. **AI API 임베딩 질의당·서버당 1회** | +| `lexical_matrix.py` | 문자열 매치 artifact — 본문에 질의가 그대로 있는지를 (질의×소유자×Record)로 굳힌다. 본문은 저장하지 않는다. **스냅샷 DB(:25432) 읽기 · AI API 0회** | +| `lexical_sweep.py` | 문자열 병합 규칙 격자 — 게이트 3단×병합 3종. `lexical_matrix.json` 과 행렬 셋만 읽는다 — **DB 도 AI API 도 부르지 않는다** | `matrix.json` · `recall_probe.json` · `word_grid.json` · `keyword_matrix.json` 은 **커밋한다.** -다시 뜨려면 GMS 를 부르고, `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). +다시 뜨려면 AI API 를 부르고, `tau_grid` 의 것과 달리 Context 본문을 담지 않는다(장소명까지). -## 단어형 컷 격자 (`S15P11A705-266`) +## 단어형 컷 격자 (Jira 작업) `recall_probe.py` 와 대상이 다르다 — 저쪽은 **세 이슈의 원인을 가르려고** 질의 표현을 바꿔 가며 같은 Record 를 추적하고, 이쪽은 **컷 값을 정하려고** 질의를 늘려 대역을 잰다. @@ -50,14 +50,14 @@ 어디까지 올라오는가」가 전부이므로 그 대역을 1점으로 재면 안 된다. ```bash -.venv/Scripts/python.exe tools/search_cut/word_matrix.py # GMS 배치 1회. DB 를 읽는다 +.venv/Scripts/python.exe tools/search_cut/word_matrix.py # AI API 배치 1회. DB 를 읽는다 .venv/Scripts/python.exe tools/search_cut/word_sweep.py # 파일 둘만 읽는다 ``` `word_sweep.py` 는 `word_grid.json`(단어형)과 `matrix.json`(문장형)을 **함께** 읽는다. 따로 내면 「한 값이 둘 다를 만족하는가」에 답할 수 없기 때문이다. -`word_matrix.py` 는 재기 전에 둘을 확인하고 **어긋나면 GMS 를 부르지 않고 멈춘다.** +`word_matrix.py` 는 재기 전에 둘을 확인하고 **어긋나면 AI API 를 부르지 않고 멈춘다.** ``` 무관 통제가 본문에 있다 그 행은 통제가 아니라 정답 있는 질의다 @@ -89,7 +89,7 @@ 결론은 [구현 리포트](../../docs/implements/2026-08-03-word-query-cut.md)에 있다. -## 단어형 경계 정의 (`S15P11A705-273`) +## 단어형 경계 정의 (Jira 작업) `word_matrix.py` 와 대상이 다르다 — 저쪽은 **컷 값**을 정하려고 1어절 질의의 대역을 재고, 이쪽은 **경계 정의**(글자 수인가 어절 수인가)를 가르려고 **공백만 다른 짝**을 @@ -97,8 +97,8 @@ 스스로 후속으로 남겼다. ```bash -.venv/Scripts/python.exe tools/search_cut/boundary_matrix.py --dry # 대역 분포만. GMS 미호출 -.venv/Scripts/python.exe tools/search_cut/boundary_matrix.py # GMS 배치 3회. DB 를 읽는다 +.venv/Scripts/python.exe tools/search_cut/boundary_matrix.py --dry # 대역 분포만. AI API 미호출 +.venv/Scripts/python.exe tools/search_cut/boundary_matrix.py # AI API 배치 3회. DB 를 읽는다 .venv/Scripts/python.exe tools/search_cut/boundary_sweep.py --focus # 파일 둘만 읽는다 ``` @@ -124,26 +124,26 @@ 결론은 [구현 리포트](../../docs/implements/2026-08-05-short-query-boundary.md)에 있다. -## 재현율 프로브 (`S15P11A705-255`) +## 재현율 프로브 (Jira 작업) `matrix.py` 와 대상이 다르다 — 저쪽은 **격자를 훑기 위해** 검증 질의 12건의 전량 유사도를 굳히고, 이쪽은 **질의 표현을 바꿔 가며** 같은 Record 가 어떻게 움직이는지 본다. ```bash -.venv/Scripts/python.exe tools/search_cut/recall_probe.py # GMS 배치 1회 +.venv/Scripts/python.exe tools/search_cut/recall_probe.py # AI API 배치 1회 .venv/Scripts/python.exe tools/search_cut/recall_probe.py --replay .search/recall_probe.json .venv/Scripts/python.exe tools/search_cut/recall_probe.py --lengths .search/recall_probe.json ``` -`--replay` 는 **판정 규칙만** 다시 낸다(DB·GMS 미호출). 컷도 판정도 유사도에 걸릴 뿐 -임베딩에 걸리지 않으므로, `RECOVER_RANK` 나 컷 값을 바꿔 볼 때 GMS 를 다시 부르지 않는다. +`--replay` 는 **판정 규칙만** 다시 낸다(DB·AI API 미호출). 컷도 판정도 유사도에 걸릴 뿐 +임베딩에 걸리지 않으므로, `RECOVER_RANK` 나 컷 값을 바꿔 볼 때 AI API 를 다시 부르지 않는다. **`--replay` 는 기본적으로 파일을 쓰지 않는다.** 재판정은 화면으로 읽는 것이 목적이고, 기본 출력 경로를 두면 위 명령을 그대로 돌린 사람이 **커밋된 행렬을 판정 결과로 덮어쓴다** — -행렬은 GMS 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다. 남기려면 `--out` 에 **다른** +행렬은 AI API 를 불러야 다시 뜨므로 그 손실이 판정보다 무겁다. 남기려면 `--out` 에 **다른** 경로를 준다(입력과 같으면 쓰지 않고 멈춘다). -이 프로브의 질의는 **단어형**이라 `τ_abs` 가 질의별로 갈린다(`S15P11A705-266`). 단일 하한을 +이 프로브의 질의는 **단어형**이라 `τ_abs` 가 질의별로 갈린다(Jira 작업). 단일 하한을 쓰면 이미 고쳐진 증상(`ai#87` 의 `그네`·`스팟`)을 계속 「① 컷이 잘랐다」로 보고한다 — **진단 도구가 닫힌 증상을 미해결로 읽는다.** `--lengths` 는 본문 길이와 유사도의 순위 상관을 낸다(DB 만 읽는다 — 행렬이 본문을 담지 @@ -157,7 +157,7 @@ cd ai export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" -.venv/Scripts/python.exe tools/search_cut/matrix.py # GMS 임베딩 1배치. DB 를 읽는다 +.venv/Scripts/python.exe tools/search_cut/matrix.py # AI API 임베딩 1배치. DB 를 읽는다 .venv/Scripts/python.exe tools/search_cut/sweep.py # 파일만 읽는다 ``` @@ -170,13 +170,13 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" **worktree 에서 돌린다면 `.env` 를 그쪽에도 둔다.** `get_settings()` 의 `env_file` 은 CWD 기준이고 `.env` 는 gitignore 라 worktree 에 없다 — `GMS_API_KEY` 부터 없어서 죽는다(T41). -### keyword fusion (P48 1단계, `S15P11A705-336` 실측) +### keyword fusion (P48 1단계, Jira 작업 실측) ```bash .venv/Scripts/python.exe -m pytest tests/test_search_fusion.py tests/test_keyword_matrix_parse.py -q - # 픽스처 검증. DB·GMS 0회 + # 픽스처 검증. DB·AI API 0회 .venv/Scripts/python.exe tools/search_cut/rank_score.py # 순위 baseline. 파일만 읽는다 -.venv/Scripts/python.exe tools/search_cut/keyword_matrix.py # GMS 배치 1회 + DB 읽기 +.venv/Scripts/python.exe tools/search_cut/keyword_matrix.py # AI API 배치 1회 + DB 읽기 .venv/Scripts/python.exe tools/search_cut/fusion_sweep.py --rrf-cutoff-grid "0,0.004,0.008,0.016" --floor 0.35 # 파일만 읽는다 ``` @@ -187,7 +187,7 @@ CWD 기준이고 `.env` 는 gitignore 라 worktree 에 없다 — `GMS_API_KEY` 행렬이면 `fusion_sweep.py` 의 가드가 재지 않고 멈춘다. 결과 판정 기준은 P48 §6.1, 실측 기록은 [구현 리포트 I53](../../docs/implements/2026-08-05-fusion-measurement.md). -### keyword 재정렬 전용 병합 (P49 작업 4, `S15P11A705-339`) +### keyword 재정렬 전용 병합 (P49 작업 4, Jira 작업) ```bash .venv/Scripts/python.exe tools/search_cut/fusion_rerank_sweep.py # 파일만 읽는다 @@ -220,7 +220,7 @@ profile·preset_version 이 현행과 어긋나도 멈춘다(`--expect-*` 로 ```bash export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:25432/pinlog" # 스냅샷 DB -.venv/Scripts/python.exe tools/search_cut/lexical_matrix.py # DB 읽기. GMS 0회 +.venv/Scripts/python.exe tools/search_cut/lexical_matrix.py # DB 읽기. AI API 0회 .venv/Scripts/python.exe tools/search_cut/lexical_sweep.py # 파일만 읽는다 ``` @@ -241,11 +241,11 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:25432/pinlog" `word_grid.json` 이 있으면 단어형도 함께 던진다 — 다만 **두 하한(단어형·문장형)에서 결과가 갈리는 행만** 고른다. 갈리지 않는 행은 서버가 분기를 타든 안 타든 통과하므로 -GMS 호출만 늘고 재는 값이 늘지 않는다. 재구성 쪽에도 길이 분기를 **다시 적어** 두었다 — +AI API 호출만 늘고 재는 값이 늘지 않는다. 재구성 쪽에도 길이 분기를 **다시 적어** 두었다 — 구현을 `import` 하면 서버가 옛 단일값 경로를 돌아도 검증이 통과한다. 로그는 파이프가 아니라 리디렉션으로 받는다 — 파이프는 프로세스가 사는 동안 0바이트다(T30). -질의 수만큼 GMS 임베딩을 부른다(요청당 1회). +질의 수만큼 AI API 임베딩을 부른다(요청당 1회). ## 데이터가 바뀌면 @@ -253,4 +253,4 @@ GMS 호출만 늘고 재는 값이 늘지 않는다. 재구성 쪽에도 길이 `sweep.py` 가 **재지 않고 멈춘다**(라벨과 행렬의 대조를 먼저 한다). 그때는 `matrix.py` 를 다시 돌리고 `label_sheet.py` 로 본문을 보며 라벨을 다시 맞춘다. -라벨에 이의가 있으면 해당 행만 고치고 `sweep.py` 를 다시 돌리면 된다. GMS 는 부르지 않는다. +라벨에 이의가 있으면 해당 행만 고치고 `sweep.py` 를 다시 돌리면 된다. AI API 는 부르지 않는다. diff --git a/tools/tau_grid/README.md b/tools/tau_grid/README.md index 7010931..01d7fb4 100644 --- a/tools/tau_grid/README.md +++ b/tools/tau_grid/README.md @@ -1,6 +1,6 @@ # 후보 유사도 임계값 τ 측정 -`S15P11A705-210`. 결론과 수치는 +Jira 작업. 결론과 수치는 [구현 리포트](../../docs/implements/2026-07-31-candidate-threshold.md)에 있고, 이 문서는 **어떻게 다시 돌리는가**만 적는다. @@ -8,7 +8,7 @@ `tools/emb_grid` 는 조건마다 **전량 재시딩**한다. 임베딩 모델·차원이 조건이라 벡터를 다시 만들 수밖에 없기 때문이다. **τ 는 임베딩을 바꾸지 않는다** — 후보 선정에만 걸린다. -그대로 쓰면 조건마다 GMS 임베딩 42회를 이유 없이 다시 쓴다. +그대로 쓰면 조건마다 AI API 임베딩 42회를 이유 없이 다시 쓴다. 대신 벡터를 한 번 떠서 굳히고 τ 를 오프라인으로 훑는다. 구조(조건 정본·사전 검증·JSON 산출)는 `emb_grid` 를 따랐다. @@ -18,7 +18,7 @@ | | | |---|---| | `matrix.py` | DB 에서 42×27 유사도와 현행 판정을 떠서 `.tau/matrix.json` 으로 굳힌다 | -| `sweep.py` | 분포와 τ 격자. **DB 도 GMS 도 부르지 않는다** | +| `sweep.py` | 분포와 τ 격자. **DB 도 AI API 도 부르지 않는다** | | `labels.yaml` | 현행 판정 83행의 적합성 라벨. 판정 기준이 머리말에 있다 | | `score.py` | 라벨을 붙여 **오분류 감소분과 정상 판정 누락분을 함께** 센다 | | `probe_gate.py` | 무관·짧은정상 본문을 임베딩해 Context 게이트 γ 가 둘을 가르는지 본다 | @@ -30,7 +30,7 @@ cd ai export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" -.venv/Scripts/python.exe tools/tau_grid/matrix.py # GMS 호출 없음. DB 만 읽는다 +.venv/Scripts/python.exe tools/tau_grid/matrix.py # AI API 호출 없음. DB 만 읽는다 .venv/Scripts/python.exe tools/tau_grid/sweep.py # 파일만 읽는다 .venv/Scripts/python.exe tools/tau_grid/score.py # 파일만 읽는다 ``` @@ -40,7 +40,7 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" `.venv/Scripts/python.exe` 를 쓴다. `python` 은 시스템 Python 을 타고 의존성이 없다(T29). -### GMS 를 부르는 둘 +### AI API 를 부르는 둘 ```bash .venv/Scripts/python.exe tools/tau_grid/probe_gate.py # 임베딩 16회 @@ -59,4 +59,4 @@ export DATABASE_URL="postgresql://pinlog:pinlog-local@localhost:15432/pinlog" 다시 돌리고 본문 기준으로 라벨을 다시 맞춘다. 라벨에 이의가 있으면 `labels.yaml` 의 해당 행만 고치고 `score.py` 를 다시 돌리면 된다. -GMS 는 부르지 않는다. +AI API 는 부르지 않는다.