diff --git a/docs/news-morning-templates.md b/docs/news-morning-templates.md index 271a2e8..6202071 100644 --- a/docs/news-morning-templates.md +++ b/docs/news-morning-templates.md @@ -109,6 +109,31 @@ news002 的卡片图会先上传到微信正文素材接口,再由 inline 渲 真实图片来源按优先级取 SearXNG 结果里的 `img_src`/`thumbnail`,以及文章来源的 `og:image:secure_url`、`og:image`、`twitter:image`;不使用与事件无法核验关系的泛图片搜索结果。 +## 内容安全底线闸门 + +成人、未成年人保护、法律合规、人文伦理、来源资质是底线,**只过滤、不阻断**:命中就删卡或改写,生成和发布照常进行。 + +| 层 | 位置 | 做什么 | +|----|------|--------| +| 提示词 | `NEWS_MORNING_CONTENT_SAFETY_TASK_SPEC`(拼进生成任务,不改冻结的 news001 模板) | 告诉 Agent 底线 | +| 检索 | `wechat-news-morning-search.mjs` | SearXNG `safesearch=2`;预取结果先过 `filterNewsSearchGroupsForSafety`,因为 SearXNG 补位会把预取结果直接写进页面 | +| 页面 | `wechat-news-morning-content-safety.mjs`,worker 在去重后、配图检查后各跑一次 | 逐块检查卡片 / 速读 / 深读 / 导语;一次批量模型复审 | +| 推送 | `pushDraft` / 预览前 `sanitizeNewsMorningHtmlSync` | 只删 block 级,标题与摘要残留词打码 | + +规则在 [`news-content-safety-rules.mjs`](../news-content-safety-rules.mjs),分两级: + +- `block`:命中必删,模型复审不能放行(色情、荐股、虚拟货币推广、赌博毒品交易、歧视词、八卦、灾难调侃、非新闻来源、自媒体作时政来源等)。 +- `review`:交给模型复审(性犯罪新闻、涉未成年人伤害、自杀、恐袭、传闻、标题党、币价行情等)。**复审不可用时按失败即删处理。** + +删卡导致栏目不满时,被删过内容的栏目会进 `exemptSectionIds`,配图/写满检查对这些栏目豁免,否则「过滤」会变成「阻断发布」。 +被删事件出现在 description 里时,用剩余速读重建 description;残留在 meta、JSON-LD 的 block 级词打码。 +页面 footer 与微信草稿底部追加「本文由 AI 辅助整理」显式标识,`
` 写入 `memind-aigc`(隐式标识)与 `memind-content-safety`(审计标记)。 + +- 关闭模型复审(规则仍生效):`MEMIND_NEWS_MORNING_CONTENT_REVIEW=0` +- 复审模型 / 超时:`MEMIND_NEWS_MORNING_CONTENT_REVIEW_MODEL`、`MEMIND_NEWS_MORNING_CONTENT_REVIEW_TIMEOUT_MS`(默认 90s) + +> 规则只能加严或修正误伤,不得为了写满栏目放宽 block 级规则。修正误伤时必须在 `news-content-safety-rules.test.mjs` 补一条「不应命中」的用例。 + ## 预览与验证 ```bash @@ -120,6 +145,8 @@ node scripts/build-news002-real-design-sample.mjs # 相关单测 node --test news-morning-templates.test.mjs \ + news-content-safety-rules.test.mjs \ + wechat-news-morning-content-safety.test.mjs \ wechat-news-morning-images.test.mjs \ wechat-news-morning-dedup.test.mjs \ wechat-news-morning-draft.test.mjs \ diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs index 44c29c2..0eef7d0 100644 --- a/mindsearch-providers.mjs +++ b/mindsearch-providers.mjs @@ -10,6 +10,7 @@ export async function searchSearxng(query, { categories = '', language = '', engines = '', + safesearch = null, } = {}) { if (!endpoint) throw new Error('SearXNG endpoint is not configured'); try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); } @@ -19,6 +20,7 @@ export async function searchSearxng(query, { if (categories) url.searchParams.set('categories', String(categories)); if (language) url.searchParams.set('language', String(language)); if (engines) url.searchParams.set('engines', String(engines)); + if (safesearch != null) url.searchParams.set('safesearch', String(safesearch)); const timeout = AbortSignal.timeout(timeoutMs); const response = await fetchImpl(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, diff --git a/news-content-safety-rules.mjs b/news-content-safety-rules.mjs new file mode 100644 index 0000000..e44c710 --- /dev/null +++ b/news-content-safety-rules.mjs @@ -0,0 +1,260 @@ +/** + * 新闻内容安全底线规则(成人 / 未成年人保护 / 法律合规 / 人文伦理 / 来源资质)。 + * + * 纯函数、零业务依赖:检索预取、页面清洗、推送复检三处共用同一套规则, + * 不得 import 其它业务模块,避免与 wechat-news-morning-* 形成循环依赖。 + * + * level: + * - block 命中即删(模型复审不能放行) + * - review 交给模型复审;复审不可用时按失败即删处理 + * + * REGRESSION GUARD: 规则只能加严或修正误伤,不得为了「写满栏目」放宽 block 级规则。 + */ + +export const NEWS_CONTENT_SAFETY_VERSION = '2026-09-23.v1'; + +export const NEWS_SAFETY_CATEGORY_LABELS = Object.freeze({ + adult: '成人低俗', + minor: '未成年人保护', + 'self-harm': '自杀自残', + violence: '暴恐血腥', + gambling: '赌博', + drugs: '毒品', + finance: '金融违规(荐股/虚拟货币炒作)', + health: '医疗虚假宣传', + discrimination: '歧视侮辱', + ethics: '人文伦理', + gossip: '八卦隐私', + rumor: '未经证实', + clickbait: '标题党', + provenance: '来源资质', + legal: '其它违法有害', +}); + +const MINOR_TERMS = '未成年|少年|少女|男童|女童|儿童|幼童|幼儿|婴儿|小学生|中学生|初中生|高中生|学生|孩子|幼儿园|男孩|女孩|\\d{1,2}岁(?:男|女)?(?:孩|童|生)'; +const MINOR_HARM_TERMS = '性侵|强奸|猥亵|性骚扰|虐待|虐童|殴打|欺凌|霸凌|自杀|轻生|跳楼|自残|遇害|身亡|溺亡|死亡|失踪|拐卖|诱拐|坠楼|烫伤|体罚'; +const TRAGEDY_TERMS = '遇难|身亡|死亡|丧生|罹难|地震|空难|坠机|火灾|爆炸|溺亡|踩踏|伤亡|遗体|海啸|洪灾|塌方|车祸'; +const FRIVOLOUS_TERMS = '😂|🤣|😆|😁|😄|🎉|🥳|哈哈|笑死|活该|喜大普奔|吃瓜|狂欢|爽翻'; +const CRYPTO_TERMS = '比特币|以太坊|虚拟货币|加密货币|数字货币|币圈|山寨币|meme币|代币|NFT|狗狗币|BTC|ETH|USDT'; +const CRYPTO_HYPE_TERMS = '暴涨|抄底|百倍|千倍|起飞|翻倍|飙升'; +const CRYPTO_PROMO_TERMS = '梭哈|上车|空投|注册返佣|开户返佣|交易所注册|返佣|带单|喊单|财富自由|稳赚'; +const POLITICAL_TERMS = '外交部|国务院|中共中央|中央政治局|政治局|全国人大|人大常委|全国政协|国防部|解放军|国家主席|总书记|国务院总理|省委书记|市委书记|时政|领导人|党中央|中央纪委|国台办|港澳办|涉台|涉港|涉疆|涉藏'; + +function rx(source, flags = 'iu') { + return new RegExp(source, flags); +} + +/** 单条规则:命中 pattern 即判定。 */ +const TERM_RULES = Object.freeze([ + // 成人低俗 + { id: 'adult-explicit', category: 'adult', level: 'block', re: rx('色情(?:网站|视频|图片|直播|片)|黄色(?:网站|视频|小说)|黄网|黄片|成人(?:网站|视频|直播|电影)|AV女优|裸聊|约炮|援交|一夜情|性爱视频|艳照|露点(?:照|视频|写真)|福利姬|情色|擦边(?:视频|直播|写真|女主播)|porn|onlyfans|nsfw|xvideos') }, + { id: 'adult-sexual-crime', category: 'adult', level: 'review', re: rx('性侵|强奸|猥亵|性骚扰|卖淫|嫖娼|淫秽|裸照|不雅视频|偷拍') }, + + // 自杀自残 + { id: 'self-harm-method', category: 'self-harm', level: 'block', re: rx('(?:自杀|轻生|自残|自缢)(?:方法|方式|教程|攻略|步骤)|割腕|烧炭自杀|服毒自杀') }, + { id: 'self-harm-topic', category: 'self-harm', level: 'review', re: rx('自杀|轻生|自残|自缢|跳楼(?!价|甩卖|大甩卖)') }, + + // 暴恐 + { id: 'violence-howto', category: 'violence', level: 'block', re: rx('(?:制作|自制|组装)(?:炸弹|炸药|爆炸物|枪支)|买卖枪支|枪支出售|出售枪支|恐怖主义宣传|圣战(?:号召|宣传)') }, + { id: 'violence-gore', category: 'violence', level: 'review', re: rx('斩首|碎尸|肢解|血腥视频|虐杀|恐怖袭击|恐袭|砍杀') }, + + // 赌博 / 毒品 + { id: 'gambling-promo', category: 'gambling', level: 'block', re: rx('博彩(?:平台|网站|公司)|线上赌场|网上赌场|在线赌场|百家乐|时时彩|赌球平台|娱乐城|菠菜网|彩票(?:预测|必中|内部)') }, + { id: 'drugs-trade', category: 'drugs', level: 'block', re: rx('(?:购买|出售|代购|哪里买|求购)(?:冰毒|大麻|K粉|海洛因|摇头丸|可卡因|笑气)') }, + + // 金融违规 + { id: 'finance-stock-advice', category: 'finance', level: 'block', re: rx('荐股|牛股推荐|必涨|稳赚不赔|包赚|保本高收益|内幕消息|明日涨停|跟着(?:买|操作)|(?:建议|推荐)(?:大家|读者|投资者|你)?(?:买入|满仓|加仓|梭哈)') }, + { id: 'finance-bottom-fishing', category: 'finance', level: 'review', re: rx('抄底|满仓|梭哈|翻倍股') }, + + // 医疗虚假宣传 + { id: 'health-false-claim', category: 'health', level: 'block', re: rx('包治百病|根治(?:癌症|糖尿病|高血压)|神药|祖传秘方|无任何副作用|药到病除') }, + + // 歧视侮辱 + { id: 'discrimination-slur', category: 'discrimination', level: 'block', re: rx('支那|黑鬼|尼哥|高丽棒子|小日本|娘炮|绿茶婊|婊子|滚出中国|低等民族|劣等民族|死胖子') }, + + // 八卦隐私 + { id: 'gossip', category: 'gossip', level: 'block', re: rx('出轨|劈腿|绯闻|婚变|小三|私生活|偷情|开房记录') }, + + // 未经证实 + { id: 'rumor', category: 'rumor', level: 'review', re: rx('网传|据传|传闻|爆料|小道消息|未经证实|疑似曝光') }, + + // 标题党 + { id: 'clickbait', category: 'clickbait', level: 'review', re: rx('震惊|惊呆了|吓尿|速看|速删|转疯了|不看后悔|99%的人不知道|刚刚.{0,6}突发') }, +]); + +/** 组合规则:同一段文字同时命中两组词才判定。 */ +const COMBO_RULES = Object.freeze([ + { id: 'minor-harm', category: 'minor', level: 'review', all: [rx(MINOR_TERMS), rx(MINOR_HARM_TERMS)] }, + { id: 'tragedy-frivolous', category: 'ethics', level: 'block', all: [rx(TRAGEDY_TERMS), rx(FRIVOLOUS_TERMS)] }, + { id: 'crypto-promo', category: 'finance', level: 'block', all: [rx(CRYPTO_TERMS), rx(CRYPTO_PROMO_TERMS)] }, + { id: 'crypto-hype', category: 'finance', level: 'review', all: [rx(CRYPTO_TERMS), rx(CRYPTO_HYPE_TERMS)] }, +]); + +/** 非新闻来源:考试培训、内容农场等,不管什么题材都不得作为新闻来源。 */ +const NON_NEWS_SOURCE_DOMAINS = Object.freeze([ + 'huatu.com', + 'offcn.com', + 'chinagwy.org', + 'zgjsks.com', + '51test.net', + 'ruiwen.com', + 'diyifanwen.com', + 'wenmi.com', + 'docin.com', + 'doc88.com', + 'wenku.baidu.com', + 'renrendoc.com', +]); + +/** 自媒体 / 境外社交平台:可作为科技、文娱线索,但不得作为时政新闻的来源。 */ +const SELF_MEDIA_DOMAINS = Object.freeze([ + 'x.com', + 'twitter.com', + 'facebook.com', + 'instagram.com', + 'youtube.com', + 'tiktok.com', + 'reddit.com', + 't.me', + 'telegram.org', + 'medium.com', + 'substack.com', + 'weibo.com', + 'weibo.cn', + 'zhihu.com', + 'douyin.com', + 'bilibili.com', + 'xiaohongshu.com', + 'baijiahao.baidu.com', + 'mp.weixin.qq.com', + 'toutiao.com', + 'sohu.com/a/', +]); + +const UNSAFE_DOMAIN_RE = /(?:porn|xxx|sex|casino|bet365|gamble|18av|hentai|jav)/i; +const POLITICAL_RE = rx(POLITICAL_TERMS); +const POLITICAL_SECTION_IDS = new Set(['policy', 'geopolitics', 'politics']); +const POLITICAL_TAG_RE = /时政|政策|政务|外交/u; + +function hostAndPath(url) { + try { + const parsed = new URL(String(url ?? '').trim()); + return { + host: parsed.hostname.toLowerCase().replace(/^www\./, ''), + pathname: parsed.pathname, + }; + } catch { + return { host: '', pathname: '' }; + } +} + +function matchesDomain(host, pathname, domain) { + const slash = domain.indexOf('/'); + const domainHost = slash >= 0 ? domain.slice(0, slash) : domain; + const domainPath = slash >= 0 ? domain.slice(slash) : ''; + const hostMatches = host === domainHost || host.endsWith(`.${domainHost}`); + return hostMatches && (!domainPath || pathname.startsWith(domainPath)); +} + +/** + * 来源资质判定。 + * @returns {{ level: 'block'|'clean', hits: Array }} + */ +export function assessNewsSourceUrl(url, { political = false } = {}) { + const { host, pathname } = hostAndPath(url); + const hits = []; + if (!host) return { level: 'clean', hits }; + if (UNSAFE_DOMAIN_RE.test(host)) { + hits.push({ id: 'source-unsafe-domain', category: 'adult', level: 'block', match: host }); + } else if (NON_NEWS_SOURCE_DOMAINS.some((domain) => matchesDomain(host, pathname, domain))) { + hits.push({ id: 'source-non-news', category: 'provenance', level: 'block', match: host }); + } else if (political && SELF_MEDIA_DOMAINS.some((domain) => matchesDomain(host, pathname, domain))) { + hits.push({ id: 'source-self-media-politics', category: 'provenance', level: 'block', match: host }); + } + return { level: hits.length ? 'block' : 'clean', hits }; +} + +export function isPoliticalNewsText(text, { sectionId = '', tag = '' } = {}) { + if (POLITICAL_SECTION_IDS.has(String(sectionId))) return true; + if (POLITICAL_TAG_RE.test(String(tag))) return true; + return POLITICAL_RE.test(String(text ?? '')); +} + +function resolveLevel(hits) { + if (hits.some((hit) => hit.level === 'block')) return 'block'; + if (hits.some((hit) => hit.level === 'review')) return 'review'; + return 'clean'; +} + +/** + * 判定一段新闻文字(标题 + 摘要 + 为什么重要等)及其来源。 + * @returns {{ level: 'block'|'review'|'clean', hits: Array<{id, category, level, match}> }} + */ +export function classifyNewsSafetyText(text, { url = '', sectionId = '', tag = '' } = {}) { + const source = String(text ?? ''); + const hits = []; + for (const rule of TERM_RULES) { + const match = source.match(rule.re); + if (match) hits.push({ id: rule.id, category: rule.category, level: rule.level, match: match[0] }); + } + for (const rule of COMBO_RULES) { + const matches = rule.all.map((re) => source.match(re)); + if (matches.every(Boolean)) { + hits.push({ + id: rule.id, + category: rule.category, + level: rule.level, + match: matches.map((item) => item[0]).join('+'), + }); + } + } + if (url) { + const political = isPoliticalNewsText(source, { sectionId, tag }); + hits.push(...assessNewsSourceUrl(url, { political }).hits); + } + return { level: resolveLevel(hits), hits }; +} + +/** 只用于兜底打码:单条 block 级词表(不含组合规则与来源规则)。 */ +const MASKABLE_BLOCK_RULES = TERM_RULES.filter((rule) => rule.level === 'block'); + +/** 把残留的 block 级词替换成「**」,用于 meta、JSON-LD、知识卡等非卡片文本。 */ +export function maskBlockedNewsTerms(text) { + let output = String(text ?? ''); + let count = 0; + for (const rule of MASKABLE_BLOCK_RULES) { + const global = new RegExp(rule.re.source, 'giu'); + output = output.replace(global, () => { + count += 1; + return '**'; + }); + } + return { text: output, count }; +} + +/** + * 检索预取层过滤:删掉 block 级结果,review 级保留给页面层复审。 + * 预取结果会被 SearXNG 补位直接写进页面,所以这里必须先过一遍。 + */ +export function filterNewsSearchGroupsForSafety(groups = []) { + const removed = []; + const filtered = (Array.isArray(groups) ? groups : []).map((group) => { + const results = (Array.isArray(group?.results) ? group.results : []).filter((item) => { + const verdict = classifyNewsSafetyText( + [item?.title, item?.snippet, item?.content].filter(Boolean).join(' '), + { url: item?.url ?? '', sectionId: group?.id ?? '' }, + ); + if (verdict.level !== 'block') return true; + removed.push({ groupId: group?.id ?? '', title: item?.title ?? '', url: item?.url ?? '', hits: verdict.hits }); + return false; + }); + return { ...group, results }; + }); + return { groups: filtered, removed }; +} + +export function formatNewsSafetyHits(hits = []) { + return hits + .map((hit) => `${NEWS_SAFETY_CATEGORY_LABELS[hit.category] ?? hit.category}:${hit.match}`) + .join(','); +} diff --git a/news-content-safety-rules.test.mjs b/news-content-safety-rules.test.mjs new file mode 100644 index 0000000..1750568 --- /dev/null +++ b/news-content-safety-rules.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assessNewsSourceUrl, + classifyNewsSafetyText, + filterNewsSearchGroupsForSafety, + maskBlockedNewsTerms, +} from './news-content-safety-rules.mjs'; + +function levelOf(text, options) { + return classifyNewsSafetyText(text, options).level; +} + +test('adult explicit content is blocked, sexual-crime news goes to review', () => { + assert.equal(levelOf('某平台被曝传播色情视频'), 'block'); + assert.equal(levelOf('91每日大赛-今日吃瓜爆料资讯平台,热门黑料绯闻每日更新'), 'block'); + assert.equal(levelOf('男子强奸案一审宣判,被判有期徒刑十年'), 'review'); + assert.equal(levelOf('央行宣布下调存款准备金率 0.5 个百分点'), 'clean'); +}); + +test('common news phrases are not false positives', () => { + assert.equal(levelOf('商场国庆跳楼价大甩卖'), 'clean'); + assert.equal(levelOf('今日露点温度 18℃,体感闷热'), 'clean'); + assert.equal(levelOf('监管部门严查打擦边球的违规营销'), 'clean'); + assert.equal(levelOf('小知识:恐龙是怎么死的?'), 'clean'); + assert.equal(levelOf('高盛上调腾讯目标价至 600 港元'), 'clean'); +}); + +test('minor harm, suicide and tragedy tone rules', () => { + assert.equal(levelOf('某地一名初中生坠楼身亡,警方介入调查'), 'review'); + assert.equal(levelOf('网友分享自杀方法引发关注'), 'block'); + assert.equal(levelOf('台风致 3 人遇难 😂'), 'block'); + assert.equal(levelOf('台风致 3 人遇难,救援持续进行'), 'clean'); +}); + +test('finance rules block stock tips and crypto promotion but review price news', () => { + assert.equal(levelOf('分析师荐股:这只股票明日涨停'), 'block'); + assert.equal(levelOf('比特币暴涨,现在上车还来得及,注册返佣'), 'block'); + assert.equal(levelOf('比特币价格暴涨突破 10 万美元'), 'review'); + assert.equal(levelOf('多部门重申虚拟货币相关业务属于非法金融活动'), 'clean'); +}); + +test('source provenance: non-news domains always blocked, self-media only for politics', () => { + assert.equal(assessNewsSourceUrl('https://m.ah.huatu.com/list/szrd/').level, 'block'); + assert.equal(assessNewsSourceUrl('https://x.com/nasa/status/1', { political: true }).level, 'block'); + assert.equal(assessNewsSourceUrl('https://x.com/nasa/status/1', { political: false }).level, 'clean'); + assert.equal(assessNewsSourceUrl('https://m.sohu.com/a/123456_1', { political: true }).level, 'block'); + assert.equal(assessNewsSourceUrl('https://www.news.cn/politics/20260923/abc.html', { political: true }).level, 'clean'); + assert.equal(assessNewsSourceUrl('https://www.pornhub.com/view').level, 'block'); + + assert.equal(levelOf('外交部发言人就相关问题答记者问', { url: 'https://weibo.com/123' }), 'block'); + assert.equal(levelOf('宇树科技冲刺 A 股 IPO', { url: 'https://www.toutiao.com/article/1/', sectionId: 'domestic' }), 'clean'); + assert.equal(levelOf('央行存款新规落地', { url: 'https://www.toutiao.com/article/1/', sectionId: 'policy' }), 'block'); +}); + +test('maskBlockedNewsTerms masks block-level terms only', () => { + const masked = maskBlockedNewsTerms('标签:色情网站, 荐股, 强奸案宣判'); + assert.equal(masked.count, 2); + assert.doesNotMatch(masked.text, /色情网站|荐股/u); + assert.match(masked.text, /强奸案宣判/u); +}); + +test('filterNewsSearchGroupsForSafety drops blocked results and keeps review items', () => { + const { groups, removed } = filterNewsSearchGroupsForSafety([ + { + id: 'domestic', + results: [ + { title: '『时事政治』2026年时事政治热点汇总', url: 'https://m.ah.huatu.com/list/szrd/', snippet: '' }, + { title: '初中生坠楼身亡,警方介入', url: 'https://www.thepaper.cn/newsDetail_1', snippet: '' }, + { title: '国产大飞机交付第 100 架', url: 'https://www.news.cn/a/1.html', snippet: '' }, + ], + }, + { id: 'culture', results: [{ title: '某明星出轨风波', url: 'https://ent.example.com/1', snippet: '' }] }, + ]); + assert.equal(removed.length, 2); + assert.deepEqual(groups[0].results.map((item) => item.title), ['初中生坠楼身亡,警方介入', '国产大飞机交付第 100 架']); + assert.equal(groups[1].results.length, 0); +}); diff --git a/news-morning-templates.mjs b/news-morning-templates.mjs index b30dbcc..a5ced6a 100644 --- a/news-morning-templates.mjs +++ b/news-morning-templates.mjs @@ -231,14 +231,18 @@ export function auditNews002SectionFill( minFlexSections = NEWS002_MIN_FLEX_SECTIONS, requireCoreMax = true, requireFlexMax = true, + exemptSectionIds = [], } = {}, ) { const counts = Object.fromEntries( NEWS002_SECTIONS.map((section) => [section.id, countNews002SectionItems(html, section.id)]), ); const violations = []; + // 内容安全闸门删过条目的栏目不再要求写满,否则「过滤」会变成「阻断发布」。 + const exempt = new Set(exemptSectionIds); for (const section of NEWS002_SECTIONS.filter((item) => item.tier === 'core')) { + if (exempt.has(section.id)) continue; const count = counts[section.id] ?? 0; if (section.id === 'deepdive') { if (count !== section.max) { @@ -252,11 +256,15 @@ export function auditNews002SectionFill( } const flexPresent = NEWS002_FLEX_SECTION_IDS.filter((id) => extractNews002SectionBlock(html, id)); - if (flexPresent.length < minFlexSections) { - violations.push(`insufficient-flex-sections:${flexPresent.length}/${minFlexSections}`); + const exemptMissingFlex = NEWS002_FLEX_SECTION_IDS + .filter((id) => exempt.has(id) && !flexPresent.includes(id)).length; + const requiredFlex = Math.max(0, minFlexSections - exemptMissingFlex); + if (flexPresent.length < requiredFlex) { + violations.push(`insufficient-flex-sections:${flexPresent.length}/${requiredFlex}`); } if (requireFlexMax) { for (const id of flexPresent) { + if (exempt.has(id)) continue; const section = NEWS002_SECTIONS.find((item) => item.id === id); const count = counts[id] ?? 0; if (section && count < section.max) { diff --git a/news-morning-templates.test.mjs b/news-morning-templates.test.mjs index dec63ed..2b8ce59 100644 --- a/news-morning-templates.test.mjs +++ b/news-morning-templates.test.mjs @@ -116,6 +116,15 @@ test('auditNews002SectionFill requires core max counts and flex coverage', () => assert.equal(countNews002SectionItems(sparse, 'headlines'), 1); }); +test('auditNews002SectionFill exempts sections the content safety gate filtered', () => { + const sparse = `${deepText}