Fix date framing in briefings and make Blogger posts idempotent

The 9/1 briefing called the US 8/31 session "9월 첫 거래일" - the LLM
was anchoring on the KRX date. Both prompts now pin every temporal
phrase (weekday, first/last trading day, month boundaries) to the US
session date, and the verifier treats a mismatch as a major issue.
BloggerDeliverer now updates the existing post for the same KRX date
instead of creating a duplicate on resends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jsahn
2026-09-01 14:13:01 +09:00
co-authored by Claude Fable 5
parent b75bf0b887
commit 830e4e0522
3 changed files with 131 additions and 29 deletions
+67 -15
View File
@@ -64,8 +64,8 @@ class FakeHttp:
self._responses = list(responses) self._responses = list(responses)
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
def post(self, url: str, **kwargs: Any) -> Any: def request(self, method: str, url: str, **kwargs: Any) -> Any:
self.calls.append({"url": url, **kwargs}) self.calls.append({"method": method, "url": url, **kwargs})
if not self._responses: if not self._responses:
raise AssertionError("unexpected extra HTTP call") raise AssertionError("unexpected extra HTTP call")
item = self._responses.pop(0) item = self._responses.pop(0)
@@ -73,6 +73,9 @@ class FakeHttp:
raise item raise item
return item return item
def post(self, url: str, **kwargs: Any) -> Any:
return self.request("POST", url, **kwargs)
def make_deliverer(responses: list[Any]) -> tuple[BloggerDeliverer, FakeHttp]: def make_deliverer(responses: list[Any]) -> tuple[BloggerDeliverer, FakeHttp]:
client = FakeHttp(responses) client = FakeHttp(responses)
@@ -97,23 +100,59 @@ def test_post_title_with_and_without_headline():
# ------------------------------------------------------------------ delivery # ------------------------------------------------------------------ delivery
def test_deliver_refreshes_token_posts_and_returns_url(): EMPTY_LIST = {"kind": "blogger#postList"} # no items key on an empty blog
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(200, POST_OK)])
def test_deliver_refreshes_token_lists_then_inserts():
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST), _Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result()) receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"] assert receipt == POST_OK["url"]
token_call, post_call = client.calls token_call, list_call, insert_call = client.calls
assert token_call["data"]["grant_type"] == "refresh_token" assert token_call["data"]["grant_type"] == "refresh_token"
assert "refresh_token" not in post_call["url"] # secrets never in URLs assert list_call["method"] == "GET" and list_call["params"]["fetchBodies"] == "false"
assert post_call["url"].endswith("/blogs/12345/posts/") assert insert_call["method"] == "POST"
assert post_call["headers"]["Authorization"] == "Bearer at-1" assert "refresh_token" not in insert_call["url"] # secrets never in URLs
body = post_call["json"] assert insert_call["url"].endswith("/blogs/12345/posts/")
assert insert_call["headers"]["Authorization"] == "Bearer at-1"
body = insert_call["json"]
assert body["title"].startswith("미국 마감 브리핑 2026-09-01") assert body["title"].startswith("미국 마감 브리핑 2026-09-01")
assert body["labels"] == ["미국증시"] and "<table>" in body["content"] assert body["labels"] == ["미국증시"] and "<table>" in body["content"]
def test_deliver_updates_existing_post_for_same_day():
listing = {"items": [{"id": "42", "title": "미국 마감 브리핑 2026-09-01 — 이전 헤드라인"}]}
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, listing), _Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
update_call = client.calls[-1]
assert update_call["method"] == "PUT"
assert update_call["url"].endswith("/blogs/12345/posts/42")
def test_deliver_other_day_posts_do_not_match():
listing = {"items": [{"id": "41", "title": "미국 마감 브리핑 2026-08-29 — 다른 날"}]}
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, listing), _Resp(200, POST_OK)]
)
deliverer.deliver(make_result())
assert client.calls[-1]["method"] == "POST" # inserted, not updated
def test_deliver_reuses_cached_token(): def test_deliver_reuses_cached_token():
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(200, POST_OK), _Resp(200, POST_OK)]) deliverer, client = make_deliverer(
[
_Resp(200, TOKEN_OK),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK),
]
)
deliverer.deliver(make_result()) deliverer.deliver(make_result())
deliverer.deliver(make_result()) deliverer.deliver(make_result())
token_calls = [c for c in client.calls if "oauth2" in c["url"]] token_calls = [c for c in client.calls if "oauth2" in c["url"]]
@@ -124,8 +163,9 @@ def test_deliver_retries_once_on_401_with_fresh_token():
deliverer, client = make_deliverer( deliverer, client = make_deliverer(
[ [
_Resp(200, TOKEN_OK), _Resp(200, TOKEN_OK),
_Resp(401, {"error": "invalid token"}), _Resp(401, {"error": "invalid token"}), # list call rejected
_Resp(200, {"access_token": "at-2", "expires_in": 3600}), _Resp(200, {"access_token": "at-2", "expires_in": 3600}),
_Resp(200, EMPTY_LIST),
_Resp(200, POST_OK), _Resp(200, POST_OK),
] ]
) )
@@ -134,20 +174,32 @@ def test_deliver_retries_once_on_401_with_fresh_token():
assert client.calls[-1]["headers"]["Authorization"] == "Bearer at-2" assert client.calls[-1]["headers"]["Authorization"] == "Bearer at-2"
def test_deliver_lookup_failure_still_publishes(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK)] + [_Resp(503, {"error": "backend"})] * 3 + [_Resp(200, POST_OK)]
)
receipt = deliverer.deliver(make_result())
assert receipt == POST_OK["url"]
assert client.calls[-1]["method"] == "POST"
def test_deliver_retries_5xx_then_raises(monkeypatch): def test_deliver_retries_5xx_then_raises(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None) monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _client = make_deliverer( deliverer, _client = make_deliverer(
[_Resp(200, TOKEN_OK)] + [_Resp(503, {"error": "backend"})] * 3 [_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST)] + [_Resp(503, {"error": "backend"})] * 3
) )
with pytest.raises(DeliveryError, match="HTTP 503"): with pytest.raises(DeliveryError, match="HTTP 503"):
deliverer.deliver(make_result()) deliverer.deliver(make_result())
def test_deliver_does_not_retry_4xx(): def test_deliver_does_not_retry_4xx():
deliverer, client = make_deliverer([_Resp(200, TOKEN_OK), _Resp(403, {"error": "forbidden"})]) deliverer, client = make_deliverer(
[_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST), _Resp(403, {"error": "forbidden"})]
)
with pytest.raises(DeliveryError, match="HTTP 403"): with pytest.raises(DeliveryError, match="HTTP 403"):
deliverer.deliver(make_result()) deliverer.deliver(make_result())
assert len(client.calls) == 2 # no extra attempts assert len(client.calls) == 3 # no extra attempts
def test_token_failure_raises_delivery_error(): def test_token_failure_raises_delivery_error():
@@ -159,7 +211,7 @@ def test_token_failure_raises_delivery_error():
def test_network_error_wrapped(monkeypatch): def test_network_error_wrapped(monkeypatch):
monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None) monkeypatch.setattr("us2kr.deliver.blogger.time.sleep", lambda _s: None)
deliverer, _ = make_deliverer( deliverer, _ = make_deliverer(
[_Resp(200, TOKEN_OK)] + [httpx.ConnectError("boom")] * 3 [_Resp(200, TOKEN_OK), _Resp(200, EMPTY_LIST)] + [httpx.ConnectError("boom")] * 3
) )
with pytest.raises(DeliveryError, match="ConnectError"): with pytest.raises(DeliveryError, match="ConnectError"):
deliverer.deliver(make_result()) deliverer.deliver(make_result())
+56 -13
View File
@@ -38,12 +38,16 @@ def briefing_html(markdown_text: str) -> str:
return _MD.convert("\n".join(lines).strip()) return _MD.convert("\n".join(lines).strip())
def post_title_prefix(result: BriefingResult) -> str:
"""Stable per-day prefix - resends update the existing post instead of creating a duplicate."""
return f"미국 마감 브리핑 {result.payload.krx_date.isoformat()}"
def post_title(result: BriefingResult) -> str: def post_title(result: BriefingResult) -> str:
"""'미국 마감 브리핑 YYYY-MM-DD — 헤드라인' (falls back to the session date without a report).""" """'미국 마감 브리핑 YYYY-MM-DD — 헤드라인' (falls back to the session date without a report)."""
p = result.payload base = post_title_prefix(result)
base = f"미국 마감 브리핑 {p.krx_date.isoformat()}"
headline = (result.report.headline or "").strip() if result.report else "" headline = (result.report.headline or "").strip() if result.report else ""
return f"{base} — {headline}" if headline else f"{base} (미 {p.session_date.isoformat()} 세션)" return f"{base} — {headline}" if headline else f"{base} (미 {result.payload.session_date.isoformat()} 세션)"
class BloggerDeliverer: class BloggerDeliverer:
@@ -101,7 +105,7 @@ class BloggerDeliverer:
# ------------------------------------------------------------------ delivery # ------------------------------------------------------------------ delivery
def deliver(self, result: BriefingResult) -> str: def deliver(self, result: BriefingResult) -> str:
"""Create the post and return its public URL.""" """Create the day's post - or update it when one already exists - and return its public URL."""
body: dict[str, Any] = { body: dict[str, Any] = {
"kind": "blogger#post", "kind": "blogger#post",
"title": post_title(result), "title": post_title(result),
@@ -109,31 +113,70 @@ class BloggerDeliverer:
} }
if self.labels: if self.labels:
body["labels"] = self.labels body["labels"] = self.labels
url = f"{API_BASE}/blogs/{self.blog_id}/posts/"
existing_id = self._find_existing(post_title_prefix(result))
if existing_id:
data = self._request("PUT", f"{API_BASE}/blogs/{self.blog_id}/posts/{existing_id}", json_body=body)
action = "updated"
else:
data = self._request("POST", f"{API_BASE}/blogs/{self.blog_id}/posts/", json_body=body)
action = "published"
post_url = data.get("url") or f"post id {data.get('id', '?')}"
log.info("blogger post %s: %s", action, post_url)
return post_url
def _find_existing(self, title_prefix: str) -> str | None:
"""Id of the newest post whose title carries this day's prefix; None on miss or lookup failure."""
try:
data = self._request(
"GET",
f"{API_BASE}/blogs/{self.blog_id}/posts",
params={"fetchBodies": "false", "maxResults": "25"},
)
except DeliveryError as exc:
if "token refresh failed" in str(exc):
raise # a broken token will fail the publish too - surface it once, immediately
# any other lookup failure must not block a fresh publish
log.warning("blogger post lookup failed, publishing as a new post: %s", exc)
return None
for item in data.get("items", []):
if str(item.get("title", "")).startswith(title_prefix):
return str(item["id"])
return None
def _request(
self,
method: str,
url: str,
*,
params: dict[str, str] | None = None,
json_body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""One authorized API call with retries (fresh token on 401, backoff on 429/5xx)."""
force_token = False force_token = False
last_error = "unknown error" last_error = "unknown error"
for attempt in range(1, MAX_ATTEMPTS + 1): for attempt in range(1, MAX_ATTEMPTS + 1):
try: try:
resp = self._client.post( resp = self._client.request(
url, json=body, headers={"Authorization": f"Bearer {self._token(force=force_token)}"} method,
url,
params=params,
json=json_body,
headers={"Authorization": f"Bearer {self._token(force=force_token)}"},
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
last_error = f"{type(exc).__name__}: {exc}" last_error = f"{type(exc).__name__}: {exc}"
log.warning("blogger post attempt %d/%d failed: %s", attempt, MAX_ATTEMPTS, last_error) log.warning("blogger %s attempt %d/%d failed: %s", method, attempt, MAX_ATTEMPTS, last_error)
time.sleep(BACKOFF_BASE_SECONDS * attempt) time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue continue
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() return resp.json()
post_url = data.get("url") or f"post id {data.get('id', '?')}"
log.info("blogger post published: %s", post_url)
return post_url
last_error = f"HTTP {resp.status_code} {resp.text[:200]}" last_error = f"HTTP {resp.status_code} {resp.text[:200]}"
if resp.status_code == 401 and not force_token: if resp.status_code == 401 and not force_token:
force_token = True # cached token stale/revoked - mint a fresh one and retry force_token = True # cached token stale/revoked - mint a fresh one and retry
continue continue
if resp.status_code in (429, 500, 502, 503, 504) and attempt < MAX_ATTEMPTS: if resp.status_code in (429, 500, 502, 503, 504) and attempt < MAX_ATTEMPTS:
log.warning("blogger post attempt %d/%d failed: %s", attempt, MAX_ATTEMPTS, last_error) log.warning("blogger %s attempt %d/%d failed: %s", method, attempt, MAX_ATTEMPTS, last_error)
time.sleep(BACKOFF_BASE_SECONDS * attempt) time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue continue
break # non-retryable (4xx) break # non-retryable (4xx)
+8 -1
View File
@@ -64,6 +64,11 @@ SYSTEM_PROMPT: str = f"""당신은 한국 투자자를 위한 미국 시장 마
8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다. 8. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다.
배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지 배열 필드는 반드시 JSON 배열(각 항목이 한 문장인 문자열 배열)로 출력하고, 여러 문장을 하나의 문자열로 이어 쓰지
않습니다. 않습니다.
9. 날짜·요일·"첫 거래일 / 마지막 거래일"·"월초 / 월말" 같은 시점 표현은 전부 **미국 거래일**(세션 정보의
'미국 거래일') 기준으로만 씁니다. 미국 장 마감이 한국 시간으로 다음 날 새벽이라 '한국 거래일'은 하루 뒤인
경우가 많은데, 한국 거래일 날짜를 미국 장 서술에 쓰면 안 됩니다.
예: 미국 거래일이 8월 31일(월)이면 이 세션은 "8월 마지막 거래일"입니다 — 한국 거래일이 9월 1일이어도
"9월 첫 거래일"이라고 쓰면 틀립니다.
## 입력 데이터 용어집 ## 입력 데이터 용어집
사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다. 사용자 메시지의 시세는 아래 그룹으로 나뉘어 제공됩니다.
@@ -171,7 +176,9 @@ VERIFY_SYSTEM_PROMPT: str = """당신은 미국 시장 브리핑의 팩트체커
1. 판단 근거는 사용자 메시지에 제공된 데이터(시세·히스토리 노트·뉴스·경제지표·실적)뿐입니다. 1. 판단 근거는 사용자 메시지에 제공된 데이터(시세·히스토리 노트·뉴스·경제지표·실적)뿐입니다.
당신의 배경지식이나 최신 시황과 달라 보여도, 제공된 데이터와 일치하면 문제가 아닙니다. 당신의 배경지식이나 최신 시황과 달라 보여도, 제공된 데이터와 일치하면 문제가 아닙니다.
2. major: 브리핑의 숫자가 데이터와 다르거나(반올림 오차 ±0.05는 허용), 상승/하락 방향이 반대이거나, 2. major: 브리핑의 숫자가 데이터와 다르거나(반올림 오차 ±0.05는 허용), 상승/하락 방향이 반대이거나,
데이터에 없는 숫자·사건을 사실처럼 서술한 경우. 데이터에 없는 숫자·사건을 사실처럼 서술한 경우. 시점 표현이 세션 정보의 '미국 거래일'과 어긋나는 경우도
major입니다 — 날짜·요일·"N월 첫/마지막 거래일"·"월초/월말"은 미국 거래일 기준이어야 하며, 한국 거래일
날짜로 미국 장을 서술하면 안 됩니다 (예: 미국 거래일이 8월 31일인 세션을 "9월 첫 거래일"로 서술).
3. minor: 뉴스 헤드라인만으로 확인되지 않는 인과관계를 단정, 제공된 표기와 다른 종목명, 모호하지만 3. minor: 뉴스 헤드라인만으로 확인되지 않는 인과관계를 단정, 제공된 표기와 다른 종목명, 모호하지만
틀렸다고 단정할 수 없는 서술. 틀렸다고 단정할 수 없는 서술.
4. 각 이슈의 claim에는 브리핑 원문을 그대로 인용하고, problem에는 데이터의 실제 값과 함께 한 문장으로 4. 각 이슈의 claim에는 브리핑 원문을 그대로 인용하고, problem에는 데이터의 실제 값과 함께 한 문장으로