Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ branch: {type}/{jira-key}-{summary}
commit: {type}({jira-key}): {summary}
```

For example, on `feat/S15P11A705-14-member-search` a commit reads `feat(S15P11A705-14): add member search`. Use `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, or `perf` for `type`.
For example, on `feat/{jira-key}-member-search` a commit reads `feat({jira-key}): add member search`. Use `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, or `perf` for `type`.

The backend foundation reset is an exception: it is tracked solely by [GitHub Issue #9](https://github.com/Team-PinLog/back/issues/9) without Jira, and that exception applies to its branch names, commit messages, and PRs. It does not apply to normal work, which keeps using Jira keys.

Expand Down Expand Up @@ -144,6 +144,6 @@ Before adding a new rule, decide its enforcement point first. Prefer CI: it runs

Keep shared, repo-wide protections in [`.claude/settings.json`](.claude/settings.json) only. Put per-person permissions and environment settings in `.claude/settings.local.json` (Git-ignored); copy the [example file](.claude/settings.local.json.example) to start. Do not commit personal settings or add personal permissions to the team settings.

**Design docs stay local.** Brainstorming specs and plans go under `.claude/superpowers/`, which is Git-ignored — this repo deliberately excludes them (S15P11A705-28). What must outlive the branch goes to `docs/backend/` instead, where it is reviewed and preserved: decisions as `BD-##`, implementation reports and troubleshooting as their own entries.
**Design docs stay local.** Brainstorming specs and plans go under `.claude/superpowers/`, which is Git-ignored — this repo deliberately excludes them (Jira 작업). What must outlive the branch goes to `docs/backend/` instead, where it is reviewed and preserved: decisions as `BD-##`, implementation reports and troubleshooting as their own entries.

**Hooks are personal, not shared.** `.claude/hooks/` is Git-ignored. Set one up if you want the same failure reported a few minutes before CI does, and register it in your own `settings.local.json` — never in the shared `settings.json`, which would error for everyone who does not have your scripts. Keep the rule itself in CI so a dead hook costs you convenience and nothing more. The example file explains the portability traps that make hand-written shell hooks fail silently.
22 changes: 11 additions & 11 deletions docs/ai/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,63 +10,63 @@
- 작업 기록 신설. 결정·트러블슈팅·리포트 폴더를 만들었다 (back#6) — [proposals/](proposals/)·[implements/](implements/)·[troubleshooting/](troubleshooting/)
- 문서 체계 재구조화. spec/proposals/implements/troubleshooting 구조와 WORKLOG를 도입하고, ADR 명칭을 P 번호로 바꿨다 (back#6) — 이 트리 전체

## 2026-07-28 — Feed 합의 반영 (S15P11A705-125)
## 2026-07-28 — Feed 합의 반영 (Jira 작업)

back#58에서 이루어진 Feed 합의를 문서에 반영했다. 구현 담당은 김가현으로 정했다. MVP에서 category·region을 제외했다. 공용 API 계약(size 20·opaque cursor·별도 requestId)을 우선하기로 했고, 향후 placeMeta를 임베딩으로 전환할 조건을 기록했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md) · [Feed 명세](spec/feed-recommendation.md)

같은 날 담당을 긴급 정정했다(S15P11A705-125). Feed Spring 구현을 이정헌에게 재배치하고, 김가현은 Feed 범위에서 제외해 별도 추가 AI 기능 담당으로 분리했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md)
같은 날 담당을 긴급 정정했다(Jira 작업). Feed Spring 구현을 이정헌에게 재배치하고, 김가현은 Feed 범위에서 제외해 별도 추가 AI 기능 담당으로 분리했다. — [P42](proposals/P42-feed-mvp-without-place-metadata.md)

## 2026-07-28 — 최신 dev 기준 Feed 계약 재검토 (S15P11A705-125)
## 2026-07-28 — 최신 dev 기준 Feed 계약 재검토 (Jira 작업)

최신 `dev` 기준으로 Feed 계약을 재검토했다. 합의 당시보다 `dev`가 5커밋 앞서 있었다. 그중 S15P11A705-117이 "요청 입력의 서버 방어 상한은 이름 붙은 상수 한 곳(`InputLimits`)에 모으고 `CursorPage.MAX_SIZE`와 같은 값을 쓴다"는 규약을 세웠다.
최신 `dev` 기준으로 Feed 계약을 재검토했다. 합의 당시보다 `dev`가 5커밋 앞서 있었다. 그중 Jira 작업이 "요청 입력의 서버 방어 상한은 이름 붙은 상수 한 곳(`InputLimits`)에 모으고 `CursorPage.MAX_SIZE`와 같은 값을 쓴다"는 규약을 세웠다.

Feed 명세의 `size=20`·opaque cursor는 공통 커서 계약(`DEFAULT_SIZE` 20 · `MAX_SIZE` 100 · `normalizeSize` 보정)과 이미 일치해 충돌이 없었다. 명세에 그 근거를 명시했다. 반면 `POST .../feed/events`의 배열 상한은 "예: 50"으로 미정이라 그 규약과 어긋났고, 100으로 고정했다.

경로 표기 불일치도 함께 정리했다. §4 코드블록만 `/api/core/v1/...`로 바뀌고, 본문·`feed-event.md`·`feed-tests.md`는 옛 경로 `POST /feed/events`로 남아 있던 상태였다. — [Feed 명세](spec/feed-recommendation.md) · [Feed 이벤트](spec/feed-event.md)

## 2026-07-29 — back#74 리뷰 반영 (S15P11A705-125)
## 2026-07-29 — back#74 리뷰 반영 (Jira 작업)

`page-size`를 10에서 20으로 바꿀 때 탐색 슬롯 수가 따라가지 않아, 탐색 비중이 20%에서 10%로 절반이 된 것을 발견했다. 슬롯을 일반 2→4, Cold Start 3→6으로 올려 비율을 원복했다. 정책 변경이 아니라 누락 보정이다.

Place region 제거로 "AI 미완료 Collection이 점수를 얻는 경로"가 사라졌는데, 최신 채널을 60에서 100으로 늘린 것이 이 손실을 **부분적으로만** 보상한다는 점을 명시했다. Collection 쪽 Cold Start를 P42의 "감수하는 것" 목록에 추가했다.

수치 서술 두 곳을 바로잡았다. 후보 풀 상한 200과 채널 배분 합 200이 같아 잘라내기 규칙이 발동할 수 없다는 사실을 적었고, "10배 여유"로 적혀 있던 값의 실제치가 팔로우 0인 사용자 기준 6배 남짓임을 적었다. `InputLimits.FEED_EVENTS_MAX`라는 상수 이름과 상한 초과 시 `400 INVALID_INPUT` 응답을 못박았다. 이번에 고정한 API 계약 4가지를 `feed-tests.md`에 §11·E9로 추가했다. — [Feed 점수](spec/feed-scoring.md) · [Feed 테스트](spec/feed-tests.md) · [P42](proposals/P42-feed-mvp-without-place-metadata.md)

## 2026-07-29 — 내부 인증 헤더 표기 정정 (S15P11A705-96)
## 2026-07-29 — 내부 인증 헤더 표기 정정 (Jira 작업)

내부 인증 헤더 표기가 구현과 어긋난 것을 바로잡았다. 명세 §7이 `X-Internal-Token: <pinlog.ai.internal-token>`으로 적혀 있었으나, 실제 구현은 `ai/app/core/security.py`의 `INTERNAL_SECRET_HEADER = "X-Internal-Secret"`이고 테스트·E2E 도구도 전부 `X-Internal-Secret`을 쓴다. 플레이스홀더도 실제 주입 이름인 `INTERNAL_SHARED_SECRET`으로 맞췄다. 정정 전에는 이 문서만 보고 Spring을 붙이면 FastAPI가 401을 반환하는 상태였다. — [AI 연동 명세](spec/ai-integration.md)

## 2026-07-30 — 재스캔 명세 3.1의 근거 시나리오 정정 (S15P11A705-160)
## 2026-07-30 — 재스캔 명세 3.1의 근거 시나리오 정정 (Jira 작업)

재스캔 명세 3.1이 「Finalize를 먼저」의 근거로 든 시나리오가 실제로는 일어나지 않는다는 것이 back#104 구현 중 실측으로 드러났다. 두 단계를 맞바꿔도 테스트가 통과했다.

실제로 마지막 재시도의 창을 만드는 것은 `retry_count` 증가 시의 `updated_at` 갱신과 Finalizer 만료 조건이다. 스키마의 `DEFAULT now()`는 INSERT 기본값이라 UPDATE 시 자동 갱신되지 않는데도, 3.1 처리 순서에는 이 갱신이 빠져 있었다. 구현이 스스로 채워 넣은 장치가 명세에 없던 상태다.

3.1 순서 목록에 `updated_at` 갱신을 넣고, 근거를 이미 정확하던 6.1과 일치시켰다. 창을 만드는 것은 만료 조건과 `updated_at` 갱신이고, 순서는 심층 방어다. 순서 자체는 유지했다. 나중에 어느 구현이 `updated_at` 갱신을 빠뜨리면 그때 유일하게 남는 방어선이기 때문이다. 3.1·5·6.1이 서로를 참조하도록 연결했다. 구현 변경은 없다. — [재스캔 명세](spec/ai-rescan-scheduler.md)

## 2026-08-03 — Feed keywords를 display_name으로 교체 (back#146, S15P11A705-252)
## 2026-08-03 — Feed keywords를 display_name으로 교체 (back#146, Jira 작업)

Feed `keywords`가 `keyword_preset.code`를 내보내던 것을 `display_name`으로 교체했다(08 §6.1). 점수 계산(`FeedScorer.weightedJaccard`)의 키는 `code`로 유지하고 응답 조립 시점에만 매핑했다. `display_name`을 키로 쓰면 당장은 Jaccard가 그대로 성립해 테스트도 깨지지 않고 오류도 나지 않지만, 표시값이 바뀌는 순간 과거 Profile과의 매칭이 조용히 어긋나기 때문이다.

표시값을 못 찾은 code는 `code`로 대신 채우지 않고 응답에서 뺀다. 그 폴백 자체가 명세 위반이 되기 때문이다. N+1 부재는 `SqlQueryCounter`(신설)로 측정해 확인했고, 같은 카운터로 미검증 상태였던 feed-tests N4·N5도 함께 고정했다. back#145(조회 응답 3곳의 빈 keywords)는 이미 CLOSED였고 `ContextKeywordRepository`(별도 신설)로 처리돼 있어 `code`를 내보내지 않는 것을 확인했다. — [implements](implements/2026-08-03-feed-keyword-display-name.md)

## 2026-08-03 — 검색 응답 상태 노출을 금지하던 명세 §5 개정 (back#136, S15P11A705-209)
## 2026-08-03 — 검색 응답 상태 노출을 금지하던 명세 §5 개정 (back#136, Jira 작업)

검색 응답이 「분석 중」·「0건 완료」·「실패」를 구분하지 못하는 문제를 응답 조립 명세 §5 개정으로 풀었다. 직전 판까지 §5는 「상태 필드를 노출하지 않는다」·「구분할 필요도 없다」였고, 그 근거는 **처리가 짧게 끝난다는 가정**이었다. 실측이 그 가정을 벗어났다. -121·-197에서 GMS 판정이 분당 2건이었고, -198에서 PROCESSING 잔류로 10분 결빙이 관측됐다.

뒤집은 것은 그 두 줄뿐이다. 빈 배열 유지·`null` 금지는 그대로다. 이전 판단의 비용 근거(「모든 클라이언트가 3상태를 분기해야 한다」)가 아직 유효하다고 보아, 노출을 **검색 응답 하나로 좁히고** 필드를 무시하면 개정 전과 동일하게 동작하도록 설계했다.

내부 5값을 응답 3값으로 접는다. `PENDING`은 `PROCESSING`으로 합류하고, `CANCELLED`는 집계에서 제외하며, 상태 행이 없으면 `COMPLETED`로 본다. 사용자에게 필요한 판단이 「기다리면 오는가」 하나이기 때문이다. 행 없음을 `PROCESSING`으로 접으면 영영 오지 않는 것에 「분석 중」을 띄우게 된다. 그것이 바로 이 개정이 고치려던 증상이므로, 그렇게 접으면 증상이 재발한다. 타인 응답에 넣지 않은 것은 남의 처리 진행 상황이 새기 때문이다(§2 Visibility). 구현은 후속 작업으로 남겼다. — [응답 조립 명세 §5](spec/ai-response-assembly.md)

## 2026-08-03 — 검색 응답에 keywordStatus 구현 (back#136, S15P11A705-209)
## 2026-08-03 — 검색 응답에 keywordStatus 구현 (back#136, Jira 작업)

검색 응답에 Record 단위 `keywordStatus`를 노출했다(명세 §5.1 개정분). **기존 세 Keyword 쿼리는 한 글자도 바꾸지 않았다.** 그 쿼리들은 `keyword_status = 'COMPLETED'` INNER JOIN이라 미완료 Context를 애초에 만나지 못해, 상태 집계를 같은 쿼리로 합칠 수 없다. 합치려고 조건을 풀면 `keywords` 배열의 계약이 바뀐다. 그래서 `LEFT JOIN` 집계를 별도 쿼리로 두고 쿼리 1회 증가를 택했다. 하위 호환 위험을 코드 구조로 없애는 값이 왕복 1회보다 크다고 판단했다.

`CASE`의 분기 순서가 상태 접기 규칙이며, `PROCESSING`이 `FAILED`를 이긴다. 처리 중인 것이 끝나면 Keyword가 더 붙을 수 있기 때문이다. 조립 단계에서 `keywords`와 `keywordStatus`를 서로 맞추지 않는다. 두 값이 다른 쿼리에서 오고 그 사이 판정이 끝날 수 있어, 맞추면 둘 중 하나를 조용히 거짓으로 만들기 때문이다.

하위 호환 검증에서 「기존 쿼리 무변경」은 코드 읽기로만 확인되는 절반이다. 그래서 **미완료 Context가 섞인 Record에서 배열이 그대로인지를 실행으로** 붙잡았고, 기존 36건이 그대로 통과하는 것이 나머지 절반이다. N+1 부재는 `SqlQueryCounter`로 측정했다. 결과가 3→12건으로 늘어도 상태 조회는 1회로 고정된다(-252 선례). 공용 계약 반영(docs#41)이 구현보다 먼저였다. 백엔드가 직렬화 테스트를 문서 근거로 고정할 수 있어야 한다는 요청이다. — [implements I17](implements/2026-08-03-search-keyword-status.md) · [응답 조립 명세 §5.1](spec/ai-response-assembly.md)

## 2026-08-03 — Feed keywords 상한 3과 동점 규칙 (ai#93, S15P11A705-278)
## 2026-08-03 — Feed keywords 상한 3과 동점 규칙 (ai#93, Jira 작업)

Feed 응답 `keywords`에 상한이 없어 프론트(front#88 책장 레이아웃)가 카드를 확정하지 못하던 것을 풀었다. **개수 3과 빈도 내림차순은 프론트와의 구두 합의(2026-08-03)이고, 이 작업의 결정은 동점 규칙 하나다.** 명세에 출처 표를 두어 합의분과 결정분을 갈라 적었다. 개수를 데이터로 역산하지 않는다. 초판에서 「4 = 프리셋 축 수」로 적었던 것을 걷어냈다. 상한은 같은 날 4에서 3으로 한 번 더 바뀌었고, 그때 움직인 것도 화면 쪽이지 측정값이 아니었다.

Expand Down
4 changes: 2 additions & 2 deletions docs/ai/implements/2026-08-03-feed-keyword-display-name.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Feed keywords를 code에서 display_name으로 교체 (S15P11A705-252)
# Feed keywords를 code에서 display_name으로 교체 (Jira 작업)

- **상태**: ✅ 완료
- **관련**: back#146(정본) · back#145(선행 조건, CLOSED) · S15P11A705-252
- **관련**: back#146(정본) · back#145(선행 조건, CLOSED) · Jira 작업

## 무엇을 만들었나

Expand Down
12 changes: 6 additions & 6 deletions docs/ai/implements/2026-08-03-feed-keyword-display-order.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Feed 표시 Keyword의 동점 규칙과 상한 3 (S15P11A705-278)
# Feed 표시 Keyword의 동점 규칙과 상한 3 (Jira 작업)

- **상태**: ✅ 완료
- **관련**: S15P11A705-278 · [ai#93](https://github.com/Team-PinLog/ai/issues/93)(제기 — 프론트) ·
[front#88](https://github.com/Team-PinLog/front/issues/88)(S15P11A705-279 책장 레이아웃) ·
- **관련**: Jira 작업 · [ai#93](https://github.com/Team-PinLog/ai/issues/93)(제기 — 프론트) ·
[front#88](https://github.com/Team-PinLog/front/issues/88)(Jira 작업 책장 레이아웃) ·
[P46](../proposals/P46-feed-keyword-display-order.md)(결정) ·
[S15P11A705-252](2026-08-03-feed-keyword-display-name.md)(선행 — 표시값 경계)
[Jira 작업](2026-08-03-feed-keyword-display-name.md)(선행 — 표시값 경계)

## 무엇을 만들었나

Expand All @@ -22,8 +22,8 @@ FeedCollectionItemResponse KEYWORD_LIMIT = 3 (설정값 아님)
## 실측 — 조사가 설계를 바꾼 지점

티켓의 전제 하나가 사실과 달랐다. **현재 순서는 `GROUP BY` 결과 순서가 아니었다.**
`FeedService.RankedFeed.keywordsOf`는 `S15P11A705-120`(back#77) 때부터 `.sorted()`를 걸고 있었고,
`S15P11A705-252` 이후로는 **표시값 가나다순**이었다. 즉 순서는 이미 결정적이었다.
`FeedService.RankedFeed.keywordsOf`는 `Jira 작업`(back#77) 때부터 `.sorted()`를 걸고 있었고,
`Jira 작업` 이후로는 **표시값 가나다순**이었다. 즉 순서는 이미 결정적이었다.

그래서 문제는 결정성이 아니라 **기준에 의미가 없다**는 것으로 바뀌었다. 「가족과」가 「활기찬」보다
앞에 올 이유가 없고, 더 나쁜 것은 **표시 라벨을 한 글자 고치면 카드에 뜨는 Keyword 구성 자체가
Expand Down
10 changes: 5 additions & 5 deletions docs/ai/implements/2026-08-03-search-keyword-status.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 검색 응답에 Keyword 판정 상태 노출 (S15P11A705-209)
# 검색 응답에 Keyword 판정 상태 노출 (Jira 작업)

- **상태**: ✅ 완료
- **관련**: [back#136](https://github.com/Team-PinLog/back/issues/136)(정본) · front#58(화면 렌더링, 프론트 파트) · S15P11A705-209
- **관련**: [back#136](https://github.com/Team-PinLog/back/issues/136)(정본) · front#58(화면 렌더링, 프론트 파트) · Jira 작업
- **PR**: back#161(명세 개정, 1/3) · [Team-PinLog/docs#41](https://github.com/Team-PinLog/docs/pull/41)(공용 계약, 2/3) · back(구현, 3/3)

## 무엇을 만들었나
Expand Down Expand Up @@ -31,8 +31,8 @@ back#136에서 백엔드가 이 조항을 근거로 **「백엔드는 계약대
§5의 판단은 *"내부 처리 상태는 사용자 관심사가 아니다"*였고 이는 **처리가 짧게 끝난다는 가정** 위에 있었다. 실측이 그 가정을 벗어났다.

```text
S15P11A705-121·-197 GMS 판정이 분당 약 2건만 통과한다 (429)
S15P11A705-198 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로
Jira 작업·-197 GMS 판정이 분당 약 2건만 통과한다 (429)
Jira 작업 PROCESSING 잔류 + PROCESSING_EXPIRY_SEC 600초로
Context 하나가 10분 얼린 사례
```

Expand Down Expand Up @@ -141,7 +141,7 @@ GROUP BY ct.record_id

### N+1 부재 — 측정으로 확인했다

`RecordSearchQueryCountTests`를 신설했다(`SqlQueryCounter` 사용, S15P11A705-252 선례). 결과가 3→12건으로 늘어도 **상태 조회는 1회 고정**이고, **기존 Keyword 조회 횟수도 그대로**다. 후자가 "필드를 더하면서 기존 조회를 늘리지 않았다"가 측정으로 드러나는 유일한 자리다.
`RecordSearchQueryCountTests`를 신설했다(`SqlQueryCounter` 사용, Jira 작업 선례). 결과가 3→12건으로 늘어도 **상태 조회는 1회 고정**이고, **기존 Keyword 조회 횟수도 그대로**다. 후자가 "필드를 더하면서 기존 조회를 늘리지 않았다"가 측정으로 드러나는 유일한 자리다.

결과 0건이면 상태 조회 자체가 나가지 않는 것도 함께 고정했다. 빈 `IN ()`으로 도는 쿼리는 문법 오류이거나 전체 스캔이기 때문이다.

Expand Down
Loading
Loading