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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/extensions/fieldbrain-tools.ts +495 -0
  4. package/extensions/index.ts +12 -0
  5. package/extensions/python-runner.ts +56 -0
  6. package/package.json +44 -0
  7. package/scripts/__pycache__/_db_lib.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/models.cpython-312.pyc +0 -0
  10. package/scripts/_db_lib.py +228 -0
  11. package/scripts/_json_out.py +19 -0
  12. package/scripts/alembic/env.py +53 -0
  13. package/scripts/alembic/versions/001_baseline.py +38 -0
  14. package/scripts/alembic/versions/002_core_tables.py +429 -0
  15. package/scripts/alembic/versions/003_durability_content_in_db.py +106 -0
  16. package/scripts/alembic/versions/004_pgvector_embedding.py +42 -0
  17. package/scripts/alembic/versions/005_project_job_number.py +37 -0
  18. package/scripts/alembic/versions/006_project_parent.py +62 -0
  19. package/scripts/alembic/versions/__init__.py +1 -0
  20. package/scripts/alembic.ini +38 -0
  21. package/scripts/db_auto_migrate.py +103 -0
  22. package/scripts/db_bootstrap.py +207 -0
  23. package/scripts/db_check.py +105 -0
  24. package/scripts/db_migrate.py +85 -0
  25. package/scripts/fieldbrain_brain_delete.py +49 -0
  26. package/scripts/fieldbrain_brain_read.py +61 -0
  27. package/scripts/fieldbrain_brain_restore.py +49 -0
  28. package/scripts/fieldbrain_brain_revisions.py +54 -0
  29. package/scripts/fieldbrain_brain_write.py +67 -0
  30. package/scripts/fieldbrain_document_attach.py +81 -0
  31. package/scripts/fieldbrain_document_catalog.py +128 -0
  32. package/scripts/fieldbrain_document_ingest.py +62 -0
  33. package/scripts/fieldbrain_document_list.py +67 -0
  34. package/scripts/fieldbrain_document_promote.py +43 -0
  35. package/scripts/fieldbrain_log_create.py +55 -0
  36. package/scripts/fieldbrain_log_delete.py +39 -0
  37. package/scripts/fieldbrain_log_restore.py +39 -0
  38. package/scripts/fieldbrain_log_revisions.py +39 -0
  39. package/scripts/fieldbrain_log_search.py +58 -0
  40. package/scripts/fieldbrain_log_update.py +52 -0
  41. package/scripts/fieldbrain_project_create.py +72 -0
  42. package/scripts/fieldbrain_project_list.py +53 -0
  43. package/scripts/fieldbrain_search.py +74 -0
  44. package/scripts/fieldbrain_ui_brain_list.py +60 -0
  45. package/scripts/fieldbrain_ui_project_create.py +95 -0
  46. package/scripts/fieldbrain_ui_project_list.py +49 -0
  47. package/scripts/models.py +355 -0
  48. package/scripts/pgvector_enable.py +165 -0
  49. package/scripts/pgvector_guide.py +44 -0
  50. package/scripts/pgvector_install_files.py +66 -0
  51. package/scripts/pgvector_install_from_folder.py +148 -0
  52. package/scripts/postbuild-ui.mjs +13 -0
  53. package/scripts/requirements.txt +7 -0
  54. package/scripts/services/__init__.py +1 -0
  55. package/scripts/services/__pycache__/__init__.cpython-312.pyc +0 -0
  56. package/scripts/services/__pycache__/brain_paths.cpython-312.pyc +0 -0
  57. package/scripts/services/__pycache__/document_formats.cpython-312.pyc +0 -0
  58. package/scripts/services/__pycache__/document_service.cpython-312.pyc +0 -0
  59. package/scripts/services/__pycache__/pgvector_windows.cpython-312.pyc +0 -0
  60. package/scripts/services/__pycache__/project_naming.cpython-312.pyc +0 -0
  61. package/scripts/services/__pycache__/project_service.cpython-312.pyc +0 -0
  62. package/scripts/services/brain_paths.py +81 -0
  63. package/scripts/services/brain_service.py +280 -0
  64. package/scripts/services/document_catalog.py +186 -0
  65. package/scripts/services/document_formats.py +116 -0
  66. package/scripts/services/document_ingest.py +254 -0
  67. package/scripts/services/document_service.py +316 -0
  68. package/scripts/services/embedding_service.py +72 -0
  69. package/scripts/services/global_brain_support.py +36 -0
  70. package/scripts/services/maintenance_log_service.py +258 -0
  71. package/scripts/services/migrate_lock.py +24 -0
  72. package/scripts/services/pgvector_windows.py +190 -0
  73. package/scripts/services/project_naming.py +72 -0
  74. package/scripts/services/project_service.py +368 -0
  75. package/scripts/services/search_service.py +250 -0
  76. package/scripts/status.py +39 -0
  77. package/shared/README.md +7 -0
  78. package/skills/fieldbrain/SKILL.md +197 -0
  79. package/skills/fieldbrain/SKILL.md.bak +83 -0
  80. package/skills/fieldbrain/routes/fieldbrain/assets/index-B56utPpP.css +1 -0
  81. package/skills/fieldbrain/routes/fieldbrain/assets/index-DO0AD0df.js +55 -0
  82. package/skills/fieldbrain/routes/fieldbrain/fallback.md +7 -0
  83. 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
+ }