feat: consume independent news engine

This commit is contained in:
john
2026-09-22 10:19:54 +08:00
parent c1d43c8ea3
commit a96a9d0e14
6 changed files with 286 additions and 7 deletions
+58
View File
@@ -0,0 +1,58 @@
# MeMind News Engine MVP
## Boundary
- `memind-news-engine` owns collection, event clustering, ranking, and briefing candidates.
- `Memind` consumes ranked candidates and owns page generation and delivery.
- `memind_adm` owns source configuration, ranking policy editing, review, run monitoring, and publish controls.
- The legacy `ops/` UI is not extended.
## Current pipeline
```text
Memind SearXNG query groups
-> memind-news-engine HTTP API
-> event clustering by canonical URL and title similarity
-> deterministic rule score
-> optional structured LLM assessment
-> global score plus optional user-interest score
-> breaking-news override
-> category quotas and source diversity
-> ranked candidates in the existing briefing prompt
-> existing page generation, dedupe, image, and WeChat delivery guards
```
The first slice is deliberately deterministic. The model may provide the six semantic dimensions, but application code calculates the weighted score and owns final selection.
## Score contract
LLM assessment values are 0-10:
```json
{
"importance": 8,
"novelty": 9,
"impact": 8,
"credibility": 9,
"relevance": 8,
"actionability": 7,
"category": "ai_tech",
"reason": "A flagship model release changes capability and API economics."
}
```
Semantic score weights are 25% importance, 20% impact, 15% novelty, 15% credibility, 15% relevance, and 10% actionability. When the LLM assessment is present, global score is 35% rule score plus 65% semantic score. When a user-interest score is present, final score is 70% global plus 30% personal.
Breaking override is active when importance is at least 9, there are at least two distinct sources, and credibility is at least 8.
## Runtime controls
- `MEMIND_NEWS_ENGINE_DAILY_LIMIT`: selected event count, default `15`.
- `MEMIND_NEWS_ENGINE_MINIMUM_SCORE`: minimum final score, default `0` during the observation phase.
- `MEMIND_NEWS_ENGINE_URL`: independent service base URL; ranking is skipped when unset.
- `MEMIND_NEWS_ENGINE_API_TOKEN`: service-to-service bearer token.
- `MEMIND_NEWS_ENGINE_TIMEOUT_MS`: request timeout, default `5000`.
## Next slice
The engine repository now owns persistence migrations and the ranking API. Next work is the bounded LLM scoring worker, collection scheduler, and `memind_adm` management UI.
+87
View File
@@ -0,0 +1,87 @@
const EMPTY_RANKING = Object.freeze({
candidateCount: 0,
eventCount: 0,
events: [],
selected: [],
});
export function emptyNewsEngineRanking() {
return {
candidateCount: 0,
eventCount: 0,
events: [],
selected: [],
};
}
export function resolveNewsEngineEndpoint(env = process.env) {
return String(env.MEMIND_NEWS_ENGINE_URL ?? '').trim().replace(/\/+$/, '');
}
export async function rankNewsWithEngine(groups, {
env = process.env,
now = Date.now(),
fetchImpl = fetch,
logger = console,
} = {}) {
const endpoint = resolveNewsEngineEndpoint(env);
if (!endpoint) return emptyNewsEngineRanking();
const timeoutMs = Math.max(
500,
Number(env.MEMIND_NEWS_ENGINE_TIMEOUT_MS ?? 5000) || 5000,
);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
try {
const token = String(env.MEMIND_NEWS_ENGINE_API_TOKEN ?? '').trim();
const response = await fetchImpl(`${endpoint}/v1/rank`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
groups: Array.isArray(groups) ? groups : [],
options: {
now,
limit: Number(env.MEMIND_NEWS_ENGINE_DAILY_LIMIT ?? 15) || 15,
minimumScore: Number(env.MEMIND_NEWS_ENGINE_MINIMUM_SCORE ?? 0) || 0,
},
}),
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const ranking = await response.json();
if (!ranking || !Array.isArray(ranking.events) || !Array.isArray(ranking.selected)) {
throw new Error('invalid ranking response');
}
return ranking;
} catch (error) {
logger.warn?.(
'[NewsEngine] ranking unavailable:',
error instanceof Error ? error.message : error,
);
return { ...EMPTY_RANKING, events: [], selected: [] };
} finally {
clearTimeout(timer);
}
}
export function formatRankedNewsCandidates(ranking) {
const selected = Array.isArray(ranking?.selected) ? ranking.selected : [];
if (!selected.length) return '';
const lines = [
'【News Engine 编辑候选】以下事件已完成聚类、规则评分和栏目配额选择。生成早报时优先采用;must_include=true 的事件必须进入正文。',
];
for (const event of selected) {
const source = event.primaryArticle?.sourceDomain || 'unknown';
lines.push(
`${event.rank}. [${event.category}] ${event.canonicalTitle}`,
` score=${event.finalScore} sources=${event.sourceCount} must_include=${event.mustInclude} primary=${source}`,
);
if (event.primaryArticle?.url) lines.push(` ${event.primaryArticle.url}`);
}
return lines.join('\n');
}
+66
View File
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
formatRankedNewsCandidates,
rankNewsWithEngine,
resolveNewsEngineEndpoint,
} from './news-engine-client.mjs';
test('news engine client is disabled without an endpoint', async () => {
assert.equal(resolveNewsEngineEndpoint({}), '');
const ranking = await rankNewsWithEngine([], {
env: {},
fetchImpl: async () => { throw new Error('must not fetch'); },
});
assert.deepEqual(ranking.selected, []);
});
test('news engine client sends groups and authentication to the independent service', async () => {
const calls = [];
const ranking = await rankNewsWithEngine([{ id: 'tech', results: [] }], {
now: 123,
env: {
MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092/',
MEMIND_NEWS_ENGINE_API_TOKEN: 'service-token',
MEMIND_NEWS_ENGINE_DAILY_LIMIT: '12',
},
fetchImpl: async (url, init) => {
calls.push({ url, init, body: JSON.parse(init.body) });
return {
ok: true,
async json() {
return {
candidateCount: 1,
eventCount: 1,
events: [],
selected: [{
rank: 1,
category: 'ai_tech',
canonicalTitle: '模型发布',
finalScore: 8.8,
sourceCount: 2,
mustInclude: true,
primaryArticle: { sourceDomain: 'example.com', url: 'https://example.com/a' },
}],
};
},
};
},
});
assert.equal(calls[0].url, 'http://127.0.0.1:8092/v1/rank');
assert.equal(calls[0].init.headers.authorization, 'Bearer service-token');
assert.equal(calls[0].body.options.limit, 12);
assert.equal(ranking.selected[0].mustInclude, true);
assert.match(formatRankedNewsCandidates(ranking), /模型发布/);
});
test('news engine client fails open when the service is unavailable', async () => {
const warnings = [];
const ranking = await rankNewsWithEngine([], {
env: { MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092' },
fetchImpl: async () => { throw new Error('offline'); },
logger: { warn: (...args) => warnings.push(args) },
});
assert.deepEqual(ranking.selected, []);
assert.match(String(warnings[0]), /offline/);
});
+1
View File
File diff suppressed because one or more lines are too long
+36 -3
View File
@@ -2,6 +2,10 @@ import { searchSearxng } from './mindsearch-providers.mjs';
import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
import { getLocalParts } from './schedule-time.mjs';
import { enrichNewsMorningSearchGroupsWithImages } from './wechat-news-morning-images.mjs';
import {
formatRankedNewsCandidates,
rankNewsWithEngine,
} from './news-engine-client.mjs';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
@@ -191,7 +195,11 @@ function truncateSnippet(value, max = 90) {
return text.length > max ? `${text.slice(0, max - 1)}` : text;
}
export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null, dedupeRemovedCount = 0 } = {}) {
export function formatNewsMorningSearchBrief(groups = [], {
dateLabel = null,
dedupeRemovedCount = 0,
ranking = null,
} = {}) {
const lines = [
'【专用联网搜索预取】以下结果来自专用新闻检索(与 web_search 并列,不是替代)。',
'写页面时必须:1)优先从下列结果覆盖各栏目;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search;4)全页去重:同一 URL/同一事件只允许出现一次,优先保留要闻/国内/国际/财经/科技,扩展栏目用不同角度或跳过;5)英文栏目标题可英文,正文用中文摘要并附原文链接。',
@@ -201,6 +209,8 @@ export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null, de
if (dedupeRemovedCount > 0) {
lines.push(`预取跨栏目去重:已剔除 ${dedupeRemovedCount} 条重复 URL/标题,写页时仍须对双路搜索结果再次去重。`);
}
const rankedCandidates = formatRankedNewsCandidates(ranking);
if (rankedCandidates) lines.push('', rankedCandidates);
lines.push('');
let hitCount = 0;
@@ -262,18 +272,33 @@ export async function prefetchNewsMorningSearxngBundle({
env = process.env,
searchImpl = searchSearxng,
fetchImpl = fetch,
newsEngineFetchImpl = fetch,
logger = console,
limit = 5,
} = {}) {
const dateLabel = formatLocalDateParts(now, timezone);
const queries = buildNewsMorningSearchQueries(dateLabel);
const emptyGroups = queries.map((item) => ({ ...item, results: [] }));
const emptyBrief = formatNewsMorningSearchBrief(emptyGroups, { dateLabel });
const emptyRanking = { candidateCount: 0, eventCount: 0, events: [], selected: [] };
if (!shouldPrefetchNewsMorningSearxng(env)) {
return { brief: emptyBrief, groups: emptyGroups, dateLabel, dedupeRemovedCount: 0 };
return {
brief: emptyBrief,
groups: emptyGroups,
ranking: emptyRanking,
dateLabel,
dedupeRemovedCount: 0,
};
}
const endpoint = resolvePortalSearxngEndpoint(env);
if (!endpoint) {
return { brief: emptyBrief, groups: emptyGroups, dateLabel, dedupeRemovedCount: 0 };
return {
brief: emptyBrief,
groups: emptyGroups,
ranking: emptyRanking,
dateLabel,
dedupeRemovedCount: 0,
};
}
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const useNewsCategory = shouldUseNewsMorningNewsCategory(env);
@@ -294,6 +319,12 @@ export async function prefetchNewsMorningSearxngBundle({
).slice(0, limit);
return { ...item, results };
});
const ranking = await rankNewsWithEngine(groups, {
env,
now,
fetchImpl: newsEngineFetchImpl,
logger,
});
const { groups: dedupedGroups, removedCount } = dedupeNewsMorningSearchGroups(groups);
const imageEnrichment = await enrichNewsMorningSearchGroupsWithImages(dedupedGroups, {
fetchImpl,
@@ -305,8 +336,10 @@ export async function prefetchNewsMorningSearxngBundle({
brief: formatNewsMorningSearchBrief(imageEnrichment.groups, {
dateLabel,
dedupeRemovedCount: removedCount,
ranking,
}),
groups: imageEnrichment.groups,
ranking,
dateLabel,
dedupeRemovedCount: removedCount,
};
+38 -4
View File
@@ -139,7 +139,10 @@ test('prefetchNewsMorningSearxngBrief queries news category then falls back', as
test('prefetchNewsMorningSearxngBundle returns structured groups for fallback', async () => {
const bundle = await prefetchNewsMorningSearxngBundle({
now: Date.parse('2026-09-15T05:00:00+08:00'),
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
env: {
TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search',
MEMIND_NEWS_ENGINE_URL: 'http://127.0.0.1:8092',
},
searchImpl: async () => ([
{ title: '样例新闻', url: 'https://news.example/story', snippet: '摘要' },
]),
@@ -148,10 +151,35 @@ test('prefetchNewsMorningSearxngBundle returns structured groups for fallback',
headers: { get: () => 'text/html' },
text: async () => '<meta property="og:image" content="https://cdn.example/story.jpg">',
}),
newsEngineFetchImpl: async () => ({
ok: true,
async json() {
return {
candidateCount: 1,
eventCount: 1,
events: [],
selected: [{
rank: 1,
category: 'international',
canonicalTitle: '样例新闻',
finalScore: 8.1,
sourceCount: 2,
mustInclude: false,
primaryArticle: {
sourceDomain: 'news.example',
url: 'https://news.example/story',
},
}],
};
},
}),
});
assert.ok(Array.isArray(bundle.groups));
assert.ok(bundle.groups.length > 10);
assert.ok(bundle.groups.some((group) => group.results.length > 0));
assert.ok(bundle.ranking.eventCount > 0);
assert.ok(bundle.ranking.selected.length > 0);
assert.match(bundle.brief, /News Engine 编辑候选/);
assert.match(bundle.brief, /专用联网搜索预取/);
});
@@ -177,13 +205,19 @@ test('prefetchNewsMorningSearxngBrief skips news category by default', async ()
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {
const brief = await prefetchNewsMorningSearxngBrief({
const bundle = await prefetchNewsMorningSearxngBundle({
now: Date.parse('2026-09-15T05:00:00+08:00'),
env: {},
searchImpl: async () => {
throw new Error('should not run');
},
});
assert.match(brief, /预取没有返回条目|本路预取为空/);
assert.match(brief, /tkmind_search/);
assert.deepEqual(bundle.ranking, {
candidateCount: 0,
eventCount: 0,
events: [],
selected: [],
});
assert.match(bundle.brief, /预取没有返回条目|本路预取为空/);
assert.match(bundle.brief, /tkmind_search/);
});