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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 19 additions & 3 deletions app/adapters/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 9 additions & 9 deletions app/adapters/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,7 +119,7 @@ def _scrape_full_content(url: str, *, body_fallback: bool) -> str | None:
selector that matches: on Hugging Face's blog ``<article>`` 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)
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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}' "
Expand Down Expand Up @@ -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

Expand All @@ -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"})
Expand Down
8 changes: 4 additions & 4 deletions app/adapters/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion app/adapters/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions app/application/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 13 additions & 9 deletions app/application/prompts/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -376,9 +377,10 @@ class GreetingPrompt(BasePrompt):
**금지** — 모델이 기본값으로 집어드는 패턴이라서입니다: "예전엔 X였는데 이제 Y야",
"과거엔 X했지만 이제는 Y해" 같은 **과거-현재 대조 템플릿을 어떤 표현으로든** 쓰지 마세요.
이번 주 주제와 무관한 잡지식, "AI가 빠르게 발전하고 있어" 같은 뻔한 서술도 금지
3. 아티클끼리 실제로 겹치는 주제가 있으면 그것을 구체적으로 짚고, 없으면 억지로 묶지 말고
두세 편을 각각 한 구절로 소개하세요
4. 각 카드에는 그 아티클의 한 줄 요약이 실리므로, 그 문장을 옮기거나 풀어 쓰지 마세요
3. 공통 주제는 **모든** 아티클에 맞을 때만 쓰세요. 쓰기 전에 아티클마다 그 주제에 맞는지
확인하고, 하나라도 맞지 않으면 묶지 말고 두세 편을 각각 한 구절로 소개하세요
4. 각 카드에는 그 아티클의 한 줄 요약이 실리므로, 그 문장을 옮기거나 풀어 쓰지 말고 거기 나온
수치도 다시 쓰지 마세요
5. 반말, 자신감 있는 어조. 기술 용어는 필요하면 영어 그대로
6. "혁신적인", "강력한", "놀라운", "다양한" 같은 칭찬·뭉갬 형용사와 "~라는 점이야" 같은
맺음은 쓰지 마세요. 구체적인 것을 그냥 말하면 됩니다
Expand Down Expand Up @@ -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 <code>, never Markdown backticks; write math as plain text or Unicode (QKᵀ), never LaTeX
- Ensure all code uses <pre><code> blocks
- Lists go in <ul><li> or <ol><li>; 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
Expand All @@ -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.
Expand Down Expand Up @@ -805,7 +808,7 @@ class SummarizationPrompt(BasePrompt):
<h3>🔄 핵심 아키텍처와 동작 방식</h3>
Describe the system design and workflow clearly. Cover main components, their interactions, and key design choices.
관련 이미지는 문맥 없이 놓이지 않도록 **캡션과 함께 figure로 감싸서** 넣으세요:
<figure><img src="full_url" alt="descriptive text"><figcaption>이 이미지에서 독자가 얻어야 할 것을 한 문장으로</figcaption></figure>
<figure><img src="full_url" alt="이미지 내용을 한국어로 짧게"><figcaption>이 이미지에서 독자가 얻어야 할 것을 한 문장으로</figcaption></figure>
(완전한 URL만 사용: https://example.com/image.jpg. 캡션은 alt 텍스트를 반복하지 말고 정보를 더해야 합니다.)
Avoid redundancy with the technical deep dive section.

Expand All @@ -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 <code>, never Markdown backticks; write math as plain text or Unicode (QKᵀ), never LaTeX
- Ensure all code uses <pre><code> blocks
- 목록은 <ul><li>·<ol><li>로 쓰고, 줄 앞에 "-"·"•"를 붙이지 마세요
- Make technical explanations accessible but accurate
- Write content in Korean; keep a technical term in English when the Korean rendering would be awkward
- 한 개념에는 처음 고른 용어 하나만 쓰세요. 같은 구성 요소를 "추론"과 "인퍼런스"처럼 번갈아 부르지 마세요
Expand All @@ -841,7 +845,7 @@ class SummarizationPrompt(BasePrompt):
규칙을 아는 것과 지킨 것은 다릅니다. 아래 여섯 가지는 지난 호들에서 실제로 가장 자주 남은
실수이므로, 초안을 쓴 뒤 하나씩 찾아서 고치고 나서 출력하세요.
1. 📊 섹션의 숫자를 하나씩 보세요. 📌·🔄·🛠️에도 같은 숫자가 있으면 📊에 남기고 앞쪽은 "지연을
줄입니다"처럼 말로 바꾸세요. 그다음 원문에 없는 비즈니스 함의, 직접 계산한 비율, 전망을 담은 📊
줄입니다"처럼 말로 바꾸세요. 그다음 원문에 없는 비즈니스 함의, 직접 세거나 계산한 수·비율, 전망을 담은 📊
문장을 지우세요.
2. "제공합니다", "지원합니다"로 끝나는 문장을 찾아 그 기능이 실제로 무엇을 하는지로 바꾸세요.
3. "~라는/다는 점" 절, "~하는 것" 명사절, 수량 표현 뒤의 "-들"("16개 GPU들"), 칭찬 형용사와 메타
Expand Down
26 changes: 13 additions & 13 deletions app/application/summarize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -555,15 +554,16 @@ 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

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(
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Loading
Loading