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,355 @@
1
+ """SQLAlchemy models for sylo-logicscout shared Postgres data.
2
+
3
+ Auth, chat, and batch L5X analysis job tables are intentionally omitted.
4
+ Sylo SQLite owns chats; LogicForge owns L5X parse tooling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sqlalchemy import (
10
+ Boolean,
11
+ Column,
12
+ DateTime,
13
+ Float,
14
+ ForeignKey,
15
+ Index,
16
+ Integer,
17
+ LargeBinary,
18
+ String,
19
+ Text,
20
+ UniqueConstraint,
21
+ func,
22
+ text as sql_text,
23
+ )
24
+ from sqlalchemy.dialects.postgresql import TSVECTOR
25
+ from sqlalchemy.orm import declarative_base
26
+
27
+ Base = declarative_base()
28
+
29
+ DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434"
30
+
31
+
32
+ class Project(Base):
33
+ """Project metadata (L5X path/hash point at LogicForge-managed files)."""
34
+
35
+ __tablename__ = "projects"
36
+
37
+ id = Column(Integer, primary_key=True, index=True)
38
+ name = Column(String(255), nullable=False, index=True)
39
+ controller_name = Column(String(255), nullable=True)
40
+ processor_type = Column(String(100), nullable=True)
41
+ software_revision = Column(String(50), nullable=True)
42
+ l5x_file_path = Column(String(500), nullable=True)
43
+ l5x_file_hash = Column(String(64), nullable=True, index=True)
44
+ plc_ip = Column(String(45), nullable=True)
45
+ plc_read_enabled = Column(Boolean, default=False, nullable=False)
46
+ max_alert_chats = Column(Integer, nullable=False, default=10)
47
+ hmi_embed_token = Column(String(255), nullable=True)
48
+ cross_project_share = Column(String(20), nullable=False, default="share")
49
+ cross_project_search = Column(Boolean, nullable=False, default=True)
50
+ org_id = Column(String(100), nullable=False, default="default", index=True)
51
+ project_tags_json = Column(Text, nullable=True)
52
+ domain = Column(String(100), nullable=True)
53
+ controller_family = Column(String(100), nullable=True)
54
+ reminders_muted = Column(Boolean, nullable=False, default=False)
55
+ is_system_hidden = Column(Boolean, nullable=False, default=False, server_default="false")
56
+ job_number = Column(String(20), nullable=True, index=True)
57
+ parent_project_id = Column(Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True)
58
+ l5x_status = Column(String(20), nullable=False, default="analyzed")
59
+ created_at = Column(DateTime, default=func.now())
60
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
61
+
62
+
63
+ class Document(Base):
64
+ """Uploaded document metadata; ingestion pipeline fills analysis rows later."""
65
+
66
+ __tablename__ = "documents"
67
+
68
+ id = Column(Integer, primary_key=True, index=True)
69
+ scope = Column(String(20), nullable=False, default="project_local", index=True)
70
+ owner_project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True)
71
+ title = Column(String(255), nullable=False)
72
+ original_filename = Column(String(255), nullable=False)
73
+ stored_path = Column(String(500), nullable=False)
74
+ file_hash = Column(String(64), nullable=False, index=True)
75
+ mime_type = Column(String(100), nullable=True)
76
+ file_size = Column(Integer, nullable=True)
77
+ category = Column(String(20), nullable=False, default="other", index=True)
78
+ tags_json = Column(Text, nullable=True)
79
+ manufacturer = Column(String(100), nullable=True)
80
+ model = Column(String(100), nullable=True)
81
+ version = Column(String(50), nullable=True)
82
+ archived = Column(Boolean, nullable=False, default=False)
83
+ file_content = Column(LargeBinary, nullable=True)
84
+ created_at = Column(DateTime, default=func.now())
85
+
86
+ __table_args__ = (UniqueConstraint("file_hash", name="uq_documents_file_hash"),)
87
+
88
+
89
+ class ProjectDocumentLink(Base):
90
+ """Attach a document to a project."""
91
+
92
+ __tablename__ = "project_document_links"
93
+
94
+ id = Column(Integer, primary_key=True, index=True)
95
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
96
+ document_id = Column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
97
+ role = Column(String(20), nullable=False, default="reference", index=True)
98
+ attached_at = Column(DateTime, default=func.now())
99
+
100
+ __table_args__ = (UniqueConstraint("project_id", "document_id", name="uq_project_document_link"),)
101
+
102
+
103
+ class GlobalSettings(Base):
104
+ """Server-wide settings singleton."""
105
+
106
+ __tablename__ = "global_settings"
107
+
108
+ id = Column(Integer, primary_key=True, index=True)
109
+ ollama_endpoint = Column(String(500), nullable=True, default=DEFAULT_OLLAMA_ENDPOINT)
110
+ llm_model = Column(String(100), nullable=True)
111
+ summarizer_model = Column(String(100), nullable=True)
112
+ vision_model = Column(String(100), nullable=True)
113
+ doc_vision_model = Column(String(100), nullable=True)
114
+ analysis_model = Column(String(100), nullable=True)
115
+ embedding_model = Column(String(100), nullable=True)
116
+ search_index_schema_version = Column(Integer, nullable=False, default=1, server_default="1")
117
+ search_query_expansion = Column(Boolean, nullable=False, default=True)
118
+ search_reranking_enabled = Column(Boolean, nullable=False, default=True)
119
+ search_rerank_pool = Column(Integer, nullable=False, default=15)
120
+ search_context_window = Column(Integer, nullable=False, default=1)
121
+ search_rerank_backend = Column(String(20), nullable=False, default="ollama")
122
+ search_rerank_model = Column(String(100), nullable=True)
123
+ ollama_num_ctx = Column(Integer, nullable=True)
124
+ ollama_num_ctx_agents_json = Column(Text, nullable=True)
125
+ ollama_profiles_json = Column(Text, nullable=True)
126
+ ollama_agent_endpoints_json = Column(Text, nullable=True)
127
+ marker_pdf_enabled = Column(Boolean, nullable=False, default=True, server_default="true")
128
+ marker_pdf_use_llm = Column(Boolean, nullable=False, default=False, server_default="false")
129
+ marker_pdf_ollama_model = Column(String(100), nullable=True)
130
+ vision_render_dpi = Column(Integer, nullable=False, default=200, server_default="200")
131
+ max_inflight_vision = Column(Integer, nullable=False, default=1, server_default="1")
132
+ max_inflight_text = Column(Integer, nullable=False, default=1, server_default="1")
133
+ l5x_analysis_concurrency = Column(Integer, nullable=False, default=1, server_default="1")
134
+ vision_timeout_seconds = Column(Integer, nullable=False, default=300, server_default="300")
135
+ text_timeout_seconds = Column(Integer, nullable=False, default=180, server_default="180")
136
+ find_logic_llm_model = Column(String(100), nullable=True)
137
+ documentation_search_llm_model = Column(String(100), nullable=True)
138
+ orchestrator_temperature = Column(Float, nullable=True)
139
+ summarizer_temperature = Column(Float, nullable=True)
140
+ summarizer_max_summary_chars = Column(Integer, nullable=True)
141
+ vision_temperature = Column(Float, nullable=True)
142
+ doc_vision_temperature = Column(Float, nullable=True)
143
+ analysis_temperature = Column(Float, nullable=True)
144
+ documentation_search_temperature = Column(Float, nullable=True)
145
+ find_logic_temperature = Column(Float, nullable=True)
146
+ search_rerank_temperature = Column(Float, nullable=True)
147
+ orchestrator_max_iterations = Column(Integer, nullable=True)
148
+ find_logic_default_max_iterations = Column(Integer, nullable=True)
149
+ documentation_search_default_max_iterations = Column(Integer, nullable=True)
150
+ find_logic_effort_low_max_iterations = Column(Integer, nullable=True)
151
+ find_logic_effort_medium_max_iterations = Column(Integer, nullable=True)
152
+ find_logic_effort_high_max_iterations = Column(Integer, nullable=True)
153
+ find_logic_system_prompt_extra = Column(Text, nullable=True)
154
+ find_logic_inner_user_message = Column(Text, nullable=True)
155
+ find_logic_allowed_tools_json = Column(Text, nullable=True)
156
+ ollama_warmup_on_start = Column(Boolean, nullable=False, default=False, server_default=sql_text("false"))
157
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
158
+
159
+
160
+ class DocumentAnalysis(Base):
161
+ """LLM-generated analysis for an uploaded document."""
162
+
163
+ __tablename__ = "document_analyses"
164
+
165
+ id = Column(Integer, primary_key=True, index=True)
166
+ document_id = Column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
167
+ llm_index_description = Column(Text, nullable=False)
168
+ parsed_text_with_images = Column(Text, nullable=True)
169
+ analyzed_at = Column(DateTime, default=func.now())
170
+
171
+
172
+ class DocumentPageImage(Base):
173
+ """Vision-model description for a document page containing images or diagrams."""
174
+
175
+ __tablename__ = "document_page_images"
176
+
177
+ id = Column(Integer, primary_key=True, index=True)
178
+ document_id = Column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
179
+ page_number = Column(Integer, nullable=False)
180
+ has_images = Column(Boolean, default=True, nullable=False)
181
+ llm_image_description = Column(Text, nullable=True)
182
+ llm_page_detail = Column(Text, nullable=True)
183
+ ocr_text = Column(Text, nullable=True)
184
+ created_at = Column(DateTime, default=func.now())
185
+
186
+
187
+ class DocumentPdfOutline(Base):
188
+ """Per-document TOC entries (physical_pdf_page is 0-based)."""
189
+
190
+ __tablename__ = "document_pdf_outline"
191
+
192
+ id = Column(Integer, primary_key=True, index=True)
193
+ document_id = Column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
194
+ level = Column(Integer, nullable=False)
195
+ title = Column(Text, nullable=False)
196
+ toc_page_claimed = Column(Integer, nullable=True)
197
+ physical_pdf_page = Column(Integer, nullable=False)
198
+ verification = Column(String(32), nullable=False)
199
+ sequence = Column(Integer, nullable=False)
200
+ created_at = Column(DateTime, default=func.now())
201
+
202
+
203
+ class Alert(Base):
204
+ """Tag-monitoring alert definition."""
205
+
206
+ __tablename__ = "alerts"
207
+
208
+ id = Column(Integer, primary_key=True, index=True)
209
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
210
+ tag_name = Column(String(255), nullable=False)
211
+ trigger_value_type = Column(String(20), nullable=False)
212
+ trigger_operator = Column(String(20), nullable=False, default="eq")
213
+ trigger_value = Column(Text, nullable=False)
214
+ command = Column(Text, nullable=False)
215
+ enabled = Column(Boolean, default=True, nullable=False)
216
+ created_at = Column(DateTime, default=func.now())
217
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
218
+
219
+
220
+ class AlertResponse(Base):
221
+ """Agent response to a triggered alert."""
222
+
223
+ __tablename__ = "alert_responses"
224
+
225
+ id = Column(Integer, primary_key=True, index=True)
226
+ alert_id = Column(Integer, ForeignKey("alerts.id", ondelete="CASCADE"), nullable=False, index=True)
227
+ tag_value = Column(Text, nullable=True)
228
+ agent_response = Column(Text, nullable=False, default="")
229
+ is_processing = Column(Boolean, default=False, nullable=False)
230
+ triggered_at = Column(DateTime, default=func.now())
231
+ responded_at = Column(DateTime, nullable=True)
232
+
233
+
234
+ class TagTroubleshootingSolution(Base):
235
+ """Human-approved resolution for a project tag."""
236
+
237
+ __tablename__ = "tag_troubleshooting_solutions"
238
+
239
+ id = Column(Integer, primary_key=True, index=True)
240
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
241
+ tag_name = Column(String(255), nullable=False, index=True)
242
+ solution_text = Column(Text, nullable=False)
243
+ source_ref = Column(String(255), nullable=True)
244
+ logged_by = Column(String(100), nullable=True)
245
+ created_at = Column(DateTime, default=func.now())
246
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
247
+
248
+ __table_args__ = (Index("ix_tag_trouble_solutions_project_tag", "project_id", "tag_name"),)
249
+
250
+
251
+ class BrainDraft(Base):
252
+ """Brain change audit record."""
253
+
254
+ __tablename__ = "brain_drafts"
255
+
256
+ id = Column(Integer, primary_key=True, index=True)
257
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
258
+ source_ref = Column(String(255), nullable=True)
259
+ status = Column(String(20), nullable=False, default="pending")
260
+ op = Column(String(50), nullable=False)
261
+ target_path = Column(String(500), nullable=False)
262
+ payload_json = Column(Text, nullable=False)
263
+ content_hash_before = Column(String(64), nullable=True)
264
+ created_at = Column(DateTime, default=func.now())
265
+ expires_at = Column(DateTime, nullable=True)
266
+ applied_at = Column(DateTime, nullable=True)
267
+
268
+
269
+ class MaintenanceLogEntry(Base):
270
+ """Shop maintenance log entry (retrieval-first)."""
271
+
272
+ __tablename__ = "maintenance_log_entries"
273
+
274
+ id = Column(Integer, primary_key=True, index=True)
275
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True, index=True)
276
+ title = Column(String(255), nullable=False)
277
+ body = Column(Text, nullable=False)
278
+ fault_code = Column(String(100), nullable=True, index=True)
279
+ equipment_tag = Column(String(255), nullable=True, index=True)
280
+ logged_by = Column(String(100), nullable=True)
281
+ deleted_at = Column(DateTime, nullable=True, index=True)
282
+ created_at = Column(DateTime, default=func.now(), index=True)
283
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
284
+ search_vector = Column(TSVECTOR, nullable=True)
285
+
286
+
287
+ class MaintenanceLogRevision(Base):
288
+ """Prior version of a maintenance log entry."""
289
+
290
+ __tablename__ = "maintenance_log_revisions"
291
+
292
+ id = Column(Integer, primary_key=True, index=True)
293
+ log_entry_id = Column(
294
+ Integer, ForeignKey("maintenance_log_entries.id", ondelete="CASCADE"), nullable=False, index=True
295
+ )
296
+ title = Column(String(255), nullable=False)
297
+ body = Column(Text, nullable=False)
298
+ fault_code = Column(String(100), nullable=True)
299
+ equipment_tag = Column(String(255), nullable=True)
300
+ logged_by = Column(String(100), nullable=True)
301
+ revision_number = Column(Integer, nullable=False)
302
+ created_at = Column(DateTime, default=func.now())
303
+
304
+
305
+ class BrainDocument(Base):
306
+ """Brain markdown stored in Postgres (canonical; local disk is optional cache)."""
307
+
308
+ __tablename__ = "brain_documents"
309
+
310
+ id = Column(Integer, primary_key=True, index=True)
311
+ scope = Column(String(20), nullable=False, index=True)
312
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True)
313
+ relative_path = Column(String(500), nullable=False)
314
+ content = Column(Text, nullable=False, default="")
315
+ deleted_at = Column(DateTime, nullable=True, index=True)
316
+ created_at = Column(DateTime, default=func.now())
317
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
318
+
319
+
320
+ class BrainDocumentRevision(Base):
321
+ """Prior version of a brain document."""
322
+
323
+ __tablename__ = "brain_document_revisions"
324
+
325
+ id = Column(Integer, primary_key=True, index=True)
326
+ brain_document_id = Column(
327
+ Integer, ForeignKey("brain_documents.id", ondelete="CASCADE"), nullable=False, index=True
328
+ )
329
+ relative_path = Column(String(500), nullable=False)
330
+ content = Column(Text, nullable=False)
331
+ revision_number = Column(Integer, nullable=False)
332
+ created_at = Column(DateTime, default=func.now())
333
+
334
+
335
+ class SearchIndex(Base):
336
+ """Hybrid search projection (tsvector + optional pgvector embedding).
337
+
338
+ The ``embedding`` column is ``vector(768)`` when pgvector is installed;
339
+ created in migration 002, not mapped here.
340
+ """
341
+
342
+ __tablename__ = "search_index"
343
+
344
+ id = Column(Integer, primary_key=True, index=True)
345
+ project_id = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
346
+ source_type = Column(String(50), nullable=False, index=True)
347
+ source_id = Column(String(500), nullable=True)
348
+ label = Column(String(500), nullable=True)
349
+ path = Column(String(500), nullable=True)
350
+ content = Column(Text, nullable=True)
351
+ chunk_index = Column(Integer, default=0)
352
+ section_id = Column(Integer, nullable=False, default=0)
353
+ entry_share = Column(String(20), nullable=False, default="project_only")
354
+ brain_type = Column(String(50), nullable=True)
355
+ brain_status = Column(String(50), nullable=True)
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env python3
2
+ """Enable pgvector in the FieldBrain database and ensure embedding schema."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import urllib.parse
9
+
10
+ from sqlalchemy import create_engine, text
11
+
12
+ from _db_lib import SCHEMA_VERSION, check_pgvector, load_database_config, run_health_check
13
+ from _json_out import emit, emit_error
14
+ from services.pgvector_windows import layman_pgvector_steps, windows_install_status
15
+
16
+
17
+ def _build_admin_url(host: str, port: int, database: str, user: str, password: str) -> str:
18
+ return (
19
+ f"postgresql://{urllib.parse.quote(user)}:{urllib.parse.quote(password)}"
20
+ f"@{host}:{port}/{urllib.parse.quote(database)}"
21
+ )
22
+
23
+
24
+ def _try_create_extension(app_url: str) -> tuple[bool, str | None]:
25
+ engine = create_engine(app_url, isolation_level="AUTOCOMMIT", pool_pre_ping=True)
26
+ try:
27
+ with engine.connect() as conn:
28
+ conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
29
+ with engine.connect() as conn:
30
+ row = conn.execute(
31
+ text("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
32
+ ).fetchone()
33
+ if row:
34
+ return True, str(row[0])
35
+ return False, "CREATE EXTENSION ran but vector extension not visible"
36
+ except Exception as exc:
37
+ return False, str(exc)
38
+ finally:
39
+ engine.dispose()
40
+
41
+
42
+ def _ensure_embedding_schema(app_url: str) -> tuple[bool, str | None]:
43
+ engine = create_engine(app_url, isolation_level="AUTOCOMMIT", pool_pre_ping=True)
44
+ try:
45
+ with engine.connect() as conn:
46
+ pg_ok = conn.execute(
47
+ text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")
48
+ ).fetchone()
49
+ if not pg_ok:
50
+ return False, "pgvector extension not enabled in this database"
51
+ conn.execute(
52
+ text("ALTER TABLE search_index ADD COLUMN IF NOT EXISTS embedding vector(768)")
53
+ )
54
+ conn.execute(
55
+ text(
56
+ """
57
+ CREATE INDEX IF NOT EXISTS idx_search_index_embedding
58
+ ON search_index USING hnsw (embedding vector_cosine_ops)
59
+ """
60
+ )
61
+ )
62
+ return True, None
63
+ except Exception as exc:
64
+ return False, str(exc)
65
+ finally:
66
+ engine.dispose()
67
+
68
+
69
+ def _run_migrate(app_url: str) -> tuple[int | None, str | None]:
70
+ from alembic import command
71
+
72
+ from _db_lib import read_applied_schema_version, reset_engine
73
+ from db_migrate import alembic_config
74
+
75
+ os.environ["SYLO_FIELDBRAIN_DATABASE_URL"] = app_url
76
+ reset_engine()
77
+ try:
78
+ command.upgrade(alembic_config(), "head")
79
+ except Exception as exc:
80
+ return None, str(exc)
81
+ reset_engine()
82
+ try:
83
+ applied = read_applied_schema_version()
84
+ except Exception as exc:
85
+ return None, f"Migration ran but schema version unreadable: {exc}"
86
+ if applied != SCHEMA_VERSION:
87
+ return applied, f"Expected schema {SCHEMA_VERSION}, got {applied}"
88
+ return applied, None
89
+
90
+
91
+ def main() -> None:
92
+ parser = argparse.ArgumentParser()
93
+ parser.add_argument("--admin-user", default="postgres")
94
+ parser.add_argument("--admin-password", default="")
95
+ parser.add_argument("--host", default="")
96
+ parser.add_argument("--port", type=int, default=0)
97
+ parser.add_argument("--database", default="")
98
+ args = parser.parse_args()
99
+
100
+ admin_password = (args.admin_password or os.environ.get("SYLO_FIELDBRAIN_BOOTSTRAP_ADMIN_PASSWORD") or "").strip()
101
+ if not admin_password:
102
+ emit_error("Postgres superuser password is required (not saved).")
103
+
104
+ cfg = load_database_config() or {}
105
+ host = (args.host or str(cfg.get("host") or "localhost")).strip()
106
+ db_mode = "remote" if host not in ("localhost", "127.0.0.1") else "local"
107
+ port = args.port or int(cfg.get("port") or 5432)
108
+ database = (args.database or str(cfg.get("database") or "fieldbrain")).strip()
109
+ admin_user = (args.admin_user or "postgres").strip()
110
+
111
+ health = run_health_check()
112
+ if health.get("postgres_connected") is not True:
113
+ emit_error(health.get("postgres_error", "Connect to Postgres first (Test connection)."))
114
+
115
+ major = health.get("postgres_major")
116
+ if isinstance(major, str) and major.isdigit():
117
+ major = int(major)
118
+ install = windows_install_status(major if isinstance(major, int) else None)
119
+
120
+ admin_url = _build_admin_url(host, port, database, admin_user, admin_password)
121
+ ext_ok, ext_detail = _try_create_extension(admin_url)
122
+ if not ext_ok:
123
+ guide = layman_pgvector_steps(
124
+ major if isinstance(major, int) else None,
125
+ files_installed=bool(install.get("vector_files_installed")),
126
+ db_mode=db_mode,
127
+ )
128
+ emit_error(
129
+ f"Could not enable pgvector: {ext_detail}",
130
+ pgvector_setup=guide,
131
+ **install,
132
+ )
133
+
134
+ app_user = str(cfg.get("username") or "fieldbrain")
135
+ app_password = str(cfg.get("password") or "")
136
+ app_url = _build_admin_url(host, port, database, app_user, app_password)
137
+ schema_ok, schema_err = _ensure_embedding_schema(app_url)
138
+ if not schema_ok:
139
+ emit_error(f"pgvector enabled but schema update failed: {schema_err}", pgvector_detail=ext_detail)
140
+
141
+ applied, migrate_err = _run_migrate(app_url)
142
+ if migrate_err:
143
+ emit_error(migrate_err, pgvector_detail=ext_detail)
144
+
145
+ pg_ok, pg_ver = check_pgvector()
146
+ lines = [
147
+ f"Semantic search enabled (pgvector {ext_detail}).",
148
+ f"Schema version {applied}.",
149
+ "Superuser password was not saved.",
150
+ "Re-ingest documents later if you want embeddings on existing files.",
151
+ ]
152
+ emit(
153
+ {
154
+ "ok": True,
155
+ "operator_chat": "\n".join(lines),
156
+ "pgvector_ok": pg_ok,
157
+ "pgvector_detail": pg_ver,
158
+ "applied_schema_version": applied,
159
+ **install,
160
+ }
161
+ )
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+ """Layman pgvector setup guide for FieldBrain."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from _db_lib import load_database_config, run_health_check
7
+ from _json_out import emit
8
+ from services.pgvector_windows import layman_pgvector_steps, windows_install_status
9
+
10
+
11
+ def main() -> None:
12
+ summary = run_health_check()
13
+ cfg = load_database_config() or {}
14
+ host = str(cfg.get("host") or "localhost").strip()
15
+ db_mode = "remote" if host not in ("localhost", "127.0.0.1") else "local"
16
+
17
+ major = summary.get("postgres_major")
18
+ if isinstance(major, str) and major.isdigit():
19
+ major = int(major)
20
+ install = windows_install_status(major if isinstance(major, int) else None)
21
+ pg_ok = summary.get("pgvector_available") is True
22
+ steps = layman_pgvector_steps(
23
+ major if isinstance(major, int) else None,
24
+ files_installed=bool(install.get("vector_files_installed")),
25
+ db_mode=db_mode,
26
+ )
27
+ if pg_ok:
28
+ steps = [
29
+ "pgvector is already enabled — semantic search is available when Ollama is running.",
30
+ ]
31
+ emit(
32
+ {
33
+ "ok": True,
34
+ "pgvector_ok": pg_ok,
35
+ "operator_chat": "\n".join(steps),
36
+ "pgvector_setup": steps,
37
+ **summary,
38
+ **install,
39
+ }
40
+ )
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Install pgvector files from an operator-selected folder or zip."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+
10
+ from _json_out import emit, emit_error
11
+ from services.pgvector_windows import (
12
+ guess_pgroot_for_major,
13
+ install_pgvector_files,
14
+ resolve_source_dir,
15
+ windows_install_status,
16
+ )
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--source", required=True, help="Folder or .zip containing pgvector files")
22
+ parser.add_argument("--postgres-major", type=int, default=0)
23
+ parser.add_argument("--pgroot", default="", help="Override PostgreSQL install directory")
24
+ args = parser.parse_args()
25
+
26
+ major = args.postgres_major or int(os.environ.get("SYLO_FIELDBRAIN_POSTGRES_MAJOR") or 0) or None
27
+ pgroot_raw = (args.pgroot or os.environ.get("SYLO_FIELDBRAIN_PGROOT") or "").strip()
28
+ if pgroot_raw:
29
+ from pathlib import Path
30
+
31
+ pgroot = Path(pgroot_raw)
32
+ elif major:
33
+ pgroot = guess_pgroot_for_major(major)
34
+ else:
35
+ pgroot = None
36
+
37
+ if pgroot is None:
38
+ emit_error("Could not determine PostgreSQL install folder. Connect to Postgres and retry.")
39
+
40
+ source_dir, tmp, err = resolve_source_dir(args.source)
41
+ if err or source_dir is None:
42
+ emit_error(err or "Invalid source path")
43
+
44
+ try:
45
+ result = install_pgvector_files(source_dir, pgroot)
46
+ finally:
47
+ if tmp is not None:
48
+ tmp.cleanup()
49
+
50
+ status = windows_install_status(major)
51
+ payload = {**result, **status}
52
+ if result.get("ok"):
53
+ emit(
54
+ {
55
+ **payload,
56
+ "operator_chat": (
57
+ f"Copied pgvector files into {result.get('pgroot')}.\n"
58
+ "Next: Enable semantic search in FieldBrain Settings."
59
+ ),
60
+ }
61
+ )
62
+ emit_error(result.get("error", "Install failed"), **payload)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()