src / title / HeuristicTitleRules.ts
src / title / HeuristicTitleRules.ts
/**
* Pure deterministic title-cleaning rules for time-sensitive queries in Browse.
* Lightweight, synchronous, 100% deterministic with zero external dependencies.
*
* CONSTRAINTS:
* - Public API: applyHeuristicTitleRules(originalUserMessage: string): string
* - Max length: 50 characters (including trailing "...")
* - No LLM/NLP libraries, no external dependencies, no external side effects.
*/
export interface TitleRule {
name: string;
apply(title: string): string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Rule 1: normalizeWhitespace
// ─────────────────────────────────────────────────────────────────────────────
export const normalizeWhitespaceRule: TitleRule = {
name: "normalizeWhitespace",
apply(title: string): string {
return title.replace(/[\s\r\n\t]+/g, " ").trim();
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 2: simplifyComparisons ("difference between X and Y" -> "X vs Y")
// ─────────────────────────────────────────────────────────────────────────────
export const simplifyComparisonsRule: TitleRule = {
name: "simplifyComparisons",
apply(title: string): string {
const text = title.replace(/[?!.]+$|\s+$/g, "").trim();
const diffMatch = text.match(/^(?:what\s+is\s+)?(?:the\s+)?difference\s+between\s+(.+?)\s+and\s+(.+)$/i);
if (diffMatch && diffMatch[1] && diffMatch[2]) {
return `${diffMatch[1].trim()} vs ${diffMatch[2].trim()}`;
}
const betterMatch = text.match(/^(?:whether\s+|if\s+)?(.+?)\s+(?:is|are)\s+better\s+than\s+(.+)$/i);
if (betterMatch && betterMatch[1] && betterMatch[2]) {
return `${betterMatch[1].trim()} vs ${betterMatch[2].trim()}`;
}
return title;
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 3: timeSensitiveScaffolding (Strip question/request openers safely)
// ─────────────────────────────────────────────────────────────────────────────
export const timeSensitiveScaffoldingRule: TitleRule = {
name: "timeSensitiveScaffolding",
apply(title: string): string {
let res = title.trim();
// Weather ("What's the weather?" -> "Weather", "What's the weather today?" -> "Weather Today")
const weather = res.match(/^what(?:'s|\s+is)\s+(?:the\s+)?weather(?:\s+like)?(?:\s+(in\s+[^?!.]+?))?(?:\s+(today|tomorrow|yesterday|this\s+week))?[?!.]?$/i);
if (weather) {
const loc = weather[1] ? ` ${weather[1].trim()}` : "";
const time = weather[2] ? ` ${weather[2].trim()}` : "";
return `Weather${loc}${time}`;
}
// Office Holders ("who is currently the CEO of OpenAI?" -> "Current CEO of OpenAI")
const currentlyOffice = res.match(/^who\s+is\s+currently\s+(?:the\s+)?(.+?)[?!.]?$/i);
if (currentlyOffice && currentlyOffice[1]) {
return `Current ${currentlyOffice[1].trim()}`;
}
const currentOffice = res.match(/^who\s+is\s+(?:the\s+)?current\s+(.+?)[?!.]?$/i);
if (currentOffice && currentOffice[1]) {
return `Current ${currentOffice[1].trim()}`;
}
// Markets & Events ("what happened in the markets today?" -> "Markets Today")
const eventIn = res.match(/^what\s+happened\s+in\s+(?:the\s+)?(.+?)\s+(today|yesterday|this week)[?!.]?$/i);
if (eventIn && eventIn[1] && eventIn[2]) {
return `${eventIn[1].trim()} ${eventIn[2].trim()}`;
}
const eventTimeIn = res.match(/^what\s+happened\s+(today|yesterday|this week)\s+in\s+(?:the\s+)?(.+?)[?!.]?$/i);
if (eventTimeIn && eventTimeIn[1] && eventTimeIn[2]) {
return `${eventTimeIn[2].trim()} ${eventTimeIn[1].trim()}`;
}
// Conversational & Request Opener Stripping
res = res.replace(/^(?:bring|get|show|tell|find|look\s+up|search\s+for)\s+(?:me\s+)?(?:about\s+)?(?:the\s+)?/i, "");
// Question Opener Stripping (safety check: preserve short queries like "What is IT?")
const questionMatch = res.match(/^(?:what(?:'s|\s+is)|who\s+is|where\s+is|what\s+are)\s+(?:the\s+)?(.+)$/i);
if (questionMatch && questionMatch[1]) {
const remainder = questionMatch[1].replace(/[?!.]+$|\s+$/g, "").trim();
const words = remainder.split(/\s+/).filter(Boolean);
if (words.length >= 2 || (remainder.length >= 4 && !/^[A-Z]{1,3}$/.test(remainder))) {
res = remainder;
}
}
res = res.replace(/^(?:how\s+do\s+I|how\s+to)\s+/i, "");
return res.trim();
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 4: essentialCasing (High-value entities, domains & capitalization)
// ─────────────────────────────────────────────────────────────────────────────
const ESSENTIAL_CASE_MAP: Record<string, string> = {
openai: "OpenAI",
nvidia: "NVIDIA",
techcrunch: "TechCrunch",
qwen: "Qwen",
gemma: "Gemma",
deepseek: "DeepSeek",
gpu: "GPU",
cpu: "CPU",
api: "API",
http: "HTTP",
tcp: "TCP",
udp: "UDP",
cds: "CDS",
ceo: "CEO",
};
export const essentialCasingRule: TitleRule = {
name: "essentialCasing",
apply(title: string): string {
if (!title) return title;
let text = title.replace(/[?!.]+$|\s+$/g, "").trim();
// Clean domain names: e.g. techcrunch.com -> TechCrunch
text = text.replace(/\b([a-z0-9-]+)\.(?:com|org|net|io|ai)\b/gi, (m, base) => {
const lower = base.toLowerCase();
return ESSENTIAL_CASE_MAP[lower] || (base.charAt(0).toUpperCase() + base.slice(1));
});
const tokens = text.split(/(\s+|[-/])/);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const lower = token.toLowerCase();
// Possessive match e.g. openai's -> OpenAI's
const possessive = token.match(/^(.+?)(['’]s)$/i);
if (possessive && ESSENTIAL_CASE_MAP[possessive[1].toLowerCase()]) {
tokens[i] = ESSENTIAL_CASE_MAP[possessive[1].toLowerCase()] + possessive[2].toLowerCase();
continue;
}
if (ESSENTIAL_CASE_MAP[lower]) {
tokens[i] = ESSENTIAL_CASE_MAP[lower];
}
}
let result = tokens.join("").trim();
if (result.length > 0 && /^[a-z]/.test(result)) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
return result;
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 5: shortenTitle (Strict max 50 chars including "...")
// ─────────────────────────────────────────────────────────────────────────────
export const shortenTitleRule: TitleRule = {
name: "shortenTitle(50)",
apply(title: string): string {
const clean = title.trim();
if (clean.length <= 50) return clean;
const targetLen = 47; // 50 - 3 for "..."
let truncated = clean.substring(0, targetLen);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 15) {
truncated = truncated.substring(0, lastSpace);
}
truncated = truncated.replace(/\s+(?:with|and|or|for|in|of|the|a|an|to|from|by|at|on|as|is|are|vs|about)$/i, "");
truncated = truncated.replace(/[:\-\s,.!?]+$/, "");
return `${truncated}...`;
},
};
/** Ordered list of heuristic rules applied during title cleaning. */
export const defaultTitleRules: readonly TitleRule[] = [
normalizeWhitespaceRule,
simplifyComparisonsRule,
timeSensitiveScaffoldingRule,
essentialCasingRule,
shortenTitleRule,
];
/**
* Pure function that transforms a raw user message into a clean, normalized conversation title.
* 100% deterministic, zero side effects, no external dependencies.
*/
export function applyHeuristicTitleRules(originalUserMessage: string): string {
if (!originalUserMessage || !originalUserMessage.trim()) {
return "Conversation";
}
const rawMessage = originalUserMessage.trim();
let currentTitle = rawMessage;
for (const rule of defaultTitleRules) {
try {
currentTitle = rule.apply(currentTitle);
} catch {
// Retain title state if an unexpected edge case occurs
}
}
currentTitle = currentTitle.replace(/[\s\r\n\t]+/g, " ").trim();
// Fallback safety check: return clean rawMessage if result is empty or too short
if (!currentTitle || currentTitle === "..." || currentTitle.length < 2) {
currentTitle = rawMessage.replace(/[\s\r\n\t]+/g, " ").trim();
if (currentTitle.length > 50) {
let truncated = currentTitle.substring(0, 47);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 15) truncated = truncated.substring(0, lastSpace);
currentTitle = `${truncated.replace(/[:\-\s,.!?]+$/, "")}...`;
}
}
return currentTitle;
}
/**
* Pure deterministic title-cleaning rules for time-sensitive queries in Browse.
* Lightweight, synchronous, 100% deterministic with zero external dependencies.
*
* CONSTRAINTS:
* - Public API: applyHeuristicTitleRules(originalUserMessage: string): string
* - Max length: 50 characters (including trailing "...")
* - No LLM/NLP libraries, no external dependencies, no external side effects.
*/
export interface TitleRule {
name: string;
apply(title: string): string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Rule 1: normalizeWhitespace
// ─────────────────────────────────────────────────────────────────────────────
export const normalizeWhitespaceRule: TitleRule = {
name: "normalizeWhitespace",
apply(title: string): string {
return title.replace(/[\s\r\n\t]+/g, " ").trim();
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 2: simplifyComparisons ("difference between X and Y" -> "X vs Y")
// ─────────────────────────────────────────────────────────────────────────────
export const simplifyComparisonsRule: TitleRule = {
name: "simplifyComparisons",
apply(title: string): string {
const text = title.replace(/[?!.]+$|\s+$/g, "").trim();
const diffMatch = text.match(/^(?:what\s+is\s+)?(?:the\s+)?difference\s+between\s+(.+?)\s+and\s+(.+)$/i);
if (diffMatch && diffMatch[1] && diffMatch[2]) {
return `${diffMatch[1].trim()} vs ${diffMatch[2].trim()}`;
}
const betterMatch = text.match(/^(?:whether\s+|if\s+)?(.+?)\s+(?:is|are)\s+better\s+than\s+(.+)$/i);
if (betterMatch && betterMatch[1] && betterMatch[2]) {
return `${betterMatch[1].trim()} vs ${betterMatch[2].trim()}`;
}
return title;
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 3: timeSensitiveScaffolding (Strip question/request openers safely)
// ─────────────────────────────────────────────────────────────────────────────
export const timeSensitiveScaffoldingRule: TitleRule = {
name: "timeSensitiveScaffolding",
apply(title: string): string {
let res = title.trim();
// Weather ("What's the weather?" -> "Weather", "What's the weather today?" -> "Weather Today")
const weather = res.match(/^what(?:'s|\s+is)\s+(?:the\s+)?weather(?:\s+like)?(?:\s+(in\s+[^?!.]+?))?(?:\s+(today|tomorrow|yesterday|this\s+week))?[?!.]?$/i);
if (weather) {
const loc = weather[1] ? ` ${weather[1].trim()}` : "";
const time = weather[2] ? ` ${weather[2].trim()}` : "";
return `Weather${loc}${time}`;
}
// Office Holders ("who is currently the CEO of OpenAI?" -> "Current CEO of OpenAI")
const currentlyOffice = res.match(/^who\s+is\s+currently\s+(?:the\s+)?(.+?)[?!.]?$/i);
if (currentlyOffice && currentlyOffice[1]) {
return `Current ${currentlyOffice[1].trim()}`;
}
const currentOffice = res.match(/^who\s+is\s+(?:the\s+)?current\s+(.+?)[?!.]?$/i);
if (currentOffice && currentOffice[1]) {
return `Current ${currentOffice[1].trim()}`;
}
// Markets & Events ("what happened in the markets today?" -> "Markets Today")
const eventIn = res.match(/^what\s+happened\s+in\s+(?:the\s+)?(.+?)\s+(today|yesterday|this week)[?!.]?$/i);
if (eventIn && eventIn[1] && eventIn[2]) {
return `${eventIn[1].trim()} ${eventIn[2].trim()}`;
}
const eventTimeIn = res.match(/^what\s+happened\s+(today|yesterday|this week)\s+in\s+(?:the\s+)?(.+?)[?!.]?$/i);
if (eventTimeIn && eventTimeIn[1] && eventTimeIn[2]) {
return `${eventTimeIn[2].trim()} ${eventTimeIn[1].trim()}`;
}
// Conversational & Request Opener Stripping
res = res.replace(/^(?:bring|get|show|tell|find|look\s+up|search\s+for)\s+(?:me\s+)?(?:about\s+)?(?:the\s+)?/i, "");
// Question Opener Stripping (safety check: preserve short queries like "What is IT?")
const questionMatch = res.match(/^(?:what(?:'s|\s+is)|who\s+is|where\s+is|what\s+are)\s+(?:the\s+)?(.+)$/i);
if (questionMatch && questionMatch[1]) {
const remainder = questionMatch[1].replace(/[?!.]+$|\s+$/g, "").trim();
const words = remainder.split(/\s+/).filter(Boolean);
if (words.length >= 2 || (remainder.length >= 4 && !/^[A-Z]{1,3}$/.test(remainder))) {
res = remainder;
}
}
res = res.replace(/^(?:how\s+do\s+I|how\s+to)\s+/i, "");
return res.trim();
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 4: essentialCasing (High-value entities, domains & capitalization)
// ─────────────────────────────────────────────────────────────────────────────
const ESSENTIAL_CASE_MAP: Record<string, string> = {
openai: "OpenAI",
nvidia: "NVIDIA",
techcrunch: "TechCrunch",
qwen: "Qwen",
gemma: "Gemma",
deepseek: "DeepSeek",
gpu: "GPU",
cpu: "CPU",
api: "API",
http: "HTTP",
tcp: "TCP",
udp: "UDP",
cds: "CDS",
ceo: "CEO",
};
export const essentialCasingRule: TitleRule = {
name: "essentialCasing",
apply(title: string): string {
if (!title) return title;
let text = title.replace(/[?!.]+$|\s+$/g, "").trim();
// Clean domain names: e.g. techcrunch.com -> TechCrunch
text = text.replace(/\b([a-z0-9-]+)\.(?:com|org|net|io|ai)\b/gi, (m, base) => {
const lower = base.toLowerCase();
return ESSENTIAL_CASE_MAP[lower] || (base.charAt(0).toUpperCase() + base.slice(1));
});
const tokens = text.split(/(\s+|[-/])/);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const lower = token.toLowerCase();
// Possessive match e.g. openai's -> OpenAI's
const possessive = token.match(/^(.+?)(['’]s)$/i);
if (possessive && ESSENTIAL_CASE_MAP[possessive[1].toLowerCase()]) {
tokens[i] = ESSENTIAL_CASE_MAP[possessive[1].toLowerCase()] + possessive[2].toLowerCase();
continue;
}
if (ESSENTIAL_CASE_MAP[lower]) {
tokens[i] = ESSENTIAL_CASE_MAP[lower];
}
}
let result = tokens.join("").trim();
if (result.length > 0 && /^[a-z]/.test(result)) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
return result;
},
};
// ─────────────────────────────────────────────────────────────────────────────
// Rule 5: shortenTitle (Strict max 50 chars including "...")
// ─────────────────────────────────────────────────────────────────────────────
export const shortenTitleRule: TitleRule = {
name: "shortenTitle(50)",
apply(title: string): string {
const clean = title.trim();
if (clean.length <= 50) return clean;
const targetLen = 47; // 50 - 3 for "..."
let truncated = clean.substring(0, targetLen);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 15) {
truncated = truncated.substring(0, lastSpace);
}
truncated = truncated.replace(/\s+(?:with|and|or|for|in|of|the|a|an|to|from|by|at|on|as|is|are|vs|about)$/i, "");
truncated = truncated.replace(/[:\-\s,.!?]+$/, "");
return `${truncated}...`;
},
};
/** Ordered list of heuristic rules applied during title cleaning. */
export const defaultTitleRules: readonly TitleRule[] = [
normalizeWhitespaceRule,
simplifyComparisonsRule,
timeSensitiveScaffoldingRule,
essentialCasingRule,
shortenTitleRule,
];
/**
* Pure function that transforms a raw user message into a clean, normalized conversation title.
* 100% deterministic, zero side effects, no external dependencies.
*/
export function applyHeuristicTitleRules(originalUserMessage: string): string {
if (!originalUserMessage || !originalUserMessage.trim()) {
return "Conversation";
}
const rawMessage = originalUserMessage.trim();
let currentTitle = rawMessage;
for (const rule of defaultTitleRules) {
try {
currentTitle = rule.apply(currentTitle);
} catch {
// Retain title state if an unexpected edge case occurs
}
}
currentTitle = currentTitle.replace(/[\s\r\n\t]+/g, " ").trim();
// Fallback safety check: return clean rawMessage if result is empty or too short
if (!currentTitle || currentTitle === "..." || currentTitle.length < 2) {
currentTitle = rawMessage.replace(/[\s\r\n\t]+/g, " ").trim();
if (currentTitle.length > 50) {
let truncated = currentTitle.substring(0, 47);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 15) truncated = truncated.substring(0, lastSpace);
currentTitle = `${truncated.replace(/[:\-\s,.!?]+$/, "")}...`;
}
}
return currentTitle;
}