From 5990dfc207c28e881193e282ac911d65ea6ae4df Mon Sep 17 00:00:00 2001 From: dev-akshatshukla Date: Mon, 1 Jun 2026 17:00:58 +0530 Subject: [PATCH 1/2] Add news-articles category schema, examples, and tests --- .../news-articles/normalized-context.json | 109 +++++ examples/news-articles/raw-app-activity.json | 163 +++++++ examples/news-articles/wiki-entries.json | 163 +++++++ package.json | 1 + scripts/generate-examples.mjs | 28 ++ src/categories/news-articles.mjs | 411 ++++++++++++++++++ test/news-articles.test.mjs | 198 +++++++++ 7 files changed, 1073 insertions(+) create mode 100644 examples/news-articles/normalized-context.json create mode 100644 examples/news-articles/raw-app-activity.json create mode 100644 examples/news-articles/wiki-entries.json create mode 100644 scripts/generate-examples.mjs create mode 100644 src/categories/news-articles.mjs create mode 100644 test/news-articles.test.mjs diff --git a/examples/news-articles/normalized-context.json b/examples/news-articles/normalized-context.json new file mode 100644 index 0000000..1012f40 --- /dev/null +++ b/examples/news-articles/normalized-context.json @@ -0,0 +1,109 @@ +{ + "category": "news-articles", + "durable_interests": [ + { + "topic": "Technology", + "confidence": 1, + "article_count": 4, + "distinct_days": 3, + "average_scroll_depth_percent": 78, + "first_seen": "2026-05-10T08:30:00Z", + "last_seen": "2026-05-12T08:45:00Z" + }, + { + "topic": "Renewable Energy", + "confidence": 0.9, + "article_count": 4, + "distinct_days": 2, + "average_scroll_depth_percent": 83, + "first_seen": "2026-05-10T08:30:00Z", + "last_seen": "2026-05-15T15:10:00Z" + } + ], + "temporary_interests": [ + { + "topic": "Deep Space", + "confidence": 0.4, + "article_count": 1, + "distinct_days": 1, + "average_scroll_depth_percent": 70, + "first_seen": "2026-05-16T19:30:00Z", + "last_seen": "2026-05-16T19:30:00Z", + "reason": "curiosity_click" + }, + { + "topic": "Politics", + "confidence": 0.4, + "article_count": 2, + "distinct_days": 2, + "average_scroll_depth_percent": 88, + "first_seen": "2026-05-17T08:15:00Z", + "last_seen": "2026-05-18T09:40:00Z", + "reason": "sensitive_topic_protection", + "requires_user_confirmation": true + }, + { + "topic": "Election", + "confidence": 0.4, + "article_count": 1, + "distinct_days": 1, + "average_scroll_depth_percent": 90, + "first_seen": "2026-05-17T08:15:00Z", + "last_seen": "2026-05-17T08:15:00Z", + "reason": "curiosity_click", + "requires_user_confirmation": true + }, + { + "topic": "Finance", + "confidence": 0.4, + "article_count": 1, + "distinct_days": 1, + "average_scroll_depth_percent": 15, + "first_seen": "2026-05-18T11:20:00Z", + "last_seen": "2026-05-18T11:20:00Z", + "reason": "curiosity_click", + "requires_user_confirmation": true + }, + { + "topic": "Crypto-Speculation", + "confidence": 0.4, + "article_count": 1, + "distinct_days": 1, + "average_scroll_depth_percent": 15, + "first_seen": "2026-05-18T11:20:00Z", + "last_seen": "2026-05-18T11:20:00Z", + "reason": "curiosity_click", + "requires_user_confirmation": true + } + ], + "reading_preferences": { + "preferred_article_lengths": [ + "medium", + "long" + ], + "preferred_formats": [ + "explainers", + "deep_dives", + "summarys" + ], + "preferred_publishers": [ + "TechChronicle", + "EcoTimes", + "GlobalNews" + ], + "reading_cadence": "morning_reader" + }, + "skipped_topics": [ + "celebrity drama", + "finance", + "crypto-speculation" + ], + "saved_articles": [ + { + "title": "Why Solid-State Batteries Are the Future of EVs", + "url": "https://techchronicle.com/solid-state-batteries", + "topic": "Technology", + "saved_at": "2026-05-10T08:32:00Z" + } + ] +} \ No newline at end of file diff --git a/examples/news-articles/raw-app-activity.json b/examples/news-articles/raw-app-activity.json new file mode 100644 index 0000000..7f90958 --- /dev/null +++ b/examples/news-articles/raw-app-activity.json @@ -0,0 +1,163 @@ +[ + { + "id": "act_001", + "title": "Why Solid-State Batteries Are the Future of EVs", + "publisher": "TechChronicle", + "url": "https://techchronicle.com/solid-state-batteries", + "topics": ["Technology", "Renewable Energy"], + "length_minutes": 12, + "format": "deep_dive", + "scroll_depth_percent": 90, + "time_spent_seconds": 320, + "action": "read", + "occurred_at": "2026-05-10T08:30:00Z" + }, + { + "id": "act_002", + "title": "Why Solid-State Batteries Are the Future of EVs", + "publisher": "TechChronicle", + "url": "https://techchronicle.com/solid-state-batteries", + "topics": ["Technology"], + "action": "save", + "occurred_at": "2026-05-10T08:32:00Z" + }, + { + "id": "act_003", + "title": "A Beginner's Guide to TypeScript Decorators", + "publisher": "TechChronicle", + "url": "https://techchronicle.com/typescript-decorators", + "topics": ["Technology"], + "length_minutes": 5, + "format": "explainer", + "scroll_depth_percent": 85, + "time_spent_seconds": 180, + "action": "read", + "occurred_at": "2026-05-11T09:15:00Z" + }, + { + "id": "act_004", + "title": "Understanding the New JavaScript Temporal API", + "publisher": "TechChronicle", + "url": "https://techchronicle.com/js-temporal-api", + "topics": ["Technology"], + "length_minutes": 6, + "format": "explainer", + "scroll_depth_percent": 95, + "time_spent_seconds": 210, + "action": "read", + "occurred_at": "2026-05-12T08:00:00Z" + }, + { + "id": "act_005", + "title": "WebAssembly in 2026: State of the Ecosystem", + "publisher": "TechChronicle", + "url": "https://techchronicle.com/wasm-2026", + "topics": ["Technology"], + "length_minutes": 15, + "format": "deep_dive", + "scroll_depth_percent": 40, + "time_spent_seconds": 90, + "action": "read", + "occurred_at": "2026-05-12T08:45:00Z" + }, + { + "id": "act_006", + "title": "Wind Energy Advances in Northern Europe", + "publisher": "EcoTimes", + "url": "https://ecotimes.com/wind-energy-advances", + "topics": ["Renewable Energy"], + "length_minutes": 7, + "format": "deep_dive", + "scroll_depth_percent": 80, + "time_spent_seconds": 240, + "action": "read", + "occurred_at": "2026-05-15T14:20:00Z" + }, + { + "id": "act_007", + "title": "Solar Grid Integration Challenges", + "publisher": "EcoTimes", + "url": "https://ecotimes.com/solar-grid-challenges", + "topics": ["Renewable Energy"], + "length_minutes": 8, + "format": "deep_dive", + "scroll_depth_percent": 75, + "time_spent_seconds": 230, + "action": "read", + "occurred_at": "2026-05-15T14:45:00Z" + }, + { + "id": "act_008", + "title": "Geothermal Power: The Hidden Energy Giant", + "publisher": "EcoTimes", + "url": "https://ecotimes.com/geothermal-giant", + "topics": ["Renewable Energy"], + "length_minutes": 6, + "format": "explainer", + "scroll_depth_percent": 88, + "time_spent_seconds": 190, + "action": "read", + "occurred_at": "2026-05-15T15:10:00Z" + }, + { + "id": "act_009", + "title": "James Webb Telescope Discovers Ancient Deep Space Galaxy", + "publisher": "SpaceJournal", + "url": "https://spacejournal.com/ancient-galaxy", + "topics": ["Deep Space"], + "length_minutes": 4, + "format": "explainer", + "scroll_depth_percent": 70, + "time_spent_seconds": 120, + "action": "read", + "occurred_at": "2026-05-16T19:30:00Z" + }, + { + "id": "act_010", + "title": "Latest Polls: Prime Minister Election Developments", + "publisher": "GlobalNews", + "url": "https://globalnews.com/pm-election-polls", + "topics": ["Politics", "Election"], + "length_minutes": 8, + "format": "summary", + "scroll_depth_percent": 90, + "time_spent_seconds": 250, + "action": "read", + "occurred_at": "2026-05-17T08:15:00Z" + }, + { + "id": "act_011", + "title": "Cabinet Member Resigns Amid Policy Disagreements", + "publisher": "GlobalNews", + "url": "https://globalnews.com/cabinet-resignation", + "topics": ["Politics"], + "length_minutes": 5, + "format": "summary", + "scroll_depth_percent": 85, + "time_spent_seconds": 170, + "action": "read", + "occurred_at": "2026-05-18T09:40:00Z" + }, + { + "id": "act_012", + "title": "Shocking Celebrity Relationship Drama Escalates", + "publisher": "GossipFeed", + "url": "https://gossipfeed.com/celebrity-drama", + "topics": ["Celebrity Drama"], + "action": "skip", + "occurred_at": "2026-05-18T10:00:00Z" + }, + { + "id": "act_013", + "title": "Secret Penny Stock That Will Gain 1000x This Month", + "publisher": "SpeculatorHub", + "url": "https://speculatorhub.com/penny-stock-tip", + "topics": ["Finance", "Crypto-Speculation"], + "length_minutes": 3, + "format": "summary", + "scroll_depth_percent": 15, + "time_spent_seconds": 15, + "action": "read", + "occurred_at": "2026-05-18T11:20:00Z" + } +] diff --git a/examples/news-articles/wiki-entries.json b/examples/news-articles/wiki-entries.json new file mode 100644 index 0000000..de65f46 --- /dev/null +++ b/examples/news-articles/wiki-entries.json @@ -0,0 +1,163 @@ +[ + { + "id": "wiki_durable_technology", + "type": "interest", + "sub_type": "durable", + "proposed_text": "Frequently reads articles about Technology.", + "raw_source_summary": "Detected across 4 articles over 3 different days.", + "confidence": 1, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_durable_renewable_energy", + "type": "interest", + "sub_type": "durable", + "proposed_text": "Frequently reads articles about Renewable Energy.", + "raw_source_summary": "Detected across 4 articles over 2 different days.", + "confidence": 0.9, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_temporary_deep_space", + "type": "interest", + "sub_type": "temporary", + "proposed_text": "Briefly looked into Deep Space recently.", + "raw_source_summary": "Detected 1 read(s) under temporary curiosity rules.", + "confidence": 0.4, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_temporary_politics", + "type": "interest", + "sub_type": "temporary", + "proposed_text": "Has occasionally looked up Politics topics (temporary research or curiosity).", + "raw_source_summary": "Protected topic read detected. Treated as temporary research click to avoid permanent identity claim.", + "confidence": 0.4, + "requires_user_confirmation": true, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_temporary_election", + "type": "interest", + "sub_type": "temporary", + "proposed_text": "Has occasionally looked up Election topics (temporary research or curiosity).", + "raw_source_summary": "Protected topic read detected. Treated as temporary research click to avoid permanent identity claim.", + "confidence": 0.4, + "requires_user_confirmation": true, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_temporary_finance", + "type": "interest", + "sub_type": "temporary", + "proposed_text": "Has occasionally looked up Finance topics (temporary research or curiosity).", + "raw_source_summary": "Protected topic read detected. Treated as temporary research click to avoid permanent identity claim.", + "confidence": 0.4, + "requires_user_confirmation": true, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_temporary_crypto_speculation", + "type": "interest", + "sub_type": "temporary", + "proposed_text": "Has occasionally looked up Crypto-Speculation topics (temporary research or curiosity).", + "raw_source_summary": "Protected topic read detected. Treated as temporary research click to avoid permanent identity claim.", + "confidence": 0.4, + "requires_user_confirmation": true, + "actions": [ + "approve", + "edit", + "reject", + "delete" + ] + }, + { + "id": "wiki_pref_length", + "type": "preference", + "sub_type": "article_length", + "proposed_text": "Prefers medium-length articles.", + "raw_source_summary": "Calculated based on article reading duration metadata.", + "confidence": 0.8, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject" + ] + }, + { + "id": "wiki_pref_format", + "type": "preference", + "sub_type": "article_format", + "proposed_text": "Often reads long-form explainers.", + "raw_source_summary": "Derived from content format patterns.", + "confidence": 0.75, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject" + ] + }, + { + "id": "wiki_pref_publisher", + "type": "preference", + "sub_type": "publisher", + "proposed_text": "Regularly reads articles from TechChronicle and EcoTimes and GlobalNews.", + "raw_source_summary": "Frequent publisher interaction detected in history.", + "confidence": 0.85, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject" + ] + }, + { + "id": "wiki_pref_cadence", + "type": "preference", + "sub_type": "reading_cadence", + "proposed_text": "Maintains a morning reader reading routine.", + "raw_source_summary": "Generated from peak daily reading hour activity.", + "confidence": 0.7, + "requires_user_confirmation": false, + "actions": [ + "approve", + "edit", + "reject" + ] + } +] \ No newline at end of file diff --git a/package.json b/package.json index 0028ba1..3af4bc1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "scripts": { "check": "node ./scripts/check.mjs", + "test": "node test/schema-packets.test.mjs && node test/news-articles.test.mjs", "schema": "node ./src/cli.mjs", "sample": "node ./src/cli.mjs --input examples/sample-inference-output.json --format report", "sample:json": "node ./src/cli.mjs --input examples/sample-inference-output.json --format json" diff --git a/scripts/generate-examples.mjs b/scripts/generate-examples.mjs new file mode 100644 index 0000000..e7ba411 --- /dev/null +++ b/scripts/generate-examples.mjs @@ -0,0 +1,28 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeReadingActivity, generateWikiEntries } from "../src/categories/news-articles.mjs"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const rawPath = join(__dirname, "../examples/news-articles/raw-app-activity.json"); +const normPath = join(__dirname, "../examples/news-articles/normalized-context.json"); +const wikiPath = join(__dirname, "../examples/news-articles/wiki-entries.json"); + +async function main() { + const rawData = JSON.parse(await readFile(rawPath, "utf8")); + + // Normalize + const normalized = normalizeReadingActivity(rawData); + await writeFile(normPath, JSON.stringify(normalized, null, 2), "utf8"); + console.log(`Generated: ${normPath}`); + + // Wiki entries + const wikiEntries = generateWikiEntries(normalized); + await writeFile(wikiPath, JSON.stringify(wikiEntries, null, 2), "utf8"); + console.log(`Generated: ${wikiPath}`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/categories/news-articles.mjs b/src/categories/news-articles.mjs new file mode 100644 index 0000000..40752c5 --- /dev/null +++ b/src/categories/news-articles.mjs @@ -0,0 +1,411 @@ +/** + * News & Articles Category Schema and Context Shaping Module + * Defines the schema structure, permissions, sensitivity rules, + * normalization logic, and Wiki templates for news/article apps. + */ + +// 1. Schema Field Definitions +export const NEWS_ARTICLES_SCHEMA = { + category: "news-articles", + description: "User context shaped from news, articles, and reading activity.", + fields: { + durable_interests: { + type: "Array", + description: "Topics demonstrating repeated reading interest over multiple days.", + item_properties: { + topic: { type: "String", description: "The categorized topic name." }, + confidence: { type: "Number", description: "Confidence score between 0.0 and 1.0." }, + article_count: { type: "Number", description: "Total articles read on this topic." }, + distinct_days: { type: "Number", description: "Number of unique calendar days of activity." }, + first_seen: { type: "String", description: "ISO timestamp of first read." }, + last_seen: { type: "String", description: "ISO timestamp of most recent read." } + } + }, + temporary_interests: { + type: "Array", + description: "One-off curiosity clicks, research tasks, or trending news interactions.", + item_properties: { + topic: { type: "String", description: "The categorized topic name." }, + confidence: { type: "Number", description: "Confidence score between 0.0 and 1.0." }, + article_count: { type: "Number", description: "Total articles read on this topic." }, + first_seen: { type: "String", description: "ISO timestamp of first read." }, + last_seen: { type: "String", description: "ISO timestamp of most recent read." }, + reason: { type: "String", description: "Reason for temporary classification (e.g. single_day_burst, curiosity_click)." } + } + }, + reading_preferences: { + type: "Object", + description: "Preferred content structures, publishers, and habits.", + properties: { + preferred_article_lengths: { + type: "Array", + description: "Preferred article lengths (short < 3m, medium 3-8m, long > 8m)." + }, + preferred_formats: { + type: "Array", + description: "Preferred article formats (explainers, summaries, deep_dives)." + }, + preferred_publishers: { + type: "Array", + description: "Publishers read frequently." + }, + reading_cadence: { + type: "String", + description: "Identified reading routine/habit pattern." + } + } + }, + skipped_topics: { + type: "Array", + description: "Topics the user deliberately skips, hides, or shows extremely low scroll depth on." + }, + saved_articles: { + type: "Array", + description: "Articles explicitly bookmarked or saved for later.", + item_properties: { + title: { type: "String", description: "Article title." }, + url: { type: "String", description: "Article link." }, + topic: { type: "String", description: "Primary topic associated with the article." }, + saved_at: { type: "String", description: "ISO timestamp of when the article was saved." } + } + } + } +}; + +// 2. Permission Suggestions (Least-Privilege Defaults) +export const NEWS_ARTICLES_PERMISSIONS = [ + { + scope: "news_articles.preferences.read", + description: "Read reading format, length, and publisher preferences.", + sensitive: false, + default_granted: true + }, + { + scope: "news_articles.interests.read", + description: "Read established durable reading interests. Excludes temporary curiosity or sensitive topics.", + sensitive: false, + default_granted: true + }, + { + scope: "news_articles.saved.read", + description: "Read titles and metadata of articles bookmarked by the user.", + sensitive: false, + default_granted: false + }, + { + scope: "news_articles.activity.read", + description: "Read complete detailed engagement stats, scroll depths, and skipped topics.", + sensitive: true, + default_granted: false + } +]; + +// 3. Sensitive Topics Definition +export const SENSITIVE_TOPIC_RULES = { + sensitive_keywords: [ + "politics", "election", "democrat", "republican", "government", "policy", + "religion", "faith", "theology", "prayer", "church", "mosque", "temple", "bible", "quran", + "health", "medical", "disease", "symptom", "therapy", "clinical", "mental-health", "depression", + "finance", "stock-tip", "investment-advice", "crypto-speculation", "bankruptcy", + "sexuality", "ideology" + ], + isSensitive: (topicName) => { + if (!topicName) return false; + const lower = topicName.toLowerCase(); + return SENSITIVE_TOPIC_RULES.sensitive_keywords.some(keyword => lower.includes(keyword)); + } +}; + +// 4. Normalization and Shaping Logic +export function normalizeReadingActivity(rawActivity = [], options = {}) { + const records = Array.isArray(rawActivity) ? rawActivity : []; + + // Group activities by topic + const topicStats = {}; + const savedArticles = []; + const skippedTopics = new Set(); + const publisherCounts = {}; + const lengthCounts = { short: 0, medium: 0, long: 0 }; + const formatCounts = { explainer: 0, summary: 0, deep_dive: 0 }; + const hourCounts = {}; + + records.forEach(record => { + // 1. Saved Articles + if (record.action === "save" || record.action === "bookmark") { + savedArticles.push({ + title: record.title || "Untitled Article", + url: record.url || "", + topic: Array.isArray(record.topics) && record.topics[0] ? record.topics[0] : "general", + saved_at: record.occurred_at || new Date().toISOString() + }); + } + + // 2. Track Skip Signals + if (record.action === "skip" || record.action === "hide" || (record.scroll_depth_percent !== undefined && record.scroll_depth_percent < 20)) { + if (Array.isArray(record.topics)) { + record.topics.forEach(topic => skippedTopics.add(topic.toLowerCase())); + } + } + + // 3. Gather Topic Metrics + if (record.action === "read" || !record.action) { + const topics = Array.isArray(record.topics) ? record.topics : ["general"]; + const time = record.occurred_at ? new Date(record.occurred_at) : new Date(); + const dateStr = time.toISOString().slice(0, 10); + const hour = time.getUTCHours(); + + // Reading hour cadence + hourCounts[hour] = (hourCounts[hour] || 0) + 1; + + // Publisher preferences + if (record.publisher) { + publisherCounts[record.publisher] = (publisherCounts[record.publisher] || 0) + 1; + } + + // Format counts + if (record.format && formatCounts[record.format] !== undefined) { + formatCounts[record.format]++; + } + + // Length counts + if (record.length_minutes !== undefined) { + if (record.length_minutes < 3) lengthCounts.short++; + else if (record.length_minutes <= 8) lengthCounts.medium++; + else lengthCounts.long++; + } + + topics.forEach(topic => { + const topicLower = topic.toLowerCase(); + if (!topicStats[topicLower]) { + topicStats[topicLower] = { + topic: topic, + article_count: 0, + dates: new Set(), + first_seen: record.occurred_at || new Date().toISOString(), + last_seen: record.occurred_at || new Date().toISOString(), + total_scroll: 0, + scroll_count: 0 + }; + } + + const stat = topicStats[topicLower]; + stat.article_count++; + stat.dates.add(dateStr); + + if (record.occurred_at) { + if (new Date(record.occurred_at) < new Date(stat.first_seen)) stat.first_seen = record.occurred_at; + if (new Date(record.occurred_at) > new Date(stat.last_seen)) stat.last_seen = record.occurred_at; + } + + if (record.scroll_depth_percent !== undefined) { + stat.total_scroll += record.scroll_depth_percent; + stat.scroll_count++; + } + }); + } + }); + + // Separate into durable and temporary interests + const durable_interests = []; + const temporary_interests = []; + + Object.values(topicStats).forEach(stat => { + const isSensitive = SENSITIVE_TOPIC_RULES.isSensitive(stat.topic); + const distinctDays = stat.dates.size; + const isDurable = stat.article_count >= 3 && distinctDays >= 2; + + // Calculate confidence: base score derived from distinct reading days and frequency + let confidence = Math.min(1.0, (distinctDays * 0.35) + (stat.article_count * 0.05)); + + // Sensitivity rules: Cap confidence and require confirmation to avoid identity claim + if (isSensitive) { + confidence = Math.min(0.4, confidence); + } + + const avgScroll = stat.scroll_count > 0 ? Math.round(stat.total_scroll / stat.scroll_count) : null; + + if (isDurable && !isSensitive) { + durable_interests.push({ + topic: stat.topic, + confidence: parseFloat(confidence.toFixed(2)), + article_count: stat.article_count, + distinct_days: distinctDays, + average_scroll_depth_percent: avgScroll, + first_seen: stat.first_seen, + last_seen: stat.last_seen + }); + } else { + // Determine temporary reason + let reason = "single_day_burst"; + if (stat.article_count === 1) { + reason = "curiosity_click"; + } else if (distinctDays === 1) { + reason = "single_day_research_binge"; + } else if (isSensitive) { + reason = "sensitive_topic_protection"; + } + + temporary_interests.push({ + topic: stat.topic, + confidence: parseFloat(confidence.toFixed(2)), + article_count: stat.article_count, + distinct_days: distinctDays, + average_scroll_depth_percent: avgScroll, + first_seen: stat.first_seen, + last_seen: stat.last_seen, + reason, + requires_user_confirmation: isSensitive ? true : undefined + }); + } + }); + + // Sort interests by confidence desc + durable_interests.sort((a, b) => b.confidence - a.confidence); + temporary_interests.sort((a, b) => b.confidence - a.confidence); + + // Preferred publishers (read at least twice) + const preferred_publishers = Object.entries(publisherCounts) + .filter(([, count]) => count >= 2) + .map(([pub]) => pub); + + // Preferred formats + const preferred_formats = Object.entries(formatCounts) + .filter(([, count]) => count > 0) + .sort((a, b) => b[1] - a[1]) + .map(([fmt]) => fmt + "s"); // e.g., explainers, summaries + + // Preferred article lengths + const preferred_article_lengths = Object.entries(lengthCounts) + .filter(([, count]) => count > 0) + .sort((a, b) => b[1] - a[1]) + .map(([len]) => len); + + // Reading cadence + let reading_cadence = "intermittent"; + const hours = Object.entries(hourCounts).map(([h, count]) => ({ hour: parseInt(h), count })); + if (hours.length > 0) { + hours.sort((a, b) => b.count - a.count); + const peakHour = hours[0].hour; + if (peakHour >= 5 && peakHour <= 10) reading_cadence = "morning_reader"; + else if (peakHour >= 11 && peakHour <= 16) reading_cadence = "midday_reader"; + else if (peakHour >= 17 && peakHour <= 22) reading_cadence = "evening_reader"; + else reading_cadence = "night_reader"; + } + + return { + category: "news-articles", + durable_interests, + temporary_interests, + reading_preferences: { + preferred_article_lengths, + preferred_formats, + preferred_publishers, + reading_cadence + }, + skipped_topics: Array.from(skippedTopics), + saved_articles: savedArticles + }; +} + +// 5. Wiki Entry Proposals Generator +export function generateWikiEntries(normalizedContext) { + const proposals = []; + const { durable_interests, temporary_interests, reading_preferences } = normalizedContext; + + // Generate durable interest Wiki entries + durable_interests.forEach(interest => { + proposals.push({ + id: `wiki_durable_${interest.topic.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, + type: "interest", + sub_type: "durable", + proposed_text: `Frequently reads articles about ${interest.topic}.`, + raw_source_summary: `Detected across ${interest.article_count} articles over ${interest.distinct_days} different days.`, + confidence: interest.confidence, + requires_user_confirmation: false, + actions: ["approve", "edit", "reject", "delete"] + }); + }); + + // Generate temporary interest Wiki entries (avoiding identity claims) + temporary_interests.forEach(interest => { + const isSensitive = interest.requires_user_confirmation === true; + let proposed_text = `Has recently explored articles about ${interest.topic}.`; + + if (isSensitive) { + proposed_text = `Has occasionally looked up ${interest.topic} topics (temporary research or curiosity).`; + } else if (interest.reason === "curiosity_click") { + proposed_text = `Briefly looked into ${interest.topic} recently.`; + } + + proposals.push({ + id: `wiki_temporary_${interest.topic.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, + type: "interest", + sub_type: "temporary", + proposed_text, + raw_source_summary: isSensitive + ? "Protected topic read detected. Treated as temporary research click to avoid permanent identity claim." + : `Detected ${interest.article_count} read(s) under temporary curiosity rules.`, + confidence: interest.confidence, + requires_user_confirmation: isSensitive, + actions: ["approve", "edit", "reject", "delete"] + }); + }); + + // Generate preference Wiki entries + const { preferred_article_lengths, preferred_formats, preferred_publishers, reading_cadence } = reading_preferences; + + if (preferred_article_lengths.length > 0) { + proposals.push({ + id: "wiki_pref_length", + type: "preference", + sub_type: "article_length", + proposed_text: `Prefers ${preferred_article_lengths[0]}-length articles.`, + raw_source_summary: "Calculated based on article reading duration metadata.", + confidence: 0.8, + requires_user_confirmation: false, + actions: ["approve", "edit", "reject"] + }); + } + + if (preferred_formats.length > 0) { + proposals.push({ + id: "wiki_pref_format", + type: "preference", + sub_type: "article_format", + proposed_text: `Often reads long-form ${preferred_formats[0]}.`, + raw_source_summary: "Derived from content format patterns.", + confidence: 0.75, + requires_user_confirmation: false, + actions: ["approve", "edit", "reject"] + }); + } + + if (preferred_publishers.length > 0) { + proposals.push({ + id: "wiki_pref_publisher", + type: "preference", + sub_type: "publisher", + proposed_text: `Regularly reads articles from ${preferred_publishers.join(" and ")}.`, + raw_source_summary: "Frequent publisher interaction detected in history.", + confidence: 0.85, + requires_user_confirmation: false, + actions: ["approve", "edit", "reject"] + }); + } + + if (reading_cadence && reading_cadence !== "intermittent") { + const routineName = reading_cadence.replace("_", " "); + proposals.push({ + id: "wiki_pref_cadence", + type: "preference", + sub_type: "reading_cadence", + proposed_text: `Maintains a ${routineName} reading routine.`, + raw_source_summary: "Generated from peak daily reading hour activity.", + confidence: 0.7, + requires_user_confirmation: false, + actions: ["approve", "edit", "reject"] + }); + } + + return proposals; +} diff --git a/test/news-articles.test.mjs b/test/news-articles.test.mjs new file mode 100644 index 0000000..fcd3375 --- /dev/null +++ b/test/news-articles.test.mjs @@ -0,0 +1,198 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeReadingActivity } from "../src/categories/news-articles.mjs"; + +test("Durable Interest Graduation: topic with >= 3 reads across >= 2 days graduates", () => { + const rawEvents = [ + { + id: "1", + topics: ["Gaming"], + action: "read", + occurred_at: "2026-05-01T10:00:00Z" + }, + { + id: "2", + topics: ["Gaming"], + action: "read", + occurred_at: "2026-05-01T15:00:00Z" + }, + { + id: "3", + topics: ["Gaming"], + action: "read", + occurred_at: "2026-05-02T10:00:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + // Verify Gaming has graduated to durable interests + const gamingDurable = result.durable_interests.find(i => i.topic === "Gaming"); + assert.ok(gamingDurable, "Gaming topic should have graduated to durable interests"); + assert.equal(gamingDurable.article_count, 3); + assert.equal(gamingDurable.distinct_days, 2); + assert.ok(gamingDurable.confidence > 0.7, "Gaming durable interest should have high confidence"); + + // Verify Gaming is not in temporary interests + const gamingTemp = result.temporary_interests.find(i => i.topic === "Gaming"); + assert.ok(!gamingTemp, "Gaming should not be in temporary interests"); +}); + +test("Temporary Curiosity Containment: single click remains low confidence temporary interest", () => { + const rawEvents = [ + { + id: "1", + topics: ["Space Exploration"], + action: "read", + occurred_at: "2026-05-01T10:00:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + // Verify Space Exploration is temporary, not durable + const spaceDurable = result.durable_interests.find(i => i.topic === "Space Exploration"); + assert.ok(!spaceDurable, "One-off read should not graduate to durable interest"); + + const spaceTemp = result.temporary_interests.find(i => i.topic === "Space Exploration"); + assert.ok(spaceTemp, "One-off read should remain in temporary interests"); + assert.equal(spaceTemp.article_count, 1); + assert.equal(spaceTemp.distinct_days, 1); + assert.equal(spaceTemp.reason, "curiosity_click"); + assert.equal(spaceTemp.confidence, 0.4); +}); + +test("Temporary Curiosity Containment: single-day research binge remains temporary", () => { + const rawEvents = [ + { + id: "1", + topics: ["React Native"], + action: "read", + occurred_at: "2026-05-01T10:00:00Z" + }, + { + id: "2", + topics: ["React Native"], + action: "read", + occurred_at: "2026-05-01T11:00:00Z" + }, + { + id: "3", + topics: ["React Native"], + action: "read", + occurred_at: "2026-05-01T12:00:00Z" + }, + { + id: "4", + topics: ["React Native"], + action: "read", + occurred_at: "2026-05-01T13:00:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + // Verify React Native is temporary despite 4 reads, because they occurred on the same day + const rnDurable = result.durable_interests.find(i => i.topic === "React Native"); + assert.ok(!rnDurable, "Same-day research binge should not graduate to durable interest"); + + const rnTemp = result.temporary_interests.find(i => i.topic === "React Native"); + assert.ok(rnTemp, "Same-day research binge should remain in temporary interests"); + assert.equal(rnTemp.article_count, 4); + assert.equal(rnTemp.distinct_days, 1); + assert.equal(rnTemp.reason, "single_day_research_binge"); + assert.ok(rnTemp.confidence < 0.6, "Research binge should have limited confidence"); +}); + +test("Sensitive Topic Guardrails: politics and health reads do not become durable interests, cap confidence, flag user confirmation", () => { + const rawEvents = [ + { + id: "1", + topics: ["Global Politics"], + action: "read", + occurred_at: "2026-05-01T10:00:00Z" + }, + { + id: "2", + topics: ["Global Politics"], + action: "read", + occurred_at: "2026-05-02T10:00:00Z" + }, + { + id: "3", + topics: ["Global Politics"], + action: "read", + occurred_at: "2026-05-03T10:00:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + // Verify Politics topic is treated with extra care + const polDurable = result.durable_interests.find(i => i.topic === "Global Politics"); + assert.ok(!polDurable, "Sensitive topic should never automatically graduate to durable interest"); + + const polTemp = result.temporary_interests.find(i => i.topic === "Global Politics"); + assert.ok(polTemp, "Sensitive topic should remain in temporary interests for protection"); + assert.equal(polTemp.requires_user_confirmation, true, "Sensitive topic must flag requires_user_confirmation"); + assert.equal(polTemp.reason, "sensitive_topic_protection"); + assert.ok(polTemp.confidence <= 0.4, "Sensitive topic confidence must be capped at 0.4"); +}); + +test("Preference Mapping: correct extraction of publisher, length, format, and cadence preferences", () => { + const rawEvents = [ + { + id: "1", + topics: ["Cooking"], + publisher: "FoodNetwork", + length_minutes: 2, + format: "summary", + action: "read", + occurred_at: "2026-05-01T08:00:00Z" + }, + { + id: "2", + topics: ["Cooking"], + publisher: "FoodNetwork", + length_minutes: 1, + format: "summary", + action: "read", + occurred_at: "2026-05-02T08:30:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + assert.deepEqual(result.reading_preferences.preferred_publishers, ["FoodNetwork"]); + assert.deepEqual(result.reading_preferences.preferred_article_lengths, ["short"]); + assert.deepEqual(result.reading_preferences.preferred_formats, ["summarys"]); + assert.equal(result.reading_preferences.reading_cadence, "morning_reader"); +}); + +test("Skipped and Saved Articles: correctly traces skip and save actions", () => { + const rawEvents = [ + { + id: "1", + title: "How to Cook Pasta", + topics: ["Cooking"], + action: "save", + url: "https://example.com/pasta", + occurred_at: "2026-05-01T12:00:00Z" + }, + { + id: "2", + title: "Boring Politics News", + topics: ["Boring Politics"], + action: "skip", + occurred_at: "2026-05-01T12:05:00Z" + } + ]; + + const result = normalizeReadingActivity(rawEvents); + + assert.equal(result.saved_articles.length, 1); + assert.equal(result.saved_articles[0].title, "How to Cook Pasta"); + assert.equal(result.saved_articles[0].url, "https://example.com/pasta"); + + assert.ok(result.skipped_topics.includes("boring politics")); +}); From b0c5e688856215d06b4c2c9c802a506a62b4f112 Mon Sep 17 00:00:00 2001 From: dev-akshatshukla Date: Mon, 1 Jun 2026 21:59:55 +0530 Subject: [PATCH 2/2] Fix format pluralization, soften Wiki proposal texts, and document sensitive topics as draft heuristics --- .../news-articles/normalized-context.json | 2 +- examples/news-articles/wiki-entries.json | 6 +-- src/categories/news-articles.mjs | 16 ++++++-- test/news-articles.test.mjs | 41 ++++++++++++++++++- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/examples/news-articles/normalized-context.json b/examples/news-articles/normalized-context.json index 1012f40..1d09107 100644 --- a/examples/news-articles/normalized-context.json +++ b/examples/news-articles/normalized-context.json @@ -84,7 +84,7 @@ "preferred_formats": [ "explainers", "deep_dives", - "summarys" + "summaries" ], "preferred_publishers": [ "TechChronicle", diff --git a/examples/news-articles/wiki-entries.json b/examples/news-articles/wiki-entries.json index de65f46..9f22ef8 100644 --- a/examples/news-articles/wiki-entries.json +++ b/examples/news-articles/wiki-entries.json @@ -3,7 +3,7 @@ "id": "wiki_durable_technology", "type": "interest", "sub_type": "durable", - "proposed_text": "Frequently reads articles about Technology.", + "proposed_text": "Recently reads several articles about Technology.", "raw_source_summary": "Detected across 4 articles over 3 different days.", "confidence": 1, "requires_user_confirmation": false, @@ -18,7 +18,7 @@ "id": "wiki_durable_renewable_energy", "type": "interest", "sub_type": "durable", - "proposed_text": "Frequently reads articles about Renewable Energy.", + "proposed_text": "Recently reads several articles about Renewable Energy.", "raw_source_summary": "Detected across 4 articles over 2 different days.", "confidence": 0.9, "requires_user_confirmation": false, @@ -150,7 +150,7 @@ "id": "wiki_pref_cadence", "type": "preference", "sub_type": "reading_cadence", - "proposed_text": "Maintains a morning reader reading routine.", + "proposed_text": "Often reads articles in the morning.", "raw_source_summary": "Generated from peak daily reading hour activity.", "confidence": 0.7, "requires_user_confirmation": false, diff --git a/src/categories/news-articles.mjs b/src/categories/news-articles.mjs index 40752c5..61f1e15 100644 --- a/src/categories/news-articles.mjs +++ b/src/categories/news-articles.mjs @@ -101,6 +101,8 @@ export const NEWS_ARTICLES_PERMISSIONS = [ ]; // 3. Sensitive Topics Definition +// NOTE: This sensitive keyword list is a draft/heuristic protection rule, not a final sensitivity system. +// Some keywords like "policy" and "government" may over-trigger and are included here as first-pass heuristics. export const SENSITIVE_TOPIC_RULES = { sensitive_keywords: [ "politics", "election", "democrat", "republican", "government", "policy", @@ -272,7 +274,10 @@ export function normalizeReadingActivity(rawActivity = [], options = {}) { const preferred_formats = Object.entries(formatCounts) .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]) - .map(([fmt]) => fmt + "s"); // e.g., explainers, summaries + .map(([fmt]) => { + if (fmt === "summary") return "summaries"; + return fmt + "s"; + }); // Preferred article lengths const preferred_article_lengths = Object.entries(lengthCounts) @@ -318,7 +323,7 @@ export function generateWikiEntries(normalizedContext) { id: `wiki_durable_${interest.topic.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, type: "interest", sub_type: "durable", - proposed_text: `Frequently reads articles about ${interest.topic}.`, + proposed_text: `Recently reads several articles about ${interest.topic}.`, raw_source_summary: `Detected across ${interest.article_count} articles over ${interest.distinct_days} different days.`, confidence: interest.confidence, requires_user_confirmation: false, @@ -394,12 +399,15 @@ export function generateWikiEntries(normalizedContext) { } if (reading_cadence && reading_cadence !== "intermittent") { - const routineName = reading_cadence.replace("_", " "); + let cadenceText = `Often reads articles in the ${reading_cadence.replace("_reader", "")}.`; + if (reading_cadence === "night_reader") { + cadenceText = "Often reads articles at night."; + } proposals.push({ id: "wiki_pref_cadence", type: "preference", sub_type: "reading_cadence", - proposed_text: `Maintains a ${routineName} reading routine.`, + proposed_text: cadenceText, raw_source_summary: "Generated from peak daily reading hour activity.", confidence: 0.7, requires_user_confirmation: false, diff --git a/test/news-articles.test.mjs b/test/news-articles.test.mjs index fcd3375..9682ca7 100644 --- a/test/news-articles.test.mjs +++ b/test/news-articles.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { normalizeReadingActivity } from "../src/categories/news-articles.mjs"; +import { normalizeReadingActivity, generateWikiEntries } from "../src/categories/news-articles.mjs"; test("Durable Interest Graduation: topic with >= 3 reads across >= 2 days graduates", () => { const rawEvents = [ @@ -165,7 +165,7 @@ test("Preference Mapping: correct extraction of publisher, length, format, and c assert.deepEqual(result.reading_preferences.preferred_publishers, ["FoodNetwork"]); assert.deepEqual(result.reading_preferences.preferred_article_lengths, ["short"]); - assert.deepEqual(result.reading_preferences.preferred_formats, ["summarys"]); + assert.deepEqual(result.reading_preferences.preferred_formats, ["summaries"]); assert.equal(result.reading_preferences.reading_cadence, "morning_reader"); }); @@ -196,3 +196,40 @@ test("Skipped and Saved Articles: correctly traces skip and save actions", () => assert.ok(result.skipped_topics.includes("boring politics")); }); + +test("Wiki Wording Softening: generates non-identity, softened Wiki texts for durable and cadence preferences", () => { + const rawEvents = [ + { + id: "1", + topics: ["Gaming"], + action: "read", + publisher: "GameSpot", + occurred_at: "2026-05-01T08:00:00Z" + }, + { + id: "2", + topics: ["Gaming"], + action: "read", + publisher: "GameSpot", + occurred_at: "2026-05-02T08:30:00Z" + }, + { + id: "3", + topics: ["Gaming"], + action: "read", + publisher: "GameSpot", + occurred_at: "2026-05-03T08:45:00Z" + } + ]; + + const context = normalizeReadingActivity(rawEvents); + const wikiEntries = generateWikiEntries(context); + + const gamingDurable = wikiEntries.find(e => e.id === "wiki_durable_gaming"); + assert.ok(gamingDurable); + assert.equal(gamingDurable.proposed_text, "Recently reads several articles about Gaming."); + + const cadencePref = wikiEntries.find(e => e.id === "wiki_pref_cadence"); + assert.ok(cadencePref); + assert.equal(cadencePref.proposed_text, "Often reads articles in the morning."); +});