Send #rss get summaries as 3-5 separate mesh messages (v0.21.0)
CI / build + unit (pull_request) Successful in 1m20s

The single-message summary was too small (owner request). The summarizer
prompt now asks for exactly 3-5 lines, one item per line, in the format
'<short header>: <one or two short sentences>' with a <=20-char header,
no intro/outro/numbering. maxTokens stays 2500 (reasoning-token headroom).

The reply is parsed into items: drop empty lines and lines without a ':'
header separator, cap at 5; fewer than 3 valid lines sends what we got
(no failure/retry), zero valid lines sends nothing. Each item goes out as
its own mesh message via respond(id, line) - all threaded to the trigger
packet id on its channel. Per-item backstop fitOneLine() hard-cuts any
line >185 chars at a word boundary near 180 so every item fits one packet;
the old whole-reply fitOnePacket() cut is gone.

CommandService sends array replies line by line through the same routing
as single replies (respond per line, or legacy sendText fallback per line
when there is no packet id). LLM failure path unchanged: single
'summary failed' reply.
This commit is contained in:
2026-09-25 20:09:16 +00:00
parent 172e7bed9c
commit cdd52a3ddb
7 changed files with 183 additions and 75 deletions
+101 -35
View File
@@ -5,7 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LlmService } from '../llm/llm.service.js';
import { RssStore } from './rss.store.js';
import { fitOnePacket, RssService } from './rss.service.js';
import { fitOneLine, parseSummaryItems, RssService } from './rss.service.js';
const FEED_A = `<rss version="2.0"><channel>
<item><title>Alpha headline</title></item>
@@ -149,40 +149,79 @@ describe('RssService.handle', () => {
expect(await svc.handle(ctx('#rss sources'))).toBe('Aye, Bee');
});
it('get <name>: summarizes the latest titles via chat with a bounded maxTokens', async () => {
it('get <name>: summarizes via chat with a bounded maxTokens and replies with headered lines', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce('Alpha and beta happened.');
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\nPark: Riverfront expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toBe('Alpha and beta happened.');
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: Riverfront expansion approved.',
]);
const [messages, opts] = llm.chat.mock.calls[0];
// headroom for the server's reasoning tokens (they count against max_tokens) — see rss.service.ts
expect(opts).toEqual({ maxTokens: 2500 });
const prompt = messages[0].content;
expect(prompt).toContain('at most 3 short bullet lines');
expect(prompt).toContain('each under 45 characters');
expect(prompt).toContain('no intro, no other text');
expect(prompt).toContain('exactly 3-5 lines');
expect(prompt).toContain('<short header>: <one or two short sentences>');
expect(prompt).toContain('at most 20 characters');
expect(prompt).toContain('No intro, no outro, no numbering');
expect(prompt).toContain('Alpha headline');
expect(prompt).toContain('Beta headline');
});
it('get <name>: cuts an over-length LLM reply at bullet boundaries so it fits one packet', async () => {
it('get <name>: caps items at 5', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
// 5 bullets × 46 chars = 234 joined — over the 190-char one-packet cap
const tooLong = ['a', 'b', 'c', 'd', 'e']
.map((c) => `-${c}`.padEnd(46, c))
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item ${c}`)
.join('\n');
llm.chat.mockResolvedValueOnce(tooLong);
llm.chat.mockResolvedValueOnce(seven);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply.length).toBeLessThanOrEqual(190);
// the first 4 bullets fit (47*4-1=187), the 5th does not — and only complete lines survive
expect(reply.split('\n')).toHaveLength(4);
for (const line of reply.split('\n')) expect(line.length).toBe(46);
expect(reply).toEqual([
'a: item a',
'b: item b',
'c: item c',
'd: item d',
'e: item e',
]);
});
it('get <name>: drops empty and separator-less lines, sends what remains when fewer than 3 are valid', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
llm.chat.mockResolvedValueOnce(
'Chips: EU funds foundries.\n\nJust a sentence without a header\n \nPark: expansion approved.',
);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(reply).toEqual([
'Chips: EU funds foundries.',
'Park: expansion approved.',
]);
});
it('get <name>: hard-cuts an over-length line at a word boundary so each item fits one packet', async () => {
stored().add({ name: 'myfeed', url: 'https://a.example/rss', tags: [] });
fetchMock.mockResolvedValueOnce(feedResponse(FEED_A));
const longLine = `Chips: ${'word '.repeat(40)}`; // 207 chars — over the per-line cap
llm.chat.mockResolvedValueOnce(`${longLine}\nPark: short line.`);
const reply = await svc.handle(ctx('#rss get myfeed'));
expect(Array.isArray(reply)).toBe(true);
const [cut, ok] = reply as string[];
expect(cut.length).toBeLessThanOrEqual(180);
expect(longLine.startsWith(cut)).toBe(true); // prefix — cut at a word boundary, not mid-word
expect(cut.endsWith(' ')).toBe(false);
expect(ok).toBe('Park: short line.'); // other lines untouched
});
it('get #tag: collects items across all sources with the tag; one dead feed does not sink it', async () => {
@@ -193,11 +232,11 @@ describe('RssService.handle', () => {
.mockResolvedValueOnce(feedResponse(FEED_A)) // A answers (Alpha, Beta)
.mockRejectedValueOnce(new Error('down')); // B is dead
llm.chat.mockResolvedValueOnce('Only alpha survived.');
llm.chat.mockResolvedValueOnce('Chips: only alpha survived.');
const reply = await svc.handle(ctx('#rss get #tech'));
// B's fetch rejected, yet the summary still went out from A's titles alone
expect(reply).toBe('Only alpha survived.');
expect(reply).toEqual(['Chips: only alpha survived.']);
expect(llm.chat).toHaveBeenCalledTimes(1);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
@@ -210,9 +249,15 @@ describe('RssService.handle', () => {
.mockResolvedValueOnce(feedResponse(FEED_A)) // A → Alpha, Beta
.mockResolvedValueOnce(feedResponse(FEED_B)); // B → Gamma
llm.chat.mockResolvedValueOnce('all three.');
await svc.handle(ctx('#rss get #tech'));
llm.chat.mockResolvedValueOnce(
'Alpha: first headline.\nGamma: second headline.',
);
const reply = await svc.handle(ctx('#rss get #tech'));
expect(reply).toEqual([
'Alpha: first headline.',
'Gamma: second headline.',
]);
const content = llm.chat.mock.calls[0][0][0].content;
expect(content).toContain('Alpha headline');
expect(content).toContain('Gamma headline'); // proves B's feed was collected too
@@ -227,25 +272,46 @@ describe('RssService.handle', () => {
});
});
describe('fitOnePacket (one mesh packet ≤ 190 chars)', () => {
it('passes through replies that already fit, including exactly 190 chars', () => {
const short = '- a\n- b';
expect(fitOnePacket(short)).toBe(short);
const exact = 'x'.repeat(190);
expect(fitOnePacket(exact)).toBe(exact);
describe('parseSummaryItems (per-line items for multi-message summaries)', () => {
it('keeps lines with a header separator, drops empty and separator-less ones', () => {
expect(
parseSummaryItems(
'Chips: EU funds foundries.\n\nNo header here\n \nPark: expansion approved.',
),
).toEqual(['Chips: EU funds foundries.', 'Park: expansion approved.']);
});
it('keeps complete bullet lines that fit and drops the rest', () => {
// 46-char bullets: 4 fit (47*4-1=187), 5 do not (234)
const five = ['a', 'b', 'c', 'd', 'e']
.map((c) => `-${c}`.padEnd(46, c))
it('caps at 5 items', () => {
const seven = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
.map((c) => `${c}: item`)
.join('\n');
expect(fitOnePacket(five).split('\n')).toHaveLength(4);
expect(fitOnePacket(five).length).toBeLessThanOrEqual(190);
expect(parseSummaryItems(seven)).toHaveLength(5);
expect(parseSummaryItems(seven)[4]).toBe('e: item');
});
it('hard-cuts a non-bulleted over-length paragraph rather than replying empty', () => {
const para = 'w'.repeat(250);
expect(fitOnePacket(para)).toBe('w'.repeat(190));
it('returns [] when nothing is valid', () => {
expect(parseSummaryItems('no headers at all\n')).toEqual([]);
});
});
describe('fitOneLine (one mesh packet per line)', () => {
it('passes through lines that already fit, including exactly 185 chars', () => {
const short = 'Chips: EU funds foundries.';
expect(fitOneLine(short)).toBe(short);
const exact = 'x'.repeat(185);
expect(fitOneLine(exact)).toBe(exact);
});
it('cuts over-long lines at a word boundary near 180', () => {
const long = `Chips: ${'word '.repeat(40)}`; // 207 chars
const cut = fitOneLine(long);
expect(cut.length).toBeLessThanOrEqual(180);
expect(long.startsWith(cut)).toBe(true); // prefix — not mid-word
expect(cut.endsWith(' ')).toBe(false);
});
it('hard-cuts at 180 when there is no word boundary in reach', () => {
const long = 'w'.repeat(250);
expect(fitOneLine(long)).toBe('w'.repeat(180));
});
});