Skip to content

Commit 4c8b2c0

Browse files
peasclaude
andcommitted
Stricter story filter: real conversation only, weight by text length
- Stories need 2+ msgs with >200 chars OR 1 msg with >300 chars - Telegram editorial posts now qualify as stories (long single-author) - Weight includes text length: msgs + authors*3 + links*2 + chars/100 - 126→97 stories (29 link-only/short signals filtered out) - Top stories: 50 msgs/3191 chars down to 2 msgs/400 chars minimum Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cd32219 commit 4c8b2c0

3 files changed

Lines changed: 803 additions & 3440 deletions

File tree

scripts/build-signals.ts

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -107,47 +107,92 @@ function countMessages(body: string): number {
107107
}
108108

109109
/** Extract conversation messages from signal body.
110-
* Format: **[Author · HH:MM]** Message text...
110+
* Handles two formats:
111+
* 1. WhatsApp: **[Author · HH:MM]** Message text...
112+
* 2. Telegram: # Author — Group\n**DD/MM/YYYY, HH:MM**\n\nMessage text...
111113
* Returns only messages with actual text content (not just links). */
112114
function extractConversation(body: string): ConversationMsg[] {
113115
const msgs: ConversationMsg[] = [];
114-
// Split by message headers
115-
const parts = body.split(/\*\*\[/);
116-
for (const part of parts) {
117-
if (!part.trim()) continue;
118-
const headerEnd = part.indexOf("**");
119-
if (headerEnd < 0) continue;
120-
const header = part.slice(0, headerEnd);
121-
const text = part.slice(headerEnd + 2).trim();
122-
123-
// Parse author and time from "Author · HH:MM]"
124-
const match = header.match(/^(.+?)(?:\s*[·]\s*(\d{1,2}:\d{2}))?\]/);
125-
if (!match) continue;
126-
const author = match[1].trim();
127-
const time = match[2] || undefined;
128-
129-
// Skip messages that are ONLY a URL (no commentary)
130-
const stripped = text.replace(/https?:\/\/[^\s]+/g, "").trim();
131-
if (stripped.length < 10) continue;
132-
133-
// Clean up: remove markdown image refs, keep text
134-
const clean = text
135-
.replace(/!\[.*?\]\(.*?\)/g, "") // remove images
136-
.replace(/\n{3,}/g, "\n\n")
137-
.trim();
138-
139-
if (clean.length < 10) continue;
140-
141-
msgs.push({ author, text: clean, time });
116+
117+
// Try WhatsApp format first: **[Author · HH:MM]**
118+
const whatsappParts = body.split(/\*\*\[/);
119+
if (whatsappParts.length > 1) {
120+
for (const part of whatsappParts) {
121+
if (!part.trim()) continue;
122+
const headerEnd = part.indexOf("**");
123+
if (headerEnd < 0) continue;
124+
const header = part.slice(0, headerEnd);
125+
const text = part.slice(headerEnd + 2).trim();
126+
127+
const match = header.match(/^(.+?)(?:\s*[·]\s*(\d{1,2}:\d{2}))?\]/);
128+
if (!match) continue;
129+
const author = match[1].trim();
130+
const time = match[2] || undefined;
131+
132+
const clean = cleanMsgText(text);
133+
if (!clean) continue;
134+
135+
msgs.push({ author, text: clean, time });
136+
}
137+
}
138+
139+
// Try Telegram format: # Author — Group\n**date**\n\nBody
140+
if (msgs.length === 0) {
141+
const authorMatch = body.match(/^#\s+(.+?)\s*(?:||-)\s*.+$/m);
142+
const timeMatch = body.match(/\*\*(\d{2}\/\d{2}\/\d{4},?\s*\d{2}:\d{2})\*\*/);
143+
if (authorMatch) {
144+
// Everything after the date line is the message body
145+
const dateLineEnd = timeMatch ? body.indexOf(timeMatch[0]) + timeMatch[0].length : 0;
146+
const text = body.slice(dateLineEnd).trim();
147+
const clean = cleanMsgText(text);
148+
if (clean) {
149+
msgs.push({
150+
author: authorMatch[1].trim(),
151+
text: clean,
152+
time: timeMatch?.[1]?.split(",")[1]?.trim(),
153+
});
154+
}
155+
}
142156
}
157+
143158
return msgs;
144159
}
145160

146-
/** Check if a signal has enough real discussion to be a story.
147-
* Needs at least 2 messages with actual text (not just shared links). */
148-
function hasSubstantiveDiscussion(body: string): boolean {
149-
const msgs = extractConversation(body);
150-
return msgs.length >= 2;
161+
/** Clean a message text: remove link-only content, images, emoji-only lines */
162+
function cleanMsgText(text: string): string | null {
163+
// Strip markdown images
164+
let clean = text.replace(/!\[.*?\]\(.*?\)/g, "").trim();
165+
// Strip lines that are ONLY a URL
166+
clean = clean.split("\n").filter(line => {
167+
const stripped = line.replace(/https?:\/\/[^\s]+/g, "").replace(/🔗/g, "").trim();
168+
return stripped.length > 0;
169+
}).join("\n").trim();
170+
// Remove excessive newlines
171+
clean = clean.replace(/\n{3,}/g, "\n\n");
172+
// Skip if too short after cleanup
173+
if (clean.length < 15) return null;
174+
return clean;
175+
}
176+
177+
/** Calculate total substantive text in a conversation (chars of actual commentary). */
178+
function conversationTextLength(msgs: ConversationMsg[]): number {
179+
return msgs.reduce((sum, m) => sum + m.text.length, 0);
180+
}
181+
182+
/** Check if a signal qualifies as a story.
183+
* - Long single-author post (>300 chars) = story (Telegram editorial posts)
184+
* - Thread with 3+ substantive messages AND >200 chars total = story
185+
* - Otherwise just a signal */
186+
function qualifiesAsStory(msgs: ConversationMsg[]): boolean {
187+
if (msgs.length === 0) return false;
188+
const totalChars = conversationTextLength(msgs);
189+
// Single author with substantial text (editorial post)
190+
if (msgs.length === 1 && totalChars >= 300) return true;
191+
// Thread with real discussion
192+
if (msgs.length >= 3 && totalChars >= 200) return true;
193+
// 2 messages but with real substance
194+
if (msgs.length >= 2 && totalChars >= 400) return true;
195+
return false;
151196
}
152197

153198
function extractLinks(body: string, frontmatterLinks?: any[]): Array<{ url: string; title?: string; site?: string }> {
@@ -238,9 +283,7 @@ function buildStories(signals: RawSignal[]): Story[] {
238283
const sortedByDate = group.sort((a, b) => a.date.localeCompare(b.date));
239284
const combinedBody = sortedByDate.map(s => s.body).join("\n\n");
240285
const conversation = extractConversation(combinedBody);
241-
242-
// Story quality: need real discussion (2+ messages with text)
243-
if (conversation.length < 2) continue;
286+
if (!qualifiesAsStory(conversation)) continue;
244287

245288
const allAuthors = [...new Set(conversation.map(m => m.author))];
246289
const allTags = [...new Set(group.flatMap(s => s.tags))];
@@ -259,16 +302,14 @@ function buildStories(signals: RawSignal[]): Story[] {
259302
messageCount: conversation.length,
260303
authorCount: allAuthors.length,
261304
linkCount: allLinks.length,
262-
weight: conversation.length + allAuthors.length * 3 + allLinks.length * 2,
305+
weight: conversation.length + allAuthors.length * 3 + allLinks.length * 2 + Math.floor(conversationTextLength(conversation) / 100),
263306
});
264307
}
265308

266309
// WhatsApp signals → stories (already consolidated threads)
267310
for (const s of standaloneSignals) {
268311
const conversation = extractConversation(s.body);
269-
270-
// Story quality: need real discussion (2+ messages with text)
271-
if (conversation.length < 2) continue;
312+
if (!qualifiesAsStory(conversation)) continue;
272313

273314
const groupLabel = s.source === "whatsapp-clauders" ? "Clauders"
274315
: s.source === "whatsapp-sob-controle" ? "IA Sob Controle"
@@ -289,7 +330,7 @@ function buildStories(signals: RawSignal[]): Story[] {
289330
messageCount: conversation.length,
290331
authorCount: conversationAuthors.length,
291332
linkCount: s.links.length,
292-
weight: conversation.length + conversationAuthors.length * 3 + s.links.length * 2,
333+
weight: conversation.length + conversationAuthors.length * 3 + s.links.length * 2 + Math.floor(conversationTextLength(conversation) / 100),
293334
});
294335
}
295336

0 commit comments

Comments
 (0)