Compare commits

...

2 Commits

Author SHA1 Message Date
36dbe26c91 [feat] solution/backend: 미니 블로그 글을 배정 날짜에 맞는 소재로 만든다
예전에는 소재 목록을 순서대로 뽑아 날짜에 차례로 붙여서, 9월 날짜에
'한겨울' 글이나 끝난 축제 글이 붙을 수 있었다. 이제 날짜를 먼저 정하고
그 날짜에 맞는 소재를 고른다.

- 축제: 시작 14일 전 ~ 종료일 사이만 / 계절: 게시일 절기 하나
  / 날씨: 그 달에 있을 법한 것만(눈 12~2월, 소나기 6~8월) / 주변: 늘 후보
- 프롬프트에 게시일을 넣고, 날씨를 단정하지 않게 문구를 고쳤다
- 계절·날씨·축제 주제 키에 연(월)을 넣어 해마다 다시 쓸 수 있게 했다
- 자동·구간·개별 세 경로가 _compose_for_dates 하나로 만든다

같이 고친 버그: materials 가 스냅샷에 없는 local.festivals·attractions 를
읽어 축제·주변 소재가 늘 비어 있었다. 원문 행(local.contents)을 읽는다.
2026-09-23 09:41:23 +09:00
be3e43162a [chore] solution/backend: 미니 블로그 새벽 자동 생성 잡을 잠시 끈다
사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다. 등록 두 줄만
주석으로 막고 잡 함수는 그대로 둔다. 09:00 메일 발송 잡은 사장님이 만든
글도 보내야 해서 켜 둔다.
2026-09-23 09:41:23 +09:00
5 changed files with 245 additions and 140 deletions

View File

@ -54,15 +54,17 @@ def start_scheduler():
_scheduler.add_job(run_scheduled_check, "interval", minutes=10,
id="search-console", max_instances=1, coalesce=True)
from scheduler.jobs import sweep_alert_outbox, sweep_blog_drafts, sweep_blog_mail, sweep_queue_health
from scheduler.jobs import sweep_alert_outbox, sweep_blog_mail, sweep_queue_health
_scheduler.add_job(sweep_alert_outbox, "interval", minutes=1,
id="alert-outbox", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
id="queue-health", max_instances=1, coalesce=True)
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
# LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다.
_scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
id="blog-drafts", max_instances=1, coalesce=True)
# 자동 생성은 잠시 끈다 — 사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다(2026-09-23).
# 되살리려면 위 import 에 sweep_blog_drafts 를 다시 넣고 아래 두 줄 주석을 푼다.
# _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
# id="blog-drafts", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
id="blog-mail", max_instances=1, coalesce=True)
_scheduler.start()

View File

@ -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:
break
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,
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:
continue
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,
"generation_meta": {"model": model},
"scheduled_date": target, "generation_meta": {"model": model},
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음
})
used_set.add(key)
break
return rows
async def _generate_for_place(place) -> int:
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
if await _pending_count(place.place_id) >= REFILL_BELOW:
return 0
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)
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 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
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:

View File

@ -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

View File

@ -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)

View File

@ -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 로 그대로 넘긴다 — 링크는 여기서
붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면