{
  "sha": "68ba63c955bf358d5e9d0256ebf8c992f46c6390",
  "message": "新增 Apple 風格重點摘要卡與精簡撰稿規則 (#72)\n\n在首頁與新聞內頁加入可辨識的重點摘要卡，縮短既有顯示摘要，並要求未來摘要以單句交代主體、地點、時間與實際影響。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T22:38:49Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 34,
      "deletions": 0,
      "changes": 34
    },
    {
      "filename": "pipeline/write.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 3,
      "changes": 11
    },
    {
      "filename": "prompts/rare-aircraft-news-zh.txt",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 1,
      "changes": 6
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 48,
      "deletions": 0,
      "changes": 48
    },
    {
      "filename": "templates/_summary.html",
      "previous_filename": "",
      "status": "added",
      "additions": 13,
      "deletions": 0,
      "changes": 13
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 2,
      "changes": 5
    },
    {
      "filename": "tests/test_fulltext.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 2,
      "changes": 7
    },
    {
      "filename": "tests/test_mobile_layout.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 14,
      "deletions": 0,
      "changes": 14
    }
  ],
  "diff": "@@ -79,6 +79,7 @@ def static_asset_version() -> str:\\n         \"flash\": \"即時快訊\", \"sourceStatus\": \"來源狀態\",\\n         \"latest\": \"最新新聞\", \"topStory\": \"頭條\", \"mainSource\": \"主要來源\",\\n         \"briefLabel\": \"短訊\",\\n+        \"summaryLabel\": \"重點摘要\",\\n         \"readMore\": \"閱讀全文 →\", \"back\": \"← 返回首頁\",\\n         \"heroImg\": \"[ 頭條照片 — 由來源圖庫自動帶入，灰階顯示 ]\",\\n         \"today\": \"今日全球數據\", \"statFlights\": \"目前追蹤航班\", \"statDelay\": \"全球延誤率\",\\n@@ -182,6 +183,7 @@ def static_asset_version() -> str:\\n         \"flash\": \"Live flash\", \"sourceStatus\": \"Source status\",\\n         \"latest\": \"Latest news\", \"topStory\": \"Top story\", \"mainSource\": \"Primary source\",\\n         \"briefLabel\": \"Brief\",\\n+        \"summaryLabel\": \"Quick Summary\",\\n         \"readMore\": \"Read more →\", \"back\": \"← Back to front page\",\\n         \"heroImg\": \"[ Lead photo — pulled from source library, grayscale ]\",\\n         \"today\": \"Global today\", \"statFlights\": \"Flights tracked now\", \"statDelay\": \"Global delay rate\",\\n@@ -999,8 +1001,40 @@ def collect_articles():\\n     return sorted(by_id.values(), key=lambda a: a[\"dt\"], reverse=True)\\n \\n \\n+def notification_summary(value: str, lang: str) -> str:\\n+    \"\"\"Make legacy article summaries glanceable without changing source data.\"\"\"\\n+    text = re.sub(r\"\\s+\", \" \", str(value or \"\")).strip()\\n+    if not text:\\n+        return text\\n+    if lang == \"zh\":\\n+        limit, minimum = 92, 44\\n+        if len(text) <= limit:\\n+            return text\\n+        for marks in (\"。！？\", \"；，\"):\\n+            breaks = [i + 1 for i, char in enumerate(text[:limit])\\n+                      if char in marks and i + 1 >= minimum]\\n+            if breaks:\\n+                end = breaks[-1]\\n+                result = text[:end].rstrip(\"，；\")\\n+                return result if result.endswith(tuple(\"。！？\")) else result + \"…\"\\n+        return text[:limit].rstrip(\"，；、 \") + \"…\"\\n+\\n+    words = text.split()\\n+    limit_words, minimum_words = 42, 20\\n+    if len(words) <= limit_words:\\n+        return text\\n+    sentence = re.match(r\"^.*?[.!?](?=\\s|$)\", text)\\n+    if sentence:\\n+        first = sentence.group(0).strip()\\n+        count = len(first.split())\\n+        if minimum_words <= count <= limit_words:\\n+            return first\\n+    return \" \".join(words[:limit_words]).rstrip(\",;: \") + \"…\"\\n+\\n+\\n def art_view(a, lang: str):\\n     v = dict(a[lang])\\n+    v[\"display_summary\"] = notification_summary(v.get(\"summary\"), lang)\\n     v.update(\\n         id=a[\"id\"], cat=a[\"cat\"], tag_class=a[\"tag_class\"], image=a[\"image\"],\\n         cat_label=_bi(CATS.get(a[\"cat\"]), lang, a[\"cat\"]),\\n@@ -419,9 +419,14 @@\\n   If the event and useful background fit the brief contract, publish_brief;\\n   never promote it to publish merely to make the article longer.\\n - zh.title: 18 to 38 Chinese/alphanumeric characters excluding punctuation\\n-  and spaces. zh.summary: 80 to 140 Chinese characters. en.title: 45 to 100\\n-  characters including spaces.\\n-  en.summary: 70-130 English words. Do not pad unsupported content.\\n+  and spaces. zh.summary: one sentence of 45 to 85 Chinese characters.\\n+  en.title: 45 to 100 characters including spaces. en.summary: one sentence\\n+  of 25 to 45 English words. The summary is a notification-style glanceable\\n+  brief: lead with the named operator/organisation and event, then include the\\n+  supported place/route, timing and concrete operational, passenger, rescue or\\n+  safety impact when relevant. Omit background history, editorial process and\\n+  low-value detail. Never invent a missing airline, place, time or impact and\\n+  never pad unsupported content.\\n - Bodies are plain text with every load-bearing claim traceable to facts.\\n   publish: 4 to 7 substantive paragraphs per language; zh at least 500 Chinese/alphanumeric content characters; en at least 250 words.\\n   publish_brief: 2 to 7 substantive paragraphs per language; zh 180-320 Chinese/\\n@@ -39,7 +39,11 @@ ABSOLUTE EVIDENCE RULES:\\n \\n OUTPUT (same schema and language rules as the main pipeline):\\n - Traditional Chinese (Taiwan usage) zh + English en; zh.title 18-38 chars;\\n-  zh.summary 80-140 chars; en.title 45-100 chars; en.summary 70-130 words;\\n+  zh.summary is one 45-85 character sentence; en.title 45-100 chars;\\n+  en.summary is one 25-45 word sentence. Make each summary immediately\\n+  scannable: identify the operator, aircraft/event, supported location or\\n+  route, timing and operational impact when available; omit low-value\\n+  background and never invent a missing detail;\\n   each language body MUST contain 4-7 substantive factual paragraphs built\\n   only from quoted facts. When official operator/registry/route background is\\n   present, devote at least one paragraph to aircraft/operator identity and one\\n@@ -245,6 +245,51 @@ a.flash-row:hover { color: inherit; }\\n .hero-title { margin: 8px 0 10px; font-size: 34px; line-height: 1.15; }\\n .hero-meta { display: flex; gap: 16px; align-items: baseline; margin-top: 10px; font-size: 12px; }\\n \\n+/* Apple-style glanceable summary: the private iOS glyph is represented by\\n+   an original two-lines-and-arrow SVG rather than copied platform artwork. */\\n+.summary-preview {\\n+  display: grid; grid-template-columns: auto minmax(0, 1fr);\\n+  align-items: start; gap: 7px 11px; padding: 10px 12px;\\n+  border: 1px solid color-mix(in srgb, #7c6be8 28%, var(--color-divider));\\n+  border-radius: 15px;\\n+  background: color-mix(in srgb, #7c6be8 7%, var(--color-surface));\\n+  box-shadow: 0 2px 9px color-mix(in srgb, #5d4dcc 9%, transparent);\\n+}\\n+.summary-preview__label {\\n+  display: inline-flex; align-items: center; gap: 5px; padding-top: 1px;\\n+  color: color-mix(in srgb, #6d5bd0 84%, var(--color-text));\\n+  font-size: 10.5px; font-weight: 800; line-height: 1.45;\\n+  letter-spacing: .06em; white-space: nowrap;\\n+}\\n+.summary-preview__icon {\\n+  width: 17px; height: 17px; fill: none; stroke: currentColor;\\n+  stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;\\n+}\\n+.summary-preview__text {\\n+  min-width: 0; color: color-mix(in srgb, var(--color-text) 82%, transparent);\\n+  font-size: 13.5px; font-weight: 600; line-height: 1.58;\\n+  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;\\n+  overflow: hidden;\\n+}\\n+.summary-preview--hero { margin: 0; }\\n+.summary-preview--feed {\\n+  margin-top: 7px; padding: 7px 9px; border-radius: 12px;\\n+  box-shadow: none;\\n+}\\n+.summary-preview--feed .summary-preview__label { font-size: 9.5px; }\\n+.summary-preview--feed .summary-preview__icon { width: 15px; height: 15px; }\\n+.summary-preview--feed .summary-preview__text { font-size: 12.5px; line-height: 1.55; }\\n+.summary-preview--article { margin: -8px 0 24px; padding: 13px 15px; }\\n+.summary-preview--article .summary-preview__text { font-size: 15px; line-height: 1.65; }\\n+\\n+[data-theme=\"dark\"] .summary-preview {\\n+  border-color: color-mix(in srgb, #a99cff 38%, var(--color-divider));\\n+  background: color-mix(in srgb, #8171e8 11%, var(--color-surface));\\n+}\\n+[data-theme=\"dark\"] .summary-preview__label {\\n+  color: color-mix(in srgb, #b8adff 88%, var(--color-text));\\n+}\\n+\\n /* — article — */\\n .article-page { max-width: 860px; margin: 0 auto; padding: 28px 20px 64px; }\\n .article-title { margin: 14px 0 12px; font-size: 38px; line-height: 1.18; }\\n@@ -552,6 +597,9 @@ a.flash-row:hover { color: inherit; }\\n   .article-title { font-size: 29px; line-height: 1.22; }\\n   .article-meta { gap: 7px 12px; }\\n   .article-meta > :last-child { margin-left: 0 !important; flex-basis: 100%; }\\n+  .summary-preview { grid-template-columns: minmax(0, 1fr); gap: 5px; }\\n+  .summary-preview--article { margin-top: -6px; padding: 11px 12px; }\\n+  .summary-preview--article .summary-preview__text { font-size: 14px; }\\n   .article-sources { padding: 14px 12px 10px; }\\n   .article-source-row { flex-wrap: wrap; gap: 4px 8px; }\\n   .footer-grid { padding: 20px 12px 28px; gap: 24px; }\\n@@ -0,0 +1,13 @@\\n+{% macro notification_summary(text, label, modifier='') -%}\\n+<div class=\"summary-preview{% if modifier %} summary-preview--{{ modifier }}{% endif %}\"\\n+     role=\"note\" aria-label=\"{{ label }}\">\\n+  <span class=\"summary-preview__label\">\\n+    <svg class=\"summary-preview__icon\" viewBox=\"0 0 24 24\"\\n+         aria-hidden=\"true\" focusable=\"false\">\\n+      <path d=\"M4 5.5h11M4 9.5h8M4 16h14M14.5 12.5 18 16l-3.5 3.5\"/>\\n+    </svg>\\n+    <span>{{ label }}</span>\\n+  </span>\\n+  <span class=\"summary-preview__text\">{{ text }}</span>\\n+</div>\\n+{%- endmacro %}\\n@@ -1,4 +1,5 @@\\n {% extends \"base.html\" %}\\n+{% from \"_summary.html\" import notification_summary %}\\n {% block content %}\\n   <div class=\"article-page\">\\n     <a href=\"{{ home_url }}\" class=\"btn btn-ghost\" style=\"margin-bottom:18px;\">{{ t.back }}</a>\\n@@ -14,6 +15,7 @@ <h1 class=\"article-title\">{{ a.title }}</h1>\\n         <span style=\"margin-left:auto;\">{{ t.refreshNote }}</span>\\n       </div>\\n     </div>\\n+    {{ notification_summary(a.display_summary, t.summaryLabel, 'article') }}\\n     {% if a.image %}\\n     <figure style=\"margin:0 0 22px;\">\\n       <img src=\"{{ a.image.url }}\" alt=\"{{ a.image.subject or a.title }}\" loading=\"lazy\" style=\"width:100%;height:auto;max-height:560px;object-fit:contain;background:color-mix(in srgb,var(--color-text) 6%,var(--color-surface));border:1px solid var(--color-divider);\">\\n@@ -1,4 +1,5 @@\\n {% extends \"base.html\" %}\\n+{% from \"_summary.html\" import notification_summary %}\\n {% block content %}\\n   <div class=\"home-grid\">\\n     <div class=\"rail-left\">\\n@@ -48,7 +49,7 @@ <h6 style=\"margin:28px 0 12px;\">{{ t.sourceStatus }}</h6>\\n         <div style=\"margin-top:16px;\">\\n           <span class=\"card-kicker\" style=\"font-size:11px;\">{{ hero.cat_label }}{% if hero.article_format == \"brief\" %} · {{ t.briefLabel }}{% endif %} — {{ t.topStory }}</span>\\n           <h2 class=\"hero-title\">{{ hero.title }}</h2>\\n-          <p class=\"text-muted\" style=\"font-size:14.5px;line-height:1.75;margin:0;\">{{ hero.summary }}</p>\\n+          {{ notification_summary(hero.display_summary, t.summaryLabel, 'hero') }}\\n           <div class=\"hero-meta\">\\n             <span style=\"font-weight:800;color:var(--color-accent);\">{{ hero.time }}</span>\\n             <span class=\"text-muted\">{{ t.mainSource }}: {{ hero.source }}</span>\\n@@ -80,7 +81,7 @@ <h6 style=\"margin:0;\">{{ t.latest }}</h6>\\n         <span class=\"feed-category tag {{ item.tag_class }}\">{{ item.cat_label }}{% if item.article_format == \"brief\" %} · {{ t.briefLabel }}{% endif %}</span>\\n         <span class=\"feed-copy\">\\n           <span style=\"display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;\">{{ item.title }}</span>\\n-          <span class=\"text-muted\" style=\"display:block;font-size:12.5px;line-height:1.65;margin-top:3px;\">{{ item.summary }}</span>\\n+          {{ notification_summary(item.display_summary, t.summaryLabel, 'feed') }}\\n         </span>\\n         <span class=\"feed-source text-muted\">{{ item.source }}</span>\\n       </a>\\n@@ -261,8 +261,11 @@ def group_with(urls):\\n check(\"system prompt bans newsroom-process padding and favors briefs\",\\n       \"never narrate the editorial process\" in write.SYSTEM_PROMPT\\n       and \"never promote it to publish merely\" in write.SYSTEM_PROMPT)\\n-check(\"zh summary spec unchanged (80-140)\",\\n-      \"80 to 140 Chinese characters\" in write.SYSTEM_PROMPT)\\n+check(\"notification-style summaries are short and information-dense\",\\n+      \"one sentence of 45 to 85 Chinese characters\" in write.SYSTEM_PROMPT\\n+      and \"one sentence\\n  of 25 to 45 English words\" in write.SYSTEM_PROMPT\\n+      and \"supported place/route, timing and concrete operational\" in\\n+      write.SYSTEM_PROMPT)\\n \\n print(f\"\\n{CHECKS} checks passed, {FAILED} failed\"\\n       if not FAILED else f\"\\n{CHECKS - FAILED}/{CHECKS} passed, \"\\n@@ -27,14 +27,28 @@ def main() -> None:\\n     assert 'class=\"ticker-window\"' in home\\n     assert 'class=\"ticker-pass\"' in home\\n     assert 'class=\"feed-copy\"' in home\\n+    assert 'class=\"summary-preview summary-preview--hero\"' in home\\n+    assert 'class=\"summary-preview summary-preview--feed\"' in home\\n+    assert 'aria-label=\"重點摘要\"' in home\\n     assert 'class=\"article-page\"' in article\\n+    assert 'class=\"summary-preview summary-preview--article\"' in article\\n+    assert 'class=\"summary-preview__icon\"' in article\\n     assert 'class=\"article-source-row\"' in article\\n \\n+    zh_long = \"中華航空8月10日宣布調整桃園至福岡航班，受機場作業限制影響，兩班延後三小時，旅客須留意報到時間。\" * 2\\n+    zh_short = build.notification_summary(zh_long, \"zh\")\\n+    assert len(zh_short) <= 93\\n+    assert \"中華航空\" in zh_short and \"福岡\" in zh_short\\n+    en_short = build.notification_summary(\"word \" * 60, \"en\")\\n+    assert len(en_short.split()) <= 42\\n+\\n     assert \"@media (max-width: 720px)\" in css\\n     assert \".js .site-nav.is-open { display: grid; }\" in css\\n     assert \"grid-template-columns: repeat(2, minmax(0, 1fr))\" in css\\n     assert \".center-col { order: 1;\" in css\\n     assert \".feed-copy { grid-column: 1 / -1; }\" in css\\n+    assert \".summary-preview__text\" in css\\n+    assert \"-webkit-line-clamp: 2\" in css\\n     assert \"--ticker-duration: 130s\" in css\\n     assert 'font-family: \"Noto Sans TC\", sans-serif' in css\\n     assert \"font-size: 13px; font-weight: 400\" in css"
}
