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,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)