[feat] solution/backend: 미니 블로그 글을 배정 날짜에 맞는 소재로 만든다
예전에는 소재 목록을 순서대로 뽑아 날짜에 차례로 붙여서, 9월 날짜에 '한겨울' 글이나 끝난 축제 글이 붙을 수 있었다. 이제 날짜를 먼저 정하고 그 날짜에 맞는 소재를 고른다. - 축제: 시작 14일 전 ~ 종료일 사이만 / 계절: 게시일 절기 하나 / 날씨: 그 달에 있을 법한 것만(눈 12~2월, 소나기 6~8월) / 주변: 늘 후보 - 프롬프트에 게시일을 넣고, 날씨를 단정하지 않게 문구를 고쳤다 - 계절·날씨·축제 주제 키에 연(월)을 넣어 해마다 다시 쓸 수 있게 했다 - 자동·구간·개별 세 경로가 _compose_for_dates 하나로 만든다 같이 고친 버그: materials 가 스냅샷에 없는 local.festivals·attractions 를 읽어 축제·주변 소재가 늘 비어 있었다. 원문 행(local.contents)을 읽는다.
This commit is contained in:
parent
be3e43162a
commit
36dbe26c91
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 로 그대로 넘긴다 — 링크는 여기서
|
||||
붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면
|
||||
|
||||
Loading…
Reference in New Issue
Block a user