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
20 changes: 20 additions & 0 deletions apps/characters/migrations/0013_add_appearance_payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("characters", "0012_remove_character_gen_img_object_key"),
]

operations = [
migrations.AddField(
model_name="character",
name="appearance_payload",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="charactergenerationjob",
name="appearance_payload",
field=models.JSONField(blank=True, null=True),
),
]
4 changes: 4 additions & 0 deletions apps/characters/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class Status(models.TextChoices):
persona = models.TextField(blank=True)
# AI 생성 외형 묘사. 확정 등록(CHAR-004) 시 Character.visual 로 옮긴다.
appearance = models.CharField(max_length=255, blank=True)
# 이미지 워커가 반환한 정규화 외형. 사용자가 나중에 캐릭터를 확정할 때 복사한다.
appearance_payload = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

Expand Down Expand Up @@ -93,6 +95,8 @@ class Character(models.Model):
gen_img_url = models.TextField()
persona = models.TextField()
visual = models.CharField(max_length=255, blank=True)
# 피드 생성 시 동일한 캐릭터 외형을 재현하기 위한 정규화 데이터.
appearance_payload = models.JSONField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Expand Down
2 changes: 2 additions & 0 deletions apps/characters/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,15 @@ def process_character_generation_job(
locked.gen_img_url = result.get("image_url", "")
locked.gen_img_object_key = result.get("gen_img_object_key", "")
locked.appearance = result.get("appearance", "")
locked.appearance_payload = result.get("appearance_payload")
locked.persona = _compose_persona(result)
locked.status = CharacterGenerationJob.Status.SUCCEEDED
locked.save(
update_fields=[
"gen_img_url",
"gen_img_object_key",
"appearance",
"appearance_payload",
"persona",
"status",
"updated_at",
Expand Down
1 change: 1 addition & 0 deletions apps/characters/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ def post(self, request: Request) -> Response:
gen_img_url=gen_img_object_key,
persona=job.persona,
visual=job.appearance,
appearance_payload=job.appearance_payload,
)

job.status = CharacterGenerationJob.Status.CONSUMED
Expand Down
1 change: 1 addition & 0 deletions apps/posts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def generate_feed_post(self, quest_id: str) -> None:
"personality": character.persona,
"speech_style": "",
"appearance_keywords": [character.visual] if character.visual else [],
"appearance_payload": character.appearance_payload,
# gen_img_url 은 S3 object key 라 AI fetch 용 presigned URL 로 변환.
"image_url": _resolve_gen_img_url(character.gen_img_url),
},
Expand Down
20 changes: 16 additions & 4 deletions docs/api-character-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ sequenceDiagram
3. `CharacterGenerationJob` 조회(`gen_job_id`, 본인 소유). 없으면 `404 JOB_NOT_FOUND`.
4. `status==CONSUMED` → `409 JOB_ALREADY_CONSUMED` (한 잡은 한 번만 등록 가능).
5. `status!=SUCCEEDED` → `400 JOB_NOT_SUCCEEDED`.
6. `Character` 생성: `generation_job=job`, `character_name=name`, `gen_img_url=job.gen_img_url`, `persona=persona`, **`visual=job.custom_prompt`**(잡 생성 시 입력한 외형 묘사를 캐릭터로 보존).
6. `Character` 생성: `generation_job=job`, `character_name=name`, `gen_img_url=job.gen_img_url`, `persona=job.persona`, `visual=job.appearance`, `appearance_payload=job.appearance_payload`.
- 생성 완료와 확정 등록은 서로 다른 시점이므로, 구조화 외형 데이터는 먼저 Job에 보관했다가 이 단계에서 Character로 복사한다.
7. `job.status = CONSUMED`.
8. `201` 응답 + (권장) `Location: /api/v1/characters/:id`.

Expand Down Expand Up @@ -243,8 +244,12 @@ sequenceDiagram
- 헤더 `X-API-Key: <AI_SERVICE_TOKEN>` (⚠️ Bearer 아님)
- Body `{user_id, name, persona, personality_keywords, source_image_url}`
- `timeout = 120초`
4. 응답 envelope에서 **`result`** 키 파싱(⚠️ `data` 아님): `result.image_url` → `job.gen_img_url`, `result.gen_img_object_key` → `job.gen_img_object_key`.
5. `status = SUCCEEDED` 저장.
4. 완료 응답 envelope에서 **`result`** 키 파싱(⚠️ `data` 아님):
- `result.image_url` → `job.gen_img_url`
- `result.gen_img_object_key` → `job.gen_img_object_key`
- `result.appearance` → `job.appearance`
- `result.appearance_payload` → `job.appearance_payload`
5. `status = SUCCEEDED` 저장. 사용자가 나중에 확정 등록할 때 Job의 외형 데이터를 Character로 복사한다.
6. 예외 발생 시 `status = FAILED`, `error_code/error_message` 저장.

**mongle-ai 응답 예시**
Expand All @@ -257,13 +262,20 @@ sequenceDiagram
"persona": "...",
"personality": "...",
"speech_style": "...",
"background": "..."
"background": "...",
"appearance": "round brown bear with a red ribbon",
"appearance_payload": {
"character_type": "bear",
"main_colors": ["brown"],
"accessories": ["red ribbon"]
}
},
"error": null
}
```

- ⚠️ 개인화 필드(`personality`/`speech_style`/`background`)는 현재 **저장하지 않는다**(향후 챗봇 메모리 기능에서 도입 예정).
- `appearance_payload`는 프론트 표시용이 아니라 피드 이미지에서 동일 캐릭터를 재현하기 위한 내부 데이터다. 캐릭터 확정 후 `Character`에 보관하며, 이후 피드 생성 태스크가 `POST /v1/feed/generate`의 `character.appearance_payload`로 전달한다.
- ⚠️ **운영 주의**: 콜드스타트 시 AI 응답이 `timeout(120초)`을 초과할 수 있다(RunPod LLM·이미지 워커 동시 cold). 첫 요청/유휴 후 첫 요청에서 `FAILED` 위험 → 워커 웜 유지 또는 timeout 상향 검토 필요.

---
Expand Down
19 changes: 18 additions & 1 deletion tests/test_characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,19 @@ def test_character_register_success(
def test_character_register_saves_appearance_as_visual(
auth_client: APIClient, user: User
) -> None:
appearance_payload = {
"character_type": "bear",
"main_colors": ["yellow"],
"accessories": [],
}
job = CharacterGenerationJob.objects.create(
user=user,
personality_keywords=["활발한"],
status=CharacterGenerationJob.Status.SUCCEEDED,
gen_img_url="https://example.com/gen.png",
persona="밝은 캐릭터",
appearance="둥근 얼굴에 노란 털",
appearance_payload=appearance_payload,
)
# 요청에 persona 를 보내도 무시되고, job 의 AI 정제본이 저장돼야 한다.
response = auth_client.post(
Expand All @@ -399,6 +405,7 @@ def test_character_register_saves_appearance_as_visual(
assert response.status_code == 201
character = Character.objects.get(character_id=response.json()["character_id"])
assert character.visual == "둥근 얼굴에 노란 털"
assert character.appearance_payload == appearance_payload
assert character.persona == "밝은 캐릭터"

# 개인화 프로필(JSON)에 캐릭터 정보가 기록돼야 한다.
Expand Down Expand Up @@ -871,10 +878,19 @@ def test_process_job_submits_polls_and_saves_appearance(user: User, settings) ->
submit = _mock_response(
{"status": "pending", "result": {"job_id": "ai-1"}, "error": None}
)
appearance_payload = {
"character_type": "bear",
"main_colors": ["brown"],
"accessories": [],
}
poll = _mock_response(
{
"status": "done",
"result": {"image_url": "https://cdn/x.png", "appearance": "둥근 갈색 몸"},
"result": {
"image_url": "https://cdn/x.png",
"appearance": "둥근 갈색 몸",
"appearance_payload": appearance_payload,
},
"error": None,
}
)
Expand Down Expand Up @@ -902,6 +918,7 @@ def test_process_job_submits_polls_and_saves_appearance(user: User, settings) ->
assert job.status == "SUCCEEDED"
assert job.gen_img_url == "https://cdn/x.png"
assert job.appearance == "둥근 갈색 몸"
assert job.appearance_payload == appearance_payload


# 생성 실패 시 제출 시점에 차감한 일일 생성 횟수(ImgGenLog)가 환불(삭제)되는지 확인
Expand Down
34 changes: 34 additions & 0 deletions tests/test_posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from datetime import timedelta
from unittest.mock import MagicMock, patch

from django.utils import timezone
import pytest
Expand All @@ -16,6 +17,39 @@
from apps.users.models import TokenTransaction, User


@pytest.mark.django_db
def test_generate_feed_post_sends_appearance_payload(
character: Character, quest: Quest, settings
) -> None:
appearance_payload = {
"character_type": "bear",
"main_colors": ["brown"],
"accessories": ["red ribbon"],
}
character.appearance_payload = appearance_payload
character.save(update_fields=["appearance_payload", "updated_at"])
settings.AI_SERVICE_URL = "http://ai.test"
settings.AI_SERVICE_TOKEN = "secret-key" # noqa: S105

response = MagicMock()
response.raise_for_status.return_value = None
response.json.return_value = {
"result": {
"image_url": "https://cdn.test/feed.png",
"caption": "오늘도 산책을 완료했어요.",
}
}

with patch("apps.posts.tasks.httpx.post", return_value=response) as mock_post:
from apps.posts.tasks import generate_feed_post

generate_feed_post.run(str(quest.quest_id))

payload = mock_post.call_args.kwargs["json"]
assert payload["character"]["appearance_payload"] == appearance_payload
assert Post.objects.filter(quest=quest).exists()


def _make_other_user_post() -> Post:
"""다른 사용자 소유의 게시글을 하나 만든다(스코프 격리 검증용)."""
other = User.objects.create_user(
Expand Down
Loading