sylo-fieldbrain 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/extensions/fieldbrain-tools.ts +495 -0
- package/extensions/index.ts +12 -0
- package/extensions/python-runner.ts +56 -0
- package/package.json +44 -0
- package/scripts/__pycache__/_db_lib.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
- package/scripts/__pycache__/models.cpython-312.pyc +0 -0
- package/scripts/_db_lib.py +228 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/alembic/env.py +53 -0
- package/scripts/alembic/versions/001_baseline.py +38 -0
- package/scripts/alembic/versions/002_core_tables.py +429 -0
- package/scripts/alembic/versions/003_durability_content_in_db.py +106 -0
- package/scripts/alembic/versions/004_pgvector_embedding.py +42 -0
- package/scripts/alembic/versions/005_project_job_number.py +37 -0
- package/scripts/alembic/versions/006_project_parent.py +62 -0
- package/scripts/alembic/versions/__init__.py +1 -0
- package/scripts/alembic.ini +38 -0
- package/scripts/db_auto_migrate.py +103 -0
- package/scripts/db_bootstrap.py +207 -0
- package/scripts/db_check.py +105 -0
- package/scripts/db_migrate.py +85 -0
- package/scripts/fieldbrain_brain_delete.py +49 -0
- package/scripts/fieldbrain_brain_read.py +61 -0
- package/scripts/fieldbrain_brain_restore.py +49 -0
- package/scripts/fieldbrain_brain_revisions.py +54 -0
- package/scripts/fieldbrain_brain_write.py +67 -0
- package/scripts/fieldbrain_document_attach.py +81 -0
- package/scripts/fieldbrain_document_catalog.py +128 -0
- package/scripts/fieldbrain_document_ingest.py +62 -0
- package/scripts/fieldbrain_document_list.py +67 -0
- package/scripts/fieldbrain_document_promote.py +43 -0
- package/scripts/fieldbrain_log_create.py +55 -0
- package/scripts/fieldbrain_log_delete.py +39 -0
- package/scripts/fieldbrain_log_restore.py +39 -0
- package/scripts/fieldbrain_log_revisions.py +39 -0
- package/scripts/fieldbrain_log_search.py +58 -0
- package/scripts/fieldbrain_log_update.py +52 -0
- package/scripts/fieldbrain_project_create.py +72 -0
- package/scripts/fieldbrain_project_list.py +53 -0
- package/scripts/fieldbrain_search.py +74 -0
- package/scripts/fieldbrain_ui_brain_list.py +60 -0
- package/scripts/fieldbrain_ui_project_create.py +95 -0
- package/scripts/fieldbrain_ui_project_list.py +49 -0
- package/scripts/models.py +355 -0
- package/scripts/pgvector_enable.py +165 -0
- package/scripts/pgvector_guide.py +44 -0
- package/scripts/pgvector_install_files.py +66 -0
- package/scripts/pgvector_install_from_folder.py +148 -0
- package/scripts/postbuild-ui.mjs +13 -0
- package/scripts/requirements.txt +7 -0
- package/scripts/services/__init__.py +1 -0
- package/scripts/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/brain_paths.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/document_formats.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/document_service.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/pgvector_windows.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/project_naming.cpython-312.pyc +0 -0
- package/scripts/services/__pycache__/project_service.cpython-312.pyc +0 -0
- package/scripts/services/brain_paths.py +81 -0
- package/scripts/services/brain_service.py +280 -0
- package/scripts/services/document_catalog.py +186 -0
- package/scripts/services/document_formats.py +116 -0
- package/scripts/services/document_ingest.py +254 -0
- package/scripts/services/document_service.py +316 -0
- package/scripts/services/embedding_service.py +72 -0
- package/scripts/services/global_brain_support.py +36 -0
- package/scripts/services/maintenance_log_service.py +258 -0
- package/scripts/services/migrate_lock.py +24 -0
- package/scripts/services/pgvector_windows.py +190 -0
- package/scripts/services/project_naming.py +72 -0
- package/scripts/services/project_service.py +368 -0
- package/scripts/services/search_service.py +250 -0
- package/scripts/status.py +39 -0
- package/shared/README.md +7 -0
- package/skills/fieldbrain/SKILL.md +197 -0
- package/skills/fieldbrain/SKILL.md.bak +83 -0
- package/skills/fieldbrain/routes/fieldbrain/assets/index-B56utPpP.css +1 -0
- package/skills/fieldbrain/routes/fieldbrain/assets/index-DO0AD0df.js +55 -0
- package/skills/fieldbrain/routes/fieldbrain/fallback.md +7 -0
- package/skills/fieldbrain/routes/fieldbrain/index.html +13 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Catalog metadata for library documents (agent-read summary, not full-file ingest)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from models import Document, DocumentAnalysis, DocumentPdfOutline
|
|
11
|
+
from services.document_formats import normalize_category
|
|
12
|
+
from services.document_ingest import _index_project_id_for_document, index_document_text
|
|
13
|
+
from services.document_service import document_to_dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_tags(raw: str | list[str] | None) -> list[str]:
|
|
17
|
+
if raw is None:
|
|
18
|
+
return []
|
|
19
|
+
if isinstance(raw, list):
|
|
20
|
+
return [str(t).strip() for t in raw if str(t).strip()]
|
|
21
|
+
text = raw.strip()
|
|
22
|
+
if not text:
|
|
23
|
+
return []
|
|
24
|
+
if text.startswith("["):
|
|
25
|
+
try:
|
|
26
|
+
parsed = json.loads(text)
|
|
27
|
+
if isinstance(parsed, list):
|
|
28
|
+
return [str(t).strip() for t in parsed if str(t).strip()]
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
pass
|
|
31
|
+
return [p.strip() for p in text.replace(";", ",").split(",") if p.strip()]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_outline(entries: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
35
|
+
if not entries:
|
|
36
|
+
return []
|
|
37
|
+
out: list[dict[str, Any]] = []
|
|
38
|
+
for i, row in enumerate(entries):
|
|
39
|
+
if not isinstance(row, dict):
|
|
40
|
+
continue
|
|
41
|
+
title = str(row.get("title") or "").strip()
|
|
42
|
+
if not title:
|
|
43
|
+
continue
|
|
44
|
+
level = int(row.get("level") or 1)
|
|
45
|
+
page = row.get("page")
|
|
46
|
+
if page is None:
|
|
47
|
+
page = row.get("physical_pdf_page")
|
|
48
|
+
physical_page = int(page) if page is not None and str(page).isdigit() else 0
|
|
49
|
+
out.append(
|
|
50
|
+
{
|
|
51
|
+
"level": max(1, level),
|
|
52
|
+
"title": title,
|
|
53
|
+
"physical_pdf_page": physical_page,
|
|
54
|
+
"toc_page_claimed": row.get("toc_page_claimed"),
|
|
55
|
+
"verification": str(row.get("verification") or "agent"),
|
|
56
|
+
"sequence": int(row.get("sequence") if row.get("sequence") is not None else i),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_index_text(
|
|
63
|
+
document: Document,
|
|
64
|
+
description: str,
|
|
65
|
+
tags: list[str],
|
|
66
|
+
outline: list[dict[str, Any]],
|
|
67
|
+
) -> str:
|
|
68
|
+
parts = [
|
|
69
|
+
f"Title: {document.title}",
|
|
70
|
+
f"Filename: {document.original_filename}",
|
|
71
|
+
f"Category: {document.category}",
|
|
72
|
+
]
|
|
73
|
+
if document.manufacturer:
|
|
74
|
+
parts.append(f"Manufacturer: {document.manufacturer}")
|
|
75
|
+
if document.model:
|
|
76
|
+
parts.append(f"Model: {document.model}")
|
|
77
|
+
if document.version:
|
|
78
|
+
parts.append(f"Version: {document.version}")
|
|
79
|
+
if tags:
|
|
80
|
+
parts.append(f"Tags: {', '.join(tags)}")
|
|
81
|
+
parts.append("")
|
|
82
|
+
parts.append(description.strip())
|
|
83
|
+
if outline:
|
|
84
|
+
parts.append("")
|
|
85
|
+
parts.append("Outline:")
|
|
86
|
+
for row in outline:
|
|
87
|
+
indent = " " * (max(1, row["level"]) - 1)
|
|
88
|
+
page_note = f" (p.{row['physical_pdf_page'] + 1})" if row["physical_pdf_page"] else ""
|
|
89
|
+
parts.append(f"{indent}- {row['title']}{page_note}")
|
|
90
|
+
return "\n".join(parts).strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def catalog_document(
|
|
94
|
+
session: Session,
|
|
95
|
+
document_id: int,
|
|
96
|
+
*,
|
|
97
|
+
description: str,
|
|
98
|
+
tags: str | list[str] | None = None,
|
|
99
|
+
category: str | None = None,
|
|
100
|
+
title: str | None = None,
|
|
101
|
+
manufacturer: str | None = None,
|
|
102
|
+
model: str | None = None,
|
|
103
|
+
version: str | None = None,
|
|
104
|
+
outline: list[dict[str, Any]] | None = None,
|
|
105
|
+
project_id: int | None = None,
|
|
106
|
+
embed: bool = True,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Write catalog rows and index summary text for search (not full-file extraction)."""
|
|
109
|
+
desc = (description or "").strip()
|
|
110
|
+
if not desc:
|
|
111
|
+
raise ValueError("description is required — agent must summarize after reading the file.")
|
|
112
|
+
|
|
113
|
+
document = (
|
|
114
|
+
session.query(Document)
|
|
115
|
+
.filter(Document.id == document_id, Document.archived.is_(False))
|
|
116
|
+
.first()
|
|
117
|
+
)
|
|
118
|
+
if not document:
|
|
119
|
+
raise ValueError("Document not found")
|
|
120
|
+
|
|
121
|
+
tag_list = _parse_tags(tags)
|
|
122
|
+
outline_rows = _normalize_outline(outline)
|
|
123
|
+
|
|
124
|
+
if title and title.strip():
|
|
125
|
+
document.title = title.strip()[:255]
|
|
126
|
+
if category:
|
|
127
|
+
document.category = normalize_category(category)
|
|
128
|
+
if manufacturer is not None:
|
|
129
|
+
document.manufacturer = manufacturer.strip()[:100] or None
|
|
130
|
+
if model is not None:
|
|
131
|
+
document.model = model.strip()[:100] or None
|
|
132
|
+
if version is not None:
|
|
133
|
+
document.version = version.strip()[:50] or None
|
|
134
|
+
if tag_list:
|
|
135
|
+
document.tags_json = json.dumps(tag_list)
|
|
136
|
+
|
|
137
|
+
analysis = (
|
|
138
|
+
session.query(DocumentAnalysis)
|
|
139
|
+
.filter(DocumentAnalysis.document_id == document.id)
|
|
140
|
+
.first()
|
|
141
|
+
)
|
|
142
|
+
if analysis:
|
|
143
|
+
analysis.llm_index_description = desc
|
|
144
|
+
else:
|
|
145
|
+
session.add(
|
|
146
|
+
DocumentAnalysis(
|
|
147
|
+
document_id=document.id,
|
|
148
|
+
llm_index_description=desc,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
session.query(DocumentPdfOutline).filter(DocumentPdfOutline.document_id == document.id).delete()
|
|
153
|
+
for row in outline_rows:
|
|
154
|
+
session.add(
|
|
155
|
+
DocumentPdfOutline(
|
|
156
|
+
document_id=document.id,
|
|
157
|
+
level=row["level"],
|
|
158
|
+
title=row["title"],
|
|
159
|
+
toc_page_claimed=row.get("toc_page_claimed"),
|
|
160
|
+
physical_pdf_page=row["physical_pdf_page"],
|
|
161
|
+
verification=row["verification"],
|
|
162
|
+
sequence=row["sequence"],
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
session.flush()
|
|
167
|
+
index_pid = _index_project_id_for_document(session, document, project_id)
|
|
168
|
+
index_text = _build_index_text(document, desc, tag_list, outline_rows)
|
|
169
|
+
index_stats = index_document_text(
|
|
170
|
+
session,
|
|
171
|
+
document,
|
|
172
|
+
index_text,
|
|
173
|
+
index_project_id=index_pid,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"document": document_to_dict(document),
|
|
178
|
+
"catalog": {
|
|
179
|
+
"description_chars": len(desc),
|
|
180
|
+
"tags": tag_list,
|
|
181
|
+
"outline_entries": len(outline_rows),
|
|
182
|
+
"indexed_for_search": True,
|
|
183
|
+
"embeddings_written": index_stats.get("embeddings_written", 0) if embed else 0,
|
|
184
|
+
},
|
|
185
|
+
**index_stats,
|
|
186
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Supported library file types and which Sylo skill reads them (agent-side)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Extensions FieldBrain stores in the global/project document library (bytes in Postgres).
|
|
6
|
+
ALLOWED_EXTENSIONS: frozenset[str] = frozenset(
|
|
7
|
+
{
|
|
8
|
+
".pdf",
|
|
9
|
+
".txt",
|
|
10
|
+
".md",
|
|
11
|
+
".markdown",
|
|
12
|
+
".docx",
|
|
13
|
+
".xlsx",
|
|
14
|
+
".xlsm",
|
|
15
|
+
".ods",
|
|
16
|
+
".csv",
|
|
17
|
+
".jpg",
|
|
18
|
+
".jpeg",
|
|
19
|
+
".png",
|
|
20
|
+
".webp",
|
|
21
|
+
".gif",
|
|
22
|
+
".bmp",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
DOCUMENT_CATEGORIES: frozenset[str] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"manual",
|
|
31
|
+
"datasheet",
|
|
32
|
+
"requirements",
|
|
33
|
+
"email",
|
|
34
|
+
"howto",
|
|
35
|
+
"guide",
|
|
36
|
+
"schematic",
|
|
37
|
+
"spreadsheet",
|
|
38
|
+
"image",
|
|
39
|
+
"markdown",
|
|
40
|
+
"reference",
|
|
41
|
+
"other",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
MIME_BY_EXT: dict[str, str] = {
|
|
46
|
+
".pdf": "application/pdf",
|
|
47
|
+
".txt": "text/plain",
|
|
48
|
+
".md": "text/markdown",
|
|
49
|
+
".markdown": "text/markdown",
|
|
50
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
51
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
52
|
+
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
53
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
54
|
+
".csv": "text/csv",
|
|
55
|
+
".jpg": "image/jpeg",
|
|
56
|
+
".jpeg": "image/jpeg",
|
|
57
|
+
".png": "image/png",
|
|
58
|
+
".webp": "image/webp",
|
|
59
|
+
".gif": "image/gif",
|
|
60
|
+
".bmp": "image/bmp",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Agent reads the file with these Sylo capabilities before calling fieldbrain_document_catalog.
|
|
64
|
+
READER_HINTS: dict[str, str] = {
|
|
65
|
+
".pdf": "sylo-pdf-reader: search_schematic_pdf (and region tools as needed)",
|
|
66
|
+
".xlsx": "sylo-spreadsheet: read_spreadsheet",
|
|
67
|
+
".xlsm": "sylo-spreadsheet: read_spreadsheet (formulas optional)",
|
|
68
|
+
".ods": "sylo-spreadsheet: read_spreadsheet",
|
|
69
|
+
".csv": "Pi read tool (plain text)",
|
|
70
|
+
".txt": "Pi read tool",
|
|
71
|
+
".md": "Pi read tool",
|
|
72
|
+
".markdown": "Pi read tool",
|
|
73
|
+
".docx": "Chat attachment + agent summary (extract headings/topics); template-docx-writer for embedded images only",
|
|
74
|
+
".jpg": "Vision on attachment — describe what the image shows",
|
|
75
|
+
".jpeg": "Vision on attachment — describe what the image shows",
|
|
76
|
+
".png": "Vision on attachment — describe what the image shows",
|
|
77
|
+
".webp": "Vision on attachment — describe what the image shows",
|
|
78
|
+
".gif": "Vision on attachment — describe what the image shows",
|
|
79
|
+
".bmp": "Vision on attachment — describe what the image shows",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
IMAGE_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def mime_for_ext(ext: str) -> str | None:
|
|
86
|
+
return MIME_BY_EXT.get(ext.lower())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reader_hint_for_ext(ext: str) -> str | None:
|
|
90
|
+
return READER_HINTS.get(ext.lower())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_category(raw: str | None) -> str:
|
|
94
|
+
cat = (raw or "other").strip().lower().replace("-", "").replace("_", "")
|
|
95
|
+
aliases = {
|
|
96
|
+
"howto": "howto",
|
|
97
|
+
"howtoguide": "howto",
|
|
98
|
+
"howguide": "howto",
|
|
99
|
+
"spec": "requirements",
|
|
100
|
+
"req": "requirements",
|
|
101
|
+
"datasheets": "datasheet",
|
|
102
|
+
"manuals": "manual",
|
|
103
|
+
"photo": "image",
|
|
104
|
+
"picture": "image",
|
|
105
|
+
"img": "image",
|
|
106
|
+
"emailchain": "email",
|
|
107
|
+
"mail": "email",
|
|
108
|
+
}
|
|
109
|
+
cat = aliases.get(cat, cat)
|
|
110
|
+
if cat in DOCUMENT_CATEGORIES:
|
|
111
|
+
return cat
|
|
112
|
+
return "other"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_image_ext(ext: str) -> bool:
|
|
116
|
+
return ext.lower() in IMAGE_EXTENSIONS
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Document ingestion: extract text, chunk, index, optional embed via Ollama."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import text
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from _db_lib import check_pgvector, ollama_base_url
|
|
14
|
+
from models import Document, GlobalSettings
|
|
15
|
+
from services.document_service import ALLOWED_EXTENSIONS, register_document_from_path, read_document_bytes
|
|
16
|
+
from services.embedding_service import generate_embeddings
|
|
17
|
+
from services.global_brain_support import get_global_brain_shell_project_id
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SOURCE_DOCUMENT = "document"
|
|
22
|
+
CHUNK_TARGET_CHARS = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_text_from_bytes(data: bytes, ext: str) -> str:
|
|
26
|
+
"""Extract plain text from PDF (pymupdf) or .txt bytes."""
|
|
27
|
+
ext_l = ext.lower()
|
|
28
|
+
if ext_l == ".pdf":
|
|
29
|
+
import fitz
|
|
30
|
+
|
|
31
|
+
doc = fitz.open(stream=data, filetype="pdf")
|
|
32
|
+
try:
|
|
33
|
+
parts = [page.get_text() for page in doc]
|
|
34
|
+
finally:
|
|
35
|
+
doc.close()
|
|
36
|
+
return "\n".join(parts).strip()
|
|
37
|
+
if ext_l == ".txt":
|
|
38
|
+
return data.decode("utf-8", errors="replace").strip()
|
|
39
|
+
raise ValueError(f"Unsupported file type {ext_l}. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_text_from_file(path: Path) -> str:
|
|
43
|
+
"""Extract plain text from PDF (pymupdf) or .txt on disk."""
|
|
44
|
+
return extract_text_from_bytes(path.read_bytes(), path.suffix.lower())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_text_from_document(document: Document) -> str:
|
|
48
|
+
ext = Path(document.original_filename or document.stored_path).suffix.lower()
|
|
49
|
+
if not ext and document.mime_type == "application/pdf":
|
|
50
|
+
ext = ".pdf"
|
|
51
|
+
data = read_document_bytes(document)
|
|
52
|
+
return extract_text_from_bytes(data, ext)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def chunk_text_chars(text_body: str, target_chars: int = CHUNK_TARGET_CHARS) -> list[str]:
|
|
56
|
+
"""Split text into ~target_chars chunks on sentence boundaries."""
|
|
57
|
+
if not text_body or not text_body.strip():
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
sentences = re.split(r"(?<=[.!?])\s+", text_body.strip())
|
|
61
|
+
chunks: list[str] = []
|
|
62
|
+
current: list[str] = []
|
|
63
|
+
current_len = 0
|
|
64
|
+
|
|
65
|
+
for sent in sentences:
|
|
66
|
+
sent = sent.strip()
|
|
67
|
+
if not sent:
|
|
68
|
+
continue
|
|
69
|
+
sent_len = len(sent)
|
|
70
|
+
if current and current_len + sent_len + 1 > target_chars:
|
|
71
|
+
chunks.append(" ".join(current))
|
|
72
|
+
current = [sent]
|
|
73
|
+
current_len = sent_len
|
|
74
|
+
else:
|
|
75
|
+
current.append(sent)
|
|
76
|
+
current_len += sent_len + (1 if current_len else 0)
|
|
77
|
+
|
|
78
|
+
if current:
|
|
79
|
+
chunks.append(" ".join(current))
|
|
80
|
+
|
|
81
|
+
return chunks or [text_body.strip()]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _insert_index_row(
|
|
85
|
+
session: Session,
|
|
86
|
+
project_id: int,
|
|
87
|
+
*,
|
|
88
|
+
source_id: str,
|
|
89
|
+
label: str,
|
|
90
|
+
content: str,
|
|
91
|
+
chunk_index: int,
|
|
92
|
+
) -> int:
|
|
93
|
+
row = session.execute(
|
|
94
|
+
text(
|
|
95
|
+
"""
|
|
96
|
+
INSERT INTO search_index
|
|
97
|
+
(project_id, source_type, source_id, label, path, content, search_vector, chunk_index)
|
|
98
|
+
VALUES
|
|
99
|
+
(:pid, :stype, :sid, :label, :path, :content,
|
|
100
|
+
setweight(to_tsvector('english', coalesce(:label,'')), 'A') ||
|
|
101
|
+
setweight(to_tsvector('english', coalesce(:path,'')), 'B') ||
|
|
102
|
+
setweight(to_tsvector('english', coalesce(:content,'')), 'D'),
|
|
103
|
+
:ci)
|
|
104
|
+
RETURNING id
|
|
105
|
+
"""
|
|
106
|
+
),
|
|
107
|
+
{
|
|
108
|
+
"pid": project_id,
|
|
109
|
+
"stype": SOURCE_DOCUMENT,
|
|
110
|
+
"sid": source_id[:500],
|
|
111
|
+
"label": label[:500],
|
|
112
|
+
"path": label[:500],
|
|
113
|
+
"content": content or " ",
|
|
114
|
+
"ci": chunk_index,
|
|
115
|
+
},
|
|
116
|
+
).fetchone()
|
|
117
|
+
return int(row[0])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _remove_document_index_rows(session: Session, project_id: int, document_id: int) -> None:
|
|
121
|
+
session.execute(
|
|
122
|
+
text(
|
|
123
|
+
"""
|
|
124
|
+
DELETE FROM search_index
|
|
125
|
+
WHERE project_id = :pid AND source_type = :stype AND source_id = :sid
|
|
126
|
+
"""
|
|
127
|
+
),
|
|
128
|
+
{"pid": project_id, "stype": SOURCE_DOCUMENT, "sid": str(document_id)},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _embed_index_rows(session: Session, row_ids: list[int]) -> int:
|
|
133
|
+
if not row_ids:
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
pg_ok, _ = check_pgvector()
|
|
137
|
+
if not pg_ok:
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
settings = session.query(GlobalSettings).filter(GlobalSettings.id == 1).first()
|
|
141
|
+
model = (settings.embedding_model if settings else None) or "nomic-embed-text"
|
|
142
|
+
endpoint = ollama_base_url()
|
|
143
|
+
|
|
144
|
+
rows = session.execute(
|
|
145
|
+
text("SELECT id, content FROM search_index WHERE id = ANY(:ids)"),
|
|
146
|
+
{"ids": row_ids},
|
|
147
|
+
).fetchall()
|
|
148
|
+
if not rows:
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
texts = [(r[1] or " ") for r in rows]
|
|
152
|
+
embeddings = generate_embeddings(texts, endpoint=endpoint, model=model)
|
|
153
|
+
updated = 0
|
|
154
|
+
for (row_id, _), emb in zip(rows, embeddings):
|
|
155
|
+
if not emb:
|
|
156
|
+
continue
|
|
157
|
+
emb_str = "[" + ",".join(str(v) for v in emb) + "]"
|
|
158
|
+
session.execute(
|
|
159
|
+
text("UPDATE search_index SET embedding = CAST(:emb AS vector) WHERE id = :id"),
|
|
160
|
+
{"emb": emb_str, "id": row_id},
|
|
161
|
+
)
|
|
162
|
+
updated += 1
|
|
163
|
+
return updated
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _index_project_id_for_document(session: Session, doc: Document, project_id: int | None) -> int:
|
|
167
|
+
scope = (doc.scope or "project_local").strip().lower()
|
|
168
|
+
if scope == "global":
|
|
169
|
+
return get_global_brain_shell_project_id(session)
|
|
170
|
+
if project_id is not None:
|
|
171
|
+
return project_id
|
|
172
|
+
if doc.owner_project_id is not None:
|
|
173
|
+
return int(doc.owner_project_id)
|
|
174
|
+
raise ValueError("project_id required for project-scoped document indexing")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def index_document_text(
|
|
178
|
+
session: Session,
|
|
179
|
+
document: Document,
|
|
180
|
+
full_text: str,
|
|
181
|
+
*,
|
|
182
|
+
index_project_id: int,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Chunk document text into search_index and optionally embed."""
|
|
185
|
+
_remove_document_index_rows(session, index_project_id, document.id)
|
|
186
|
+
label = document.title or document.original_filename or "Document"
|
|
187
|
+
chunks = chunk_text_chars(full_text)
|
|
188
|
+
row_ids: list[int] = []
|
|
189
|
+
|
|
190
|
+
for ci, chunk in enumerate(chunks):
|
|
191
|
+
header = f"[{label}]"
|
|
192
|
+
body = f"{header}\n\n{chunk.strip()}"
|
|
193
|
+
row_id = _insert_index_row(
|
|
194
|
+
session,
|
|
195
|
+
index_project_id,
|
|
196
|
+
source_id=str(document.id),
|
|
197
|
+
label=label,
|
|
198
|
+
content=body,
|
|
199
|
+
chunk_index=ci,
|
|
200
|
+
)
|
|
201
|
+
row_ids.append(row_id)
|
|
202
|
+
|
|
203
|
+
embedded = _embed_index_rows(session, row_ids)
|
|
204
|
+
session.commit()
|
|
205
|
+
return {
|
|
206
|
+
"chunks_indexed": len(row_ids),
|
|
207
|
+
"embeddings_written": embedded,
|
|
208
|
+
"index_project_id": index_project_id,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ingest_document_file(
|
|
213
|
+
session: Session,
|
|
214
|
+
file_path: str | Path,
|
|
215
|
+
*,
|
|
216
|
+
scope: str,
|
|
217
|
+
project_id: int | None = None,
|
|
218
|
+
title: str | None = None,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
"""Register (if needed), extract text, index, and optionally embed a document."""
|
|
221
|
+
path = Path(file_path).expanduser().resolve()
|
|
222
|
+
reg = register_document_from_path(
|
|
223
|
+
session,
|
|
224
|
+
path,
|
|
225
|
+
scope=scope,
|
|
226
|
+
project_id=project_id,
|
|
227
|
+
title=title,
|
|
228
|
+
attach=True,
|
|
229
|
+
)
|
|
230
|
+
document = (
|
|
231
|
+
session.query(Document)
|
|
232
|
+
.filter(Document.id == reg["document"]["id"])
|
|
233
|
+
.first()
|
|
234
|
+
)
|
|
235
|
+
if not document:
|
|
236
|
+
raise ValueError("Document registration failed")
|
|
237
|
+
|
|
238
|
+
full_text = extract_text_from_document(document)
|
|
239
|
+
index_pid = _index_project_id_for_document(session, document, project_id)
|
|
240
|
+
index_stats = index_document_text(
|
|
241
|
+
session,
|
|
242
|
+
document,
|
|
243
|
+
full_text,
|
|
244
|
+
index_project_id=index_pid,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"document_id": document.id,
|
|
249
|
+
"title": document.title,
|
|
250
|
+
"scope": document.scope,
|
|
251
|
+
"stored_path": document.stored_path,
|
|
252
|
+
"text_chars": len(full_text),
|
|
253
|
+
**index_stats,
|
|
254
|
+
}
|