diff --git a/docs/AGENT.md b/docs/AGENT.md index d38b6af..c7a6cc5 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -131,8 +131,8 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 | 등급 | 도구 | 대화에서 | |---|---|---| -| `READ` | `get_site_status` · `list_facts` · `list_sections` | 바로 답한다 | -| `REVERSIBLE` | `set_fact` · `toggle_section` · `move_section` | 실행하고 알린다 | +| `READ` | `get_site_status` · `list_facts` · `list_sections` · `list_photos` | 바로 답한다 | +| `REVERSIBLE` | `set_fact` · `toggle_section` · `move_section` · `hide_photo` · `set_primary_photo` | 실행하고 알린다 | | `SEMI` | `publish` | **실행 전에 한 번 묻는다** | ### 페이지 구성 (2026-09-28) @@ -152,6 +152,27 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳 ★ **`sections` 만 갈아끼운다.** theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다. +### 사진 (2026-09-28) + +"객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 **라벨·alt** 로 한다. + +★ **대표 사진은 별도 칸이 아니라 목록의 첫 장**이다(`site_payload.primary_media`). 그래서 +'대표로 지정' 은 `sort_order` 를 가장 작게 내리는 일이다 — 칸을 따로 두면 규칙이 둘이 되고, +검색 결과에 뜨는 그림과 화면 첫 장이 갈린다. + +★ **객실·메뉴 전용 사진(`unit_id` 있음)은 대표가 될 수 없다.** `primary_media` 가 건너뛰므로 +지정하게 두면 화면만 거짓말한다. + +★ **내려도 지우지 않는다**(`REJECTED`). `origin_url`·`source_type` 이 남아 있어야 재게시 +권리(DECISIONS 1-2) 결론이 났을 때 무엇을 실었는지 되짚을 수 있고, 잘못 내렸을 때 되돌릴 수도 있다. + +★★ **업로드·교체 도구는 만들지 않았다.** 이미지 재게시 권리가 미결이라 저장 경로를 일부러 +안 만들어 둔 것이고(DECISIONS 5-3), 도구가 생기면 **그 결정을 코드가 먼저 풀어 버린다.** +테스트가 레지스트리에 `upload`·`replace` 가 없는지 실제로 검사한다. + +★ 서버 엔드포인트(`POST .../media/{id}/hide` · `/primary`)도 함께 열었다 — 에이전트 전용 +뒷문을 만들면 빌더 화면이 그 기능을 못 쓰고, 나중에 붙일 때 로직이 두 벌이 된다. + ★ **템플릿·색 변경은 아직 없다.** 목록이 프론트(`frontend/src/data/industryData.ts`)에 있고 `templatesFor()` 가 색·`look`·기본 섹션·배리에이션을 **함께 계산**한다. 백엔드가 `template_id` 만 바꾸면 색은 옛것이 남아 "레이아웃은 새것, 색은 옛것" 이 된다 — 조용히 틀리는 종류다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a60ec6e..4804df7 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,28 @@ # 개발 일지 +## 2026-09-28 — 에이전트가 사진을 내리고 대표를 지정한다 + +사진 쪽은 `MediaService` 에 `list_media` 하나뿐이었다 — **쓰기 경로가 아예 없었다.** +빌더 화면에서도 보기만 됐다. 그래서 서비스·라우터부터 열고 도구를 붙였다. + +- `crud/media_crud.set_sort_order` · `services/media_service.hide_media`·`set_primary` +- `POST .../media/{id}/hide` · `/primary` — ★ 에이전트 전용 뒷문을 만들지 않는다. + 그러면 빌더 화면이 그 기능을 못 쓰고 나중에 붙일 때 로직이 두 벌이 된다 +- 도구 셋: `list_photos`(READ) · `hide_photo` · `set_primary_photo`(REVERSIBLE) + +**★ 대표 사진에 별도 칸을 두지 않았다.** `site_payload.primary_media` 가 '첫 장' 을 쓰고 +목록이 `ORDER BY sort_order, created_at` 이라, 지정은 `sort_order` 를 가장 작게 내리는 일이다. +칸을 따로 두면 규칙이 둘이 되어 **검색 결과에 뜨는 그림과 화면 첫 장이 갈린다.** + +**★ 내려도 지우지 않는다**(`REJECTED`). `origin_url`·`source_type` 이 남아야 재게시 권리 +(DECISIONS 1-2) 결론이 났을 때 되짚을 수 있다. + +**★★ 업로드·교체는 만들지 않았다.** 미결 사항(1-2)을 코드가 먼저 푸는 자리다 — +테스트가 레지스트리에 `upload`·`replace` 가 없는지 실제로 검사한다. + +**검증** — `test_agent_runtime` 32 passed(사진 6건 추가). 전체 `871 passed / 53 failed` 이고 +그 53 은 이번 변경 전과 같다. `npm run lint` 통과. + ## 2026-09-28 — 에이전트가 페이지 구성을 바꾼다 (섹션 on/off · 순서) "문구 변경밖에 안 된다" 는 지적에서 시작했다. 페이지 구성은 `sites.theme.sections` 배열 diff --git a/solution/backend/crud/media_crud.py b/solution/backend/crud/media_crud.py index 7f0a7d2..9b6ec14 100644 --- a/solution/backend/crud/media_crud.py +++ b/solution/backend/crud/media_crud.py @@ -24,6 +24,9 @@ class IMediaCRUD(ABC): pass @abstractmethod + async def set_sort_order(self, cdb: AsyncSession, place_id, media_id, sort_order: int, ts) -> Tuple[ErrorType, int]: + pass + async def set_status(self, cdb: AsyncSession, place_id, media_id, status: int, ts) -> Tuple[ErrorType, int]: pass @@ -81,6 +84,24 @@ class MediaCRUD(IMediaCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 + async def set_sort_order(self, cdb: AsyncSession, place_id, media_id, sort_order: int, ts) -> Tuple[ErrorType, int]: + """사진 순서를 바꾼다. + + ★ 목록이 `ORDER BY sort_order, created_at` 이고 대표 사진은 그 **첫 장**이다 + (site_payload.primary_media). 그래서 '대표로 지정' 은 이 값을 가장 작게 만드는 일이다 — + 별도의 is_primary 칸을 두지 않는 이유는, 두면 두 규칙(칸 · 순서)이 생겨 + 검색 결과에 뜨는 그림과 화면 첫 장이 갈릴 수 있기 때문이다.""" + try: + query = ( + update(place_photos) + .where(place_photos.media_id == media_id, place_photos.place_id == place_id, place_photos.deleted == False) # noqa: E712 + .values(sort_order=sort_order, updated_at=ts) + ) + return await DB_SESSION_MNG.add_with_rowcount(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + async def set_status(self, cdb: AsyncSession, place_id, media_id, status: int, ts) -> Tuple[ErrorType, int]: """사람이 사진을 승인/반려한다.""" try: diff --git a/solution/backend/router/v1/media/media.py b/solution/backend/router/v1/media/media.py index aa451a0..ddfa3b8 100644 --- a/solution/backend/router/v1/media/media.py +++ b/solution/backend/router/v1/media/media.py @@ -30,3 +30,37 @@ async def list_media( publishable_only: bool = Query(False), ): return RemoveNoneResponse(await service.list_media(user_info, str(place_id), unit_id, publishable_only)) + + +@router.post( + path="/{media_id}/hide", + response_model=Res_MediaList, + summary="사진 내리기", + description="그 사진을 REJECTED 로 내려 발행본에서 뺀다. ★ 지우지 않는다 — origin_url·source_type 이 " + "남아 있어야 재게시 권리(docs/DECISIONS.md 1-2) 결론이 났을 때 되짚을 수 있고, " + "잘못 내렸을 때 되돌릴 수도 있다. 응답은 갱신된 목록이다.", +) +async def hide_media( + place_id: UUID, + media_id: UUID, + service: MediaService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.hide_media(user_info, str(place_id), str(media_id))) + + +@router.post( + path="/{media_id}/primary", + response_model=Res_MediaList, + summary="대표 사진 지정", + description="목록 맨 앞으로 올린다. ★ 대표 사진은 별도 칸이 아니라 **목록의 첫 장**이다" + "(site_payload.primary_media) — 칸을 따로 두면 검색 결과에 뜨는 그림과 화면 첫 장이 갈린다. " + "객실·메뉴 전용 사진(unit_id 가 있는 것)은 대표가 될 수 없다.", +) +async def set_primary_media( + place_id: UUID, + media_id: UUID, + service: MediaService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.set_primary(user_info, str(place_id), str(media_id))) diff --git a/solution/backend/services/agent/runtime.py b/solution/backend/services/agent/runtime.py index b5a29ff..85c70a9 100644 --- a/solution/backend/services/agent/runtime.py +++ b/solution/backend/services/agent/runtime.py @@ -82,7 +82,7 @@ async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]: return out[:30] -async def _choose(place, fields, facts, sections, message) -> dict: +async def _choose(place, fields, facts, sections, photos, message) -> dict: """LLM 한 번. 고른 도구 이름과 인자만 받는다.""" active = provider.active() async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: @@ -95,6 +95,7 @@ async def _choose(place, fields, facts, sections, message) -> dict: fields=fields, facts=facts, sections=sections, + photos=photos, message=message, ), response_schema=prompt.RESPONSE_SCHEMA, @@ -132,11 +133,13 @@ async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None facts = await _context_facts(user, place_id, place) # ★ 섹션은 이름으로 지목한다("후기 빼줘"). 목록을 안 실으면 모델이 이름을 지어낸다. sections, _theme = await registry.sections_of(ctx) + # ★ 사진도 이름으로 지목한다. 목록을 안 실으면 모델이 라벨을 지어낸다. + photos = await registry.photo_names(ctx) # ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이 # 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다. try: - choice = await _choose(place, fields, facts, sections, message) + choice = await _choose(place, fields, facts, sections, photos, message) except LlmError as ex: LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}") raise AgentError("AGENT_CALL_FAILED") from ex diff --git a/solution/backend/services/agent/tools.py b/solution/backend/services/agent/tools.py index 04e3567..a6ec2e0 100644 --- a/solution/backend/services/agent/tools.py +++ b/solution/backend/services/agent/tools.py @@ -23,12 +23,14 @@ from common.enums import ErrorType, PlaceCategory, SourceType from common.models.gmodel import UserInfo from crud.fact_crud import FactCRUD from crud.job_crud import JobQueue +from crud.media_crud import MediaCRUD from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD from router.v1.fact.protocol import Req_UpsertFact from router.v1.site.protocol import Req_SiteTheme, Req_StartBuild from services import site_payload from services.fact_service import FactService +from services.media_service import MediaService from services.site_service import SiteService @@ -67,6 +69,10 @@ def _services(): return FactService(FactCRUD(), place_crud), SiteService(SiteCRUD(), place_crud, JobQueue()) +def _media_service() -> MediaService: + return MediaService(MediaCRUD(), PlaceCRUD()) + + # ── 읽기 ──────────────────────────────────────────────────────────────── async def _get_site_status(ctx: ToolContext, args: dict) -> str: @@ -227,6 +233,71 @@ async def _move_section(ctx: ToolContext, args: dict) -> str: f"사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?") +# ── 사진 ───────────────────────────────────────────────────────────────── +# +# ★ 업로드·교체는 없다. 이미지 재게시 권리가 미결이라 저장 경로를 일부러 안 만들어 뒀다 +# (docs/DECISIONS.md 1-2 · 5-3). 아래는 **이미 있는 사진의 노출과 순서**만 바꾼다. + +def _photo_name(row) -> str: + """사장님이 부를 이름. Vision 이 만든 라벨·alt 가 유일한 단서다.""" + return (row.label or "").strip() or (row.alt_text or "").strip() or "이름 없는 사진" + + +async def _photos(ctx: ToolContext) -> list: + res = await _media_service().list_media(ctx.user, ctx.place_id) + return list(res.media or []) + + +def _find_photo(rows: list, wanted: str): + """★ 둘 이상 걸리면 고르지 않는다 — 추측으로 내리면 엉뚱한 사진이 사라지고, + 사장님은 발행하고 나서야 안다(섹션과 같은 규칙).""" + wanted = (wanted or "").strip() + if not wanted: + return None + hits = [r for r in rows if wanted in _photo_name(r)] + return hits[0] if len(hits) == 1 else None + + +async def _list_photos(ctx: ToolContext, args: dict) -> str: + rows = await _photos(ctx) + if not rows: + return "아직 등록된 사진이 없어요." + lines = [] + for i, r in enumerate(rows[:15]): + where = " (객실·메뉴 전용)" if r.unit_id else "" + mark = "" if r.publishable else " — 지금은 안 나감" + head = "대표 " if i == 0 and not r.unit_id else "" + lines.append(f"· {head}{_photo_name(r)}{where}{mark}") + more = f"\n(그 밖에 {len(rows) - 15}장 더)" if len(rows) > 15 else "" + return "홈페이지에 있는 사진입니다.\n" + "\n".join(lines) + more + + +async def _hide_photo(ctx: ToolContext, args: dict) -> str: + rows = await _photos(ctx) + row = _find_photo(rows, args.get("name")) + if row is None: + raise ToolRejected("어느 사진을 말씀하시는지 못 찾았어요. '사진 목록' 이라고 하시면 보여드릴게요.") + res = await _media_service().hide_media(ctx.user, ctx.place_id, str(row.media_id)) + if not res.result.success: + raise ToolRejected("그 사진을 내리지 못했습니다. 빌더 화면에서 확인해 주세요.") + return (f"'{_photo_name(row)}' 사진을 내렸습니다. " + f"사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?") + + +async def _set_primary_photo(ctx: ToolContext, args: dict) -> str: + rows = await _photos(ctx) + row = _find_photo(rows, args.get("name")) + if row is None: + raise ToolRejected("어느 사진을 말씀하시는지 못 찾았어요. '사진 목록' 이라고 하시면 보여드릴게요.") + if row.unit_id: + raise ToolRejected(f"'{_photo_name(row)}' 은(는) 객실·메뉴 전용 사진이라 대표로 쓸 수 없어요.") + res = await _media_service().set_primary(ctx.user, ctx.place_id, str(row.media_id)) + if not res.result.success: + raise ToolRejected("대표 사진을 바꾸지 못했습니다. 빌더 화면에서 확인해 주세요.") + return (f"대표 사진을 '{_photo_name(row)}' 으로 바꿨습니다. " + f"사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?") + + # ── 반쯤 되돌릴 수 있는 것 ─────────────────────────────────────────────── async def _publish(ctx: ToolContext, args: dict) -> str: @@ -286,6 +357,26 @@ REGISTRY: dict[str, Tool] = { args={"name": "옮길 부분의 이름", "to": "'맨 위' · '맨 아래' · 또는 그 뒤에 올 부분의 이름"}, run=_move_section, ), + Tool( + name="list_photos", + grade=ToolGrade.READ, + summary="홈페이지에 올라가 있는 사진 목록을 보여준다(맨 앞이 대표 사진).", + run=_list_photos, + ), + Tool( + name="hide_photo", + grade=ToolGrade.REVERSIBLE, + summary="사진 한 장을 홈페이지에서 내린다. 새 사진을 올리는 것은 아직 못 한다.", + args={"name": "그 사진의 이름(라벨)"}, + run=_hide_photo, + ), + Tool( + name="set_primary_photo", + grade=ToolGrade.REVERSIBLE, + summary="대표 사진을 바꾼다(검색 결과와 목록 카드에 나오는 그림).", + args={"name": "대표로 쓸 사진의 이름(라벨)"}, + run=_set_primary_photo, + ), Tool( name="publish", grade=ToolGrade.SEMI, @@ -303,6 +394,12 @@ async def sections_of(ctx: ToolContext): return await _sections_of(ctx) +async def photo_names(ctx: ToolContext) -> list[str]: + """런타임이 프롬프트에 실을 사진 이름. 도구가 찾는 이름과 **같은 함수**로 만든다 — + 다르면 모델이 본 이름과 도구가 찾는 이름이 갈린다.""" + return [_photo_name(r) for r in (await _photos(ctx))[:15]] + + def describe() -> list[dict]: """프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다.""" return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()] diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index cd5706b..857c3d6 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -5,6 +5,7 @@ from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_photos, places from common.enums import DBWRType, ErrorType, MediaStatus, SourceType +from common.utils.gtime import GTime from common.models.gmodel import UserInfo from crud.media_crud import IMediaCRUD, MediaCRUD from crud.place_crud import PlaceCRUD @@ -21,7 +22,7 @@ def _is_publishable(row) -> bool: class MediaService: - """사진 조회. + """사진 조회 · 노출 여부 · 순서. ★ 이 서비스가 지키는 규칙은 둘이다. 1. 회사 스코프 — 사업장을 먼저 회사 스코프로 로드해서 남의 회사 사진에 닿지 못하게 한다. @@ -89,3 +90,68 @@ class MediaService: # 직접 올릴 사진을 몇 장 준비해야 하는지 안다. res.crawled = sum(1 for r in rows if r.source_type == SourceType.CRAWL.value) return res + + # ---- 노출 여부 · 순서 ---- + # + # ★ 업로드·교체는 여기 없다. 이미지 재게시 권리가 미결이라(docs/DECISIONS.md 1-2) 저장 + # 경로를 일부러 안 만들어 둔 것이고(5-3), 아래 둘은 **이미 우리 DB 에 있는 사진의 + # 노출과 순서**만 바꾼다 — 그 결론이 어느 쪽으로 나든 영향받지 않는다. + + async def _owned_photo(self, user_info: UserInfo, place_id: str, media_id: str): + """사업장 스코프 + 그 사업장의 사진인지. ★ 없는 것과 남의 것을 똑같이 답한다.""" + err_type, _place = await self._load_place(user_info, place_id) + if err_type != ErrorType.SUCCESS: + return err_type, None + _err, rows = await DB_SESSION_MNG.execute_lambda( + place_photos.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_media(s, uuid.UUID(place_id), None, False, None, False), + ) + for row in rows or []: + if str(row.media_id) == str(media_id): + return ErrorType.SUCCESS, row + return ErrorType.MEDIA_NOT_FOUND, None + + async def hide_media(self, user_info: UserInfo, place_id: str, media_id: str) -> Res_MediaList: + """사진을 내린다 — REJECTED 로 내려 발행본에서 빠진다. + + ★ 지우지 않는다. `origin_url`·`source_type` 이 남아 있어야 재게시 권리(1-2) 결론이 + 나왔을 때 무엇을 실었는지 되짚을 수 있고, 잘못 내렸을 때 되돌릴 수도 있다.""" + err_type, row = await self._owned_photo(user_info, place_id, media_id) + if err_type != ErrorType.SUCCESS: + res = Res_MediaList() + res.result.SetResult(err_type) + return res + await DB_SESSION_MNG.execute_lambda_write( + place_photos.DBType(), + lambda s: self.crud.set_status(s, uuid.UUID(place_id), row.media_id, MediaStatus.REJECTED.value, GTime.UTC()), + ) + return await self.list_media(user_info, place_id) + + async def set_primary(self, user_info: UserInfo, place_id: str, media_id: str) -> Res_MediaList: + """대표 사진으로 올린다. + + ★ 대표는 목록의 **첫 장**이다(site_payload.primary_media) — 별도 칸을 두지 않는다. + 그래서 이 사진의 sort_order 를 지금 가장 작은 값보다 하나 더 작게 내리면 된다. + ★ 객실·메뉴 전용 사진(unit_id 가 있는 것)은 대표가 될 수 없다 — primary_media 가 + 그런 사진을 건너뛰므로, 지정해도 화면만 거짓말하게 된다.""" + err_type, row = await self._owned_photo(user_info, place_id, media_id) + res = Res_MediaList() + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + if row.unit_id is not None: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + + _err, rows = await DB_SESSION_MNG.execute_lambda( + place_photos.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_media(s, uuid.UUID(place_id), None, False, None, False), + ) + lowest = min((r.sort_order or 0) for r in (rows or [row])) + await DB_SESSION_MNG.execute_lambda_write( + place_photos.DBType(), + lambda s: self.crud.set_sort_order(s, uuid.UUID(place_id), row.media_id, lowest - 1, GTime.UTC()), + ) + return await self.list_media(user_info, place_id) diff --git a/solution/backend/services/prompts/agent.py b/solution/backend/services/prompts/agent.py index 780ca16..5db2d56 100644 --- a/solution/backend/services/prompts/agent.py +++ b/solution/backend/services/prompts/agent.py @@ -37,7 +37,7 @@ RESPONSE_SCHEMA = { def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], facts: list[dict], - sections: list[dict], message: str) -> str: + sections: list[dict], photos: list[str], message: str) -> str: """사장님 발화 → 도구 하나. ★ 모호하면 실행하지 말고 되물으라고 명시한다. 티오더가 "유사한 메뉴가 2개 이상이면 @@ -65,5 +65,8 @@ def build_prompt(*, place_name: str, tools: list[dict], fields: list[dict], fact 홈페이지를 이루는 부분들 — 위에서부터의 순서다(toggle_section·move_section 의 name 은 이 중 하나): {chr(10).join(f"{s['name']}" + (" [끄기 불가]" if s['locked'] else "") + (" (꺼짐)" if not s['enabled'] else "") for s in sections)} +올라가 있는 사진 — 맨 앞이 대표 사진이다(hide_photo·set_primary_photo 의 name 은 이 중 하나): +{chr(10).join(photos) if photos else "(없음)"} + 사장님 요청: {message}''' diff --git a/solution/backend/tests/test_agent_runtime.py b/solution/backend/tests/test_agent_runtime.py index b580ac5..d9302ec 100644 --- a/solution/backend/tests/test_agent_runtime.py +++ b/solution/backend/tests/test_agent_runtime.py @@ -343,3 +343,123 @@ def test_구성_도구의_등급(client=None): assert tools.REGISTRY["list_sections"].grade == ToolGrade.READ assert tools.REGISTRY["toggle_section"].grade == ToolGrade.REVERSIBLE assert tools.REGISTRY["move_section"].grade == ToolGrade.REVERSIBLE + + +# ── 사진 (내리기 · 대표 지정) ──────────────────────────────────────────── + +async def _seed_photos(db_engine, place_id, rows): + """(label, unit_id, sort_order) 로 사진을 심는다. alt 는 채운다 — 빈 alt 는 발행본이 안 그린다.""" + from common.enums import MediaStatus, SourceType + + async with db_engine.begin() as c: + for label, unit_id, order in rows: + await c.execute( + text("""INSERT INTO place_photos + (media_id, place_id, unit_id, url, source_type, label, alt_text, status, sort_order) + VALUES (:m, :p, :u, :url, :st, :l, :alt, :status, :o)"""), + {"m": uuid.uuid4(), "p": uuid.UUID(place_id), "u": unit_id, + "url": f"https://example.com/{label}.jpg", "st": SourceType.CRAWL.value, + "l": label, "alt": f"{label} 사진", "status": MediaStatus.APPROVED.value, "o": order}, + ) + + +async def _photo_rows(db_engine, place_id): + async with db_engine.begin() as c: + result = await c.execute( + text("""SELECT label, status, sort_order FROM place_photos + WHERE place_id=:p AND deleted=false + ORDER BY sort_order ASC, created_at ASC"""), + {"p": uuid.UUID(place_id)}, + ) + return result.all() + + +async def test_사진을_내리면_지우지_않고_내려간다(client, auth_headers, choose, db_engine): + """★ 지우면 origin_url·source_type 이 사라져 재게시 권리 결론이 났을 때 되짚을 수 없다.""" + from common.enums import MediaStatus + + h, pid = await seed(client, auth_headers) + await _seed_photos(db_engine, pid, [("외관", None, 0), ("객실", None, 1)]) + + choose({"tool": "hide_photo", "args": {"name": "객실"}, "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "객실 사진 내려줘"})).json() + assert body.get("rejected") is not True, body["reply"] + assert "발행" in body["reply"] + + rows = await _photo_rows(db_engine, pid) + hidden = next(r for r in rows if r.label == "객실") + assert hidden.status == MediaStatus.REJECTED.value + assert len(rows) == 2 # 행은 남는다 + + +async def test_대표_사진은_목록의_첫_장이_된다(client, auth_headers, choose, db_engine): + """★ 별도 칸을 두지 않는다 — site_payload.primary_media 가 '첫 장' 을 쓴다.""" + h, pid = await seed(client, auth_headers) + await _seed_photos(db_engine, pid, [("외관", None, 0), ("수영장", None, 1)]) + + choose({"tool": "set_primary_photo", "args": {"name": "수영장"}, "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "대표 사진 수영장으로"})).json() + assert body.get("rejected") is not True, body["reply"] + + rows = await _photo_rows(db_engine, pid) + assert rows[0].label == "수영장" + + +async def test_객실_전용_사진은_대표가_될_수_없다(client, auth_headers, choose, db_engine): + """★ primary_media 가 unit_id 있는 사진을 건너뛴다 — 지정하게 두면 화면만 거짓말한다.""" + h, pid = await seed(client, auth_headers) + await _seed_photos(db_engine, pid, [("외관", None, 0), ("침대", uuid.uuid4(), 1)]) + + choose({"tool": "set_primary_photo", "args": {"name": "침대"}, "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "대표 사진 침대로"})).json() + assert body.get("rejected") is True + assert "대표로 쓸 수 없" in body["reply"] + + rows = await _photo_rows(db_engine, pid) + assert rows[0].label == "외관" + + +async def test_어느_사진인지_모호하면_고르지_않는다(client, auth_headers, choose, db_engine): + h, pid = await seed(client, auth_headers) + await _seed_photos(db_engine, pid, [("객실 A", None, 0), ("객실 B", None, 1)]) + + choose({"tool": "hide_photo", "args": {"name": "객실"}, "message": ""}) + body = (await client.post(f"/v1/agent/chat/{pid}", headers=h, json={"message": "객실 사진 내려줘"})).json() + assert body.get("rejected") is True + assert "못 찾았" in body["reply"] + + rows = await _photo_rows(db_engine, pid) + assert all(r.status != 3 for r in rows) # 아무것도 안 내려갔다 + + +async def test_남의_가게_사진에는_닿지_못한다(client, auth_headers, db_engine): + """서비스가 사업장 스코프를 먼저 건다 — 없는 것과 남의 것을 똑같이 답한다.""" + from crud.media_crud import MediaCRUD + from crud.place_crud import PlaceCRUD + from router.v1.validator.dependencies import DecodeAccessToken + from services.media_service import MediaService + + h, pid = await seed(client, auth_headers, "내가게") + await _seed_photos(db_engine, pid, [("외관", None, 0)]) + async with db_engine.begin() as c: + media_id = ( + await c.execute(text("SELECT media_id FROM place_photos WHERE place_id=:p"), {"p": uuid.UUID(pid)}) + ).scalar_one() + + other = await auth_headers("photo-outsider") + outsider = DecodeAccessToken(other["Authorization"].split(" ", 1)[1]) + res = await MediaService(MediaCRUD(), PlaceCRUD()).hide_media(outsider, pid, str(media_id)) + assert res.result.success is False + + rows = await _photo_rows(db_engine, pid) + assert rows[0].status != 3 + + +async def test_업로드_도구는_만들지_않았다(): + """★ 이미지 재게시 권리가 미결이라 저장 경로를 일부러 안 만들어 뒀다(DECISIONS 1-2·5-3). + 도구가 생기면 그 결정을 코드가 먼저 풀어 버린다.""" + names = set(tools.REGISTRY) + assert not {n for n in names if "upload" in n or "replace" in n} + assert tools.REGISTRY["list_photos"].grade == ToolGrade.READ + assert tools.REGISTRY["hide_photo"].grade == ToolGrade.REVERSIBLE + assert tools.REGISTRY["set_primary_photo"].grade == ToolGrade.REVERSIBLE