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,316 @@
|
|
|
1
|
+
"""Document metadata queries and attach/register helpers for sylo-logicscout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import or_, text
|
|
10
|
+
from sqlalchemy.orm import Session
|
|
11
|
+
|
|
12
|
+
from models import Document, Project, ProjectDocumentLink
|
|
13
|
+
from services.brain_paths import storage_root
|
|
14
|
+
from services.document_formats import (
|
|
15
|
+
ALLOWED_EXTENSIONS,
|
|
16
|
+
MAX_FILE_SIZE,
|
|
17
|
+
mime_for_ext,
|
|
18
|
+
normalize_category,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _file_hash(path: Path) -> str:
|
|
23
|
+
hasher = hashlib.sha256()
|
|
24
|
+
with open(path, "rb") as f:
|
|
25
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
26
|
+
hasher.update(chunk)
|
|
27
|
+
return hasher.hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def document_to_dict(document: Document, *, link_role: str | None = None) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"id": document.id,
|
|
33
|
+
"scope": document.scope,
|
|
34
|
+
"owner_project_id": document.owner_project_id,
|
|
35
|
+
"title": document.title,
|
|
36
|
+
"original_filename": document.original_filename,
|
|
37
|
+
"stored_path": document.stored_path,
|
|
38
|
+
"file_hash": document.file_hash,
|
|
39
|
+
"mime_type": document.mime_type,
|
|
40
|
+
"file_size": document.file_size,
|
|
41
|
+
"category": document.category,
|
|
42
|
+
"manufacturer": document.manufacturer,
|
|
43
|
+
"model": document.model,
|
|
44
|
+
"version": document.version,
|
|
45
|
+
"archived": bool(document.archived),
|
|
46
|
+
"created_at": document.created_at.isoformat() if document.created_at else None,
|
|
47
|
+
"tags": document.tags_json,
|
|
48
|
+
"link_role": link_role,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_document(session: Session, document_id: int) -> dict[str, Any] | None:
|
|
53
|
+
document = (
|
|
54
|
+
session.query(Document)
|
|
55
|
+
.filter(Document.id == document_id, Document.archived.is_(False))
|
|
56
|
+
.first()
|
|
57
|
+
)
|
|
58
|
+
if not document:
|
|
59
|
+
return None
|
|
60
|
+
return document_to_dict(document)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_documents_for_project(session: Session, project_id: int) -> list[dict[str, Any]]:
|
|
64
|
+
rows = (
|
|
65
|
+
session.query(Document, ProjectDocumentLink)
|
|
66
|
+
.join(ProjectDocumentLink, ProjectDocumentLink.document_id == Document.id)
|
|
67
|
+
.filter(ProjectDocumentLink.project_id == project_id, Document.archived.is_(False))
|
|
68
|
+
.order_by(ProjectDocumentLink.attached_at.desc())
|
|
69
|
+
.all()
|
|
70
|
+
)
|
|
71
|
+
return [document_to_dict(doc, link_role=link.role) for doc, link in rows]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_global_documents(
|
|
75
|
+
session: Session,
|
|
76
|
+
*,
|
|
77
|
+
category: str | None = None,
|
|
78
|
+
search: str | None = None,
|
|
79
|
+
limit: int = 200,
|
|
80
|
+
) -> list[dict[str, Any]]:
|
|
81
|
+
query = session.query(Document).filter(Document.scope == "global", Document.archived.is_(False))
|
|
82
|
+
if category and category.strip():
|
|
83
|
+
query = query.filter(Document.category == category.strip().lower())
|
|
84
|
+
term = (search or "").strip()
|
|
85
|
+
if term:
|
|
86
|
+
like = f"%{term}%"
|
|
87
|
+
query = query.filter(or_(Document.title.ilike(like), Document.original_filename.ilike(like)))
|
|
88
|
+
cap = max(1, min(int(limit), 500))
|
|
89
|
+
docs = query.order_by(Document.created_at.desc()).limit(cap).all()
|
|
90
|
+
return [document_to_dict(d) for d in docs]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def promote_document_to_global(session: Session, document_id: int) -> dict[str, Any]:
|
|
94
|
+
"""Promote a project-local document to the global library.
|
|
95
|
+
|
|
96
|
+
Re-scopes the row and moves its search_index rows to the global shell project
|
|
97
|
+
(embeddings preserved, no re-index). The project link stays so the doc remains
|
|
98
|
+
visible under its original project.
|
|
99
|
+
"""
|
|
100
|
+
from services.global_brain_support import get_global_brain_shell_project_id
|
|
101
|
+
|
|
102
|
+
doc = (
|
|
103
|
+
session.query(Document)
|
|
104
|
+
.filter(Document.id == document_id, Document.archived.is_(False))
|
|
105
|
+
.first()
|
|
106
|
+
)
|
|
107
|
+
if not doc:
|
|
108
|
+
raise ValueError("Document not found")
|
|
109
|
+
if doc.scope == "global":
|
|
110
|
+
return {"document": document_to_dict(doc), "promoted": False, "reason": "already_global"}
|
|
111
|
+
if doc.scope != "project_local":
|
|
112
|
+
raise ValueError(f"Cannot promote document with scope {doc.scope!r}")
|
|
113
|
+
|
|
114
|
+
old_project_id = doc.owner_project_id
|
|
115
|
+
global_pid = get_global_brain_shell_project_id(session)
|
|
116
|
+
|
|
117
|
+
moved = 0
|
|
118
|
+
if old_project_id is not None:
|
|
119
|
+
result = session.execute(
|
|
120
|
+
text(
|
|
121
|
+
"""
|
|
122
|
+
UPDATE search_index
|
|
123
|
+
SET project_id = :gpid
|
|
124
|
+
WHERE project_id = :old_pid
|
|
125
|
+
AND source_type = 'document'
|
|
126
|
+
AND source_id = :sid
|
|
127
|
+
"""
|
|
128
|
+
),
|
|
129
|
+
{"gpid": global_pid, "old_pid": old_project_id, "sid": str(doc.id)},
|
|
130
|
+
)
|
|
131
|
+
moved = result.rowcount or 0
|
|
132
|
+
|
|
133
|
+
doc.scope = "global"
|
|
134
|
+
session.commit()
|
|
135
|
+
session.refresh(doc)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"document": document_to_dict(doc),
|
|
139
|
+
"promoted": True,
|
|
140
|
+
"previous_project_id": old_project_id,
|
|
141
|
+
"index_rows_moved": moved,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def documents_storage_dir() -> Path:
|
|
146
|
+
"""Optional local cache directory (canonical bytes live in Postgres file_content)."""
|
|
147
|
+
root = storage_root() / "documents"
|
|
148
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
return root
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pg_stored_path(file_hash: str, ext: str) -> str:
|
|
153
|
+
return f"pg://documents/{file_hash}{ext}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_document_bytes(document: Document) -> bytes:
|
|
157
|
+
if document.file_content:
|
|
158
|
+
return bytes(document.file_content)
|
|
159
|
+
path = Path(document.stored_path)
|
|
160
|
+
if path.is_file():
|
|
161
|
+
return path.read_bytes()
|
|
162
|
+
raise ValueError(f"Document {document.id} has no file_content and stored_path is missing on disk")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def attach_document_to_project(
|
|
166
|
+
session: Session,
|
|
167
|
+
project_id: int,
|
|
168
|
+
document_id: int,
|
|
169
|
+
*,
|
|
170
|
+
role: str = "reference",
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""Attach an existing library or project-local document to a project."""
|
|
173
|
+
proj = session.query(Project).filter(Project.id == project_id).first()
|
|
174
|
+
if not proj:
|
|
175
|
+
raise ValueError("Project not found")
|
|
176
|
+
|
|
177
|
+
doc = (
|
|
178
|
+
session.query(Document)
|
|
179
|
+
.filter(Document.id == document_id, Document.archived.is_(False))
|
|
180
|
+
.first()
|
|
181
|
+
)
|
|
182
|
+
if not doc:
|
|
183
|
+
raise ValueError("Document not found")
|
|
184
|
+
|
|
185
|
+
if doc.scope == "global":
|
|
186
|
+
pass
|
|
187
|
+
elif doc.scope == "project_local":
|
|
188
|
+
if doc.owner_project_id != project_id:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"Document belongs to another project. Promote to global library first or attach from that project."
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError("Invalid document scope")
|
|
194
|
+
|
|
195
|
+
role_norm = "schematic" if (role or "").lower() == "schematic" else "reference"
|
|
196
|
+
link = (
|
|
197
|
+
session.query(ProjectDocumentLink)
|
|
198
|
+
.filter(
|
|
199
|
+
ProjectDocumentLink.project_id == project_id,
|
|
200
|
+
ProjectDocumentLink.document_id == document_id,
|
|
201
|
+
)
|
|
202
|
+
.first()
|
|
203
|
+
)
|
|
204
|
+
if link:
|
|
205
|
+
if link.role != role_norm:
|
|
206
|
+
link.role = role_norm
|
|
207
|
+
session.commit()
|
|
208
|
+
session.refresh(link)
|
|
209
|
+
return {
|
|
210
|
+
"document": document_to_dict(doc, link_role=link.role),
|
|
211
|
+
"link": {"project_id": project_id, "document_id": document_id, "role": link.role},
|
|
212
|
+
"already_linked": True,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
link = ProjectDocumentLink(
|
|
216
|
+
project_id=project_id,
|
|
217
|
+
document_id=document_id,
|
|
218
|
+
role=role_norm,
|
|
219
|
+
)
|
|
220
|
+
session.add(link)
|
|
221
|
+
session.commit()
|
|
222
|
+
session.refresh(link)
|
|
223
|
+
return {
|
|
224
|
+
"document": document_to_dict(doc, link_role=link.role),
|
|
225
|
+
"link": {"project_id": project_id, "document_id": document_id, "role": link.role},
|
|
226
|
+
"already_linked": False,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def register_document_from_path(
|
|
231
|
+
session: Session,
|
|
232
|
+
source_path: Path,
|
|
233
|
+
*,
|
|
234
|
+
scope: str,
|
|
235
|
+
project_id: int | None = None,
|
|
236
|
+
title: str | None = None,
|
|
237
|
+
category: str = "other",
|
|
238
|
+
attach: bool = True,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Copy a file into shared/storage/documents/ and create Document (+ optional link)."""
|
|
241
|
+
if not source_path.is_file():
|
|
242
|
+
raise ValueError(f"File not found: {source_path}")
|
|
243
|
+
|
|
244
|
+
scope_norm = (scope or "project").strip().lower()
|
|
245
|
+
if scope_norm == "global":
|
|
246
|
+
doc_scope = "global"
|
|
247
|
+
owner_project_id = None
|
|
248
|
+
attach_project_id = project_id
|
|
249
|
+
elif scope_norm == "project":
|
|
250
|
+
if project_id is None:
|
|
251
|
+
raise ValueError("project_id is required when scope is project")
|
|
252
|
+
doc_scope = "project_local"
|
|
253
|
+
owner_project_id = project_id
|
|
254
|
+
attach_project_id = project_id
|
|
255
|
+
proj = session.query(Project).filter(Project.id == project_id).first()
|
|
256
|
+
if not proj:
|
|
257
|
+
raise ValueError("Project not found")
|
|
258
|
+
else:
|
|
259
|
+
raise ValueError("scope must be global or project")
|
|
260
|
+
|
|
261
|
+
ext = source_path.suffix.lower()
|
|
262
|
+
if ext not in ALLOWED_EXTENSIONS:
|
|
263
|
+
raise ValueError(f"Unsupported file type. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
|
|
264
|
+
|
|
265
|
+
size = source_path.stat().st_size
|
|
266
|
+
if size > MAX_FILE_SIZE:
|
|
267
|
+
raise ValueError(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit")
|
|
268
|
+
|
|
269
|
+
file_hash = _file_hash(source_path)
|
|
270
|
+
existing = session.query(Document).filter(Document.file_hash == file_hash, Document.archived.is_(False)).first()
|
|
271
|
+
file_bytes = source_path.read_bytes()
|
|
272
|
+
|
|
273
|
+
if existing:
|
|
274
|
+
doc = existing
|
|
275
|
+
created = False
|
|
276
|
+
if not doc.file_content:
|
|
277
|
+
doc.file_content = file_bytes
|
|
278
|
+
session.commit()
|
|
279
|
+
session.refresh(doc)
|
|
280
|
+
else:
|
|
281
|
+
dest_name = f"{file_hash}{ext}"
|
|
282
|
+
logical_path = pg_stored_path(file_hash, ext)
|
|
283
|
+
# Optional local cache for operator inspection (not canonical).
|
|
284
|
+
cache_path = documents_storage_dir() / dest_name
|
|
285
|
+
if not cache_path.exists():
|
|
286
|
+
cache_path.write_bytes(file_bytes)
|
|
287
|
+
|
|
288
|
+
ttl = (title or "").strip() or source_path.stem
|
|
289
|
+
doc = Document(
|
|
290
|
+
scope=doc_scope,
|
|
291
|
+
owner_project_id=owner_project_id,
|
|
292
|
+
title=ttl[:255],
|
|
293
|
+
original_filename=source_path.name[:255],
|
|
294
|
+
stored_path=logical_path,
|
|
295
|
+
file_hash=file_hash,
|
|
296
|
+
mime_type=mime_for_ext(ext),
|
|
297
|
+
file_size=size,
|
|
298
|
+
category=normalize_category(category),
|
|
299
|
+
file_content=file_bytes,
|
|
300
|
+
)
|
|
301
|
+
session.add(doc)
|
|
302
|
+
session.commit()
|
|
303
|
+
session.refresh(doc)
|
|
304
|
+
created = True
|
|
305
|
+
|
|
306
|
+
linked = False
|
|
307
|
+
if attach and attach_project_id is not None:
|
|
308
|
+
result = attach_document_to_project(session, attach_project_id, doc.id)
|
|
309
|
+
linked = not result.get("already_linked", False)
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"document": document_to_dict(doc),
|
|
313
|
+
"created": created,
|
|
314
|
+
"linked": linked,
|
|
315
|
+
"scope": doc.scope,
|
|
316
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Sync Ollama embedding service for search index vectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from _db_lib import ollama_base_url
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
EMBEDDING_DIM = 768
|
|
15
|
+
BATCH_SIZE = 64
|
|
16
|
+
DEFAULT_EMBEDDING_MODEL = "nomic-embed-text"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_endpoint(endpoint: str) -> str:
|
|
20
|
+
return endpoint.rstrip("/").replace("/v1", "")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_embeddings(
|
|
24
|
+
texts: list[str],
|
|
25
|
+
endpoint: str | None = None,
|
|
26
|
+
model: str | None = None,
|
|
27
|
+
batch_size: int = BATCH_SIZE,
|
|
28
|
+
) -> list[list[float] | None]:
|
|
29
|
+
"""Generate embeddings for texts via Ollama /api/embed (sync).
|
|
30
|
+
|
|
31
|
+
Returns a list parallel to *texts*; each element is a vector or None on failure.
|
|
32
|
+
"""
|
|
33
|
+
if not texts:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
base = _normalize_endpoint(endpoint or ollama_base_url())
|
|
37
|
+
embed_model = (model or DEFAULT_EMBEDDING_MODEL).strip() or DEFAULT_EMBEDDING_MODEL
|
|
38
|
+
results: list[list[float] | None] = [None] * len(texts)
|
|
39
|
+
|
|
40
|
+
with httpx.Client(timeout=120.0) as client:
|
|
41
|
+
for start in range(0, len(texts), batch_size):
|
|
42
|
+
batch = texts[start : start + batch_size]
|
|
43
|
+
try:
|
|
44
|
+
resp = client.post(
|
|
45
|
+
f"{base}/api/embed",
|
|
46
|
+
json={"model": embed_model, "input": batch},
|
|
47
|
+
)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
data: dict[str, Any] = resp.json()
|
|
50
|
+
embeddings = data.get("embeddings") or []
|
|
51
|
+
for i, emb in enumerate(embeddings):
|
|
52
|
+
if i < len(batch) and emb:
|
|
53
|
+
results[start + i] = emb
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.error(
|
|
56
|
+
"Embedding batch failed offset=%d size=%d: %s",
|
|
57
|
+
start,
|
|
58
|
+
len(batch),
|
|
59
|
+
exc,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def generate_single_embedding(
|
|
66
|
+
text: str,
|
|
67
|
+
endpoint: str | None = None,
|
|
68
|
+
model: str | None = None,
|
|
69
|
+
) -> list[float] | None:
|
|
70
|
+
"""Generate one embedding vector; None on failure."""
|
|
71
|
+
results = generate_embeddings([text], endpoint=endpoint, model=model, batch_size=1)
|
|
72
|
+
return results[0] if results else None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Org-level global brain shell project (hidden Projects row for search_index FK)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from models import Project
|
|
8
|
+
from services.brain_paths import GLOBAL_BRAIN_PROJECT_NAME, ensure_global_brain_layout
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ensure_global_brain_shell_project(session: Session) -> Project:
|
|
12
|
+
"""Return or create the hidden project backing global/brain/ search rows."""
|
|
13
|
+
project = session.query(Project).filter(Project.name == GLOBAL_BRAIN_PROJECT_NAME).first()
|
|
14
|
+
if project:
|
|
15
|
+
if not project.is_system_hidden:
|
|
16
|
+
project.is_system_hidden = True
|
|
17
|
+
session.commit()
|
|
18
|
+
session.refresh(project)
|
|
19
|
+
return project
|
|
20
|
+
|
|
21
|
+
ensure_global_brain_layout()
|
|
22
|
+
project = Project(
|
|
23
|
+
name=GLOBAL_BRAIN_PROJECT_NAME,
|
|
24
|
+
is_system_hidden=True,
|
|
25
|
+
l5x_status="pending",
|
|
26
|
+
org_id="default",
|
|
27
|
+
)
|
|
28
|
+
session.add(project)
|
|
29
|
+
session.commit()
|
|
30
|
+
session.refresh(project)
|
|
31
|
+
return project
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_global_brain_shell_project_id(session: Session) -> int:
|
|
35
|
+
"""Database id for the global brain shell project."""
|
|
36
|
+
return ensure_global_brain_shell_project(session).id
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Maintenance log CRUD, search, soft delete, and revision history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import func, or_
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from models import MaintenanceLogEntry, MaintenanceLogRevision, Project
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def log_entry_to_dict(entry: MaintenanceLogEntry, *, project_name: str | None = None) -> dict[str, Any]:
|
|
15
|
+
return {
|
|
16
|
+
"id": entry.id,
|
|
17
|
+
"project_id": entry.project_id,
|
|
18
|
+
"project_name": project_name,
|
|
19
|
+
"title": entry.title,
|
|
20
|
+
"body": entry.body,
|
|
21
|
+
"fault_code": entry.fault_code,
|
|
22
|
+
"equipment_tag": entry.equipment_tag,
|
|
23
|
+
"logged_by": entry.logged_by,
|
|
24
|
+
"deleted_at": entry.deleted_at.isoformat() if entry.deleted_at else None,
|
|
25
|
+
"created_at": entry.created_at.isoformat() if entry.created_at else None,
|
|
26
|
+
"updated_at": entry.updated_at.isoformat() if entry.updated_at else None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _active_logs_query(session: Session):
|
|
31
|
+
return session.query(MaintenanceLogEntry, Project.name).outerjoin(
|
|
32
|
+
Project, MaintenanceLogEntry.project_id == Project.id
|
|
33
|
+
).filter(MaintenanceLogEntry.deleted_at.is_(None))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _next_log_revision(session: Session, entry_id: int) -> int:
|
|
37
|
+
current = (
|
|
38
|
+
session.query(func.max(MaintenanceLogRevision.revision_number))
|
|
39
|
+
.filter(MaintenanceLogRevision.log_entry_id == entry_id)
|
|
40
|
+
.scalar()
|
|
41
|
+
)
|
|
42
|
+
return int(current or 0) + 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save_log_revision(session: Session, entry: MaintenanceLogEntry) -> None:
|
|
46
|
+
rev = MaintenanceLogRevision(
|
|
47
|
+
log_entry_id=entry.id,
|
|
48
|
+
title=entry.title,
|
|
49
|
+
body=entry.body,
|
|
50
|
+
fault_code=entry.fault_code,
|
|
51
|
+
equipment_tag=entry.equipment_tag,
|
|
52
|
+
logged_by=entry.logged_by,
|
|
53
|
+
revision_number=_next_log_revision(session, entry.id),
|
|
54
|
+
)
|
|
55
|
+
session.add(rev)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_log_entry(
|
|
59
|
+
session: Session,
|
|
60
|
+
*,
|
|
61
|
+
title: str,
|
|
62
|
+
body: str,
|
|
63
|
+
project_id: int | None = None,
|
|
64
|
+
fault_code: str | None = None,
|
|
65
|
+
equipment_tag: str | None = None,
|
|
66
|
+
logged_by: str | None = None,
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
clean_title = title.strip()
|
|
69
|
+
clean_body = body.strip()
|
|
70
|
+
if not clean_title:
|
|
71
|
+
raise ValueError("title is required")
|
|
72
|
+
if not clean_body:
|
|
73
|
+
raise ValueError("body is required")
|
|
74
|
+
|
|
75
|
+
project_name = None
|
|
76
|
+
if project_id is not None:
|
|
77
|
+
project = session.query(Project).filter(Project.id == project_id).first()
|
|
78
|
+
if not project:
|
|
79
|
+
raise ValueError(f"Project {project_id} not found")
|
|
80
|
+
project_name = project.name
|
|
81
|
+
|
|
82
|
+
entry = MaintenanceLogEntry(
|
|
83
|
+
project_id=project_id,
|
|
84
|
+
title=clean_title,
|
|
85
|
+
body=clean_body,
|
|
86
|
+
fault_code=(fault_code or "").strip() or None,
|
|
87
|
+
equipment_tag=(equipment_tag or "").strip() or None,
|
|
88
|
+
logged_by=(logged_by or "").strip() or None,
|
|
89
|
+
)
|
|
90
|
+
session.add(entry)
|
|
91
|
+
session.commit()
|
|
92
|
+
session.refresh(entry)
|
|
93
|
+
return log_entry_to_dict(entry, project_name=project_name)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def update_log_entry(
|
|
97
|
+
session: Session,
|
|
98
|
+
entry_id: int,
|
|
99
|
+
*,
|
|
100
|
+
title: str | None = None,
|
|
101
|
+
body: str | None = None,
|
|
102
|
+
fault_code: str | None = None,
|
|
103
|
+
equipment_tag: str | None = None,
|
|
104
|
+
logged_by: str | None = None,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
entry = (
|
|
107
|
+
session.query(MaintenanceLogEntry)
|
|
108
|
+
.filter(MaintenanceLogEntry.id == entry_id, MaintenanceLogEntry.deleted_at.is_(None))
|
|
109
|
+
.first()
|
|
110
|
+
)
|
|
111
|
+
if not entry:
|
|
112
|
+
raise ValueError(f"Log entry {entry_id} not found")
|
|
113
|
+
|
|
114
|
+
_save_log_revision(session, entry)
|
|
115
|
+
|
|
116
|
+
if title is not None and title.strip():
|
|
117
|
+
entry.title = title.strip()
|
|
118
|
+
if body is not None and body.strip():
|
|
119
|
+
entry.body = body.strip()
|
|
120
|
+
if fault_code is not None:
|
|
121
|
+
entry.fault_code = fault_code.strip() or None
|
|
122
|
+
if equipment_tag is not None:
|
|
123
|
+
entry.equipment_tag = equipment_tag.strip() or None
|
|
124
|
+
if logged_by is not None:
|
|
125
|
+
entry.logged_by = logged_by.strip() or None
|
|
126
|
+
|
|
127
|
+
entry.updated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
128
|
+
session.commit()
|
|
129
|
+
session.refresh(entry)
|
|
130
|
+
|
|
131
|
+
project_name = None
|
|
132
|
+
if entry.project_id:
|
|
133
|
+
proj = session.query(Project).filter(Project.id == entry.project_id).first()
|
|
134
|
+
project_name = proj.name if proj else None
|
|
135
|
+
return log_entry_to_dict(entry, project_name=project_name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def soft_delete_log_entry(session: Session, entry_id: int) -> dict[str, Any]:
|
|
139
|
+
entry = (
|
|
140
|
+
session.query(MaintenanceLogEntry)
|
|
141
|
+
.filter(MaintenanceLogEntry.id == entry_id, MaintenanceLogEntry.deleted_at.is_(None))
|
|
142
|
+
.first()
|
|
143
|
+
)
|
|
144
|
+
if not entry:
|
|
145
|
+
raise ValueError(f"Log entry {entry_id} not found")
|
|
146
|
+
|
|
147
|
+
_save_log_revision(session, entry)
|
|
148
|
+
entry.deleted_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
149
|
+
session.commit()
|
|
150
|
+
return {"ok": True, "id": entry_id, "soft_deleted": True}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def restore_log_entry(session: Session, entry_id: int) -> dict[str, Any]:
|
|
154
|
+
entry = (
|
|
155
|
+
session.query(MaintenanceLogEntry)
|
|
156
|
+
.filter(MaintenanceLogEntry.id == entry_id, MaintenanceLogEntry.deleted_at.isnot(None))
|
|
157
|
+
.first()
|
|
158
|
+
)
|
|
159
|
+
if not entry:
|
|
160
|
+
raise ValueError(f"No deleted log entry {entry_id} to restore")
|
|
161
|
+
|
|
162
|
+
entry.deleted_at = None
|
|
163
|
+
session.commit()
|
|
164
|
+
session.refresh(entry)
|
|
165
|
+
|
|
166
|
+
project_name = None
|
|
167
|
+
if entry.project_id:
|
|
168
|
+
proj = session.query(Project).filter(Project.id == entry.project_id).first()
|
|
169
|
+
project_name = proj.name if proj else None
|
|
170
|
+
return log_entry_to_dict(entry, project_name=project_name)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def list_log_revisions(session: Session, entry_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
174
|
+
cap = max(1, min(int(limit), 100))
|
|
175
|
+
rows = (
|
|
176
|
+
session.query(MaintenanceLogRevision)
|
|
177
|
+
.filter(MaintenanceLogRevision.log_entry_id == entry_id)
|
|
178
|
+
.order_by(MaintenanceLogRevision.revision_number.desc())
|
|
179
|
+
.limit(cap)
|
|
180
|
+
.all()
|
|
181
|
+
)
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
"revision_number": r.revision_number,
|
|
185
|
+
"title": r.title,
|
|
186
|
+
"body_preview": (r.body[:400] + "…") if len(r.body) > 400 else r.body,
|
|
187
|
+
"fault_code": r.fault_code,
|
|
188
|
+
"equipment_tag": r.equipment_tag,
|
|
189
|
+
"logged_by": r.logged_by,
|
|
190
|
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
191
|
+
}
|
|
192
|
+
for r in rows
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def search_log_entries(
|
|
197
|
+
session: Session,
|
|
198
|
+
*,
|
|
199
|
+
query: str,
|
|
200
|
+
project_id: int | None = None,
|
|
201
|
+
fault_code: str | None = None,
|
|
202
|
+
equipment_tag: str | None = None,
|
|
203
|
+
limit: int = 25,
|
|
204
|
+
include_deleted: bool = False,
|
|
205
|
+
) -> list[dict[str, Any]]:
|
|
206
|
+
term = (query or "").strip()
|
|
207
|
+
cap = max(1, min(int(limit), 100))
|
|
208
|
+
|
|
209
|
+
base = session.query(MaintenanceLogEntry, Project.name).outerjoin(
|
|
210
|
+
Project, MaintenanceLogEntry.project_id == Project.id
|
|
211
|
+
)
|
|
212
|
+
if not include_deleted:
|
|
213
|
+
base = base.filter(MaintenanceLogEntry.deleted_at.is_(None))
|
|
214
|
+
|
|
215
|
+
if project_id is not None:
|
|
216
|
+
base = base.filter(MaintenanceLogEntry.project_id == project_id)
|
|
217
|
+
if fault_code and fault_code.strip():
|
|
218
|
+
base = base.filter(MaintenanceLogEntry.fault_code.ilike(fault_code.strip()))
|
|
219
|
+
if equipment_tag and equipment_tag.strip():
|
|
220
|
+
base = base.filter(MaintenanceLogEntry.equipment_tag.ilike(f"%{equipment_tag.strip()}%"))
|
|
221
|
+
|
|
222
|
+
if term:
|
|
223
|
+
ts_query = func.plainto_tsquery("english", term)
|
|
224
|
+
ranked = base.filter(MaintenanceLogEntry.search_vector.op("@@")(ts_query)).order_by(
|
|
225
|
+
func.ts_rank_cd(MaintenanceLogEntry.search_vector, ts_query).desc(),
|
|
226
|
+
MaintenanceLogEntry.created_at.desc(),
|
|
227
|
+
)
|
|
228
|
+
rows = ranked.limit(cap).all()
|
|
229
|
+
if rows:
|
|
230
|
+
return [log_entry_to_dict(entry, project_name=name) for entry, name in rows]
|
|
231
|
+
|
|
232
|
+
like = f"%{term}%"
|
|
233
|
+
fallback = base.filter(
|
|
234
|
+
or_(
|
|
235
|
+
MaintenanceLogEntry.title.ilike(like),
|
|
236
|
+
MaintenanceLogEntry.body.ilike(like),
|
|
237
|
+
MaintenanceLogEntry.fault_code.ilike(like),
|
|
238
|
+
MaintenanceLogEntry.equipment_tag.ilike(like),
|
|
239
|
+
)
|
|
240
|
+
).order_by(MaintenanceLogEntry.created_at.desc())
|
|
241
|
+
rows = fallback.limit(cap).all()
|
|
242
|
+
return [log_entry_to_dict(entry, project_name=name) for entry, name in rows]
|
|
243
|
+
|
|
244
|
+
rows = base.order_by(MaintenanceLogEntry.created_at.desc()).limit(cap).all()
|
|
245
|
+
return [log_entry_to_dict(entry, project_name=name) for entry, name in rows]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_log_entry(session: Session, entry_id: int, *, include_deleted: bool = False) -> dict[str, Any] | None:
|
|
249
|
+
q = session.query(MaintenanceLogEntry, Project.name).outerjoin(
|
|
250
|
+
Project, MaintenanceLogEntry.project_id == Project.id
|
|
251
|
+
).filter(MaintenanceLogEntry.id == entry_id)
|
|
252
|
+
if not include_deleted:
|
|
253
|
+
q = q.filter(MaintenanceLogEntry.deleted_at.is_(None))
|
|
254
|
+
row = q.first()
|
|
255
|
+
if not row:
|
|
256
|
+
return None
|
|
257
|
+
entry, name = row
|
|
258
|
+
return log_entry_to_dict(entry, project_name=name)
|