diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index 23e00f1..b915680 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -3,7 +3,7 @@ ★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약). ★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에 못 넣어 중복이 막히지 않는다. -★ 팀 사전검수 없음 — 금칙 필터(blog_service.filter_drafts)를 통과하면 바로 REVIEWED 로 +★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로 쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다. ★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). @@ -73,11 +73,14 @@ async def _pending_count(place_id) -> int: return len(result.all()) if result is not None else 0 -async def _generate_for_place(place) -> int: - """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" - if await _pending_count(place.place_id) >= REFILL_BELOW: - return 0 +async def _compose_for_dates(place, dates: list[date]) -> list[dict]: + """날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 — + 세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다. + ★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에 + 차례로 붙여서, 글 내용이 배정된 날짜와 무관했다. + ★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다. + ★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다.""" used = await DB_SESSION_MNG.execute_lambda( place_posts.DBType(), DBWRType.DB_READ.value, lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), @@ -87,39 +90,47 @@ async def _generate_for_place(place) -> int: region = site_payload.region_label(place.road_address, place.address) rows = [] - for kind, key, material in blog_service.materials(snapshot): - if len(rows) >= BATCH_SIZE: + for target in dates: + for kind, key, material in blog_service.materials(snapshot, target): + if key in used_set: + continue + generated = await blog_service.generate_one( + place_name=place.name, region=region, topic_kind=kind, material=material, + used_topics=sorted(used_set), place_category=place.category, post_date=target, + ) + if not generated: + return rows + body, model = generated + ok, reason = blog_service.is_publishable_body(body) + used_set.add(key) # 버린 주제도 이번 회차에서 다시 고르지 않는다 + if not ok: + LOG.i(f"[blog] place={place.place_id} {target} 버림 — {reason}") + continue + rows.append({ + "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, + "scheduled_date": target, "generation_meta": {"model": model}, + "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음 + }) break - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - rows.append({ - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "generation_meta": {"model": model}, - }) - used_set.add(key) + return rows - kept, dropped = blog_service.filter_drafts(rows) - if kept: - latest = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid), - ) - next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst() - for offset, row in enumerate(kept): - row["scheduled_date"] = next_date + timedelta(days=offset) + +async def _generate_for_place(place) -> int: + """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" + if await _pending_count(place.place_id) >= REFILL_BELOW: + return 0 + + latest = await DB_SESSION_MNG.execute_lambda( + place_posts.DBType(), DBWRType.DB_READ.value, + lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid), + ) + next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst() + rows = await _compose_for_dates(place, [next_date + timedelta(days=i) for i in range(BATCH_SIZE)]) + if rows: await DB_SESSION_MNG.execute_lambda_run( - [place_posts.DBType()], [lambda s, r=kept: _crud.add_many(s, r)], + [place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)], ) - if dropped: - LOG.i(f"[blog] place={place.place_id} 버린 {len(dropped)}건 — {dropped[0][1]}") - return len(kept) + return len(rows) async def generate_drafts() -> int: @@ -135,8 +146,8 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에 상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 — - 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 소재가 떨어지면(구간 안에서 - 더 만들 topic_key 가 없으면) 그 자리에서 멈춘다 — 못 채운 나머지는 그대로 빈 날짜로 남는다.""" + 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면 + 그 날짜는 빈 날짜로 남는다(_compose_for_dates).""" place = None for p, _user in await _published_places(): if str(p.place_id) == str(place_id): @@ -157,38 +168,7 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic if not empty_dates: return {"requested": requested, "created": 0} - used = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), - ) - used_set = set(used or []) - snapshot = await build_snapshot(place) - region = site_payload.region_label(place.road_address, place.address) - - rows = [] - for kind, key, material in blog_service.materials(snapshot): - if len(rows) >= len(empty_dates): - break - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - ok, _reason = blog_service.is_publishable_body(body) - used_set.add(key) - if not ok: - continue - rows.append({ - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value, - }) - - for row, d in zip(rows, empty_dates): - row["scheduled_date"] = d + rows = await _compose_for_dates(place, empty_dates) if rows: await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)], @@ -208,39 +188,12 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None if place is None: return None - used = await DB_SESSION_MNG.execute_lambda( - place_posts.DBType(), DBWRType.DB_READ.value, - lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid), - ) - used_set = set(used or []) - snapshot = await build_snapshot(place) - region = site_payload.region_label(place.road_address, place.address) - - for kind, key, material in blog_service.materials(snapshot): - if key in used_set: - continue - generated = await blog_service.generate_one( - place_name=place.name, region=region, topic_kind=kind, - material=material, used_topics=sorted(used_set), place_category=place.category, - ) - if not generated: - continue - body, model = generated - ok, _reason = blog_service.is_publishable_body(body) - if not ok: - used_set.add(key) # 이 주제는 이번 시도에서 다시 고르지 않는다 - continue - - row = { - "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, - "scheduled_date": target_date, "generation_meta": {"model": model}, - "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 벌크 경로(filter_drafts)와 동일 - } - inserted = await DB_SESSION_MNG.execute_lambda_write( - place_posts.DBType(), lambda s, r=row: _crud.add_one(s, r), - ) - return inserted # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 - return None + rows = await _compose_for_dates(place, [target_date]) + if not rows: + return None + return await DB_SESSION_MNG.execute_lambda_write( + place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r), + ) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str: diff --git a/solution/backend/services/blog_service.py b/solution/backend/services/blog_service.py index d96bbfc..49eb017 100644 --- a/solution/backend/services/blog_service.py +++ b/solution/backend/services/blog_service.py @@ -8,9 +8,9 @@ import hashlib import re import secrets -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone -from common.enums import PlaceCategory, PostStatus, PostTopicKind +from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind from common.logger import LOG # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. @@ -66,12 +66,13 @@ def issue_token() -> tuple[str, str, object]: # 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다. TOPIC_RULES: dict[int, str] = { + # ★ 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다. PostTopicKind.WEATHER.value: - "오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.", + "소재로 주어진 날씨인 날, 이 숙소에서 하기 좋은 일을 한 장면으로 적는다. 게시일의 날씨를 단정하지 않는다.", PostTopicKind.FESTIVAL.value: - "주어진 축제 하나를 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.", + "주어진 축제 하나를 게시일 기준으로(곧 열리는지, 열리는 중인지) 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.", PostTopicKind.SEASON.value: - "지금 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.", + "게시일 무렵 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.", PostTopicKind.NEARBY.value: "주어진 주변 장소 하나를 손님 시선에서 적는다. 영업시간과 가격은 쓰지 않는다.", PostTopicKind.GUIDE.value: @@ -102,17 +103,36 @@ _RULES = ( "- '최고' '유일' 같은 최상급을 쓰지 않는다.\n" "- 손님에게 말하듯 존댓말로 적는다.\n" "- 아래 '이미 쓴 주제'와 겹치는 소재를 고르지 않는다.\n" + "- 게시일과 맞지 않는 계절·날씨·행사 이야기를 쓰지 않는다.\n" ) +_WEEKDAYS = "월화수목금토일" + + +def season_term(on: date) -> str: + """게시일 → 절기 이름(materials 의 계절 소재와 같은 말). 달로만 가른다.""" + return { + 3: "봄", 4: "봄", 5: "봄", + 6: "초여름", 7: "한여름", 8: "한여름", + 9: "초가을", 10: "늦가을", 11: "늦가을", + 12: "초겨울", 1: "한겨울", 2: "한겨울", + }[on.month] + + +def _date_line(on: date) -> str: + return f"게시일: {on.year}년 {on.month}월 {on.day}일({_WEEKDAYS[on.weekday()]}) · {season_term(on)}\n" + def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], - place_category: int = PlaceCategory.LODGING.value) -> str: - """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다.""" + place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None) -> str: + """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다. + post_date 가 있으면 게시일을 알려 준다 — 글이 그 날짜의 계절·행사와 맞게 쓰이도록(2026-09-23).""" used = ", ".join(used_topics[:40]) or "없음" noun = _business_noun(place_category) rules = _topic_rules(place_category) return ( f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n" + f"{_date_line(post_date) if post_date else ''}" f"갈래: {rules.get(topic_kind, '')}\n" f"소재: {material}\n" f"이미 쓴 주제: {used}\n\n" @@ -146,7 +166,7 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]: async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str], place_category: int = PlaceCategory.LODGING.value, - client=None) -> tuple[str, str] | None: + post_date: date | None = None, client=None) -> tuple[str, str] | None: """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다. 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시). @@ -166,7 +186,8 @@ async def generate_one(*, place_name: str, region: str, topic_kind: int, materia return None prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, - material=material, used_topics=used_topics, place_category=place_category) + material=material, used_topics=used_topics, place_category=place_category, + post_date=post_date) owns = client is None if owns: import httpx @@ -183,27 +204,68 @@ async def generate_one(*, place_name: str, region: str, topic_kind: int, materia await client.aclose() -def materials(snapshot: dict) -> list[tuple[int, str, str]]: - """(갈래, topic_key, 소재). 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다.""" - local = snapshot.get("local") or {} +# 축제 글을 시작일 며칠 전부터 낼 수 있나. 끝난 축제는 내지 않는다. +FESTIVAL_LEAD_DAYS = 14 + +# 그 달에 말이 되는 날씨만 소재로 쓴다 — 여름에 "눈인 날" 글이 나가지 않게. +_SKIES = ("맑음", "흐림", "비", "안개") +_SKIES_BY_MONTH = {12: ("눈",), 1: ("눈",), 2: ("눈",), 6: ("소나기",), 7: ("소나기",), 8: ("소나기",)} + + +def _ymd(value) -> date | None: + digits = "".join(ch for ch in str(value or "") if ch.isdigit()) + if len(digits) != 8: + return None + try: + return date(int(digits[:4]), int(digits[4:6]), int(digits[6:])) + except ValueError: + return None + + +def materials(snapshot: dict, on: date) -> list[tuple[int, str, str]]: + """게시일 on 에 맞는 (갈래, topic_key, 소재). 앞에 있을수록 먼저 고른다 — 축제 → 계절 → 주변 → 날씨. + 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다. + + ★ 날짜에 맞춘다(2026-09-23). 예전에는 날짜와 무관한 한 줄 목록이라, 9월 날짜에 '한겨울' 글이나 + 이미 끝난 축제 글이 붙을 수 있었다. + - 축제: 시작 FESTIVAL_LEAD_DAYS 일 전 ~ 끝나는 날 사이에만. 기간을 모르는 축제는 쓰지 않는다. + - 계절: 게시일의 절기 하나. 키에 연도를 넣어 해마다 한 번씩 다시 쓸 수 있다. + - 날씨: 그 달에 있을 법한 것만. 키에 연·월을 넣어 달마다 다시 쓸 수 있다. + - 주변 장소: 날짜와 무관해 늘 후보다. + ★ 스냅샷의 지역 정보는 원문 행 목록(snapshot["local"]["contents"])이다. 예전 코드는 + site_payload 모양(local.festivals·attractions)을 읽어 축제·주변 소재가 늘 비어 있었다.""" + contents = (snapshot.get("local") or {}).get("contents") or [] + by_type: dict[int, list[dict]] = {} + for row in contents: + if isinstance(row, dict): + by_type.setdefault(row.get("content_type"), []).append(row) out: list[tuple[int, str, str]] = [] - for festival in (local.get("festivals") or [])[:12]: - name = (festival.get("name") or "").strip() + for row in by_type.get(LocalContentType.FESTIVAL.value, []): + body = row.get("body") or {} + name = str(body.get("name") or row.get("title") or "").strip() + start = _ymd(body.get("eventstartdate")) + end = _ymd(body.get("eventenddate")) or start + if not name or start is None or not (start - timedelta(days=FESTIVAL_LEAD_DAYS) <= on <= end): + continue + period = f"{start:%Y.%m.%d}" + (f" ~ {end:%Y.%m.%d}" if end != start else "") + detail = f"{body.get('location') or ''} {str(body.get('overview') or '')[:300]}".strip() + out.append((PostTopicKind.FESTIVAL.value, f"festival:{start.year}:{name}"[:120], + f"{name} (기간 {period}) — {detail}".strip(" —"))) + + term = season_term(on) + out.append((PostTopicKind.SEASON.value, f"season:{on.year}:{term}", term)) + + spots = (by_type.get(LocalContentType.ATTRACTION.value, []) + + by_type.get(LocalContentType.RESTAURANT.value, [])) + for row in spots[:20]: + body = row.get("body") or {} + name = str(body.get("name") or row.get("title") or "").strip() if name: - out.append((PostTopicKind.FESTIVAL.value, f"festival:{name}", - f"{name} — {festival.get('location') or ''} {festival.get('description') or ''}".strip())) + detail = body.get("description") or body.get("overview") or body.get("location") or "" + out.append((PostTopicKind.NEARBY.value, f"nearby:{name}"[:120], f"{name} — {detail}".strip(" —"))) - for spot in ((local.get("attractions") or []) + (local.get("restaurants") or []))[:20]: - name = (spot.get("name") or "").strip() - if name: - out.append((PostTopicKind.NEARBY.value, f"nearby:{name}", - f"{name} — {spot.get('description') or spot.get('address') or ''}".strip())) - - for sky in ("맑음", "흐림", "비", "눈", "안개", "소나기"): - out.append((PostTopicKind.WEATHER.value, f"weather:{sky}", f"{sky}인 날")) - - for term in ("봄", "초여름", "한여름", "초가을", "늦가을", "초겨울", "한겨울"): - out.append((PostTopicKind.SEASON.value, f"season:{term}", term)) + for sky in _SKIES + _SKIES_BY_MONTH.get(on.month, ()): + out.append((PostTopicKind.WEATHER.value, f"weather:{on:%Y-%m}:{sky}", f"{sky}인 날")) return out diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index 42ddcc1..e8d7eef 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -140,7 +140,7 @@ async def test_deleting_a_post_frees_its_date_for_regeneration(client, db_engine 바로 다시 생성할 수 있어야 한다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("새로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -201,7 +201,7 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") — 발행된 사이트일 때만.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -241,12 +241,52 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine, assert all(start <= d <= end for d in dates) +async def test_generate_now_writes_each_post_for_its_own_date(client, db_engine, auth_headers, monkeypatch): + """글마다 배정된 날짜를 게시일로 받아 쓰고, 그 날짜의 절기 소재가 붙는다(2026-09-23, + 사장님 지시: "날짜에 맞는 글이 생성 되도록").""" + from services import blog_service + + calls = [] + + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): + calls.append((post_date, topic_kind, material)) + return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") + + monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) + + h = await auth_headers("bloggen18") + place_id = await _place(client, h, name="날짜맞춤펜션") + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"), + {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value}, + ) + winter = date(date.today().year + 1, 1, 10) + + res = await client.post( + f"/v1/place/{place_id}/post/generate", headers=h, + params={"start": winter.isoformat(), "end": winter.isoformat()}, + ) + + assert res.json()["created"] == 1 + assert calls[0][0] == winter + # 지역 소재가 없는 업장이라 첫 후보는 그 날짜의 절기다. + assert calls[0][1] == PostTopicKind.SEASON.value + assert calls[0][2] == "한겨울" + async with db_engine.begin() as conn: + row = (await conn.execute( + text("SELECT scheduled_date, topic_key FROM place_posts WHERE place_id = :pid"), {"pid": place_id}, + )).one() + assert row[0] == winter + assert row[1] == f"season:{winter.year}:한겨울" + + async def test_generate_now_does_not_append_a_publish_link(client, db_engine, auth_headers, monkeypatch): """미니 블로그에 올라가는 글에는 링크를 붙이지 않는다(2026-09-21, 사장님 지시). site_payload.publish_url() 자체는 남겨둔다 — 나중에 쓰레드 연동에서 따로 쓸 수 있게.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return (f"테스트로 만든 문구입니다. {BODY}", "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -599,7 +639,7 @@ async def test_generate_now_is_noop_for_site_without_domain(client, db_engine, a 실제로 성공하도록 목킹해 둬야 "그냥 LLM 이 설정 안 돼서 0건"과 구분된다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -720,7 +760,7 @@ async def test_generation_history_counts_by_batch(client, db_engine, auth_header 생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다).""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -819,7 +859,7 @@ async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_ """사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) @@ -847,7 +887,7 @@ async def test_generate_one_fails_when_date_already_taken(client, db_engine, aut """이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.""" from services import blog_service - async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category): + async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category, post_date=None): return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) diff --git a/solution/backend/tests/test_blog_post.py b/solution/backend/tests/test_blog_post.py index 7cdd1ac..1bc97ab 100644 --- a/solution/backend/tests/test_blog_post.py +++ b/solution/backend/tests/test_blog_post.py @@ -10,11 +10,11 @@ 예전처럼 "GET 은 확인 화면만" 을 기대하는 테스트를 두지 않는다. """ import uuid -from datetime import timezone +from datetime import date, timezone from sqlalchemy import text -from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind, SiteStatus +from common.enums import JobType, LocalContentType, PlaceCategory, PostStatus, PostTopicKind, SiteStatus from services import blog_service, site_payload BODY = ( @@ -65,6 +65,54 @@ def test_build_prompt_falls_back_to_stay_wording_for_unmapped_category(): assert "숙소" in restaurant # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다 +def _snapshot_with(*rows): + return {"local": {"region_code": "52130", "contents": list(rows)}} + + +def _festival_row(name, start, end): + return {"content_type": LocalContentType.FESTIVAL.value, "title": name, + "body": {"name": name, "eventstartdate": start, "eventenddate": end, "location": "군산 원도심"}} + + +def test_materials_follow_the_post_date(): + """게시일에 맞는 소재만 나온다(2026-09-23) — 9월 글에 '한겨울'·'눈'·끝난 축제가 붙지 않는다.""" + snapshot = _snapshot_with( + _festival_row("시간여행축제", "20261002", "20261004"), # 곧 열린다(9일 뒤) + _festival_row("벚꽃축제", "20260401", "20260405"), # 이미 끝났다 + _festival_row("겨울빛축제", "20261220", "20261231"), # 아직 한참 멀다 + {"content_type": LocalContentType.ATTRACTION.value, "title": "경암동 철길마을", + "body": {"name": "경암동 철길마을", "description": "철길 옆 골목"}}, + ) + + out = blog_service.materials(snapshot, date(2026, 9, 23)) + keys = [key for _kind, key, _material in out] + + assert keys[0] == "festival:2026:시간여행축제" + assert "2026.10.02 ~ 2026.10.04" in out[0][2] + assert not any("벚꽃" in k or "겨울빛" in k for k in keys) + assert "season:2026:초가을" in keys + assert not any(k.startswith("season:") and not k.endswith("초가을") for k in keys) + assert "nearby:경암동 철길마을" in keys + assert "weather:2026-09:비" in keys + assert not any(k.endswith(":눈") or k.endswith(":소나기") for k in keys) + + +def test_materials_allow_snow_in_winter_and_reuse_seasons_each_year(): + winter = [key for _k, key, _m in blog_service.materials(_snapshot_with(), date(2027, 1, 10))] + + assert "weather:2027-01:눈" in winter + assert "season:2027:한겨울" in winter + + +def test_build_prompt_tells_the_post_date(): + prompt = blog_service.build_prompt( + place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.SEASON.value, + material="초가을", used_topics=[], post_date=date(2026, 9, 23), + ) + + assert "게시일: 2026년 9월 23일(수) · 초가을" in prompt + + async def test_generate_one_forwards_place_category_into_the_prompt(monkeypatch): """generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서 붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면