@lotargo/memory_plugin 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,80 +1,80 @@
1
- import { getDatabase } from "../db/database.js";
2
- import { writeFileSync, mkdirSync, existsSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { MEMORY_DIR } from "../memory.js";
5
-
6
- export const EXPORTS_DIR = join(MEMORY_DIR, "exports");
7
-
8
- export function ensureExportsDir() {
9
- if (!existsSync(EXPORTS_DIR)) {
10
- mkdirSync(EXPORTS_DIR, { recursive: true });
11
- }
12
- return EXPORTS_DIR;
13
- }
14
-
15
- export function exportDocumentData(docIdOrPath, customDb = null) {
16
- const db = customDb || getDatabase();
17
- const doc = db.prepare("SELECT * FROM documents WHERE id = ? OR path = ?").get(docIdOrPath, docIdOrPath);
18
- if (!doc) {
19
- throw new Error(`Document not found for ID or path: ${docIdOrPath}`);
20
- }
21
-
22
- let toc = null;
23
- try {
24
- toc = doc.toc_json ? JSON.parse(doc.toc_json) : null;
25
- } catch {
26
- toc = doc.toc_json;
27
- }
28
-
29
- let metadata = null;
30
- try {
31
- metadata = doc.metadata_json ? JSON.parse(doc.metadata_json) : null;
32
- } catch {
33
- metadata = doc.metadata_json;
34
- }
35
-
36
- const sections = db.prepare("SELECT id, heading, breadcrumbs, content, token_count FROM sections WHERE doc_id = ?").all(doc.id);
37
- const mediumChunks = db.prepare("SELECT id, section_id, content, block_type, token_count, created_at FROM medium_chunks WHERE doc_id = ?").all(doc.id);
38
- const microChunks = db.prepare("SELECT id, medium_id, section_id, content, token_count FROM micro_chunks WHERE doc_id = ?").all(doc.id);
39
- const graphEdges = db.prepare("SELECT source_id, target_id, relation_type, metadata_json FROM graph_edges WHERE source_id = ? OR target_id = ?").all(doc.id, doc.id);
40
-
41
- return {
42
- document: {
43
- id: doc.id,
44
- path: doc.path,
45
- title: doc.title,
46
- blob_hash: doc.blob_hash,
47
- checksum: doc.checksum,
48
- toc,
49
- metadata,
50
- created_at: doc.created_at,
51
- updated_at: doc.updated_at,
52
- },
53
- counts: {
54
- sections: sections.length,
55
- medium_chunks: mediumChunks.length,
56
- micro_chunks: microChunks.length,
57
- graph_edges: graphEdges.length,
58
- },
59
- sections,
60
- medium_chunks: mediumChunks,
61
- micro_chunks: microChunks,
62
- graph_edges: graphEdges,
63
- };
64
- }
65
-
66
- export function exportDocumentToJsonString(docIdOrPath, customDb = null) {
67
- const data = exportDocumentData(docIdOrPath, customDb);
68
- return JSON.stringify(data, null, 2);
69
- }
70
-
71
- export function exportDocumentToFile(docIdOrPath, outputPath = null, customDb = null) {
72
- const targetDir = ensureExportsDir();
73
- const db = customDb || getDatabase();
74
- const data = exportDocumentData(docIdOrPath, db);
75
- const jsonStr = JSON.stringify(data, null, 2);
76
-
77
- const finalPath = outputPath || join(targetDir, `doc_export_${data.document.id}.json`);
78
- writeFileSync(finalPath, jsonStr, "utf-8");
79
- return finalPath;
80
- }
1
+ import { getDatabase } from "../db/database.js";
2
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { MEMORY_DIR } from "../memory.js";
5
+
6
+ export const EXPORTS_DIR = join(MEMORY_DIR, "exports");
7
+
8
+ export function ensureExportsDir() {
9
+ if (!existsSync(EXPORTS_DIR)) {
10
+ mkdirSync(EXPORTS_DIR, { recursive: true });
11
+ }
12
+ return EXPORTS_DIR;
13
+ }
14
+
15
+ export function exportDocumentData(docIdOrPath, customDb = null) {
16
+ const db = customDb || getDatabase();
17
+ const doc = db.prepare("SELECT * FROM documents WHERE id = ? OR path = ?").get(docIdOrPath, docIdOrPath);
18
+ if (!doc) {
19
+ throw new Error(`Document not found for ID or path: ${docIdOrPath}`);
20
+ }
21
+
22
+ let toc = null;
23
+ try {
24
+ toc = doc.toc_json ? JSON.parse(doc.toc_json) : null;
25
+ } catch {
26
+ toc = doc.toc_json;
27
+ }
28
+
29
+ let metadata = null;
30
+ try {
31
+ metadata = doc.metadata_json ? JSON.parse(doc.metadata_json) : null;
32
+ } catch {
33
+ metadata = doc.metadata_json;
34
+ }
35
+
36
+ const sections = db.prepare("SELECT id, heading, breadcrumbs, content, token_count FROM sections WHERE doc_id = ?").all(doc.id);
37
+ const mediumChunks = db.prepare("SELECT id, section_id, content, block_type, token_count, created_at FROM medium_chunks WHERE doc_id = ?").all(doc.id);
38
+ const microChunks = db.prepare("SELECT id, medium_id, section_id, content, token_count FROM micro_chunks WHERE doc_id = ?").all(doc.id);
39
+ const graphEdges = db.prepare("SELECT source_id, target_id, relation_type, metadata_json FROM graph_edges WHERE source_id = ? OR target_id = ?").all(doc.id, doc.id);
40
+
41
+ return {
42
+ document: {
43
+ id: doc.id,
44
+ path: doc.path,
45
+ title: doc.title,
46
+ blob_hash: doc.blob_hash,
47
+ checksum: doc.checksum,
48
+ toc,
49
+ metadata,
50
+ created_at: doc.created_at,
51
+ updated_at: doc.updated_at,
52
+ },
53
+ counts: {
54
+ sections: sections.length,
55
+ medium_chunks: mediumChunks.length,
56
+ micro_chunks: microChunks.length,
57
+ graph_edges: graphEdges.length,
58
+ },
59
+ sections,
60
+ medium_chunks: mediumChunks,
61
+ micro_chunks: microChunks,
62
+ graph_edges: graphEdges,
63
+ };
64
+ }
65
+
66
+ export function exportDocumentToJsonString(docIdOrPath, customDb = null) {
67
+ const data = exportDocumentData(docIdOrPath, customDb);
68
+ return JSON.stringify(data, null, 2);
69
+ }
70
+
71
+ export function exportDocumentToFile(docIdOrPath, outputPath = null, customDb = null) {
72
+ const targetDir = ensureExportsDir();
73
+ const db = customDb || getDatabase();
74
+ const data = exportDocumentData(docIdOrPath, db);
75
+ const jsonStr = JSON.stringify(data, null, 2);
76
+
77
+ const finalPath = outputPath || join(targetDir, `doc_export_${data.document.id}.json`);
78
+ writeFileSync(finalPath, jsonStr, "utf-8");
79
+ return finalPath;
80
+ }
@@ -1,104 +1,104 @@
1
- import { basename, extname } from "node:path";
2
-
3
- export function cleanHtml(html) {
4
- if (!html) return "";
5
-
6
- let cleaned = html.replace(/<(script|style|nav|header|footer|svg|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "");
7
-
8
- cleaned = cleaned.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
9
- cleaned = cleaned.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
10
- cleaned = cleaned.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
11
- cleaned = cleaned.replace(/<h[4-6][^>]*>([\s\S]*?)<\/h[4-6]>/gi, "\n#### $1\n");
12
-
13
- cleaned = cleaned.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "\n- $1");
14
- cleaned = cleaned.replace(/<(p|div|br)[^>]*>/gi, "\n");
15
-
16
- cleaned = cleaned.replace(/<[^>]+>/g, "");
17
-
18
- cleaned = cleaned
19
- .replace(/&nbsp;/g, " ")
20
- .replace(/&amp;/g, "&")
21
- .replace(/&lt;/g, "<")
22
- .replace(/&gt;/g, ">")
23
- .replace(/&quot;/g, '"')
24
- .replace(/&#39;/g, "'");
25
-
26
- cleaned = cleaned.replace(/\n\s*\n\s*\n/g, "\n\n").trim();
27
- return cleaned;
28
- }
29
-
30
- export function extractTitle(markdown, fallbackName = "Untitled Document") {
31
- const h1Match = markdown.match(/^#\s+(.+)$/m);
32
- if (h1Match && h1Match[1].trim()) {
33
- return h1Match[1].trim();
34
- }
35
- return fallbackName;
36
- }
37
-
38
- export function stripMarkdownBadgesAndNoise(text) {
39
- if (!text) return "";
40
- let cleaned = text;
41
-
42
- // 1. Remove markdown link-wrapped badges: [![alt](image_url)](link_url)
43
- cleaned = cleaned.replace(/\[\s*!\[[^\]]*\]\([^)]+\)\s*\]\([^)]+\)/g, "");
44
-
45
- // 2. Remove standalone markdown image badges: ![alt](https://img.shields.io/...) or badge URLs
46
- cleaned = cleaned.replace(/!\[[^\]]*\]\([^)]*(?:shields\.io|badge|actions\/workflows|codecov|travis-ci)[^)]*\)/gi, "");
47
-
48
- // 3. Remove raw HTML img badge tags
49
- cleaned = cleaned.replace(/<img[^>]*(?:shields\.io|badge|workflows|badge\.svg)[^>]*>/gi, "");
50
-
51
- // 4. Remove empty HTML anchor containers often surrounding badges
52
- cleaned = cleaned.replace(/<a[^>]*>\s*<\/a>/gi, "");
53
-
54
- // 5. Normalize excessive blank lines
55
- cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
56
- return cleaned;
57
- }
58
-
59
- export function normalizeContent({ content, type = "text", path = null, title = null }) {
60
- let markdown = "";
61
- let docTitle = title;
62
- const fileName = path ? basename(path) : "document";
63
-
64
- if (type === "url" || (typeof content === "string" && /<html|<body|<div/i.test(content))) {
65
- markdown = cleanHtml(content);
66
- docTitle = title || extractTitle(markdown, fileName);
67
- } else if (type === "file" && path) {
68
- const ext = extname(path).toLowerCase();
69
- const codeLangs = {
70
- ".js": "javascript",
71
- ".ts": "typescript",
72
- ".py": "python",
73
- ".json": "json",
74
- ".yaml": "yaml",
75
- ".yml": "yaml",
76
- ".css": "css",
77
- ".html": "html",
78
- };
79
-
80
- if (codeLangs[ext]) {
81
- markdown = `# ${fileName}\n\n\`\`\`${codeLangs[ext]}\n${content.trim()}\n\`\`\``;
82
- docTitle = title || fileName;
83
- } else {
84
- markdown = content.trim();
85
- docTitle = title || extractTitle(markdown, fileName);
86
- }
87
- } else {
88
- markdown = typeof content === "string" ? content.trim() : String(content);
89
- docTitle = title || extractTitle(markdown, "Direct Note");
90
- }
91
-
92
- // Apply noise and badge stripping to all developer documentation
93
- markdown = stripMarkdownBadgesAndNoise(markdown);
94
-
95
- return {
96
- markdown,
97
- title: docTitle,
98
- metadata: {
99
- source_type: type,
100
- original_path: path || null,
101
- char_count: markdown.length,
102
- },
103
- };
104
- }
1
+ import { basename, extname } from "node:path";
2
+
3
+ export function cleanHtml(html) {
4
+ if (!html) return "";
5
+
6
+ let cleaned = html.replace(/<(script|style|nav|header|footer|svg|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "");
7
+
8
+ cleaned = cleaned.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
9
+ cleaned = cleaned.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
10
+ cleaned = cleaned.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
11
+ cleaned = cleaned.replace(/<h[4-6][^>]*>([\s\S]*?)<\/h[4-6]>/gi, "\n#### $1\n");
12
+
13
+ cleaned = cleaned.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "\n- $1");
14
+ cleaned = cleaned.replace(/<(p|div|br)[^>]*>/gi, "\n");
15
+
16
+ cleaned = cleaned.replace(/<[^>]+>/g, "");
17
+
18
+ cleaned = cleaned
19
+ .replace(/&nbsp;/g, " ")
20
+ .replace(/&amp;/g, "&")
21
+ .replace(/&lt;/g, "<")
22
+ .replace(/&gt;/g, ">")
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#39;/g, "'");
25
+
26
+ cleaned = cleaned.replace(/\n\s*\n\s*\n/g, "\n\n").trim();
27
+ return cleaned;
28
+ }
29
+
30
+ export function extractTitle(markdown, fallbackName = "Untitled Document") {
31
+ const h1Match = markdown.match(/^#\s+(.+)$/m);
32
+ if (h1Match && h1Match[1].trim()) {
33
+ return h1Match[1].trim();
34
+ }
35
+ return fallbackName;
36
+ }
37
+
38
+ export function stripMarkdownBadgesAndNoise(text) {
39
+ if (!text) return "";
40
+ let cleaned = text;
41
+
42
+ // 1. Remove markdown link-wrapped badges: [![alt](image_url)](link_url)
43
+ cleaned = cleaned.replace(/\[\s*!\[[^\]]*\]\([^)]+\)\s*\]\([^)]+\)/g, "");
44
+
45
+ // 2. Remove standalone markdown image badges: ![alt](https://img.shields.io/...) or badge URLs
46
+ cleaned = cleaned.replace(/!\[[^\]]*\]\([^)]*(?:shields\.io|badge|actions\/workflows|codecov|travis-ci)[^)]*\)/gi, "");
47
+
48
+ // 3. Remove raw HTML img badge tags
49
+ cleaned = cleaned.replace(/<img[^>]*(?:shields\.io|badge|workflows|badge\.svg)[^>]*>/gi, "");
50
+
51
+ // 4. Remove empty HTML anchor containers often surrounding badges
52
+ cleaned = cleaned.replace(/<a[^>]*>\s*<\/a>/gi, "");
53
+
54
+ // 5. Normalize excessive blank lines
55
+ cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
56
+ return cleaned;
57
+ }
58
+
59
+ export function normalizeContent({ content, type = "text", path = null, title = null }) {
60
+ let markdown = "";
61
+ let docTitle = title;
62
+ const fileName = path ? basename(path) : "document";
63
+
64
+ if (type === "url" || (typeof content === "string" && /<html|<body|<div/i.test(content))) {
65
+ markdown = cleanHtml(content);
66
+ docTitle = title || extractTitle(markdown, fileName);
67
+ } else if (type === "file" && path) {
68
+ const ext = extname(path).toLowerCase();
69
+ const codeLangs = {
70
+ ".js": "javascript",
71
+ ".ts": "typescript",
72
+ ".py": "python",
73
+ ".json": "json",
74
+ ".yaml": "yaml",
75
+ ".yml": "yaml",
76
+ ".css": "css",
77
+ ".html": "html",
78
+ };
79
+
80
+ if (codeLangs[ext]) {
81
+ markdown = `# ${fileName}\n\n\`\`\`${codeLangs[ext]}\n${content.trim()}\n\`\`\``;
82
+ docTitle = title || fileName;
83
+ } else {
84
+ markdown = content.trim();
85
+ docTitle = title || extractTitle(markdown, fileName);
86
+ }
87
+ } else {
88
+ markdown = typeof content === "string" ? content.trim() : String(content);
89
+ docTitle = title || extractTitle(markdown, "Direct Note");
90
+ }
91
+
92
+ // Apply noise and badge stripping to all developer documentation
93
+ markdown = stripMarkdownBadgesAndNoise(markdown);
94
+
95
+ return {
96
+ markdown,
97
+ title: docTitle,
98
+ metadata: {
99
+ source_type: type,
100
+ original_path: path || null,
101
+ char_count: markdown.length,
102
+ },
103
+ };
104
+ }
@@ -3,8 +3,9 @@ import { getDatabase, BLOBS_DIR } from "../db/database.js";
3
3
  import { saveBlob, deleteBlob } from "../storage/blob_store.js";
4
4
  import { normalizeContent } from "./normalizer.js";
5
5
  import { buildTripleHierarchy } from "./chunker.js";
6
- import { embedText, vectorToBuffer } from "../ml/model_manager.js";
6
+ import { embedText, embedBatch, vectorToBuffer } from "../ml/model_manager.js";
7
7
  import { buildGraphEdges, saveGraphEdges } from "../graph/graph_extractor.js";
8
+ import { getConfig } from "../config/config_manager.js";
8
9
 
9
10
  export async function ingestDocument({
10
11
  content,
@@ -28,13 +29,27 @@ export async function ingestDocument({
28
29
 
29
30
  const hierarchy = buildTripleHierarchy(markdown, docId, docTitle);
30
31
 
31
- if (generateEmbeddings) {
32
- for (const micro of hierarchy.microChunks) {
33
- const contextualText = micro.breadcrumbs
32
+ if (generateEmbeddings && hierarchy.microChunks.length > 0) {
33
+ const BATCH_SIZE = getConfig().batchSize || 12;
34
+
35
+ // Smart Batching: Sort micro-chunks by character/token length to minimize ONNX zero-padding overhead
36
+ const indexedItems = hierarchy.microChunks.map((micro, idx) => ({
37
+ index: idx,
38
+ text: micro.breadcrumbs
34
39
  ? `${micro.content}\n\nContext: ${docTitle} > ${micro.breadcrumbs}`
35
- : `${micro.content}\n\nContext: ${docTitle}`;
36
- const vec = await embedText(contextualText, false);
37
- micro.vector = vectorToBuffer(vec);
40
+ : `${micro.content}\n\nContext: ${docTitle}`,
41
+ }));
42
+
43
+ indexedItems.sort((a, b) => a.text.length - b.text.length);
44
+
45
+ for (let i = 0; i < indexedItems.length; i += BATCH_SIZE) {
46
+ const batch = indexedItems.slice(i, i + BATCH_SIZE);
47
+ const batchTexts = batch.map((item) => item.text);
48
+ const batchVecs = await embedBatch(batchTexts, false);
49
+ for (let j = 0; j < batchVecs.length; j++) {
50
+ const origIdx = batch[j].index;
51
+ hierarchy.microChunks[origIdx].vector = vectorToBuffer(batchVecs[j]);
52
+ }
38
53
  }
39
54
  } else {
40
55
  for (const micro of hierarchy.microChunks) {
@@ -1,74 +1,74 @@
1
- // Multilingual Sentence Segmenter using Intl.Segmenter with Abbreviation & Boundary Protection
2
-
3
- const RU_ABBREVIATIONS = new Set([
4
- "т.д", "т.п", "т.е", "и др", "г", "гг", "ул", "руб", "коп", "стр", "рис", "см", "им", "пер", "д", "к", "п", "в"
5
- ]);
6
-
7
- const EN_ABBREVIATIONS = new Set([
8
- "e.g", "i.e", "etc", "vs", "dr", "mr", "mrs", "ms", "prof", "inc", "ltd", "co", "vol", "no", "p", "pp", "fig"
9
- ]);
10
-
11
- function isAbbreviation(word) {
12
- if (!word) return false;
13
- const clean = word.trim().toLowerCase().replace(/\.$/, "");
14
- return RU_ABBREVIATIONS.has(clean) || EN_ABBREVIATIONS.has(clean);
15
- }
16
-
17
- export function splitSentencesMultilingual(text, langHint = "ru") {
18
- if (!text || typeof text !== "string") return [];
19
- const trimmed = text.trim();
20
- if (trimmed.length === 0) return [];
21
-
22
- let rawSegments = [];
23
-
24
- if (typeof Intl !== "undefined" && Intl.Segmenter) {
25
- try {
26
- const segmenter = new Intl.Segmenter([langHint, "ru", "en"], { granularity: "sentence" });
27
- const iter = segmenter.segment(trimmed);
28
- for (const seg of iter) {
29
- if (seg.segment.trim().length > 0) {
30
- rawSegments.push(seg.segment);
31
- }
32
- }
33
- } catch {
34
- rawSegments = fallbackSentenceSplit(trimmed);
35
- }
36
- } else {
37
- rawSegments = fallbackSentenceSplit(trimmed);
38
- }
39
-
40
- // Post-process & merge false splits caused by abbreviations or numbers (e.g., "v1.0", "e.g.", "т. д.")
41
- const sentences = [];
42
- let current = "";
43
-
44
- for (let i = 0; i < rawSegments.length; i++) {
45
- const seg = rawSegments[i];
46
- if (!current) {
47
- current = seg;
48
- } else {
49
- const lastWordMatch = current.trim().match(/([a-zA-Zа-яА-Я0-9._-]+)\s*\.?$/);
50
- const lastWord = lastWordMatch ? lastWordMatch[1] : "";
51
-
52
- const isNumDot = /\b\d+\.$/.test(current.trim());
53
- const isAbbr = isAbbreviation(lastWord) || isNumDot;
54
- const startsWithLowercase = /^[a-zа-я]/.test(seg.trim());
55
-
56
- if (isAbbr || startsWithLowercase) {
57
- current += seg;
58
- } else {
59
- sentences.push(current.trim());
60
- current = seg;
61
- }
62
- }
63
- }
64
-
65
- if (current && current.trim().length > 0) {
66
- sentences.push(current.trim());
67
- }
68
-
69
- return sentences;
70
- }
71
-
72
- function fallbackSentenceSplit(text) {
73
- return text.split(/(?<=[.!?])\s+/).filter((s) => s.trim().length > 0);
74
- }
1
+ // Multilingual Sentence Segmenter using Intl.Segmenter with Abbreviation & Boundary Protection
2
+
3
+ const RU_ABBREVIATIONS = new Set([
4
+ "т.д", "т.п", "т.е", "и др", "г", "гг", "ул", "руб", "коп", "стр", "рис", "см", "им", "пер", "д", "к", "п", "в"
5
+ ]);
6
+
7
+ const EN_ABBREVIATIONS = new Set([
8
+ "e.g", "i.e", "etc", "vs", "dr", "mr", "mrs", "ms", "prof", "inc", "ltd", "co", "vol", "no", "p", "pp", "fig"
9
+ ]);
10
+
11
+ function isAbbreviation(word) {
12
+ if (!word) return false;
13
+ const clean = word.trim().toLowerCase().replace(/\.$/, "");
14
+ return RU_ABBREVIATIONS.has(clean) || EN_ABBREVIATIONS.has(clean);
15
+ }
16
+
17
+ export function splitSentencesMultilingual(text, langHint = "ru") {
18
+ if (!text || typeof text !== "string") return [];
19
+ const trimmed = text.trim();
20
+ if (trimmed.length === 0) return [];
21
+
22
+ let rawSegments = [];
23
+
24
+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
25
+ try {
26
+ const segmenter = new Intl.Segmenter([langHint, "ru", "en"], { granularity: "sentence" });
27
+ const iter = segmenter.segment(trimmed);
28
+ for (const seg of iter) {
29
+ if (seg.segment.trim().length > 0) {
30
+ rawSegments.push(seg.segment);
31
+ }
32
+ }
33
+ } catch {
34
+ rawSegments = fallbackSentenceSplit(trimmed);
35
+ }
36
+ } else {
37
+ rawSegments = fallbackSentenceSplit(trimmed);
38
+ }
39
+
40
+ // Post-process & merge false splits caused by abbreviations or numbers (e.g., "v1.0", "e.g.", "т. д.")
41
+ const sentences = [];
42
+ let current = "";
43
+
44
+ for (let i = 0; i < rawSegments.length; i++) {
45
+ const seg = rawSegments[i];
46
+ if (!current) {
47
+ current = seg;
48
+ } else {
49
+ const lastWordMatch = current.trim().match(/([a-zA-Zа-яА-Я0-9._-]+)\s*\.?$/);
50
+ const lastWord = lastWordMatch ? lastWordMatch[1] : "";
51
+
52
+ const isNumDot = /\b\d+\.$/.test(current.trim());
53
+ const isAbbr = isAbbreviation(lastWord) || isNumDot;
54
+ const startsWithLowercase = /^[a-zа-я]/.test(seg.trim());
55
+
56
+ if (isAbbr || startsWithLowercase) {
57
+ current += seg;
58
+ } else {
59
+ sentences.push(current.trim());
60
+ current = seg;
61
+ }
62
+ }
63
+ }
64
+
65
+ if (current && current.trim().length > 0) {
66
+ sentences.push(current.trim());
67
+ }
68
+
69
+ return sentences;
70
+ }
71
+
72
+ function fallbackSentenceSplit(text) {
73
+ return text.split(/(?<=[.!?])\s+/).filter((s) => s.trim().length > 0);
74
+ }