Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
SSMParams,
Summarizer,
SummarizerSettings,
TitleCaser,
check_and_download_from_s3,
format_alarm,
get_date_range,
Expand Down Expand Up @@ -291,6 +292,7 @@ def _process_posts_and_create_newsletter(
) -> Path:
"""Build the issue and archive it in S3, under ``archive_suffix`` if given
(see ``_archive_suffix``)."""
_title_case_titles(posts, config, bedrock_boto_session)
inputs_dir = _prepare_and_save_posts(posts, date_suffix)
first_section_intro = _generate_greeting(
posts, date_suffix, config, bedrock_boto_session, language
Expand Down Expand Up @@ -416,6 +418,22 @@ def _save_post_to_file(post: Post, path: Path) -> None:
path.write_text(json_str, encoding="utf-8")


def _title_case_titles(
posts: list[Post], config: Config, boto_session: boto3.Session
) -> None:
"""Recase the shipping titles in place: feeds mix sentence and title case."""
if not posts:
return
try:
caser = TitleCaser(boto_session, config.summarization.greeting_model_id)
except Exception as e:
# Like the greeting: cosmetic, and every summary is already paid for.
logger.error("Title caser unavailable (%s); keeping the feed titles.", e)
return
for post, title in zip(posts, caser.recase([p.title for p in posts]), strict=True):
post.title = title


def _generate_greeting(
posts: list[Post],
date_suffix: str,
Expand Down
2 changes: 2 additions & 0 deletions app/src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
NewsletterBuilder,
)
from .summarizer import MODEL_FAILURE_PREFIXES, Summarizer, SummarizerSettings
from .title_caser import TitleCaser
from .utils import (
HTMLTagOutputParser,
format_alarm,
Expand Down Expand Up @@ -81,6 +82,7 @@
"SourceStatus",
"Summarizer",
"SummarizerSettings",
"TitleCaser",
"check_and_download_from_s3",
"format_alarm",
"get_account_id",
Expand Down
68 changes: 0 additions & 68 deletions app/src/newsletter_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,73 +104,6 @@ def normalize_date(v: str) -> str:
return (parsed or datetime.now(UTC)).strftime("%Y-%m-%d")


# Articles, coordinating conjunctions and short prepositions stay lowercase in
# a title-cased headline unless they open or close it (Chicago-style).
# fmt: off
_TITLE_MINOR_WORDS: frozenset[str] = frozenset({
"a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet", "as", "at",
"by", "in", "of", "off", "on", "per", "to", "up", "via", "vs", "vs.", "from",
"with", "into", "onto",
})
# fmt: on
_NON_LATIN_SCRIPT = re.compile(r"[぀-ヿ㐀-鿿가-힯]")
# Punctuation after which the next word starts a new phrase and is capitalized.
_PHRASE_BREAK = re.compile(r"[:?!]$")


def _title_case_part(part: str, force_capital: bool) -> str:
match = re.search(r"[A-Za-z]", part)
if match is None:
return part
core = part[match.start() :]
# Leave names and identifiers as written: acronyms and brands with inner
# capitals (LLMs, AgentCore, iOS), versions and model ids (d1, GPT-6.1),
# and URLs, handles or file names.
if (
any(c.isupper() for c in core[1:])
or any(c.isdigit() for c in core)
or re.search(r"[./@_][A-Za-z]", core)
):
return part
if not force_capital and core.lower().rstrip(",;") in _TITLE_MINOR_WORDS:
return part[: match.start()] + core.lower()
return part[: match.start()] + core[0].upper() + core[1:]


def to_title_case(title: str) -> str:
"""Title-case an English headline so every card reads in one style.

Feeds mix sentence case ("Multimodal open d1 decision models for the edge")
with title case. A title in Korean, Japanese or Chinese is returned as is:
the convention has no meaning there.
"""
if _NON_LATIN_SCRIPT.search(title):
return title
words = title.split(" ")
content = [i for i, w in enumerate(words) if re.search(r"[A-Za-z]", w)]
if not content:
return title
first, last = content[0], content[-1]
out: list[str] = []
capitalize_next = True
for i, word in enumerate(words):
if not word:
out.append(word)
continue
force = capitalize_next or i in (first, last)
# Each segment of a hyphenated compound is cased on its own, so a minor
# word inside one stays lowercase ("Context-Aware", "End-to-End").
segments = word.split("-")
out.append(
"-".join(
_title_case_part(seg, force if j == 0 else j == len(segments) - 1)
for j, seg in enumerate(segments)
)
)
capitalize_next = bool(_PHRASE_BREAK.search(word))
return " ".join(out)


class Article(BaseModel):
title: str = Field(min_length=1)
link: str
Expand All @@ -187,7 +120,6 @@ class Article(BaseModel):
urls: list[str] = Field(default_factory=list)
score: float = Field(default=1.0, ge=0.0, le=1.0)
_normalize_date = field_validator("published_date", mode="before")(normalize_date)
_title_case = field_validator("title")(to_title_case)

@field_validator("link", mode="before")
@classmethod
Expand Down
2 changes: 2 additions & 0 deletions app/src/prompts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
GreetingPrompt,
GreetingRevisionPrompt,
SummarizationPrompt,
TitleCasePrompt,
)

__all__ = [
Expand All @@ -12,4 +13,5 @@
"GreetingPrompt",
"GreetingRevisionPrompt",
"SummarizationPrompt",
"TitleCasePrompt",
]
35 changes: 35 additions & 0 deletions app/src/prompts/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,41 @@ def for_language(
)


class TitleCasePrompt(BasePrompt):
"""Recase the issue's card titles to one headline style.

Feeds mix sentence case and title case. Which words are product, model or
project names written in lowercase (gpt-oss, vLLM, d1) is a judgment a word
list cannot make, so the model decides; ``TitleCaser`` rejects any answer
that changes more than letter case.
"""

input_variables: list[str] = ["titles"]
output_variables: list[str] = ["titles"]
system_prompt_template: str = """You are a copy editor for an English-language technical
newsletter. You apply headline title case and change nothing else."""
human_prompt_template: str = """Convert each title below to title case (Chicago style).

**TITLES (JSON array):**
{titles}

**RULES:**
1. Capitalize the first and last word, the first word after a colon, and every noun, pronoun,
verb (including "Is", "Are", "Be"), adjective and adverb.
2. Lowercase articles (a, an, the), coordinating conjunctions (and, but, or, nor, for, so, yet),
"as", "to" and prepositions (of, in, on, at, by, for, from, with, into, via, vs, ...), unless
rule 1 applies.
3. In a hyphenated compound, case each part by rules 1-2 ("Context-Aware", "End-to-End").
4. Keep acronyms, product, model, library, company and person names exactly as they are
officially written, including lowercase or mixed-case ones (gpt-oss, vLLM, iOS, d1,
AgentCore). Keep version numbers, code identifiers, file names and URLs unchanged.
5. Change letter case only: never add, remove, reorder, translate or respell characters,
spaces or punctuation. A title not written in English is returned unchanged.

**OUTPUT:** A JSON array of the converted titles, same count and order as the input. Nothing
else."""


class SummarizationPrompt(BasePrompt):
input_variables: list[str] = ["post"]
output_variables: list[str] = ["summary", "one_liner", "tags", "urls"]
Expand Down
70 changes: 70 additions & 0 deletions app/src/title_caser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import json
import re

import boto3
from langchain_core.output_parsers import StrOutputParser

from .constants import LanguageModelId
from .logger import logger
from .model_factory import BedrockLanguageModelFactory
from .prompts import TitleCasePrompt
from .utils import retry_with_backoff

_CODE_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$")


def accept_recased(original: str, recased: object) -> str:
"""``recased`` if it differs from ``original`` in letter case only, else
``original``.

The model is trusted to judge casing, not wording: a title it translated,
shortened or "fixed" would misquote the source article on the card.
"""
if isinstance(recased, str) and recased.lower() == original.lower():
return recased
logger.warning(
"Title-case answer %r changes more than case; keeping %r.", recased, original
)
return original


def parse_recased(answer: str, originals: list[str]) -> list[str]:
"""Pair the model's JSON array with the input titles, title by title.

Raises:
ValueError: The answer is not a JSON array of the input's length.
"""
parsed = json.loads(_CODE_FENCE.sub("", answer.strip()))
if not isinstance(parsed, list) or len(parsed) != len(originals):
raise ValueError(f"expected a JSON array of {len(originals)} titles")
return [accept_recased(o, r) for o, r in zip(originals, parsed, strict=True)]


class TitleCaser:
def __init__(self, boto_session: boto3.Session, model_id: LanguageModelId):
llm_factory = BedrockLanguageModelFactory(boto_session=boto_session)
if not llm_factory.get_model_info(model_id):
raise ValueError(f"Unsupported model ID: '{model_id.value}'")
llm = llm_factory.get_model(
model_id=model_id, temperature=0.0, stage="title-case"
)
self.chain = TitleCasePrompt.get_prompt() | llm | StrOutputParser()

@retry_with_backoff("title-case")
def _invoke(self, titles: list[str]) -> str:
return self.chain.invoke({"titles": json.dumps(titles, ensure_ascii=False)})

def recase(self, titles: list[str]) -> list[str]:
"""The titles in title case, or unchanged if the model fails.

Casing is cosmetic and every summary is already paid for, so no failure
here may lose the issue. A malformed answer is not re-asked: the same
prompt tends to fail the same way, and the original titles are fine.
"""
if not titles:
return []
try:
return parse_recased(self._invoke(titles), titles)
except Exception as e:
logger.error("Title casing failed (%s); keeping the feed titles.", e)
return list(titles)
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@
`min-width: 800px` 요소와 고정 폭 표가 390px 화면에서도 문서 폭을 800px로 잡아, 기기가 전체를
약 절반 크기로 줄였습니다. 본문 열을 `max-width` 유동 폭으로 바꿨습니다. 같은 점검에서 흰 배경
위 주황 링크와 흰 글씨 버튼의 명암비가 2.1:1이라 링크 색과 버튼 글자색을 바꿨고, 캡션이 본문
단락처럼 보이던 `<figure>` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬입니다. 카드 제목은 28px로 키웠고, 피드마다 대소문자 규칙이 달라 렌더 단계에서 Title Case로 맞춥니다. 본문과 리드는 왼쪽 정렬입니다.
단락처럼 보이던 `<figure>` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬입니다. 카드 제목은 28px로 키웠고, 피드마다 대소문자 규칙이 달라 인사말 모델이 제목을 Title Case로 맞춥니다. 규칙 기반 변환을 먼저 넣었다가 `gpt-oss` 같은 소문자 고유명사를 `Gpt-Oss`로 바꿔 LLM 판단으로 옮겼습니다. 본문과 리드는 왼쪽 정렬입니다.

## 오케스트레이션, 알림, 전달

Expand Down
25 changes: 16 additions & 9 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ tech-digest/
│ │ ├── feed_parser.py # 수집: RSS + 스크레이퍼 + 헬스
│ │ ├── summarizer.py # 콘텐츠 게이트 + LLM 필터 + LLM 요약
│ │ ├── greeter.py # LLM 인사말 생성기
│ │ ├── title_caser.py # LLM 제목 Title Case 정리
│ │ ├── newsletter_renderer.py # Jinja2 렌더 + Selenium HTML→이미지
│ │ ├── aws_helpers.py # S3 / SES / SNS / SSM / Batch 헬퍼
│ │ ├── model_factory.py # Bedrock 모델 레지스트리 + 모델 생성
Expand Down Expand Up @@ -1266,6 +1267,19 @@ CHANGELOG에 있습니다.
대신하고 발송을 계속합니다. 이 시점에는 요약 비용을 이미 다 냈으므로, 인사말 하나 때문에
호를 잃으면 안 됩니다.

### 제목 Title Case (`app/src/title_caser.py`)

피드마다 문장형 대소문자와 Title Case가 섞여 있어 한 호 안에서도 카드 제목 모양이
달랐습니다. 인사말 전에 `TitleCaser`가 실릴 제목들을 JSON 배열 하나로 인사말 모델에 보내고
(`TitleCasePrompt`, 온도 0) 같은 순서의 배열을 받습니다. 규칙 목록으로 하지 않고 모델에 맡긴
이유는 `gpt-oss`, `vLLM`, `d1`처럼 소문자로 쓰는 제품·모델 이름을 일반 단어와 구분하는
일이 단어 목록으로는 안 되기 때문입니다.

모델은 대소문자만 판단하고 나머지는 코드가 검사합니다. `accept_recased`는 답이 원 제목과
대소문자만 다를 때만 받아들이고, 단어를 더하거나 번역하거나 문장부호를 바꾼 답은 그 제목만
원래대로 둡니다. 답이 JSON 배열이 아니거나 개수가 다르거나 호출이 실패하면 피드 제목을 그대로
씁니다. 형식 오류는 다시 묻지 않습니다. 제목 모양은 미용상의 문제라 호를 막아서는 안 됩니다.

---

## 13. 뉴스레터 렌더링 (`app/src/newsletter_renderer.py`)
Expand All @@ -1281,13 +1295,6 @@ CHANGELOG에 있습니다.
접근성 alt 텍스트에 쓰는 `source_label`을 노출합니다. `one_liner`(한 문장 리드)와
`reading_minutes`도 여기 있습니다.

제목은 `to_title_case`로 Title Case를 맞춥니다. 피드마다 문장형 대소문자와 Title Case가
섞여 있어 카드끼리 제목 모양이 달랐기 때문입니다. 관사·등위접속사·짧은 전치사는 첫 단어나
마지막 단어, 콜론 바로 뒤가 아니면 소문자로 둡니다. 안쪽에 대문자나 숫자가 있는 단어(`LLMs`,
`AgentCore`, `d1`, `GPT-6.1`)와 URL처럼 생긴 단어는 쓰인 그대로 둡니다. 한글·일본어·중국어가
들어 있는 제목은 바꾸지 않습니다. 전부 소문자로 쓰는 고유명사(`gpt-oss`)는 구분할 수 없어
첫 글자가 대문자가 됩니다.

`normalize_date`는 들쭉날쭉한 입력 날짜를 `YYYY-MM-DD`로 맞춥니다. 크롤러가 게이트에 쓰는
`try_parse_published_date`를 그대로 쓰므로, 글을 호에 넣은 날짜는 항상 렌더됩니다. 파싱이
실패했을 때의 폴백은 **UTC** 기준 오늘입니다. Batch 이미지가 `TZ=Asia/Seoul`이라 로컬 시각을
Expand Down Expand Up @@ -1466,8 +1473,8 @@ boto3 위에 얹은, 얇고 로깅이 잘 된 래퍼들입니다. 우아한 성
6. 살아남은 글이 하나도 없으면 200으로 종료합니다. 수집은 됐는데 전량 탈락했다면 그 전에
빈 다이제스트 알림을 발행합니다. 호가 비지 않았어도 일부 글을 모델 호출 실패로 잃었다면
모델 실패 알림을 보냅니다.
7. `_process_posts_and_create_newsletter`. 글 JSON을 영속화하고, 인사말을
생성하고(§12), HTML을 빌드하고, 산출물을 S3에 업로드합니다. 클립 한계를 넘었으면 클립
7. `_process_posts_and_create_newsletter`. 제목을 Title Case로 맞추고(§12), 글 JSON을
영속화하고, 인사말을 생성하고(§12), HTML을 빌드하고, 산출물을 S3에 업로드합니다. 클립 한계를 넘었으면 클립
알림을 발행합니다.
8. 이메일이 켜져 있으면 수신자를 해석하고(CLI 인자 또는 S3 파일), **이미 이 호를 받은
수신자를 건너뛴 뒤**(아래 "전달 원장"), 수신자마다 간격을 두고 발송합니다. 결과는
Expand Down
53 changes: 0 additions & 53 deletions tests/test_newsletter_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,62 +18,9 @@
collapse_html_whitespace,
estimate_reading_minutes,
normalize_date,
to_title_case,
)


class TestTitleCase:
@pytest.mark.parametrize(
("raw", "expected"),
[
(
"Multimodal open d1 decision models for the edge",
"Multimodal Open d1 Decision Models for the Edge",
),
(
"EmbeddingGemma 2: an open, lightweight multimodal embedding model",
"EmbeddingGemma 2: An Open, Lightweight Multimodal Embedding Model",
),
(
"Manage Amazon SageMaker HyperPod Spaces directly from SageMaker Studio",
"Manage Amazon SageMaker HyperPod Spaces Directly from SageMaker Studio",
),
(
"an end-to-end guide to vLLM on huggingface.co",
"An End-to-End Guide to vLLM on huggingface.co",
),
("what the model is looking at", "What the Model Is Looking At"),
(
"Building A Context-aware Assistant",
"Building a Context-Aware Assistant",
),
(
"Introducing Claude Haiku 5.5 on AWS",
"Introducing Claude Haiku 5.5 on AWS",
),
('"quoted" start', '"Quoted" Start'),
],
)
def test_english_headlines(self, raw: str, expected: str) -> None:
assert to_title_case(raw) == expected

def test_cjk_title_is_left_alone(self) -> None:
assert (
to_title_case("Amazon Bedrock으로 만든 에이전트")
== "Amazon Bedrock으로 만든 에이전트"
)

def test_article_title_is_title_cased(self) -> None:
article = Article(
title="a guide to agents",
link="https://example.com/a",
published_date="2026-10-01",
thumbnail="t.png",
summary="<p>s</p>",
)
assert article.title == "A Guide to Agents"


class TestValidateDate:
@pytest.mark.parametrize(
"raw,expected",
Expand Down
Loading
Loading