Skip to content
Open
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
50 changes: 18 additions & 32 deletions app/(tabs)/music.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { HomeHeader } from "@/components/home/HomeHeader";
import { LocationContextCard } from "@/components/home/LocationContextCard";
import {
RecommendationFeedbackSheet,
type RecommendationFeedbackRating,
type RecommendationFeedbackSubmission,
} from "@/components/home/RecommendationFeedbackSheet";
import {
MoodRecommendationSection,
Expand Down Expand Up @@ -61,6 +61,7 @@ import { toLibraryPlaylistSummary } from "@/utils/libraryPlaylistSummary";
import { requestForegroundLocationWithStatus } from "@/utils/location";
import { getMoodTagsFromFilter } from "@/utils/moodTags";
import { createRecommendationEventContext } from "@/utils/recommendationEventContext";
import { createRecommendationFeedbackValue } from "@/utils/recommendationFeedback";
import { getPlaceDisplayTitle } from "@/utils/placeLabel";

const moodFilterToMlMood: Record<string, PlaylistMlMood> = {
Expand Down Expand Up @@ -844,13 +845,13 @@ function HomeContent() {
setFeedbackPlaylist(undefined);
}, []);
const handleSubmitRecommendationFeedback = useCallback(
(rating: RecommendationFeedbackRating, moodFilter?: string) => {
({ opinion, rating }: RecommendationFeedbackSubmission) => {
if (!feedbackPlaylist) {
return;
}

const context = createRecommendationEventContext({
moodFilter: moodFilter ?? selectedMoodFilter,
moodFilter: selectedMoodFilter,
source: feedbackPlaylist.context?.source,
});

Expand All @@ -859,39 +860,23 @@ function HomeContent() {
context,
playlistId: feedbackPlaylist.id,
type: "recommendation_feedback",
value: moodFilter ? `${rating}:${moodFilter}` : rating,
value: createRecommendationFeedbackValue({
opinion,
rating,
subject: "music",
}),
}),
);

if (moodFilter) {
setSelectedMoodFilter(moodFilter);
syncRecommendationEvent(
addRecommendationEvent({
context,
playlistId: feedbackPlaylist.id,
type: "mood_adjusted",
value: moodFilter,
}),
);
setActionMessage(
`${moodFilter} 무드로 바꿨어요. 다음 추천에 바로 반영할게요.`,
);
} else if (rating === "great") {
setActionMessage("좋아요. 비슷한 장소와 무드 추천에 반영할게요.");
} else if (rating === "okay") {
setActionMessage("피드백을 저장했어요. 다음 추천을 더 잘 맞춰볼게요.");
} else {
setActionMessage("다음 추천에서는 다른 느낌을 더 살펴볼게요.");
}
setActionMessage(
opinion?.trim()
? `${rating}점과 의견을 남겼어요.`
: `${rating}점을 남겼어요.`,
);

setFeedbackPlaylist(undefined);
},
[
addRecommendationEvent,
feedbackPlaylist,
selectedMoodFilter,
setSelectedMoodFilter,
],
[addRecommendationEvent, feedbackPlaylist, selectedMoodFilter],
);
const handleSelectMusicPlaylistTrack = useCallback(
(track: Track) => {
Expand Down Expand Up @@ -1167,10 +1152,11 @@ function HomeContent() {
visible={isMusicPlaylistSheetVisible}
/>
<RecommendationFeedbackSheet
contextLabel={feedbackPlaylist?.regionName}
onClose={handleCloseRecommendationFeedback}
onSubmit={handleSubmitRecommendationFeedback}
playlistReason={feedbackPlaylist?.reason}
regionName={feedbackPlaylist?.regionName}
recommendationReason={feedbackPlaylist?.reason}
subject="music"
visible={Boolean(feedbackPlaylist)}
/>
{currentTrack ? <MiniPlayer /> : null}
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트

## Codex

- [Soundlog AI 개발 운영 방식](codex/AI_USAGE.md): 프론트엔드 드리븐 개발, 서버 요구사항 전달, 검증, Pull Request와 병합 승인 원칙
- [비개발자용 Codex 개발 가이드](codex/NON_DEVELOPER_CODEX_GUIDE.md): Codex에게 개발을 맡기는 방식
- [Codex 요청 프롬프트 모음](codex/CODEX_PROMPTS.md): 바로 복사해서 쓸 수 있는 요청문
- [UI 피드백 루프 운영 문서](codex/UI_FEEDBACK_LOOP.md): 자연어 UI 수정 요청을 계획-리뷰-구현-리뷰 루프로 처리하는 방식
Expand Down
88 changes: 88 additions & 0 deletions docs/codex/AI_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Soundlog AI 개발 운영 방식

이 문서는 Soundlog에서 AI와 함께 기능을 설계하고 구현할 때 따르는 기본 흐름을 설명합니다. 제품 요구사항은 프론트엔드 화면과 사용자 흐름으로 먼저 구체화하고, 검증된 프론트엔드 계약을 서버 팀에 전달합니다.

## 1. 기본 원칙

Soundlog는 프론트엔드 드리븐 디벨롭 방식으로 개발합니다. 프론트엔드 드리븐 디벨롭은 사용자가 실제로 보고 누르는 화면을 먼저 설계하고, 그 화면에 필요한 데이터 계약을 나중에 서버 요구사항으로 확정하는 방식입니다.

AI는 다음 원칙을 지킵니다.

1. 사용자의 자연어 요구를 사용자 목표와 화면 상태와 수용 조건으로 바꿉니다.
2. 기존 디자인 시스템과 제품 용어를 확인한 뒤 UI를 설계합니다.
3. 프론트엔드에서 가능한 상태와 예외 흐름을 먼저 구현합니다.
4. 서버가 필요한 기능은 앱 저장소의 서버 전달 문서로 작성합니다.
5. 서버 저장소와 운영 인프라는 팀원이 담당하며 AI가 임의로 수정하거나 배포하지 않습니다.
6. 앱 변경은 iOS 시뮬레이터에서 실제 버튼을 눌러 검증합니다. Expo 웹이나 브라우저 화면은 앱 검수 근거로 사용하지 않습니다.
7. 모든 변경은 기능 브랜치와 Pull Request를 거칩니다. AI는 사용자가 해당 Pull Request 번호를 명시해 병합을 요청한 경우에만 병합합니다.

## 2. 기능 개발 순서

### 2.1 요구사항을 화면 계약으로 바꾸기

AI는 구현 전에 아래 내용을 짧은 계획 문서로 작성합니다.

- 사용자가 이 기능을 사용하는 이유
- 기능에 진입하는 화면과 CTA
- 기본 상태와 로딩 상태와 빈 상태와 실패 상태
- 입력값과 검증 규칙
- 접근성과 개인정보 보호 조건
- 완료 여부를 판단할 수 있는 수용 조건

서버 응답이 아직 없어도 화면의 모든 상태를 먼저 정의합니다. 다만 실제 저장 성공을 가짜 데이터로 확정해서 보여주면 안 됩니다.

### 2.2 UI와 프론트엔드 동작 구현하기

기존 컴포넌트를 재사용하고 Soundlog 디자인 시스템을 따릅니다. 사용자에게 보이는 기본 글자는 흰색 계열을 사용합니다. 주요 버튼은 44pt 이상의 터치 영역을 확보합니다. 네트워크 요청은 중복 제출과 실패와 재시도를 고려합니다.

서버 기능이 준비되지 않은 경우 프론트엔드가 기대하는 요청과 응답을 타입으로 고정할 수 있습니다. 이때 현재 서버가 지원하지 않는 동작을 사용자에게 성공했다고 안내하지 않습니다.

### 2.3 서버 요구사항 전달하기

서버 작업이 필요한 기능은 `docs/frontend/` 아래에 별도 스펙을 만듭니다. 문서에는 다음 내용을 포함합니다.

- 기능 목적과 호출 시점
- API 경로와 인증 조건
- 요청과 응답 예시
- 필드별 타입과 필수 여부
- 멱등성과 중복 요청 처리
- 개인정보와 로그 보존 조건
- 오류 코드와 프론트엔드 처리 방식
- 서버 팀이 확인할 수 있는 수용 시나리오

사진 파일 경로나 사용자가 입력한 민감정보를 분석 이벤트에 불필요하게 보내지 않습니다. 서버 팀은 이 문서를 기준으로 API를 구현하고, 계약 변경이 필요하면 프론트엔드 담당자와 함께 문서를 먼저 수정합니다.

### 2.4 검증과 Pull Request

AI는 타입 검사와 단위 테스트와 저장소 검증 스크립트를 실행합니다. 그 다음 iOS 시뮬레이터에서 진입부터 제출까지 확인하고 스크린샷을 남깁니다.

Pull Request에는 아래 내용을 기록합니다.

- 사용자가 체감하는 변경 사항
- 변경된 화면과 상태
- 서버 팀에 전달할 계약 문서
- 실행한 검증 명령과 시뮬레이터 결과
- 아직 서버 반영이 필요한 부분

CI가 통과해도 운영 서버와 TestFlight에서 동작한다는 뜻은 아닙니다. 병합과 앱 배포와 서버 배포와 운영 검수 결과는 각각 구분해 보고합니다.

## 3. 역할 경계

| 역할 | 담당 내용 |
| ------------------ | ----------------------------------------------------------------------------------- |
| 프론트엔드와 AI | 사용자 흐름, UI 상태, 앱 코드, 타입, 앱 테스트, 서버 요구사항 문서, 앱 Pull Request |
| 서버 팀원 | API 구현, 데이터베이스 변경, 운영 환경변수, 서버 배포, 운영 로그 확인 |
| 사용자 또는 리뷰어 | 제품 결정, Pull Request 승인, 해당 Pull Request 병합 지시, 앱 배포 승인 |

앱 빌드나 EAS 배포 요청은 서버 배포 요청으로 해석하지 않습니다. 서버 구현이 필요하면 앱 Pull Request에서 서버 전달 문서를 공유하고 팀원의 반영을 기다립니다.

## 4. 이번 기능 적용 예시

추천 피드백 기능은 아래 순서로 진행합니다.

1. 음악 추천 상세를 닫거나 추천사진 편집 화면에서 피드백에 진입합니다.
2. 사용자는 1점부터 5점까지 별점을 선택합니다.
3. 사용자는 별점만 보내거나 짧은 의견을 추가해 보낼 수 있습니다.
4. 프론트엔드는 추천 종류와 별점과 선택 의견만 이벤트로 만듭니다.
5. 추천사진의 원본 주소와 이미지 파일은 피드백 이벤트에 포함하지 않습니다.
6. 서버 팀은 [추천 피드백 서버 요구사항](../frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md)을 기준으로 이벤트를 저장하고 분석할 수 있게 구현합니다.
139 changes: 139 additions & 0 deletions docs/frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 추천 피드백 서버 요구사항

## 1. 목적

앱 사용자가 음악 추천과 관광지 추천사진에 1점부터 5점까지 별점을 남긴다. 사용자는 선택적으로 300자 이하 의견을 함께 보낼 수 있다. 서버는 이 값을 추천 품질 분석과 이후 추천 모델 개선에 사용할 수 있는 형태로 저장해야 한다.

이 문서는 프론트엔드 구현이 기대하는 서버 계약이다. 서버 저장소와 데이터베이스와 운영 배포는 서버 팀원이 담당한다.

## 2. 호출 시점

- 음악 추천 상세를 닫은 뒤 사용자가 피드백 제출 버튼을 누른 시점
- 추천사진을 리캡 편집 화면에 적용한 뒤 사용자가 피드백 제출 버튼을 누른 시점

시트를 닫거나 건너뛴 경우 요청하지 않는다.

## 3. 요청 계약

기존 엔드포인트를 사용한다.

```http
POST /v1/recommendation-events
Authorization: Bearer <access-token>
Idempotency-Key: <event-id>
Content-Type: application/json
```

요청 예시는 다음과 같다.

```json
{
"events": [
{
"id": "event-1725267600000-ab12cd",
"sessionId": "session-1725267000000-ef34gh",
"createdAt": "2026-09-02T10:00:00.000Z",
"type": "recommendation_feedback",
"playlistId": "playlist-seoul-night",
"context": {
"source": "ml-recommendation",
"moodFilter": "감성적인",
"placeId": "tour-126508",
"placeName": "서울숲"
},
"value": "{\"version\":1,\"subject\":\"music\",\"rating\":5,\"opinion\":\"산책할 때 잘 어울렸어요\"}"
}
]
}
```

`value`를 파싱한 논리 타입은 다음과 같다.

```ts
type RecommendationFeedbackValue = {
version: 1;
subject: "music" | "photo";
rating: 1 | 2 | 3 | 4 | 5;
opinion?: string;
};
```

## 4. 필드 규칙

| 필드 | 필수 여부 | 규칙 |
| ----------------- | ---------------------- | ---------------------------------------------------------- |
| `type` | 필수 | 항상 `recommendation_feedback` |
| `value.version` | 필수 | 현재 값은 `1` |
| `value.subject` | 필수 | `music` 또는 `photo` |
| `value.rating` | 필수 | 1부터 5까지의 정수 |
| `value.opinion` | 선택 | 앞뒤 공백을 제거한 1자 이상 300자 이하 문자열 |
| `playlistId` | 음악에서 필수 | 평가한 추천 플레이리스트 식별자 |
| `context.placeId` | 사진에서 가능하면 필수 | 추천사진을 제공한 관광지 식별자 |
| `context.source` | 필수 | 음악 추천 출처 또는 `recommended-photo:<관광 데이터 출처>` |

사진 피드백에는 이미지 URL과 기기 파일 URI와 이미지 바이너리와 GPS 좌표를 넣지 않는다. 장소 식별자와 사람이 읽을 수 있는 장소명만 전달한다.

## 5. 서버 처리 요구사항

1. 인증된 사용자와 이벤트 `id`와 평가 대상을 연결해 저장한다.
2. 같은 이벤트 `id` 또는 같은 `Idempotency-Key`가 다시 오면 중복 레코드를 만들지 않는다.
3. `value` JSON을 검증하고 구조화된 별점과 선택 의견으로 저장한다.
4. 알 수 없는 `version`은 조용히 잘못 해석하지 말고 지원하지 않는 계약으로 처리한다.
5. 별점 범위를 벗어나거나 의견이 300자를 넘으면 해당 이벤트를 거부한다.
6. 한 이벤트가 잘못돼도 배치 전체를 어떻게 처리하는지 응답에 명시한다.
7. 의견은 운영 로그에 원문으로 남기지 않는다. 접근 권한과 보존 기간을 정한다.

## 6. 응답 계약

최소 응답은 기존 앱과 호환되어야 한다.

```json
{
"accepted": true
}
```

배치의 일부만 거부할 수 있다면 아래 확장 응답을 권장한다.

```json
{
"accepted": false,
"acceptedEventIds": [],
"rejectedEvents": [
{
"eventId": "event-1725267600000-ab12cd",
"code": "INVALID_FEEDBACK_VALUE",
"message": "rating은 1부터 5까지의 정수여야 합니다."
}
]
}
```

## 7. 오류 처리

| 상태 | 의미 | 앱 처리 |
| ----- | ---------------------------- | ---------------------------------------------------------- |
| `400` | 요청 또는 피드백 값이 잘못됨 | 사용자 흐름은 유지하고 진단 로그로 확인 |
| `401` | 인증이 없거나 만료됨 | 현재 앱 정책에 따라 인증 복구 후 새 이벤트부터 전송 |
| `409` | 멱등성 키 충돌 | 동일 이벤트면 성공으로 취급할 수 있는 식별 정보 제공 |
| `429` | 요청 제한 | 추천과 리캡 흐름은 유지하고 이후 재시도 정책을 별도로 결정 |
| `5xx` | 서버 오류 | 추천과 리캡 흐름을 막지 않음 |

현재 앱은 이 분석 이벤트 전송 실패를 사용자 핵심 기능 실패로 처리하지 않는다. 서버가 안정적인 재전송을 요구한다면 앱의 전송 큐와 완료 상태 계약을 별도 기능으로 합의해야 한다.

## 8. 서버 수용 시나리오

1. 음악 추천에 5점만 보낸 이벤트를 저장한다.
2. 음악 추천에 3점과 의견을 보낸 이벤트를 저장한다.
3. 추천사진에 4점만 보낸 이벤트를 장소와 연결해 저장한다.
4. 공백만 있는 의견은 없는 값으로 정규화하거나 명시적인 검증 오류로 처리한다.
5. 0점과 6점과 소수점 별점을 거부한다.
6. 301자 의견을 거부한다.
7. 같은 이벤트를 두 번 보내도 하나만 저장한다.
8. 사진 이벤트의 요청과 저장 데이터에 사진 URI가 없는지 확인한다.

## 9. 연관 확인 사항

2026년 9월 2일 iOS 시뮬레이터 검수에서 서버가 반환한 관광지 추천사진 한 건이 리캡 편집 화면에서 로드되지 않았다. 피드백 계약과는 별개지만 추천사진을 평가하려면 원본 사진이 먼저 보여야 한다. 서버 팀은 관광지 응답의 `imageUrl`이 HTTPS를 사용하는지와 인증 없이 외부에서 읽을 수 있는지와 만료되지 않은 주소인지 확인해야 한다.

앱은 추천 피드백 이벤트에 이 주소를 다시 보내지 않는다. 서버 확인에는 원래 관광지 조회 응답과 서버 로그를 사용한다.
Loading
Loading