From 747ded2d6694921af07a34d1bfeed6df5605fca5 Mon Sep 17 00:00:00 2001 From: bits-bytes-nn Date: Sun, 11 Oct 2026 20:04:46 +0900 Subject: [PATCH] fix(title-case): parse the array out of a wrapped answer; document the role Slice the JSON array out of the answer so a sentence around it no longer discards every recased title. Cover the main-side assignment and braces in titles, and list title casing among the greeting model's jobs. --- README.ko.md | 2 +- README.md | 2 +- app/src/title_caser.py | 8 ++++---- docs/design.md | 3 ++- tests/test_pipeline_guards.py | 23 +++++++++++++++++++++++ tests/test_title_caser.py | 12 ++++++++++++ 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/README.ko.md b/README.ko.md index 881dde3..2d585ac 100644 --- a/README.ko.md +++ b/README.ko.md @@ -97,7 +97,7 @@ collect → gate → filter → rank → summarize → greet → render → deli - **SSM Parameter Store**. LangChain API 키와 Batch 큐/정의 이름을 저장합니다. - **SES**로 뉴스레터를 전달하고, **SNS**로 실행·헬스 알림을 보냅니다. - **Bedrock(us-west-2)**. GPT-6.1 Sol(필터), Claude Sonnet 5.5(요약), Claude Haiku 5.5(인사말, - 출력 복구). [`app/src/constants.py`](./app/src/constants.py)의 `LanguageModelId` + 제목 Title Case, 출력 복구). [`app/src/constants.py`](./app/src/constants.py)의 `LanguageModelId` 카탈로그에 있는 모델은 OpenAI GPT-6 모델을 포함해 스테이지별로 골라 쓸 수 있습니다. 모델별 요청 규칙(thinking 끄기 값, effort, GPT 호출 방식)은 design.md §8에 있습니다. diff --git a/README.md b/README.md index b0ef89f..59163a6 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ collect → gate → filter → rank → summarize → greet → render → deli - **SSM Parameter Store**. LangChain API key and Batch queue/definition names. - **SES**. newsletter delivery. **SNS** — run/health notifications. - **Bedrock (us-west-2)**. GPT-6.1 Sol (filter), Claude Sonnet 5.5 (summarize), Claude - Haiku 5.5 (greeting and output repair). Any model in the `LanguageModelId` + Haiku 5.5 (greeting, title casing and output repair). Any model in the `LanguageModelId` catalog in [`app/src/constants.py`](./app/src/constants.py) is selectable per stage, including OpenAI GPT-6 models; per-model request rules (thinking off-switches, effort, the GPT dialect) are in design.md §8. diff --git a/app/src/title_caser.py b/app/src/title_caser.py index 7c8feba..16d822d 100644 --- a/app/src/title_caser.py +++ b/app/src/title_caser.py @@ -1,5 +1,4 @@ import json -import re import boto3 from langchain_core.output_parsers import StrOutputParser @@ -10,8 +9,6 @@ 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 @@ -34,7 +31,10 @@ def parse_recased(answer: str, originals: list[str]) -> list[str]: Raises: ValueError: The answer is not a JSON array of the input's length. """ - parsed = json.loads(_CODE_FENCE.sub("", answer.strip())) + # Only the array is parsed, so a code fence or a sentence around it does not + # cost the whole issue its casing. + start, end = answer.find("["), answer.rfind("]") + parsed = json.loads(answer[start : end + 1] if 0 <= start < end else answer) 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)] diff --git a/docs/design.md b/docs/design.md index 395cc80..684929f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -225,6 +225,7 @@ tech-digest/ - `filtering_model_id` / `summarization_model_id` / `greeting_model_id`: 단계별 Bedrock 모델 ID(Claude 또는 GPT). **세 값 모두 필수이고 기본값이 없습니다.** 템플릿과 배포된 두 스테이지는 GPT-6.1 Sol / Sonnet 5.5 / Haiku 5.5를 씁니다(필터 모델을 고른 이유는 §9). + `greeting_model_id`는 카드 제목 Title Case 정리(§12)에도 쓰입니다. - `filtering_enable_thinking` / `summarization_enable_thinking`(기본 False): 해당 단계에서 확장 사고(extended thinking)를 켭니다. - `filtering_thinking_budget_tokens` / `summarization_thinking_budget_tokens`(기본 비움 = @@ -693,7 +694,7 @@ GPT-6 Astra를 Converse로 호출합니다. 직접 호출해 확인한 요청 ### `TokenUsageLogger` 모든 LLM 호출의 토큰 사용량을 **어느 단계가 호출했는지**와 함께 로그로 남깁니다. Cost Explorer는 모델 단위로만 청구하는데, 필터링·요약·출력 교정이 같은 모델을 공유하고 -인사말과 교정이 또 같은 모델을 공유합니다. 특히 필터링(수집된 글마다 1회, 각각 전문을 실어 +인사말·제목 정리·교정이 또 같은 모델을 공유합니다. 특히 필터링(수집된 글마다 1회, 각각 전문을 실어 보냄)은 요약(몇 건)과 비용 형태가 완전히 다르므로, 이 로그 없이 하는 최적화는 추측입니다. `get_model(stage=...)`로 붙이며, 콜백은 어떤 경우에도 생성을 실패시킬 수 없도록 모든 읽기를 방어적으로 처리합니다. diff --git a/tests/test_pipeline_guards.py b/tests/test_pipeline_guards.py index 54474cb..6f49dfd 100644 --- a/tests/test_pipeline_guards.py +++ b/tests/test_pipeline_guards.py @@ -147,6 +147,29 @@ class summarization: main._title_case_titles([post], _Config(), None) assert post.title == "a guide" + def test_recased_titles_replace_the_feed_titles_in_order(self, monkeypatch): + from src.feed_parser import Post + + class _Caser: + def __init__(self, *args, **kwargs): + pass + + def recase(self, titles): + return [t.title() for t in titles] + + monkeypatch.setattr(main, "TitleCaser", _Caser) + + class _Config: + class summarization: + greeting_model_id = "unused" + + posts = [ + Post(title=t, link=f"https://x.example/{i}", published_date="2026-10-09") + for i, t in enumerate(["first post", "second post"]) + ] + main._title_case_titles(posts, _Config(), None) + assert [p.title for p in posts] == ["First Post", "Second Post"] + class TestTestRunsKeepOutOfTheLogFile: def test_no_file_handler_under_pytest(self): diff --git a/tests/test_title_caser.py b/tests/test_title_caser.py index e88c242..613710c 100644 --- a/tests/test_title_caser.py +++ b/tests/test_title_caser.py @@ -48,6 +48,10 @@ def test_pairs_titles_in_order(self): "Fine-Tuning gpt-oss with LoRA", ] + def test_a_sentence_around_the_array_is_tolerated(self): + answer = "Here are the titles:\n" + json.dumps(TITLES) + "\nDone." + assert parse_recased(answer, TITLES) == TITLES + 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] @@ -84,3 +88,11 @@ def test_malformed_answer_keeps_the_feed_titles(self): def test_no_titles_makes_no_call(self): assert self._caser(lambda _t: pytest.fail("called")).recase([]) == [] + + +def test_titles_with_braces_reach_the_prompt_verbatim(): + from app.src.prompts import TitleCasePrompt + + titles = json.dumps(["Using {placeholders} in f-strings"]) + rendered = TitleCasePrompt.get_prompt().format_messages(titles=titles) + assert "Using {placeholders} in f-strings" in rendered[-1].content