From ec77cdf23d1552f50bfb18bae4635541fe301ef6 Mon Sep 17 00:00:00 2001 From: bits-bytes-nn Date: Sun, 11 Oct 2026 19:49:48 +0900 Subject: [PATCH] feat: title-case card titles with the greeting model A word list cannot tell lowercase product and model names (gpt-oss, vLLM, d1) from ordinary words, so the rule-based caser turned gpt-oss into Gpt-Oss. TitleCaser now sends the issue's titles to the greeting model in one call and keeps an answer only where it changes letter case alone; any failure keeps the feed titles. --- app/main.py | 18 +++++++ app/src/__init__.py | 2 + app/src/newsletter_renderer.py | 68 ------------------------ app/src/prompts/__init__.py | 2 + app/src/prompts/prompts.py | 35 +++++++++++++ app/src/title_caser.py | 70 +++++++++++++++++++++++++ docs/CHANGELOG.md | 2 +- docs/design.md | 25 +++++---- tests/test_newsletter_renderer.py | 53 ------------------- tests/test_pipeline_guards.py | 20 +++++++ tests/test_title_caser.py | 86 +++++++++++++++++++++++++++++++ 11 files changed, 250 insertions(+), 131 deletions(-) create mode 100644 app/src/title_caser.py create mode 100644 tests/test_title_caser.py diff --git a/app/main.py b/app/main.py index 1becae4..9d931f1 100644 --- a/app/main.py +++ b/app/main.py @@ -34,6 +34,7 @@ SSMParams, Summarizer, SummarizerSettings, + TitleCaser, check_and_download_from_s3, format_alarm, get_date_range, @@ -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 @@ -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, diff --git a/app/src/__init__.py b/app/src/__init__.py index 1f3d83a..eb446bf 100644 --- a/app/src/__init__.py +++ b/app/src/__init__.py @@ -44,6 +44,7 @@ NewsletterBuilder, ) from .summarizer import MODEL_FAILURE_PREFIXES, Summarizer, SummarizerSettings +from .title_caser import TitleCaser from .utils import ( HTMLTagOutputParser, format_alarm, @@ -81,6 +82,7 @@ "SourceStatus", "Summarizer", "SummarizerSettings", + "TitleCaser", "check_and_download_from_s3", "format_alarm", "get_account_id", diff --git a/app/src/newsletter_renderer.py b/app/src/newsletter_renderer.py index a8b7dc1..a9cc81e 100644 --- a/app/src/newsletter_renderer.py +++ b/app/src/newsletter_renderer.py @@ -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 @@ -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 diff --git a/app/src/prompts/__init__.py b/app/src/prompts/__init__.py index 1779449..15c29f4 100644 --- a/app/src/prompts/__init__.py +++ b/app/src/prompts/__init__.py @@ -4,6 +4,7 @@ GreetingPrompt, GreetingRevisionPrompt, SummarizationPrompt, + TitleCasePrompt, ) __all__ = [ @@ -12,4 +13,5 @@ "GreetingPrompt", "GreetingRevisionPrompt", "SummarizationPrompt", + "TitleCasePrompt", ] diff --git a/app/src/prompts/prompts.py b/app/src/prompts/prompts.py index 835f591..c9b3b29 100644 --- a/app/src/prompts/prompts.py +++ b/app/src/prompts/prompts.py @@ -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"] diff --git a/app/src/title_caser.py b/app/src/title_caser.py new file mode 100644 index 0000000..7c8feba --- /dev/null +++ b/app/src/title_caser.py @@ -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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 313a78b..ad52f4a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -577,7 +577,7 @@ `min-width: 800px` 요소와 고정 폭 표가 390px 화면에서도 문서 폭을 800px로 잡아, 기기가 전체를 약 절반 크기로 줄였습니다. 본문 열을 `max-width` 유동 폭으로 바꿨습니다. 같은 점검에서 흰 배경 위 주황 링크와 흰 글씨 버튼의 명암비가 2.1:1이라 링크 색과 버튼 글자색을 바꿨고, 캡션이 본문 - 단락처럼 보이던 `
` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬입니다. 카드 제목은 28px로 키웠고, 피드마다 대소문자 규칙이 달라 렌더 단계에서 Title Case로 맞춥니다. 본문과 리드는 왼쪽 정렬입니다. + 단락처럼 보이던 `
` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬입니다. 카드 제목은 28px로 키웠고, 피드마다 대소문자 규칙이 달라 인사말 모델이 제목을 Title Case로 맞춥니다. 규칙 기반 변환을 먼저 넣었다가 `gpt-oss` 같은 소문자 고유명사를 `Gpt-Oss`로 바꿔 LLM 판단으로 옮겼습니다. 본문과 리드는 왼쪽 정렬입니다. ## 오케스트레이션, 알림, 전달 diff --git a/docs/design.md b/docs/design.md index 6476c2e..395cc80 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 모델 레지스트리 + 모델 생성 @@ -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`) @@ -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`이라 로컬 시각을 @@ -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 파일), **이미 이 호를 받은 수신자를 건너뛴 뒤**(아래 "전달 원장"), 수신자마다 간격을 두고 발송합니다. 결과는 diff --git a/tests/test_newsletter_renderer.py b/tests/test_newsletter_renderer.py index c98576a..da64173 100644 --- a/tests/test_newsletter_renderer.py +++ b/tests/test_newsletter_renderer.py @@ -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="

s

", - ) - assert article.title == "A Guide to Agents" - - class TestValidateDate: @pytest.mark.parametrize( "raw,expected", diff --git a/tests/test_pipeline_guards.py b/tests/test_pipeline_guards.py index 9d13afb..54474cb 100644 --- a/tests/test_pipeline_guards.py +++ b/tests/test_pipeline_guards.py @@ -128,6 +128,26 @@ class summarization: assert intro == main.FALLBACK_INTRO[language] +class TestTitleCaseFailureKeepsTheTitles: + def test_an_unavailable_caser_leaves_titles_alone(self, monkeypatch): + from src.feed_parser import Post + + def _broken(*args, **kwargs): + raise ValueError("Unsupported model ID") + + monkeypatch.setattr(main, "TitleCaser", _broken) + + class _Config: + class summarization: + greeting_model_id = "unused" + + post = Post( + title="a guide", link="https://x.example/p", published_date="2026-10-09" + ) + main._title_case_titles([post], _Config(), None) + assert post.title == "a guide" + + class TestTestRunsKeepOutOfTheLogFile: def test_no_file_handler_under_pytest(self): from src.logger import configure_logger diff --git a/tests/test_title_caser.py b/tests/test_title_caser.py new file mode 100644 index 0000000..e88c242 --- /dev/null +++ b/tests/test_title_caser.py @@ -0,0 +1,86 @@ +"""Unit tests for the title caser's deterministic boundary: the model judges +casing, code makes sure nothing but casing changes.""" + +from __future__ import annotations + +import json + +import pytest + +from app.src.title_caser import TitleCaser, accept_recased, parse_recased + +TITLES = [ + "Multimodal open d1 decision models for the edge", + "Fine-tuning gpt-oss with LoRA", +] + + +class TestAcceptRecased: + def test_a_case_only_change_is_accepted(self): + assert accept_recased("a guide to agents", "A Guide to Agents") == ( + "A Guide to Agents" + ) + + @pytest.mark.parametrize( + "answer", + [ + "A Guide To AI Agents", # a word added + "A Guide: Agents", # punctuation changed + "에이전트 가이드", # translated + None, + ["A Guide to Agents"], + ], + ) + def test_anything_beyond_case_keeps_the_original(self, answer): + assert accept_recased("a guide to agents", answer) == "a guide to agents" + + +class TestParseRecased: + def test_pairs_titles_in_order(self): + answer = json.dumps( + [ + "Multimodal Open d1 Decision Models for the Edge", + "Fine-Tuning gpt-oss with LoRA", + ] + ) + assert parse_recased(answer, TITLES) == [ + "Multimodal Open d1 Decision Models for the Edge", + "Fine-Tuning gpt-oss with LoRA", + ] + + def test_a_code_fence_is_tolerated(self): + answer = "```json\n" + json.dumps([t.upper() for t in TITLES]) + "\n```" + assert parse_recased(answer, TITLES) == [t.upper() for t in TITLES] + + def test_one_bad_title_does_not_discard_the_others(self): + answer = json.dumps(["Multimodal Models", "Fine-Tuning gpt-oss with LoRA"]) + assert parse_recased(answer, TITLES) == [ + TITLES[0], + "Fine-Tuning gpt-oss with LoRA", + ] + + @pytest.mark.parametrize( + "answer", ["not json", json.dumps(["only one"]), json.dumps({"a": 1})] + ) + def test_a_malformed_answer_raises(self, answer): + with pytest.raises(ValueError): + parse_recased(answer, TITLES) + + +class TestRecase: + def _caser(self, invoke) -> TitleCaser: + caser = TitleCaser.__new__(TitleCaser) + caser._invoke = invoke # type: ignore[method-assign] + return caser + + def test_model_failure_keeps_the_feed_titles(self): + def fail(_titles): + raise RuntimeError("Bedrock unavailable") + + assert self._caser(fail).recase(TITLES) == TITLES + + def test_malformed_answer_keeps_the_feed_titles(self): + assert self._caser(lambda _t: "Sure! Here you go.").recase(TITLES) == TITLES + + def test_no_titles_makes_no_call(self): + assert self._caser(lambda _t: pytest.fail("called")).recase([]) == []