diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 264f8c1..4fcad83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,8 @@ name: CI on: push: branches: [main] + # Every PR, stacked ones included, not only those into main. pull_request: - branches: [main] workflow_dispatch: concurrency: diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 202851a..d863d89 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -3,8 +3,8 @@ name: Workflow validation on: push: branches: [main] + # Every PR, stacked ones included, not only those into main. pull_request: - branches: [main] workflow_dispatch: permissions: diff --git a/app/adapters/aws.py b/app/adapters/aws.py index e3babf4..b8ba6e1 100644 --- a/app/adapters/aws.py +++ b/app/adapters/aws.py @@ -188,25 +188,41 @@ def html_to_text(html: str) -> str: One line per block element, a blank line between paragraphs at most, and each link's URL after its text so a text-only reader can still open the - article. Hidden elements (the inbox preheader) are left out. + article. Hidden elements (the inbox preheader) are left out. Code keeps its + indentation, table cells are separated by " | " and list items are marked. """ soup = BeautifulSoup(html, "html.parser") for tag in soup(["head", "style", "script"]): tag.decompose() for tag in soup.find_all(style=_HIDDEN_STYLE): tag.decompose() + # Code is whitespace-sensitive: set it aside before spaces are collapsed. + code_blocks: list[str] = [] + for pre in soup.find_all("pre"): + code_blocks.append(pre.get_text().strip("\n")) + pre.replace_with(NavigableString(f"\n\x00{len(code_blocks) - 1}\x00\n")) for anchor in soup.find_all("a", href=True): href = str(anchor["href"]) if href and href != anchor.get_text(strip=True): anchor.append(f" ({href})") for br in soup.find_all("br"): br.replace_with("\n") + # Only the data tables inside summaries; the template's layout tables are not + # rows of values. + for table in soup.find_all("table", class_="table-bordered"): + for row in table.find_all("tr"): + for index, cell in enumerate(row.find_all(["td", "th"])): + if index: + cell.insert_before(NavigableString(" | ")) + for listing in soup.find_all(["ol", "ul"]): + for number, item in enumerate(listing.find_all("li", recursive=False), 1): + item.insert(0, f"{number}. " if listing.name == "ol" else "• ") for tag in soup.find_all(_TEXT_BLOCK_TAGS): tag.insert_before(NavigableString("\n")) tag.append("\n") lines = [" ".join(line.split()) for line in soup.get_text().splitlines()] - text = "\n".join(lines) - return re.sub(r"\n{3,}", "\n\n", text).strip() + "\n" + text = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip() + "\n" + return re.sub(r"\x00(\d+)\x00", lambda m: code_blocks[int(m.group(1))], text) def send_email( diff --git a/app/adapters/feeds.py b/app/adapters/feeds.py index 0285860..7008570 100644 --- a/app/adapters/feeds.py +++ b/app/adapters/feeds.py @@ -13,9 +13,9 @@ from feedparser.exceptions import CharacterEncodingOverride from app.adapters.http import ( - _make_robust_request, - _try_request, + make_robust_request, parse_page, + try_request, ) from app.domain.content import visible_text from app.domain.crawl import CrawlReport, SourceHealth, SourceStatus @@ -119,7 +119,7 @@ def _scrape_full_content(url: str, *, body_fallback: bool) -> str | None: selector that matches: on Hugging Face's blog ``
`` is a near-empty wrapper (82 visible characters against 21,669 on the page). """ - if not url or not (response := _make_robust_request(url)): + if not url or not (response := make_robust_request(url)): return None try: soup = parse_page(response) @@ -145,7 +145,7 @@ def _scrape_full_content(url: str, *, body_fallback: bool) -> str | None: return str(best) return str(body) if body_fallback else "" except Exception as e: - logger.error(f"Error parsing content for '{url}': {e}") + logger.error("Error parsing content for '%s': %s", url, e) return None @@ -209,7 +209,7 @@ def __init__(self, rss_url: str): self.truncated = False def fetch(self, start_date: datetime, end_date: datetime) -> list[Post]: - logger.info(f"Fetching posts from RSS feed: '{self.rss_url}'") + logger.info("Fetching posts from RSS feed: '%s'", self.rss_url) # Reset first: a stale count from a previous call would describe the wrong # fetch if this collector is reused. self.candidates = None @@ -306,7 +306,7 @@ def _fetch_feed(self) -> feedparser.FeedParserDict: The request layer adds the timeout, the transient-error retry and the per-hop SSRF check. The headers are the ones feedparser sends itself. """ - response = _try_request(requests.Session(), self.rss_url, self.FEED_HEADERS) + response = try_request(requests.Session(), self.rss_url, self.FEED_HEADERS) if response is None: raise SourceFetchError(f"Could not fetch feed '{self.rss_url}'") headers = {key.lower(): value for key, value in response.headers.items()} @@ -344,7 +344,7 @@ def _parse_date(self, text: str) -> datetime | None: return parsed def _fetch_page(self) -> BeautifulSoup: - if response := _make_robust_request(self.page_url): + if response := make_robust_request(self.page_url): return parse_page(response) raise SourceFetchError( f"All request attempts failed for '{self.page_url}' " @@ -373,7 +373,7 @@ def fetch(self, start_date: datetime, end_date: datetime) -> list[Post]: ): posts.append(post_from_entry(entry_data)) except Exception as e: - logger.error(f"Error processing item from {self.page_url}: {e}") + logger.error("Error processing item from %s: %s", self.page_url, e) self.candidates = self._dated return posts @@ -395,7 +395,7 @@ def fetch(self, start_date: datetime, end_date: datetime) -> list[Post]: @staticmethod def _full_title(url: str) -> str: - if not (response := _make_robust_request(url)): + if not (response := make_robust_request(url)): return "" soup = parse_page(response) meta = soup.find("meta", attrs={"property": "og:title"}) diff --git a/app/adapters/http.py b/app/adapters/http.py index 35efc0c..50a93e2 100644 --- a/app/adapters/http.py +++ b/app/adapters/http.py @@ -170,7 +170,7 @@ def _get_following_redirects( return None -def _try_request( +def try_request( session: requests.Session, url: str, headers: dict[str, str] ) -> requests.Response | None: """GET with up to two retries for transient errors (timeout / 5xx / 429). @@ -224,13 +224,13 @@ def _retry_delay(response: requests.Response | None, attempt: int) -> float: return min(base + step, 30.0) -def _make_robust_request(url: str) -> requests.Response | None: +def make_robust_request(url: str) -> requests.Response | None: """GET an article or index page with browser headers, or None if it fails. SSRF-checked at every hop, the first included; transient errors retried - (see ``_try_request``). + (see ``try_request``). """ - if response := _try_request(requests.Session(), url, REQUEST_HEADERS): + if response := try_request(requests.Session(), url, REQUEST_HEADERS): return response logger.error("Request failed for '%s'", url) return None diff --git a/app/adapters/rendering.py b/app/adapters/rendering.py index 4158f7c..bdf0762 100644 --- a/app/adapters/rendering.py +++ b/app/adapters/rendering.py @@ -269,7 +269,6 @@ def _prepare_data(self, config: BuildConfiguration) -> NewsletterData: title=config.header_title, description=config.header_description, thumbnail=config.header_thumbnail, - publish_date=config.date_suffix, ) section = Section(introduction=config.first_section_intro) footer = Footer(title=config.footer_title) diff --git a/app/application/pipeline.py b/app/application/pipeline.py index f95e99a..aaee47c 100644 --- a/app/application/pipeline.py +++ b/app/application/pipeline.py @@ -225,6 +225,13 @@ def _title_case_titles( post.title = title +def _for_language( + value: str | dict[Language, str] | None, language: Language +) -> str | None: + """A chrome text that may be set once or per language, for this issue.""" + return value.get(language) if isinstance(value, dict) else value + + def _generate_greeting( posts: list[Post], date_suffix: str, @@ -233,7 +240,6 @@ def _generate_greeting( language: Language, root_dir: Path, ) -> str: - greeter = Greeter(models(), config.summarization.greeting_model_id, language) # The summary's opening gives the model a detail beyond the card's own lede. lines = "\n".join( f"- {post.title}\n card lede (already shown): {post.one_liner}" @@ -242,6 +248,8 @@ def _generate_greeting( ) context = f"Today is {date_suffix}. This week's articles:\n{lines}" try: + # Built inside the guard: building the model can call Bedrock too. + greeter = Greeter(models(), config.summarization.greeting_model_id, language) greeting = greeter.greet(context=context) except Exception as e: # The intro is one paragraph above the articles, and by now every summary @@ -288,7 +296,7 @@ def _build_newsletter( "header_description": config.newsletter.header_description, "header_thumbnail": config.newsletter.header_thumbnail, "first_section_intro": first_section_intro, - "footer_title": config.newsletter.footer_title, + "footer_title": _for_language(config.newsletter.footer_title, language), } build_config = BuildConfiguration( stage=config.resources.stage, diff --git a/app/application/prompts/prompts.py b/app/application/prompts/prompts.py index dc4cd43..53b24dc 100644 --- a/app/application/prompts/prompts.py +++ b/app/application/prompts/prompts.py @@ -354,9 +354,10 @@ class GreetingPrompt(BasePrompt): week's articles (see the context). BANNED, because it is what a model reaches for by default: the "back then X, now Y" / "we used to X, now we Y" contrast template in any wording. Also banned: facts unrelated to this week's topics, and vague claims ("AI is moving fast") -3. If the articles genuinely share a theme, name it concretely. If they do not, do not invent - one: introduce two or three of them in a phrase each -4. Each card already opens with its one-line summary; do not restate or paraphrase it +3. Name a shared theme only if it holds for EVERY article; check each one against it. If any + article does not fit, do not group them: introduce two or three of them in a phrase each +4. Each card already opens with its one-line summary; do not restate or paraphrase it, and do not + reuse a figure from it 5. Casual, confident, technically fluent. No markdown, plain text only 6. No praise or vague adjectives ("groundbreaking", "powerful", "amazing", "various"); say the specific thing instead @@ -376,9 +377,10 @@ class GreetingPrompt(BasePrompt): **금지** — 모델이 기본값으로 집어드는 패턴이라서입니다: "예전엔 X였는데 이제 Y야", "과거엔 X했지만 이제는 Y해" 같은 **과거-현재 대조 템플릿을 어떤 표현으로든** 쓰지 마세요. 이번 주 주제와 무관한 잡지식, "AI가 빠르게 발전하고 있어" 같은 뻔한 서술도 금지 -3. 아티클끼리 실제로 겹치는 주제가 있으면 그것을 구체적으로 짚고, 없으면 억지로 묶지 말고 - 두세 편을 각각 한 구절로 소개하세요 -4. 각 카드에는 그 아티클의 한 줄 요약이 실리므로, 그 문장을 옮기거나 풀어 쓰지 마세요 +3. 공통 주제는 **모든** 아티클에 맞을 때만 쓰세요. 쓰기 전에 아티클마다 그 주제에 맞는지 + 확인하고, 하나라도 맞지 않으면 묶지 말고 두세 편을 각각 한 구절로 소개하세요 +4. 각 카드에는 그 아티클의 한 줄 요약이 실리므로, 그 문장을 옮기거나 풀어 쓰지 말고 거기 나온 + 수치도 다시 쓰지 마세요 5. 반말, 자신감 있는 어조. 기술 용어는 필요하면 영어 그대로 6. "혁신적인", "강력한", "놀라운", "다양한" 같은 칭찬·뭉갬 형용사와 "~라는 점이야" 같은 맺음은 쓰지 마세요. 구체적인 것을 그냥 말하면 됩니다 @@ -644,6 +646,7 @@ class SummarizationPrompt(BasePrompt): - No sub-headings (h4-h6) inside a section; a bold run-in label at the start of a paragraph is fine - Inline code goes in , never Markdown backticks; write math as plain text or Unicode (QKᵀ), never LaTeX - Ensure all code uses
 blocks
+- Lists go in 
  • or
    1. ; never start a line with "-" or "•" - Make technical explanations accessible but accurate - Use one name per concept throughout; do not alternate between synonyms for the same component - No meta-commentary about following these instructions @@ -653,7 +656,7 @@ class SummarizationPrompt(BasePrompt): into a published issue, so look for each one in your draft and fix it before you output: 1. Go through every number in 📊. If it also appears in 📌, 🔄 or 🛠️, keep it in 📊 and replace the earlier mention with words ("cuts latency"). Then delete any 📊 sentence about business - value, a ratio you computed, or a forecast the article does not make. + value, a count or ratio you computed, or a forecast the article does not make. 2. Find sentences whose verb is "provides", "offers" or "supports" and say what the feature actually does. 3. Find praise adjectives, "not just X, it's Y" and meta-narration (principle 5) and rewrite them. Read the last sentence of 📌: if it says who should read this or that it is worth noting, end on the finding instead. @@ -805,7 +808,7 @@ class SummarizationPrompt(BasePrompt):

      🔄 핵심 아키텍처와 동작 방식

      Describe the system design and workflow clearly. Cover main components, their interactions, and key design choices. 관련 이미지는 문맥 없이 놓이지 않도록 **캡션과 함께 figure로 감싸서** 넣으세요: -
      descriptive text
      이 이미지에서 독자가 얻어야 할 것을 한 문장으로
      +
      이미지 내용을 한국어로 짧게
      이 이미지에서 독자가 얻어야 할 것을 한 문장으로
      (완전한 URL만 사용: https://example.com/image.jpg. 캡션은 alt 텍스트를 반복하지 말고 정보를 더해야 합니다.) Avoid redundancy with the technical deep dive section. @@ -832,6 +835,7 @@ class SummarizationPrompt(BasePrompt): - No sub-headings (h4-h6) inside a section; a bold run-in label at the start of a paragraph is fine - Inline code goes in , never Markdown backticks; write math as plain text or Unicode (QKᵀ), never LaTeX - Ensure all code uses
       blocks
      +- 목록은 
      • ·
        1. 로 쓰고, 줄 앞에 "-"·"•"를 붙이지 마세요 - Make technical explanations accessible but accurate - Write content in Korean; keep a technical term in English when the Korean rendering would be awkward - 한 개념에는 처음 고른 용어 하나만 쓰세요. 같은 구성 요소를 "추론"과 "인퍼런스"처럼 번갈아 부르지 마세요 @@ -841,7 +845,7 @@ class SummarizationPrompt(BasePrompt): 규칙을 아는 것과 지킨 것은 다릅니다. 아래 여섯 가지는 지난 호들에서 실제로 가장 자주 남은 실수이므로, 초안을 쓴 뒤 하나씩 찾아서 고치고 나서 출력하세요. 1. 📊 섹션의 숫자를 하나씩 보세요. 📌·🔄·🛠️에도 같은 숫자가 있으면 📊에 남기고 앞쪽은 "지연을 - 줄입니다"처럼 말로 바꾸세요. 그다음 원문에 없는 비즈니스 함의, 직접 계산한 비율, 전망을 담은 📊 + 줄입니다"처럼 말로 바꾸세요. 그다음 원문에 없는 비즈니스 함의, 직접 세거나 계산한 수·비율, 전망을 담은 📊 문장을 지우세요. 2. "제공합니다", "지원합니다"로 끝나는 문장을 찾아 그 기능이 실제로 무엇을 하는지로 바꾸세요. 3. "~라는/다는 점" 절, "~하는 것" 명사절, 수량 표현 뒤의 "-들"("16개 GPU들"), 칭찬 형용사와 메타 diff --git a/app/application/summarize.py b/app/application/summarize.py index 7e7de22..f84b4e8 100644 --- a/app/application/summarize.py +++ b/app/application/summarize.py @@ -12,7 +12,6 @@ BatchProcessor, HTMLTagOutputParser, measure_execution_time, - retry_with_backoff, ) from app.application.ports import ChatModelProvider, ImageProbe from app.application.prompts.prompts import ( @@ -24,7 +23,7 @@ from app.domain.content import content_html, visible_text from app.domain.eval_metrics import is_anchor from app.domain.post import Post -from app.domain.quality_metrics import KO_LENGTH_BAND, is_korean +from app.domain.quality_metrics import EN_LENGTH_BAND, KO_LENGTH_BAND, is_korean from app.domain.summary import ( FILTER_FAILED, FILTER_UNPARSEABLE, @@ -33,13 +32,13 @@ SummarizationOptions, SummarizerConfig, SummaryOutput, - _encode_image_srcs, - _image_candidates, - _postprocess_summary, - _strip_unsourced_images, - _unsourced_urls, + encode_image_srcs, gate_by_content_length, + image_candidates, + postprocess_summary, select_for_digest, + strip_unsourced_images, + unsourced_urls, ) from app.logger import logger @@ -74,7 +73,7 @@ def _drop_unrenderable_images( for img in list(soup.find_all("img")): src = str(img.get("src") or "") chosen, width = None, 0 - for candidate in [src, *_image_candidates(src, source_html)]: + for candidate in [src, *image_candidates(src, source_html)]: if clock() >= deadline: break if (probed := is_renderable(candidate)) is not None: @@ -532,7 +531,7 @@ def prepare_inputs(items: list[Post]) -> list[dict[str, Any]]: post.one_liner = summary_output.one_liner post.tags = summary_output.tags post.urls = summary_output.urls - if unsourced := _unsourced_urls(post.urls, post.content): + if unsourced := unsourced_urls(post.urls, post.content): logger.warning( "%d reference link(s) for '%s' are not present in the " "source article (kept, but verify): %s", @@ -555,7 +554,7 @@ def prepare_inputs(items: list[Post]) -> list[dict[str, Any]]: # summary may run before it is sent back once to be shortened. LENGTH_LIMIT: ClassVar[dict[Language, int]] = { Language.KO: KO_LENGTH_BAND[1], - Language.EN: 2000, + Language.EN: EN_LENGTH_BAND[1], } LENGTH_TOLERANCE: ClassVar[float] = 1.1 @@ -563,7 +562,8 @@ def _length(self, summary_html: str) -> int: text = visible_text(summary_html) return len(text) if self.language is Language.KO else len(text.split()) - @retry_with_backoff("summary-length") + # One attempt, no retries: shortening is cosmetic, and retrying it could push + # the run past the Batch attempt timeout, which loses the whole issue. def _shorten(self, summary_html: str, measured: int) -> dict[str, Any]: limit = self.LENGTH_LIMIT[self.language] return self.shortener.invoke( @@ -642,7 +642,7 @@ def _unusable_summary(self, summary_html: str) -> str | None: return None def _finalize_summary(self, post: Post, summary_html: str) -> str: - cleaned, dropped = _strip_unsourced_images( + cleaned, dropped = strip_unsourced_images( summary_html, post.content, post.images ) if dropped: @@ -664,4 +664,4 @@ def _finalize_summary(self, post: Post, summary_html: str) -> str: post.title, unrenderable, ) - return _postprocess_summary(_encode_image_srcs(cleaned)) + return postprocess_summary(encode_image_srcs(cleaned)) diff --git a/app/configs/config-template.yaml b/app/configs/config-template.yaml index ee5db74..4aa9158 100644 --- a/app/configs/config-template.yaml +++ b/app/configs/config-template.yaml @@ -114,7 +114,10 @@ newsletter: header_title: "Weekly AI Tech Blog Digest" header_description: '$ cron weekly-digest | grep "AI tech" | sort -by relevance > your-inbox 🔮' header_thumbnail: "https://cdn.jsdelivr.net/gh/youngmki/playground@main/tech-digest-assets/images/peccy.png" - footer_title: "Thank you for becoming a subscriber to Weekly AI Tech Blog Digest.
          If you have any questions or feedback, please send them to example@example.com." # footer title + # Footer: one string for every issue, or one per language as below. + footer_title: + en: "Thank you for becoming a subscriber to Weekly AI Tech Blog Digest.
          If you have any questions or feedback, please send them to example@example.com." + ko: "Weekly AI Tech Blog Digest를 구독해 주셔서 감사합니다.
          질문이나 의견은 example@example.com로 보내 주세요." logos: airbnb: "https://cdn.jsdelivr.net/gh/youngmki/playground@main/tech-digest-assets/images/airbnb.png" amazon: "https://cdn.jsdelivr.net/gh/youngmki/playground@main/tech-digest-assets/images/amazon.png" diff --git a/app/configs/config.py b/app/configs/config.py index f8ecd1d..ab5631e 100644 --- a/app/configs/config.py +++ b/app/configs/config.py @@ -15,7 +15,7 @@ ) from pydantic_core import PydanticUndefined -from app.domain.constants import EnvVars, LanguageModelId +from app.domain.constants import EnvVars, Language, LanguageModelId from app.domain.summary import SummarizationOptions # AWS EventBridge cron has exactly six fields: @@ -131,7 +131,8 @@ class Newsletter(BaseModelWithDefaults): header_title: str | None = Field(default=None, min_length=1) header_description: str | None = Field(default=None, min_length=1) header_thumbnail: str | None = Field(default=None, min_length=1) - footer_title: str | None = Field(default=None, min_length=1) + # One string for every language, or one per language (`ko:` / `en:`). + footer_title: str | dict[Language, str] | None = Field(default=None) logos: dict[str, str] = Field(default_factory=dict) @model_validator(mode="after") diff --git a/app/domain/newsletter.py b/app/domain/newsletter.py index c9c00eb..f83b4ea 100644 --- a/app/domain/newsletter.py +++ b/app/domain/newsletter.py @@ -22,7 +22,12 @@ # Korean is counted in characters and English in words; both are deliberately # conservative for dense technical prose. # Source keys whose display name is not their title case (logo alt text). -SOURCE_LABELS: dict[str, str] = {"aws": "AWS"} +SOURCE_LABELS: dict[str, str] = { + "aws": "AWS", + "huggingface": "Hugging Face", + "linkedin": "LinkedIn", + "nvidia": "NVIDIA", +} READING_SPEED: dict[Language, tuple[str, int]] = { Language.KO: ("chars", 450), Language.EN: ("words", 200), @@ -38,6 +43,7 @@ "resources": "함께 볼 자료", "reading_time": "읽는 시간 {minutes}분", "relevance": "관련도 점수", + "logo": "로고", }, Language.EN: { "published_on": "Published on", @@ -45,6 +51,7 @@ "resources": "Further reading", "reading_time": "{minutes} min read", "relevance": "Relevance score", + "logo": "logo", }, } @@ -146,10 +153,6 @@ class Header(BaseModel): title: str = Field(min_length=1) description: str thumbnail: str - publish_date: str - _normalize_publish_date = field_validator("publish_date", mode="before")( - normalize_date - ) class Section(BaseModel): diff --git a/app/domain/post.py b/app/domain/post.py index 0e764e6..6d8ea15 100644 --- a/app/domain/post.py +++ b/app/domain/post.py @@ -159,7 +159,7 @@ def _extract_images(content_html: str, base_url: str) -> list[str]: ) return [url for url in image_urls if url.startswith("http")] except Exception as e: - logger.warning(f"Failed to extract images from '{base_url}': {e}") + logger.warning("Failed to extract images from '%s': %s", base_url, e) return [] @@ -209,7 +209,7 @@ def try_parse_published_date(date_str: str) -> datetime | None: return dt.astimezone(UTC) if dt.tzinfo else dt.replace(tzinfo=UTC) except (ValueError, TypeError): continue - logger.warning(f"Failed to parse date '{date_str}' with any known format.") + logger.warning("Failed to parse date '%s' with any known format.", date_str) return None diff --git a/app/domain/quality_metrics.py b/app/domain/quality_metrics.py index a4ff42d..c9f17b6 100644 --- a/app/domain/quality_metrics.py +++ b/app/domain/quality_metrics.py @@ -58,6 +58,8 @@ # tests assert the prompt states the same numbers. # --------------------------------------------------------------------------- # KO_LENGTH_BAND: tuple[int, int] = (4000, 6000) +# The English budget, in words; the summarization prompt states the same band. +EN_LENGTH_BAND: tuple[int, int] = (1300, 2000) # The UPPER bound is derived from the card layout: at the lede's font size an # 800px card fits roughly 50 Korean characters per line, and the lede must not run # past two lines. The lower bound only has to reject something that is not a lede @@ -115,7 +117,7 @@ # colon IS at a boundary yet legitimately introduces the list — so skip it when # a
            /
              follows past the intervening closing tags /
              . # -# ``summary._postprocess_summary`` corrects exactly what this matches, and the +# ``summary.postprocess_summary`` corrects exactly what this matches, and the # ``terminal_colon`` dimension counts exactly what this matches, so the two agree # by construction. They did not before: the rubric ran a simpler ``[다요죠]:`` # over the markup-stripped text, where a preserved list colon is followed by a @@ -486,7 +488,7 @@ def register_score(self) -> float: def terminal_colon_score(self) -> float: """Whether any Korean sentence still ends on a colon. - ``_postprocess_summary`` corrects these deterministically before a summary + ``postprocess_summary`` corrects these deterministically before a summary is stored, so on a healthy pipeline this is 1.0 and the dimension is a regression guard: it fires if that corrector stops running, not on the model's raw output (which is not kept). All-or-nothing for that reason — diff --git a/app/domain/summary.py b/app/domain/summary.py index 3ee97a1..d5b2054 100644 --- a/app/domain/summary.py +++ b/app/domain/summary.py @@ -151,7 +151,7 @@ def _backticks_to_code(summary: str) -> str: return str(soup) -def _postprocess_summary(summary: str) -> str: +def postprocess_summary(summary: str) -> str: """Deterministic corrections to the model's summary HTML. The Korean terminal colon (see ``KO_TERMINAL_COLON``) and Markdown backtick @@ -277,7 +277,7 @@ def _sanitize_html(html: str) -> str: _SPACES = re.compile(r"\s") -def _strip_unsourced_images( +def strip_unsourced_images( summary_html: str, source_html: str, known_images: list[str] ) -> tuple[str, list[str]]: """Remove ```` tags the source post does not actually contain. @@ -330,7 +330,7 @@ def _strip_unsourced_images( _URL_SAFE_CHARS = ":/?#[]@!$&'()*+,;=%-._~" -def _encode_image_srcs(summary_html: str) -> str: +def encode_image_srcs(summary_html: str) -> str: """Percent-encode the characters a URL cannot carry raw in ````. Source URLs are used as published, and some contain them: macOS names @@ -356,7 +356,7 @@ def _encode_image_srcs(summary_html: str) -> str: MAX_IMAGE_CANDIDATE_WIDTH = 1600 -def _image_candidates(src: str, source_html: str) -> list[str]: +def image_candidates(src: str, source_html: str) -> list[str]: """Other renditions of the image at ``src`` in the article, best first. The article's own markup lists them: ``srcset``/``data-srcset`` with widths, @@ -393,7 +393,7 @@ def rank(item: tuple[str, int]) -> tuple[int, int]: return [url for url, _ in sorted(found.items(), key=rank) if is_safe_url(url)] -def _unsourced_urls(urls: list[str], source_html: str) -> list[str]: +def unsourced_urls(urls: list[str], source_html: str) -> list[str]: """Reference links whose href does not appear verbatim in the source post. Reported (not removed): unlike an image, a slightly-rewritten but real link diff --git a/app/templates/main_contents.html b/app/templates/main_contents.html index 1325f39..6d7d2dc 100644 --- a/app/templates/main_contents.html +++ b/app/templates/main_contents.html @@ -21,7 +21,7 @@ width="56" height="56" class="thumb" - alt="{{ article.source_label ~ ' logo' if article.source_label else '' }}" + alt="{{ article.source_label ~ ' ' ~ labels.logo if article.source_label else '' }}" /> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a40b8e5..6b57ecc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -602,6 +602,9 @@ ## 인사말 +- **2026-11-01** 11-01·11-02 시험 호의 인사말이 두 번 다 세 편을 "AI 에이전트" 주제로 묶었는데, + SOCI 글은 에이전트와 무관했습니다. 공통 주제는 모든 글에 맞을 때만 쓰게 하고, 카드 리드의 수치도 + 다시 쓰지 않게 했습니다. - **2026-07-11** 실행의 인사말은 약 230자로 목표 상한의 1.5배였고, 프롬프트가 이름을 들어 금지한 "과거엔 X, 이제 Y" 대조 템플릿을 그대로 썼습니다. 분량을 맨 앞 별도 블록으로 올리고 금지 패턴에 이유를 붙였으며, 생성 후 측정해 한 번 되먹이는 루프를 만들었습니다. @@ -617,6 +620,10 @@ ## 렌더링과 템플릿 +- **2026-11-01** 평문 파트가 공백을 모두 접어 코드 들여쓰기가 사라지고(Python 예제가 깨졌습니다) + 표의 칸이 붙어 버렸습니다. 코드 블록은 그대로 두고 요약 표 칸은 ` | `로 나누고 목록에 표지를 + 붙입니다. 골든 테스트가 평문 파트도 고정합니다. 한국어 호의 로고 대체 텍스트와 꼬리말을 한국어로 + 바꿨고, 요약 이미지 대체 텍스트도 한국어로 쓰게 했습니다. - 실제 발행본 18호를 측정하니 8호가 Gmail 클립 한계(약 100KB)를 넘겼습니다. 5편이 실린 호는 전부였고(150-185KB), `prod-2026-06-02`는 3편으로도 101.4KB로 잘렸습니다. 2026-07-11 호는 91.5%였습니다. @@ -762,6 +769,10 @@ ## 테스트와 CI +- **2026-11-01** CI가 `main`을 향한 PR에서만 돌아, 쌓아 올린 PR은 병합되기 전까지 검사받지 않았습니다. + 모든 PR에서 돌게 했습니다. 실제 요약 축약 프롬프트를 포맷하는 테스트를 더했습니다(가짜 체인만 + 있으면 입력 키가 틀려도 경고 로그만 남았습니다). 로그 호출의 f-string을 `%` 형식으로 바꾸고 + ruff `G004`로 고정했습니다. - 테스트 세 개가 openai.com과 example.com에 실제로 접속하고 있었고, 네트워크가 없으면 스위트가 31초 걸렸습니다. `no_network` 자동 fixture를 넣었습니다. - 커버리지 바닥(`--cov-fail-under`)은 78에서 82, 84, 87을 거쳐 **2026-11-01** 93이 되었습니다. diff --git a/docs/design.md b/docs/design.md index 635ee36..f285413 100644 --- a/docs/design.md +++ b/docs/design.md @@ -149,7 +149,10 @@ tech-digest/ │ │ └── rendering.py # Jinja2 렌더 + Selenium HTML→이미지 │ ├── templates/ # Jinja2 HTML 이메일 파셜 │ │ ├── _styles.html # 뉴스레터·단일 글이 공유하는 단일 스타일시트 +│ │ ├── header_section.html # 머리말 (제목, 설명, 썸네일) +│ │ ├── first_section.html # 인사말 단락 │ │ ├── main_contents.html # 공유 아티클 카드 매크로 +│ │ ├── footer_section.html # 꼬리말 │ │ ├── template.html # 뉴스레터 셸 │ │ └── article.html # 독립 글 셸 (같은 카드·스타일 재사용) │ ├── assets/ # 로고 원본 (런타임 로고는 CDN에서 받습니다) @@ -159,6 +162,7 @@ tech-digest/ │ ├── deploy_infra.py # AWS CDK 스택 (Batch + IAM + 스케줄) │ ├── put_inference_profiles.py # 비용 귀속용 애플리케이션 추론 프로파일 │ ├── eval_filtering.py # 필터 점수 평가 하네스 (--live로 Bedrock) +│ ├── replay_filter.py # 지난 주 실제 후보로 필터 재채점·비교 (score만 유료) │ └── eval_summary_quality.py # 출력 품질 루브릭 (채점 무료, --regenerate만 유료) ├── tests/ # pytest 스위트 ├── docs/ # 설계 문서, 변경 이력(CHANGELOG.md), 다이어그램 @@ -169,7 +173,7 @@ tech-digest/ **계층과 의존 방향.** 코드는 세 계층으로 나뉩니다. `domain`은 입출력이 없는 규칙이고, `application`은 `ports.py`의 인터페이스에 대고 쓴 서비스이며, `adapters`가 Bedrock·AWS·HTTP·템플릿 -같은 바깥 시스템과 말합니다. 의존은 안쪽으로만 향합니다. 어댑터는 포트와 도메인만 알고, 서비스는 +같은 바깥 시스템과 말합니다. 의존은 안쪽으로만 향합니다. 어댑터는 도메인만 알고(포트는 형태로만 맞춥니다), 서비스는 도메인만 알며, 도메인은 둘 다 모릅니다. 셋을 모두 보는 곳은 `main.py` 하나이고, 여기서 어댑터를 만들어 서비스에 넘깁니다. 포트는 바깥 시스템을 떼어 놓을 때만 둡니다. 모델 제공자 (`ChatModelProvider`), 수집(`PostSource`), 저장소(`ObjectStore`), 메일(`Mailer`), @@ -277,7 +281,8 @@ tech-digest/ 토글. - `sender`(검증된 발신 이메일). `send_emails`가 켜져 있으면 필수입니다. 없으면 인프라가 SES 조건과 SNS 구독을 엉뚱한 값으로 만들기 때문에 로드 시점에 실패시킵니다. -- `header_*` / `footer_title` 문자열. +- `header_*` / `footer_title` 문자열. `footer_title`은 문자열 하나 대신 언어별(`ko:`·`en:`)로 둘 수 + 있고, 한국어 호에는 한국어 꼬리말이 붙습니다. - `logos`. 소스명 → 로고 URL 매핑(템플릿에 주입됩니다). ### `Config.load()` @@ -350,7 +355,7 @@ tech-digest/ `PostFetcher`는 `source_url`, `candidates`, `fetch(start, end)`만 요구하는 `Protocol`입니다. 소스마다 가져오는 방식이 달라도 컬렉터는 이것만 알면 됩니다. -- `RssFetcher`는 요청 계층(`_try_request`)으로 피드를 내려받고 그 바이트를 +- `RssFetcher`는 요청 계층(`try_request`)으로 피드를 내려받고 그 바이트를 `feedparser`로 파싱합니다. `feedparser.parse(url)`에 직접 맡기지 않는 이유는 소켓 타임아웃이 없어서, 연결만 받아 두고 멈춘 피드 호스트가 잡을 Batch 타임아웃까지 붙잡기 때문입니다. 이렇게 하면 타임아웃, 일시 오류 재시도, 홉마다의 SSRF 검사가 다른 요청과 @@ -400,7 +405,7 @@ URL에 맞는 스크레이퍼를 돌려주고, 맞는 게 없으면 `RssFetcher` #### 요청 계층 -`_make_robust_request`가 기사와 인덱스 페이지를 가져오는 입구이고, `_try_request`에 +`make_robust_request`가 기사와 인덱스 페이지를 가져오는 입구이고, `try_request`에 `REQUEST_HEADERS`를 넘깁니다. SSRF 검사는 그 아래 `_get_following_redirects`가 첫 요청을 포함한 모든 홉에서 하므로, 호출자가 따로 검사하지 않습니다. @@ -1027,9 +1032,11 @@ KO의 원칙 6·7은 문체 규칙이고 우선순위가 분량·구체성(원 (`pipeline._generate_greeting`). 제목만 주면 모델이 글들에 없는 공통 주제를 지어내고, `one_liner`만 주면 카드의 리드를 되풀이하는 것 말고는 할 말이 없습니다. -- 글들이 실제로 주제를 공유하면 그것을 구체적으로 짚고, 그렇지 않으면 억지로 묶지 말고 두세 - 편을 각각 한 구절로 소개하게 합니다. -- 카드마다 한 문장 리드가 이미 실리므로 그 문장을 옮기거나 풀어 쓰지 않게 합니다. +- 공통 주제는 모든 글에 맞을 때만 짚게 합니다. 글마다 맞는지 확인하고, 하나라도 맞지 않으면 + 묶지 말고 두세 편을 각각 한 구절로 소개하게 합니다. "맞는 주제가 있으면 짚으라"고만 했을 때는 + 모델이 매번 있다고 답해, 에이전트와 무관한 글까지 "에이전트" 주제로 묶었습니다. +- 카드마다 한 문장 리드가 이미 실리므로 그 문장을 옮기거나 풀어 쓰지 않고, 거기 나온 수치도 + 다시 쓰지 않게 합니다. - 이번 주 글과 직접 연결되는 관찰을 하나 넣게 하고, "예전엔 X였는데 이제 Y" 같은 과거-현재 대조 템플릿은 어떤 표현으로든 금지합니다. 모델이 기본값으로 집어드는 패턴이라서입니다. - 길이는 KO 100-150자(3-4문장), EN 50-75단어로 따로 잡았습니다. 영어 단어 기준을 @@ -1126,7 +1133,7 @@ LLM 출력은 그대로 메일에 실리기 때문에, 돌려받은 결과를 채운 뒤 **성공한 글의 리스트를 돌려줍니다**. 호출자가 "`post.summary`가 비어 있지 않으면 성공"이라고 추론하게 두면, 그 필드에 다른 텍스트가 들어 있는 순간 실패가 성공으로 보입니다. -이미지 출처 검증(`_strip_unsourced_images`)은 지어낸 이미지를 걸러냅니다. 프롬프트는 +이미지 출처 검증(`strip_unsourced_images`)은 지어낸 이미지를 걸러냅니다. 프롬프트는 원문에 있는 이미지만 넣으라고 지시하지만, LLM은 그럴듯한 CDN 경로를 만들어 낼 수 있고 그러면 발송된 메일에 깨진 이미지가 남습니다. 그래서 요약 안의 ``는 완전 일치 증거로만 통과시킵니다. 우리 파서가 원문에서 뽑은 `Post.images`에 있거나, 모델에게 준 @@ -1149,13 +1156,13 @@ src(모델이 macOS 스크린샷 이름의 U+202F를 일반 공백으로 옮긴 보여 주므로, 그대로 두면 alt 텍스트와 캡션이 한 문장처럼 이어져 보입니다. URL 확장자로 추측하지 않고 실제 응답으로 판단합니다. CDN URL은 확장자가 없는 경우가 많고, 같은 URL도 `Accept`에 따라 다른 형식을 줄 수 있어서 요청은 메일용 형식을 먼저 달라고 보냅니다. 요청은 다른 수집 요청과 같은 -SSRF 검사를 거칩니다. 남은 src는 `_encode_image_srcs`가 URL에 그대로 둘 수 없는 문자만 퍼센트 +SSRF 검사를 거칩니다. 남은 src는 `encode_image_srcs`가 URL에 그대로 둘 수 없는 문자만 퍼센트 인코딩합니다. 참고 링크는 조금 다르게 다룹니다. 살짝 다듬어졌지만 실재하는 링크는 독자에게 여전히 쓸모가 있으므로 제거하지 않고 경고만 남깁니다. 프롬프트가 드리프트하면 로그에 드러납니다. -`_postprocess_summary`는 결정적인 교정 두 가지를 적용합니다. 첫째, 마크다운 백틱 구간을 +`postprocess_summary`는 결정적인 교정 두 가지를 적용합니다. 첫째, 마크다운 백틱 구간을 ``로 바꿉니다. HTML 블록 안의 마크다운은 변환되지 않아 백틱이 메일에 그대로 찍히기 때문입니다(`
              `·`` 안은 건드리지 않습니다). 둘째, 한글 종결 콜론을
               마침표로 고치는 것인데(`KO_TERMINAL_COLON`), 종결 음절 뒤 콜론이 닫는 태그나 문자열 끝
              @@ -1541,7 +1548,8 @@ boto3 위에 얹은, 얇고 로깅이 잘 된 래퍼들입니다. 실패해도 
                 나중에 붙입니다. 이 형식에서는 마지막 파트가 우선 표시되고, 텍스트 파트가 없으면 스팸 필터가
                 HTML만 있는 대량 메일로 점수를 매기고 텍스트 전용 클라이언트에는 아무것도 보이지 않습니다.
                 텍스트 파트는 블록 요소마다 줄을 바꾸고, 링크 텍스트 뒤에 URL을 붙이고, 숨긴 요소(받은편지함
              -  프리헤더)는 뺍니다. 발신자에 도메인이 있으면 `List-Unsubscribe`(RFC 2369,
              +  프리헤더)는 뺍니다. 코드 블록은 들여쓰기를 그대로 두고, 요약 표의 칸은 ` | `로 나누고, 목록
              +  항목에는 번호나 `•`를 붙입니다. 발신자에 도메인이 있으면 `List-Unsubscribe`(RFC 2369,
                 `mailto:` 방식)를 붙입니다. 주요 메일 제공자의 대량 발송 평판 시스템이 뉴스레터
                 메일에 이 헤더를 기대하고, 없으면 독자가 쓸 수 있는 유일한 수단이 스팸 신고인데
                 그게 바로 발신 도메인 평판을 망가뜨리는 행동입니다. 존재하지 않는 원클릭 엔드포인트를
              diff --git a/pyproject.toml b/pyproject.toml
              index 5715827..2f491bb 100644
              --- a/pyproject.toml
              +++ b/pyproject.toml
              @@ -97,6 +97,7 @@ select = [
                   "C4",   # flake8-comprehensions
                   "SIM",  # flake8-simplify
                   "RUF",  # ruff-specific
              +    "G004", # logging with f-strings; arguments are interpolated lazily
               ]
               ignore = [
                   "E501",   # line length handled by formatter
              diff --git a/tests/conftest.py b/tests/conftest.py
              index ee5cc92..275214e 100644
              --- a/tests/conftest.py
              +++ b/tests/conftest.py
              @@ -121,4 +121,4 @@ def refuse_connect(self, address):
                   # backoff. Tests that exercise the request layer import the function
                   # directly, so this module-level stub does not reach them.
                   if module := sys.modules.get("app.adapters.feeds"):
              -        monkeypatch.setattr(module, "_make_robust_request", lambda url: None)
              +        monkeypatch.setattr(module, "make_robust_request", lambda url: None)
              diff --git a/tests/fixtures/golden/issue-en.txt b/tests/fixtures/golden/issue-en.txt
              new file mode 100644
              index 0000000..8860dd9
              --- /dev/null
              +++ b/tests/fixtures/golden/issue-en.txt
              @@ -0,0 +1,103 @@
              +Weekly AI Tech Blog Digest
              +
              +A curated digest of the most impactful AI developments, research and engineering from leading tech companies and research institutions.
              +
              +Hello! This week brings faster SageMaker AI job starts, a new PyTorch backend for Trainium, and a deep guide to AgentCore Gateway. If the week got away from you, these three are the ones worth reading first, in that order, with a coffee.
              +
              +Announcing Faster SageMaker AI Jobs
              +
              +Published on 2026-10-28 · 3 min read ★ 0.95
              +
              +#SageMaker #launch-a #MLOps
              +
              +The gist of launch-a in one sentence.
              +
              +📌 개요
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-a post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +• 첫째 항목
              +
              +• 둘째 항목
              +
              +Read the original → (https://aws.amazon.com/blogs/machine-learning/launch-a/)
              +
              +Further reading
              +
              +• https://docs.aws.amazon.com/launch-a/guide.html
              +
              +TorchNeuron, Amazon’s New PyTorch Backend
              +
              +Published on 2026-10-27 · 3 min read ★ 0.90
              +
              +#SageMaker #launch-b #MLOps
              +
              +The gist of launch-b in one sentence.
              +
              +📌 개요
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops. The launch-b post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +• 첫째 항목
              +
              +• 둘째 항목
              +
              +Read the original → (https://aws.amazon.com/blogs/machine-learning/launch-b/)
              +
              +Further reading
              +
              +• https://docs.aws.amazon.com/launch-b/guide.html
              +
              +Evaluating Models with Bedrock – Part 2
              +
              +Published on 2026-10-29 · 3 min read ★ 0.75
              +
              +#SageMaker #guide-new #MLOps
              +
              +The gist of guide-new in one sentence.
              +
              +📌 개요
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops. The guide-new post walks through the design and its operation step by step, naming what each component does and where it stops.
              +
              +• 첫째 항목
              +
              +• 둘째 항목
              +
              +Read the original → (https://aws.amazon.com/blogs/machine-learning/guide-new/)
              +
              +Further reading
              +
              +• https://docs.aws.amazon.com/guide-new/guide.html
              +
              +Thank you for reading! Stay tuned for next week's AI insights and discoveries.
              diff --git a/tests/fixtures/golden/issue-ko.html b/tests/fixtures/golden/issue-ko.html
              index e701711..a8f0b77 100644
              --- a/tests/fixtures/golden/issue-ko.html
              +++ b/tests/fixtures/golden/issue-ko.html
              @@ -1 +1 @@
              -          Weekly AI Tech Blog Digest     
              안녕하세요! 이번 주에는 SageMaker AI 작업을 더 빨리 시작하는 방법과 Trainium용 새 PyTorch 백엔드, 그리고 AgentCore Gateway를 깊이 있게 다룬 안내서를 골랐습니다. 바쁜 한 주였다면 위의 세 편만 읽어도 충분합니다.
               ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ 
              \ No newline at end of file + Weekly AI Tech Blog Digest
              안녕하세요! 이번 주에는 SageMaker AI 작업을 더 빨리 시작하는 방법과 Trainium용 새 PyTorch 백엔드, 그리고 AgentCore Gateway를 깊이 있게 다룬 안내서를 골랐습니다. 바쁜 한 주였다면 위의 세 편만 읽어도 충분합니다.
               ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ 
              \ No newline at end of file diff --git a/tests/fixtures/golden/issue-ko.txt b/tests/fixtures/golden/issue-ko.txt new file mode 100644 index 0000000..ce502d4 --- /dev/null +++ b/tests/fixtures/golden/issue-ko.txt @@ -0,0 +1,103 @@ +Weekly AI Tech Blog Digest + +A curated digest of the most impactful AI developments, research and engineering from leading tech companies and research institutions. + +안녕하세요! 이번 주에는 SageMaker AI 작업을 더 빨리 시작하는 방법과 Trainium용 새 PyTorch 백엔드, 그리고 AgentCore Gateway를 깊이 있게 다룬 안내서를 골랐습니다. 바쁜 한 주였다면 위의 세 편만 읽어도 충분합니다. + +Announcing Faster SageMaker AI Jobs + +발행 2026-10-28 · 읽는 시간 4분 ★ 0.95 + +#SageMaker #launch-a #MLOps + +launch-a 글의 핵심을 한 문장으로 정리했습니다. + +📌 개요 + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-a 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +• 첫째 항목 + +• 둘째 항목 + +원문 보기 → (https://aws.amazon.com/blogs/machine-learning/launch-a/) + +함께 볼 자료 + +• https://docs.aws.amazon.com/launch-a/guide.html + +TorchNeuron, Amazon’s New PyTorch Backend + +발행 2026-10-27 · 읽는 시간 4분 ★ 0.90 + +#SageMaker #launch-b #MLOps + +launch-b 글의 핵심을 한 문장으로 정리했습니다. + +📌 개요 + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. launch-b 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +• 첫째 항목 + +• 둘째 항목 + +원문 보기 → (https://aws.amazon.com/blogs/machine-learning/launch-b/) + +함께 볼 자료 + +• https://docs.aws.amazon.com/launch-b/guide.html + +Evaluating Models with Bedrock – Part 2 + +발행 2026-10-29 · 읽는 시간 4분 ★ 0.75 + +#SageMaker #guide-new #MLOps + +guide-new 글의 핵심을 한 문장으로 정리했습니다. + +📌 개요 + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. guide-new 글은 설계와 운영 방법을 단계별로 설명합니다. 구성 요소마다 역할과 한계를 짚고, 실제 배포에서 확인한 수치를 함께 보여줍니다. + +• 첫째 항목 + +• 둘째 항목 + +원문 보기 → (https://aws.amazon.com/blogs/machine-learning/guide-new/) + +함께 볼 자료 + +• https://docs.aws.amazon.com/guide-new/guide.html + +Thank you for reading! Stay tuned for next week's AI insights and discoveries. diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 80f455a..1089ba8 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -61,9 +61,6 @@ # and connection errors unchanged. Only the exception classes are imported; # the application makes no AWS call. ("application/llm_batching.py", "botocore.exceptions"), - # Adapters implement the ports, so they may import them, and nothing else - # from the application. - ("adapters/*", "app.application.ports"), ) # Top-level modules every layer imports, held to the domain's rule so that @@ -164,15 +161,11 @@ def test_any_langchain_package_is_kept_out_of_the_domain(self, root): probe.write_text("from langchain_core.runnables import Runnable\n", "utf-8") assert _violations(probe, root) == ["langchain_core.runnables.Runnable"] - def test_an_adapter_may_import_the_ports_but_no_service(self, root): + def test_an_adapter_may_not_import_the_application(self, root): + # Adapters satisfy the ports structurally; they need not import them. probe = root / "adapters" / "probe.py" - probe.write_text( - "from app.application.ports import ObjectStore\n" - "from app.application import ports\n" - "from app.application.summarize import Summarizer\n", - encoding="utf-8", - ) - assert _violations(probe, root) == ["app.application.summarize.Summarizer"] + probe.write_text("from app.application import ports\n", encoding="utf-8") + assert _violations(probe, root) == ["app.application.ports"] def test_the_shared_logger_is_held_to_the_domain_rule(self, root): probe = root / "logger.py" diff --git a/tests/test_aws_helpers.py b/tests/test_aws_helpers.py index 4b579a0..884c276 100644 --- a/tests/test_aws_helpers.py +++ b/tests/test_aws_helpers.py @@ -152,7 +152,18 @@ def test_plain_text_alternative_precedes_html(self): class TestHtmlToText: def test_blocks_become_lines_and_blank_runs_collapse(self): html = "

              One

              \n\n\n

              Two
              Three

              • a
              • b
              " - assert html_to_text(html) == "One\n\nTwo\nThree\n\na\n\nb\n" + assert html_to_text(html) == "One\n\nTwo\nThree\n\n• a\n\n• b\n" + + def test_code_keeps_its_indentation_and_tables_their_cells(self): + html = ( + "
              if x:\n    print(y)\n
              " + '' + "
              CaseTime
              A48 s
              1. first
              " + ) + text = html_to_text(html) + assert "if x:\n print(y)" in text + assert "Case | Time" in text and "A | 48 s" in text + assert "1. first" in text def test_link_whose_text_is_its_url_is_not_repeated(self): html = 'https://example.com/x' diff --git a/tests/test_crawl_health.py b/tests/test_crawl_health.py index 043a2b3..3b1ea81 100644 --- a/tests/test_crawl_health.py +++ b/tests/test_crawl_health.py @@ -197,7 +197,7 @@ class TestCandidatesComeFromTheFetchers: def offline_feed_download(self, monkeypatch): monkeypatch.setattr( fp, - "_try_request", + "try_request", lambda session, url, headers: SimpleNamespace( content=b"", headers={}, url=url ), @@ -244,7 +244,7 @@ def _bound(monkeypatch, scraper, html): monkeypatch.setattr( scraper, "_fetch_page", lambda: BeautifulSoup(html, "html.parser") ) - monkeypatch.setattr(fp, "_make_robust_request", lambda url: None) + monkeypatch.setattr(fp, "make_robust_request", lambda url: None) return scraper @pytest.mark.parametrize( @@ -364,12 +364,12 @@ class TestTruncatedFeeds: def offline(self, monkeypatch): monkeypatch.setattr( fp, - "_try_request", + "try_request", lambda session, url, headers: SimpleNamespace( content=b"", headers={}, url=url ), ) - monkeypatch.setattr(fp, "_make_robust_request", lambda url: None) + monkeypatch.setattr(fp, "make_robust_request", lambda url: None) def _fetch(self, monkeypatch, dates: list[str]) -> fp.RssFetcher: entries = [ diff --git a/tests/test_feed_parser.py b/tests/test_feed_parser.py index 1fbf544..d4d5388 100644 --- a/tests/test_feed_parser.py +++ b/tests/test_feed_parser.py @@ -420,7 +420,7 @@ class _Response: "content-type": "text/html; charset=utf-8" } - monkeypatch.setattr(fp, "_make_robust_request", lambda url: _Response()) + monkeypatch.setattr(fp, "make_robust_request", lambda url: _Response()) return fp._scrape_full_content("https://x.example/post", body_fallback=True) # An
              that is a wrapper around a heading, with the prose elsewhere. @@ -475,7 +475,7 @@ def test_a_page_with_no_body_yields_empty(self, monkeypatch): def test_fetch_failure_yields_empty(self, monkeypatch): from app.adapters import feeds as fp - monkeypatch.setattr(fp, "_make_robust_request", lambda url: None) + monkeypatch.setattr(fp, "make_robust_request", lambda url: None) assert ( fp._scrape_full_content("https://x.example/post", body_fallback=True) is None @@ -505,7 +505,7 @@ class _Response: "content-type": "text/html; charset=utf-8" } - monkeypatch.setattr(fp, "_make_robust_request", lambda url: _Response()) + monkeypatch.setattr(fp, "make_robust_request", lambda url: _Response()) def test_a_feed_body_is_kept_over_the_whole_page_but_flagged(self, monkeypatch): # The page holds no article container, so the short feed body stays diff --git a/tests/test_golden_output.py b/tests/test_golden_output.py index cf4d0a6..07a9b1e 100644 --- a/tests/test_golden_output.py +++ b/tests/test_golden_output.py @@ -297,7 +297,7 @@ def _run_issue( messages = [] for sent in ses.sent: message = email.message_from_string(sent["RawMessage"]["Data"]) - [part] = [p for p in message.walk() if p.get_content_type() == "text/html"] + parts = {p.get_content_type(): p for p in message.walk()} messages.append( { "source": sent["Source"], @@ -310,7 +310,8 @@ def _run_issue( ) for name in ("Subject", "From", "To", "List-Unsubscribe") }, - "html": part.get_payload(decode=True).decode("utf-8"), + "html": parts["text/html"].get_payload(decode=True).decode("utf-8"), + "text": parts["text/plain"].get_payload(decode=True).decode("utf-8"), } ) ledgers = { @@ -346,9 +347,12 @@ def test_the_delivered_issue_matches_its_snapshot(monkeypatch, tmp_path, languag assert [m["destinations"] for m in run["messages"]] == [[r] for r in RECIPIENTS] [html] = {m["html"] for m in run["messages"]} _check(f"issue-{language.value}.html", html) + [text] = {m["text"] for m in run["messages"]} + _check(f"issue-{language.value}.txt", text) envelope = { "messages": [ - {k: v for k, v in m.items() if k != "html"} for m in run["messages"] + {k: v for k, v in m.items() if k not in ("html", "text")} + for m in run["messages"] ], "s3_keys": run["s3_keys"], "ledgers": run["ledgers"], diff --git a/tests/test_newsletter_builder.py b/tests/test_newsletter_builder.py index 29c4d1b..3c7c24c 100644 --- a/tests/test_newsletter_builder.py +++ b/tests/test_newsletter_builder.py @@ -231,7 +231,7 @@ def test_empty_header_title_is_rejected_not_silently_shipped(self): from app.domain.newsletter import Header with pytest.raises(ValidationError): - Header(title="", description="d", thumbnail="t", publish_date="2026-08-19") + Header(title="", description="d", thumbnail="t") def test_main_omits_unset_chrome_so_defaults_apply(self, tmp_path, monkeypatch): """The pipeline must not pass "" for an unset field.""" diff --git a/tests/test_newsletter_renderer.py b/tests/test_newsletter_renderer.py index 05ddd86..fb63cae 100644 --- a/tests/test_newsletter_renderer.py +++ b/tests/test_newsletter_renderer.py @@ -114,9 +114,7 @@ def test_pre_block_left_byte_identical(self): def test_shrinks_real_template_output(self, templates_dir, sample_article_data): renderer = NewsletterRenderer(templates_dir) data = NewsletterData( - header=Header( - title="T", description="d", thumbnail="p.png", publish_date="2026-06-01" - ), + header=Header(title="T", description="d", thumbnail="p.png"), section=Section(introduction="hi"), articles=[Article.model_validate(sample_article_data)], footer=Footer(title="f"), @@ -148,7 +146,6 @@ def _build_data( title="Weekly AI Tech Blog Digest", description="desc", thumbnail="peccy.png", - publish_date="2026-06-01", ), section=Section(introduction="Hello friends!"), articles=[Article.model_validate(article_data)], @@ -226,11 +223,11 @@ def test_renders_single_article(self, templates_dir, sample_article_data): def test_single_article_has_descriptive_alt( self, templates_dir, sample_article_data ): - sample_article_data["source"] = "openai" + sample_article_data["source"] = "huggingface" renderer = NewsletterRenderer(templates_dir) html = renderer.render_article(Article.model_validate(sample_article_data)) # Standalone-article thumbnail uses the source label, not a generic alt. - assert 'alt="Openai logo"' in html + assert 'alt="Hugging Face 로고"' in html assert 'alt="Thumbnail"' not in html def test_score_badge_rendered(self, templates_dir, sample_article_data): @@ -293,10 +290,10 @@ def test_layout_is_fluid_so_phones_do_not_zoom_out( assert "max-width: 800px" in html def test_source_alt_text(self, templates_dir, sample_article_data): - sample_article_data["source"] = "openai" + sample_article_data["source"] = "linkedin" renderer = NewsletterRenderer(templates_dir) html = renderer.render_newsletter(self._build_data(sample_article_data)) - assert "Openai logo" in html + assert "LinkedIn 로고" in html def test_blanked_link_renders_no_button(self, templates_dir, sample_article_data): # Article blanks an unsafe link; a button to href="" goes nowhere. @@ -401,7 +398,6 @@ def _issue( title="Weekly AI Tech Blog Digest", description="d" * 200, thumbnail="https://cdn.example.com/peccy.png", - publish_date="2026-06-01", ), section=Section(introduction="안녕 친구들! 난 Peccy야 😎 " + "글" * 120), articles=articles, diff --git a/tests/test_pipeline_guards.py b/tests/test_pipeline_guards.py index d672841..ced88ea 100644 --- a/tests/test_pipeline_guards.py +++ b/tests/test_pipeline_guards.py @@ -447,6 +447,26 @@ def test_verdicts_alone_do_not_alert(self): assert publisher.published == [] +class TestChromeLanguage: + @pytest.mark.parametrize( + ("value", "language", "expected"), + [ + ("Thanks", Language.KO, "Thanks"), + ( + {Language.KO: "감사합니다", Language.EN: "Thanks"}, + Language.KO, + "감사합니다", + ), + ({Language.EN: "Thanks"}, Language.KO, None), + (None, Language.EN, None), + ], + ) + def test_a_footer_may_be_set_once_or_per_language(self, value, language, expected): + from app.application.pipeline import _for_language + + assert _for_language(value, language) == expected + + class TestHandlerWiring: def test_the_summarizer_gets_the_real_image_probe(self, monkeypatch): # No fixture post carries an image, so a probe that dropped every image diff --git a/tests/test_quality_metrics.py b/tests/test_quality_metrics.py index 812bedc..8a3acc9 100644 --- a/tests/test_quality_metrics.py +++ b/tests/test_quality_metrics.py @@ -475,7 +475,7 @@ def test_bare_numbers_in_a_results_table_count(self): class TestTerminalColonAgreesWithTheCorrector: - """The rubric must count exactly what ``_postprocess_summary`` corrects. + """The rubric must count exactly what ``postprocess_summary`` corrects. They had separate patterns: the corrector deliberately preserves a colon that introduces a list (the prompt permits exactly that), while the rubric ran @@ -484,14 +484,14 @@ class TestTerminalColonAgreesWithTheCorrector: """ def test_list_introducing_colon_is_not_a_defect(self): - from app.domain.summary import _postprocess_summary + from app.domain.summary import postprocess_summary html = ( "

              📌 왜

              구성 요소는 다음과 같습니다:

              • A
              " "

              🛠️ 심층

              지연이 22% 줄었습니다.

              " ) # The corrector leaves it alone... - assert _postprocess_summary(html) == html + assert postprocess_summary(html) == html # ...so the rubric must not report it. result = evaluate_summary("a", html) assert result.terminal_colons == 0 diff --git a/tests/test_request_layer.py b/tests/test_request_layer.py index 9c947f2..5a725d3 100644 --- a/tests/test_request_layer.py +++ b/tests/test_request_layer.py @@ -7,7 +7,7 @@ import requests from app.adapters import http -from app.adapters.http import _make_robust_request +from app.adapters.http import make_robust_request @pytest.fixture(autouse=True) @@ -71,7 +71,7 @@ class TestMakeRobustRequest: def test_success_first_try(self, monkeypatch): session = _FakeSession([_FakeResponse(200)]) _patch_session(monkeypatch, session) - resp = _make_robust_request("https://example.com") + resp = make_robust_request("https://example.com") assert resp is not None assert session.calls == 1 @@ -79,7 +79,7 @@ def test_retries_transient_then_succeeds(self, monkeypatch): # First attempt 503 (transient) -> retry same header set -> 200. session = _FakeSession([_FakeResponse(503), _FakeResponse(200)]) _patch_session(monkeypatch, session) - resp = _make_robust_request("https://example.com") + resp = make_robust_request("https://example.com") assert resp is not None assert session.calls == 2 @@ -89,7 +89,7 @@ def test_429_retries_up_to_three_attempts(self, monkeypatch): [_FakeResponse(429), _FakeResponse(429), _FakeResponse(200)] ) _patch_session(monkeypatch, session) - resp = _make_robust_request("https://example.com") + resp = make_robust_request("https://example.com") assert resp is not None assert session.calls == 3 @@ -100,14 +100,14 @@ def test_honors_retry_after_header(self, monkeypatch): [_FakeResponse(429, headers={"Retry-After": "5"}), _FakeResponse(200)] ) _patch_session(monkeypatch, session) - _make_robust_request("https://example.com") + make_robust_request("https://example.com") assert slept and slept[0] == 5.0 def test_a_403_is_not_retried(self, monkeypatch): # Non-transient: one call, then give up. session = _FakeSession([_FakeResponse(403)]) _patch_session(monkeypatch, session) - assert _make_robust_request("https://example.com") is None + assert make_robust_request("https://example.com") is None assert session.calls == 1 @@ -134,7 +134,7 @@ def test_blocks_internal_targets_without_requesting(self, monkeypatch, url): # A blocked host must short-circuit BEFORE any network call is made. session = _FakeSession([_FakeResponse(200)]) _patch_session(monkeypatch, session) - assert _make_robust_request(url) is None + assert make_robust_request(url) is None assert session.calls == 0 def test_blocks_redirect_to_internal_host(self, monkeypatch): @@ -146,7 +146,7 @@ def test_blocks_redirect_to_internal_host(self, monkeypatch): redirect = _FakeResponse(302, headers={"location": meta}, is_redirect=True) session = _FakeSession([redirect] * 3) _patch_session(monkeypatch, session) - assert _make_robust_request("https://example.com") is None + assert make_robust_request("https://example.com") is None def test_follows_external_redirect(self, monkeypatch): # A redirect to another EXTERNAL host is followed to completion. @@ -158,14 +158,14 @@ def test_follows_external_redirect(self, monkeypatch): final = _FakeResponse(200, url="https://example.org/final") session = _FakeSession([redirect, final]) _patch_session(monkeypatch, session) - resp = _make_robust_request("https://example.com") + resp = make_robust_request("https://example.com") assert resp is not None assert session.calls == 2 def test_allows_normal_external_host(self, monkeypatch): session = _FakeSession([_FakeResponse(200, url="https://example.com/post")]) _patch_session(monkeypatch, session) - assert _make_robust_request("https://example.com") is not None + assert make_robust_request("https://example.com") is not None def test_blocks_hostname_resolving_to_internal_ip(self, monkeypatch): # A hostname (not an IP literal) that resolves to the metadata IP must @@ -177,7 +177,7 @@ def fake_getaddrinfo(host, *a, **k): monkeypatch.setattr(http.socket, "getaddrinfo", fake_getaddrinfo) session = _FakeSession([_FakeResponse(200)]) _patch_session(monkeypatch, session) - assert _make_robust_request("http://evil.example.com/") is None + assert make_robust_request("http://evil.example.com/") is None assert session.calls == 0 def test_a_fetch_resolves_the_host_once(self, monkeypatch): @@ -190,7 +190,7 @@ def fake_getaddrinfo(host, *a, **k): monkeypatch.setattr(http.socket, "getaddrinfo", fake_getaddrinfo) _patch_session(monkeypatch, _FakeSession([_FakeResponse(200)])) - assert _make_robust_request("https://example.com/post") is not None + assert make_robust_request("https://example.com/post") is not None assert lookups == ["example.com"] @pytest.mark.parametrize( @@ -211,7 +211,7 @@ def fake_getaddrinfo(host, *a, **k): monkeypatch.setattr(http.socket, "getaddrinfo", fake_getaddrinfo) session = _FakeSession([_FakeResponse(200, url="https://example.com/post")]) _patch_session(monkeypatch, session) - assert _make_robust_request("https://example.com") is not None + assert make_robust_request("https://example.com") is not None class TestHeadersAreSentAsDeclared: @@ -220,7 +220,7 @@ def test_the_declared_set_is_sent_verbatim_and_the_session_untouched( ): session = _FakeSession([_FakeResponse(200)]) _patch_session(monkeypatch, session) - _make_robust_request("https://example.com") + make_robust_request("https://example.com") assert session.sent_headers == [http.REQUEST_HEADERS] assert session.headers == {} diff --git a/tests/test_rss_fetcher.py b/tests/test_rss_fetcher.py index 146b7a8..cc1e13c 100644 --- a/tests/test_rss_fetcher.py +++ b/tests/test_rss_fetcher.py @@ -57,7 +57,7 @@ def offline_feed_download(monkeypatch): monkeypatch.setattr( _fp, - "_try_request", + "try_request", lambda session, url, headers: SimpleNamespace(content=b"", headers={}, url=url), ) @@ -65,7 +65,7 @@ def offline_feed_download(monkeypatch): @pytest.fixture(autouse=True) def no_scrape(monkeypatch): # post_from_entry may scrape when feed content is thin; neutralize network. - monkeypatch.setattr(feeds, "_make_robust_request", lambda url: None) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: None) class TestRssFetcherBozo: @@ -143,7 +143,7 @@ def test_no_date_at_all_is_still_empty(self): def test_such_a_feed_now_yields_its_post(self, monkeypatch): parsed = feedparser.parse(self.ATOM_UPDATED_ONLY) monkeypatch.setattr(feeds.feedparser, "parse", lambda *a, **k: parsed) - monkeypatch.setattr(feeds, "_make_robust_request", lambda url: None) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: None) fetcher = RssFetcher("https://updated.example/atom") posts = fetcher.fetch( datetime(2026, 8, 1, tzinfo=UTC), datetime(2026, 8, 8, tzinfo=UTC) @@ -157,7 +157,7 @@ class TestFeedDownload: """The feed is downloaded by the request layer, which carries the timeout.""" def test_an_unreachable_feed_is_a_source_failure(self, monkeypatch, window): - monkeypatch.setattr(feeds, "_try_request", lambda *a, **k: None) + monkeypatch.setattr(feeds, "try_request", lambda *a, **k: None) with pytest.raises(SourceFetchError): RssFetcher(FEED_URL).fetch(*window) @@ -167,7 +167,7 @@ def test_bytes_and_headers_reach_feedparser(self, monkeypatch, window): seen = {} monkeypatch.setattr( feeds, - "_try_request", + "try_request", lambda session, url, headers: SimpleNamespace( content=b"", headers={"Content-Type": "application/rss+xml; charset=utf-8"}, diff --git a/tests/test_scraper_parsing.py b/tests/test_scraper_parsing.py index 7055d18..f7851a2 100644 --- a/tests/test_scraper_parsing.py +++ b/tests/test_scraper_parsing.py @@ -7,7 +7,7 @@ site-layout change or a selector regression is caught offline. Network is fully stubbed: ``_fetch_page`` returns the fixture soup and -``_make_robust_request`` is neutralized so the full-article scrape that +``make_robust_request`` is neutralized so the full-article scrape that ``post_from_entry`` triggers never hits the wire. """ @@ -45,7 +45,7 @@ def no_network(monkeypatch): # The scrapers build Posts via post_from_entry, which scrapes the full # article when the feed body is thin. Neutralize the HTTP layer so no # request is made and content stays empty. - monkeypatch.setattr(feeds, "_make_robust_request", lambda url: None) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: None) def _bind(scraper_cls, fixture: str, page_url: str, monkeypatch): @@ -128,7 +128,7 @@ def test_a_truncated_title_is_completed_from_the_post_page( 'content="Inside the data context layer: trustworthy context">' "" ) - monkeypatch.setattr(feeds, "_make_robust_request", lambda url: _Page(page)) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: _Page(page)) [post] = scraper.fetch(*window) assert post.title == "Inside the data context layer: trustworthy context" @@ -137,7 +137,7 @@ def test_a_page_without_og_title_falls_back_to_its_title_tag( ): scraper = self._scraper(monkeypatch, "Inside the data context la...") page = "Inside the data context layer" - monkeypatch.setattr(feeds, "_make_robust_request", lambda url: _Page(page)) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: _Page(page)) [post] = scraper.fetch(*window) assert post.title == "Inside the data context layer" @@ -148,9 +148,7 @@ def test_an_unreachable_post_page_keeps_the_index_title(self, monkeypatch, windo def test_a_complete_title_costs_no_request(self, monkeypatch, window): calls: list[str] = [] - monkeypatch.setattr( - feeds, "_make_robust_request", lambda url: calls.append(url) - ) + monkeypatch.setattr(feeds, "make_robust_request", lambda url: calls.append(url)) scraper = self._scraper(monkeypatch, "Building A Feature Store") [post] = scraper.fetch(*window) assert post.title == "Building A Feature Store" diff --git a/tests/test_summarizer.py b/tests/test_summarizer.py index d6e6e01..acb4493 100644 --- a/tests/test_summarizer.py +++ b/tests/test_summarizer.py @@ -9,7 +9,7 @@ SummaryOutput, _normalize_tags, _normalize_urls, - _postprocess_summary, + postprocess_summary, ) @@ -18,39 +18,39 @@ def test_terminal_colon_before_markup_becomes_period(self): # A sentence-ending syllable + colon at a clause boundary (colon followed # by a closing tag) is a mistake and is corrected to a period. assert ( - _postprocess_summary("

              이것은 동작합니다:

              ") + postprocess_summary("

              이것은 동작합니다:

              ") == "

              이것은 동작합니다.

              " ) def test_terminal_colon_at_end_of_string_becomes_period(self): - assert _postprocess_summary("문장이 끝납니다:") == "문장이 끝납니다." + assert postprocess_summary("문장이 끝납니다:") == "문장이 끝납니다." def test_introducing_colon_is_preserved(self): # Colon followed by text introduces a list/example and must NOT be # rewritten (the bug a blind "다:"->"다." replace used to cause). text = "

              결과는 다음과 같습니다: 첫째, 둘째

              " - assert _postprocess_summary(text) == text + assert postprocess_summary(text) == text def test_ratio_colon_is_preserved(self): - assert _postprocess_summary("비율은 3:1입니다.") == "비율은 3:1입니다." + assert postprocess_summary("비율은 3:1입니다.") == "비율은 3:1입니다." def test_block_list_introducing_colon_preserved(self): # After markdown, a sentence introducing a BULLETED list renders as # "

              ...습니다:

              \n
                ...". The colon is at a tag boundary yet still # legitimately introduces the list, so it must NOT become a period. text = "

                주요 구성 요소는 다음과 같습니다:

                \n
                  \n
                • A
                • \n
                " - assert _postprocess_summary(text) == text + assert postprocess_summary(text) == text def test_br_list_introducing_colon_preserved(self): # Same under nl2br: "습니다:
                \n
                  ". text = "같습니다:
                  \n
                  • x
                  " - assert _postprocess_summary(text) == text + assert postprocess_summary(text) == text def test_terminal_colon_before_paragraph_still_converted(self): # A genuine terminal colon before the NEXT paragraph (not a list) is # still corrected to a period. assert ( - _postprocess_summary("

                  동작합니다:

                  \n

                  다음

                  ") + postprocess_summary("

                  동작합니다:

                  \n

                  다음

                  ") == "

                  동작합니다.

                  \n

                  다음

                  " ) @@ -62,10 +62,10 @@ def test_host_is_not_rewritten(self): site (the two use different path layouts) and also fired on visible prose. """ text = "see https://magazine.sebastianraschka.com/p/x for details" - assert _postprocess_summary(text) == text + assert postprocess_summary(text) == text def test_no_change_when_clean(self): - assert _postprocess_summary("

                  clean

                  ") == "

                  clean

                  " + assert postprocess_summary("

                  clean

                  ") == "

                  clean

                  " class TestNormalizeTags: @@ -283,10 +283,10 @@ def test_figure_and_figcaption_survive_sanitizing(self): def test_an_image_the_parser_resolved_is_kept(self): """The source may carry a relative src that only ``Post.images`` holds in its absolute form; that is still the article's own image.""" - from app.domain.summary import _strip_unsourced_images + from app.domain.summary import strip_unsourced_images url = "https://blog.example.com/img/arch.png" - cleaned, dropped = _strip_unsourced_images( + cleaned, dropped = strip_unsourced_images( f'

                  Body

                  ', source_html='

                  Body

                  ', known_images=[url], @@ -294,9 +294,9 @@ def test_an_image_the_parser_resolved_is_kept(self): assert dropped == [] and url in cleaned def test_an_image_without_a_src_is_dropped(self): - from app.domain.summary import _strip_unsourced_images + from app.domain.summary import strip_unsourced_images - cleaned, dropped = _strip_unsourced_images( + cleaned, dropped = strip_unsourced_images( "

                  Body

                  x", source_html="

                  Body

                  ", known_images=[] ) assert dropped == ["(no src)"] and "; the surrounding figure/caption must not take the rest of the summary down with it.""" - from app.domain.summary import _strip_unsourced_images + from app.domain.summary import strip_unsourced_images - cleaned, dropped = _strip_unsourced_images( + cleaned, dropped = strip_unsourced_images( '

                  Body

                  ' "
                  Caption
                  ", source_html="

                  Body

                  ", diff --git a/tests/test_summarizer_internals.py b/tests/test_summarizer_internals.py index d52f4e5..b7d1041 100644 --- a/tests/test_summarizer_internals.py +++ b/tests/test_summarizer_internals.py @@ -7,9 +7,10 @@ from datetime import UTC, datetime, timedelta import pytest +from langchain_core.runnables import RunnableLambda from app.application.summarize import Summarizer, SummarizerSettings -from app.domain.constants import LanguageModelId +from app.domain.constants import Language, LanguageModelId from app.domain.post import Post @@ -52,6 +53,10 @@ def _settings(**overrides) -> SummarizerSettings: return SummarizerSettings.model_validate(base | overrides) +def _unexpected_shortening(_inputs): + raise AssertionError("summary shortening was not expected in this test") + + def _summarizer(min_score=0.7, **overrides): s = Summarizer.__new__(Summarizer) s.settings = _settings(min_score=min_score, **overrides) @@ -59,6 +64,9 @@ def _summarizer(min_score=0.7, **overrides): s.filtered_out_posts = [] # Every image loads; tests about the mail-client check pass their own probe. s.image_probe = lambda url: 0 + # A test that reaches the shortener without meaning to should fail loudly, + # not be absorbed by _fit_length's keep-the-original fallback. + s.shortener = RunnableLambda(_unexpected_shortening) from app.application.llm_batching import BatchProcessor s.batch_processor = BatchProcessor(max_concurrency=2, batch_size=10) @@ -969,23 +977,23 @@ class TestBackticksBecomeCode: """Markdown is not processed inside HTML, so backticks would show verbatim.""" def test_a_backtick_span_in_prose_becomes_code(self): - from app.domain.summary import _postprocess_summary + from app.domain.summary import postprocess_summary - out = _postprocess_summary("

                  Set `--tp 2` and `PD_BUFFER` first.

                  ") + out = postprocess_summary("

                  Set `--tp 2` and `PD_BUFFER` first.

                  ") assert out == ( "

                  Set --tp 2 and PD_BUFFER first.

                  " ) def test_backticks_inside_a_code_block_are_left_alone(self): - from app.domain.summary import _postprocess_summary + from app.domain.summary import postprocess_summary html = "
                  echo `date`
                  " - assert _postprocess_summary(html) == html + assert postprocess_summary(html) == html def test_escaped_text_in_a_span_stays_escaped(self): - from app.domain.summary import _postprocess_summary + from app.domain.summary import postprocess_summary - out = _postprocess_summary("

                  Check `a < b` here.

                  ") + out = postprocess_summary("

                  Check `a < b` here.

                  ") assert out == "

                  Check a < b here.

                  " @@ -1040,3 +1048,33 @@ def test_an_over_long_summary_is_shortened_once(self, monkeypatch): def test_a_failed_or_worse_rewrite_keeps_the_original(self, monkeypatch, answer): _, out = self._fit(monkeypatch, self.LONG, answer) assert "측정값과 메커니즘" in out + + +@pytest.mark.parametrize( + ("language", "limit"), [(Language.KO, 6000), (Language.EN, 2000)] +) +def test_the_real_shortening_prompt_formats_and_parses(language, limit): + """The fakes above skip the prompt; a wrong input key or a stray brace in + it would only log a warning in production, so format the real one.""" + from langchain_core.messages import AIMessage + + from app.application.llm_batching import HTMLTagOutputParser + from app.application.prompts.prompts import SummaryLengthPrompt + + seen = {} + + def model(prompt): + seen["text"] = prompt.to_string() + return AIMessage(content="

                  shorter

                  ") + + s = _summarizer(language=language) + s.shortener = ( + SummaryLengthPrompt.for_language(language).get_prompt() + | RunnableLambda(model) + | HTMLTagOutputParser(tag_names=["summary"], required_tags=["summary"]) + ) + assert ( + s._shorten("

                  {literal braces} draft

                  ", 7000)["summary"] == "

                  shorter

                  " + ) + assert str(limit) in seen["text"] and "7000" in seen["text"] + assert "{literal braces} draft" in seen["text"]