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:
+67
-15
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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에는 데이터의 실제 값과 함께 한 문장으로
|
||||||
|
|||||||
Reference in New Issue
Block a user