mirror of
https://github.com/hanzoai/brain.git
synced 2026-10-30 18:46:38 +00:00
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
/**
|
|
* Full-text-search helpers — feature-flag matrix, CJK bigrams, emoji
|
|
* trigrams, and websearch_to_tsquery semantics for backends that don't
|
|
* have it natively (SQLite FTS5).
|
|
*/
|
|
|
|
export interface FtsFeatureFlags {
|
|
scriptDetection: boolean;
|
|
trigramFallback: boolean;
|
|
bigramCjk: boolean;
|
|
multilingualConfigs: boolean;
|
|
websearchToTsquery: boolean;
|
|
}
|
|
|
|
const TRUE_VALUES = new Set(["1", "true", "TRUE", "yes", "on"]);
|
|
const FALSE_VALUES = new Set(["0", "false", "FALSE", "no", "off"]);
|
|
|
|
function envFlag(name: string, fallback: boolean): boolean {
|
|
const v = typeof process !== "undefined" ? process.env?.[name] : undefined;
|
|
if (!v) return fallback;
|
|
if (TRUE_VALUES.has(v)) return true;
|
|
if (FALSE_VALUES.has(v)) return false;
|
|
return fallback;
|
|
}
|
|
|
|
/** Read all flags from env. Defaults match Fortémi Phase-3 (all-on). */
|
|
export function ftsFlags(): FtsFeatureFlags {
|
|
return {
|
|
scriptDetection: envFlag("FTS_SCRIPT_DETECTION", true),
|
|
trigramFallback: envFlag("FTS_TRIGRAM_FALLBACK", true),
|
|
bigramCjk: envFlag("FTS_BIGRAM_CJK", true),
|
|
multilingualConfigs: envFlag("FTS_MULTILINGUAL_CONFIGS", true),
|
|
websearchToTsquery: envFlag("FTS_WEBSEARCH_TO_TSQUERY", true),
|
|
};
|
|
}
|
|
|
|
// ── Tokenizers ────────────────────────────────────────────────────────
|
|
|
|
/** Split CJK runs into 2-character grams; preserve Latin words intact. */
|
|
export function cjkBigrams(text: string): string[] {
|
|
const out: string[] = [];
|
|
let cjkBuf = "";
|
|
let latinBuf = "";
|
|
const flushCjk = () => {
|
|
if (!cjkBuf) return;
|
|
if (cjkBuf.length === 1) out.push(cjkBuf);
|
|
else for (let i = 0; i < cjkBuf.length - 1; i++) out.push(cjkBuf.slice(i, i + 2));
|
|
cjkBuf = "";
|
|
};
|
|
const flushLatin = () => {
|
|
if (!latinBuf) return;
|
|
out.push(latinBuf);
|
|
latinBuf = "";
|
|
};
|
|
for (const ch of text) {
|
|
const cp = ch.codePointAt(0)!;
|
|
if (isCjkCodepoint(cp)) {
|
|
flushLatin();
|
|
cjkBuf += ch;
|
|
} else if (/\s/.test(ch)) {
|
|
flushCjk();
|
|
flushLatin();
|
|
} else {
|
|
flushCjk();
|
|
latinBuf += ch;
|
|
}
|
|
}
|
|
flushCjk();
|
|
flushLatin();
|
|
return out.filter((t) => t.length > 0);
|
|
}
|
|
|
|
/** Emit length-3 grams over emoji + symbol runs (Unicode codepoint stream). */
|
|
export function emojiTrigrams(text: string): string[] {
|
|
const out: string[] = [];
|
|
const chars = [...text]; // codepoint-aware
|
|
for (let i = 0; i < chars.length; i++) {
|
|
const cp = chars[i].codePointAt(0)!;
|
|
if (!isSymbolish(cp)) continue;
|
|
const a = chars[i];
|
|
const b = chars[i + 1] ?? "";
|
|
const c = chars[i + 2] ?? "";
|
|
out.push(a + b + c);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function isCjkCodepoint(cp: number): boolean {
|
|
return (
|
|
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
(cp >= 0x3040 && cp <= 0x30ff) ||
|
|
(cp >= 0xac00 && cp <= 0xd7af)
|
|
);
|
|
}
|
|
|
|
function isSymbolish(cp: number): boolean {
|
|
return (
|
|
(cp >= 0x2600 && cp <= 0x27bf) ||
|
|
(cp >= 0x1f300 && cp <= 0x1faff)
|
|
);
|
|
}
|
|
|
|
// ── websearch_to_tsquery semantics (portable to SQLite FTS5) ──────────
|
|
|
|
export interface ParsedQuery {
|
|
/** Required terms (AND-joined). */
|
|
required: string[];
|
|
/** Excluded terms (NOT). */
|
|
excluded: string[];
|
|
/** Optional alternates (OR groups). Each group is OR'd internally. */
|
|
optional: string[][];
|
|
/** Phrase matches (exact ordered runs). */
|
|
phrases: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse a query into the same intent matrix Postgres's
|
|
* `websearch_to_tsquery` produces. Backends that don't have it can map
|
|
* this shape directly to their native query DSL.
|
|
*
|
|
* Supported: AND (default whitespace), `OR` (case-sensitive keyword),
|
|
* `-term` (exclude), `"exact phrase"`.
|
|
*/
|
|
export function parseWebSearch(query: string): ParsedQuery {
|
|
const required: string[] = [];
|
|
const excluded: string[] = [];
|
|
const optional: string[][] = [];
|
|
const phrases: string[] = [];
|
|
|
|
const tokens = tokenizeWithPhrases(query);
|
|
let i = 0;
|
|
while (i < tokens.length) {
|
|
const t = tokens[i];
|
|
if (t.kind === "phrase") {
|
|
phrases.push(t.value);
|
|
required.push(t.value);
|
|
i++;
|
|
continue;
|
|
}
|
|
const next = tokens[i + 1];
|
|
if (next?.kind === "word" && next.value === "OR") {
|
|
const group: string[] = [t.value];
|
|
let j = i + 1;
|
|
while (
|
|
tokens[j]?.kind === "word" && tokens[j].value === "OR" && tokens[j + 1]
|
|
) {
|
|
group.push(tokens[j + 1].value);
|
|
j += 2;
|
|
}
|
|
optional.push(group);
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (t.value.startsWith("-") && t.value.length > 1) {
|
|
excluded.push(t.value.slice(1));
|
|
i++;
|
|
continue;
|
|
}
|
|
required.push(t.value);
|
|
i++;
|
|
}
|
|
|
|
return { required, excluded, optional, phrases };
|
|
}
|
|
|
|
function tokenizeWithPhrases(q: string): Array<{ kind: "word" | "phrase"; value: string }> {
|
|
const out: Array<{ kind: "word" | "phrase"; value: string }> = [];
|
|
const re = /"([^"]+)"|(\S+)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(q)) !== null) {
|
|
if (m[1] !== undefined) out.push({ kind: "phrase", value: m[1] });
|
|
else out.push({ kind: "word", value: m[2] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Render a ParsedQuery as SQLite FTS5 MATCH expression. */
|
|
export function toFts5Match(p: ParsedQuery): string {
|
|
const parts: string[] = [];
|
|
for (const r of p.required) parts.push(quoteFts5(r));
|
|
for (const group of p.optional) {
|
|
const alts = group.map(quoteFts5).join(" OR ");
|
|
parts.push(`(${alts})`);
|
|
}
|
|
let q = parts.join(" AND ");
|
|
for (const e of p.excluded) q += ` NOT ${quoteFts5(e)}`;
|
|
return q.trim();
|
|
}
|
|
|
|
function quoteFts5(term: string): string {
|
|
if (term.includes(" ")) return `"${term.replace(/"/g, '""')}"`;
|
|
if (/^[\wÀ-]+$/.test(term)) return term;
|
|
return `"${term.replace(/"/g, '""')}"`;
|
|
}
|