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,24 @@
1
+ """Postgres advisory lock while applying Alembic migrations (shared DB, many Sylo installs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+
7
+ from sqlalchemy import text
8
+ from sqlalchemy.engine import Engine
9
+
10
+ # Stable lock id for FieldBrain schema migrations.
11
+ MIGRATE_LOCK_KEY = 74201904
12
+
13
+
14
+ @contextmanager
15
+ def migration_advisory_lock(engine: Engine):
16
+ with engine.connect() as conn:
17
+ conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": MIGRATE_LOCK_KEY})
18
+ conn.commit()
19
+ try:
20
+ yield
21
+ finally:
22
+ with engine.connect() as conn:
23
+ conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": MIGRATE_LOCK_KEY})
24
+ conn.commit()
@@ -0,0 +1,190 @@
1
+ """Windows pgvector file install + setup hints for FieldBrain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from sqlalchemy import text
13
+ from sqlalchemy.engine import Engine
14
+
15
+ PREBUILT_RELEASES_PAGE = "https://github.com/andreiramani/pgvector_pgsql_windows/releases"
16
+
17
+
18
+ def postgres_install_roots() -> list[tuple[int, Path]]:
19
+ """Installed PostgreSQL directories under Program Files (Windows)."""
20
+ roots: list[tuple[int, Path]] = []
21
+ for base in (Path(r"C:\Program Files\PostgreSQL"), Path(r"C:\Program Files (x86)\PostgreSQL")):
22
+ if not base.is_dir():
23
+ continue
24
+ for child in sorted(base.iterdir()):
25
+ if child.is_dir() and child.name.isdigit():
26
+ roots.append((int(child.name), child))
27
+ return roots
28
+
29
+
30
+ def vector_control_path(pgroot: Path) -> Path:
31
+ return pgroot / "share" / "extension" / "vector.control"
32
+
33
+
34
+ def vector_files_installed(pgroot: Path) -> bool:
35
+ return vector_control_path(pgroot).is_file()
36
+
37
+
38
+ def detect_postgres_major(engine: Engine) -> int | None:
39
+ """Read server major version from a live connection."""
40
+ try:
41
+ with engine.connect() as conn:
42
+ row = conn.execute(text("SHOW server_version")).fetchone()
43
+ if not row or not row[0]:
44
+ return None
45
+ match = re.match(r"^(\d+)", str(row[0]))
46
+ return int(match.group(1)) if match else None
47
+ except Exception:
48
+ return None
49
+
50
+
51
+ def guess_pgroot_for_major(major: int) -> Path | None:
52
+ for ver, root in postgres_install_roots():
53
+ if ver == major:
54
+ return root
55
+ return Path(rf"C:\Program Files\PostgreSQL\{major}")
56
+
57
+
58
+ def resolve_source_dir(source: str | Path) -> tuple[Path | None, tempfile.TemporaryDirectory[str] | None, str | None]:
59
+ """Return a directory containing pgvector artifacts; extract zip when needed."""
60
+ path = Path(source).expanduser().resolve()
61
+ if not path.exists():
62
+ return None, None, f"Path not found: {path}"
63
+
64
+ if path.is_file() and path.suffix.lower() == ".zip":
65
+ tmp = tempfile.TemporaryDirectory(prefix="sylo-pgvector-")
66
+ with zipfile.ZipFile(path) as zf:
67
+ zf.extractall(tmp.name)
68
+ return Path(tmp.name), tmp, None
69
+
70
+ if path.is_dir():
71
+ return path, None, None
72
+
73
+ return None, None, f"Select a pgvector folder or .zip file: {path}"
74
+
75
+
76
+ def _collect_pgvector_files(source_dir: Path) -> tuple[list[Path], list[Path], str | None]:
77
+ dlls: list[Path] = []
78
+ ext_files: list[Path] = []
79
+ for file in source_dir.rglob("*"):
80
+ if not file.is_file():
81
+ continue
82
+ name = file.name.lower()
83
+ if name.endswith(".dll"):
84
+ dlls.append(file)
85
+ elif name == "vector.control" or (name.startswith("vector--") and name.endswith(".sql")):
86
+ ext_files.append(file)
87
+ if not ext_files:
88
+ return [], [], "No vector.control or vector--*.sql found in the selected folder."
89
+ return dlls, ext_files, None
90
+
91
+
92
+ def install_pgvector_files(source_dir: Path, pgroot: Path) -> dict[str, Any]:
93
+ """Copy pgvector binaries/extension SQL into a PostgreSQL install root."""
94
+ dlls, ext_files, err = _collect_pgvector_files(source_dir)
95
+ if err:
96
+ return {"ok": False, "error": err}
97
+
98
+ lib_dir = pgroot / "lib"
99
+ ext_dir = pgroot / "share" / "extension"
100
+ copied: list[str] = []
101
+
102
+ try:
103
+ lib_dir.mkdir(parents=True, exist_ok=True)
104
+ ext_dir.mkdir(parents=True, exist_ok=True)
105
+ for dll in dlls:
106
+ dest = lib_dir / dll.name
107
+ shutil.copy2(dll, dest)
108
+ copied.append(str(dest))
109
+ for ext in ext_files:
110
+ dest = ext_dir / ext.name
111
+ shutil.copy2(ext, dest)
112
+ copied.append(str(dest))
113
+ except PermissionError as exc:
114
+ return {
115
+ "ok": False,
116
+ "error": f"Permission denied copying into {pgroot}. Approve the admin prompt or run Sylo as administrator.",
117
+ "needs_elevation": True,
118
+ "pgroot": str(pgroot),
119
+ "source_dir": str(source_dir),
120
+ }
121
+ except OSError as exc:
122
+ return {"ok": False, "error": str(exc), "pgroot": str(pgroot)}
123
+
124
+ if not vector_files_installed(pgroot):
125
+ return {
126
+ "ok": False,
127
+ "error": f"Copy finished but {vector_control_path(pgroot)} is still missing.",
128
+ "copied": copied,
129
+ "pgroot": str(pgroot),
130
+ }
131
+
132
+ return {
133
+ "ok": True,
134
+ "copied": copied,
135
+ "pgroot": str(pgroot),
136
+ "vector_control_path": str(vector_control_path(pgroot)),
137
+ }
138
+
139
+
140
+ def windows_install_status(postgres_major: int | None) -> dict[str, Any]:
141
+ """Whether pgvector files appear on disk for the detected major version."""
142
+ major = postgres_major
143
+ pgroot = guess_pgroot_for_major(major) if major else None
144
+ files_ok = bool(pgroot and vector_files_installed(pgroot))
145
+ return {
146
+ "postgres_major": major,
147
+ "pgroot": str(pgroot) if pgroot else None,
148
+ "vector_files_installed": files_ok,
149
+ "vector_control_path": str(vector_control_path(pgroot)) if pgroot else None,
150
+ "prebuilt_releases_page": PREBUILT_RELEASES_PAGE,
151
+ }
152
+
153
+
154
+ def guided_pgvector_download_step(postgres_major: int | None) -> str:
155
+ major = postgres_major if postgres_major else "your Postgres major version"
156
+ return (
157
+ f"Optional — semantic search: download pgvector for PostgreSQL {major} from "
158
+ f"{PREBUILT_RELEASES_PAGE}, extract the zip, then use Browse below."
159
+ )
160
+
161
+
162
+ def layman_pgvector_steps(
163
+ postgres_major: int | None,
164
+ *,
165
+ files_installed: bool,
166
+ db_mode: str = "local",
167
+ ) -> list[str]:
168
+ """Plain-language steps for optional semantic search."""
169
+ if db_mode == "remote":
170
+ return [
171
+ "Optional semantic search needs pgvector installed on the shared Postgres server (not this PC).",
172
+ guided_pgvector_download_step(postgres_major).replace("Browse below", "have your server admin install the files"),
173
+ "Then click Enable semantic search here (superuser password, not saved).",
174
+ "Test connection — pgvector should show enabled.",
175
+ ]
176
+
177
+ if files_installed:
178
+ return [
179
+ "pgvector files are on this PC.",
180
+ "Click Enable semantic search (superuser password above — not saved).",
181
+ "Test connection — pgvector should show enabled.",
182
+ ]
183
+
184
+ return [
185
+ "Optional: keyword search already works without pgvector.",
186
+ guided_pgvector_download_step(postgres_major),
187
+ "FieldBrain Settings → Browse → pick the extracted folder (or the .zip).",
188
+ "Click Install pgvector & enable semantic search (superuser password, not saved).",
189
+ "Test connection again.",
190
+ ]
@@ -0,0 +1,72 @@
1
+ """Project naming: job 12345, sub-project 12345-001."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ JOB_PATTERN = re.compile(r"^(\d{5})$")
8
+ SUB_SUFFIX_PATTERN = re.compile(r"^(\d{3})$")
9
+ SUBJOB_PATTERN = re.compile(r"^(\d{5})-(\d{3})$")
10
+
11
+
12
+ def normalize_digits(raw: str) -> str:
13
+ return raw.strip()
14
+
15
+
16
+ def compose_subproject_name(job_number: str, sub_number: str) -> str:
17
+ return f"{job_number}-{sub_number}"
18
+
19
+
20
+ def parse_job_number(name: str) -> str | None:
21
+ clean = normalize_digits(name)
22
+ job = JOB_PATTERN.match(clean)
23
+ if job:
24
+ return job.group(1)
25
+ sub = SUBJOB_PATTERN.match(clean)
26
+ if sub:
27
+ return sub.group(1)
28
+ return None
29
+
30
+
31
+ def parse_subproject_suffix(raw: str) -> str | None:
32
+ """Accept 001 or 12345-001; return three-digit suffix."""
33
+ clean = normalize_digits(raw)
34
+ if not clean:
35
+ return None
36
+ suffix = SUB_SUFFIX_PATTERN.match(clean)
37
+ if suffix:
38
+ return suffix.group(1)
39
+ full = SUBJOB_PATTERN.match(clean)
40
+ if full:
41
+ return full.group(2)
42
+ return None
43
+
44
+
45
+ def parse_project_name(name: str) -> tuple[str | None, str | None]:
46
+ """Return (job_number, sub_suffix) from a stored project name."""
47
+ clean = normalize_digits(name)
48
+ job = JOB_PATTERN.match(clean)
49
+ if job:
50
+ return job.group(1), None
51
+ sub = SUBJOB_PATTERN.match(clean)
52
+ if sub:
53
+ return sub.group(1), sub.group(2)
54
+ return None, None
55
+
56
+
57
+ def validate_job_number(raw: str) -> str:
58
+ clean = normalize_digits(raw)
59
+ if not JOB_PATTERN.match(clean):
60
+ raise ValueError("Project number must be five digits (e.g. 12345).")
61
+ return clean
62
+
63
+
64
+ def validate_subproject_number(raw: str, *, job_number: str) -> str:
65
+ clean = normalize_digits(raw)
66
+ suffix = parse_subproject_suffix(clean)
67
+ if suffix is None:
68
+ raise ValueError("Sub-project number must be three digits (e.g. 001) or full 12345-001.")
69
+ full = SUBJOB_PATTERN.match(clean)
70
+ if full and full.group(1) != job_number:
71
+ raise ValueError(f"Sub-project {clean!r} does not match project number {job_number}.")
72
+ return suffix
@@ -0,0 +1,368 @@
1
+ """Project CRUD helpers for sylo-fieldbrain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Literal
7
+
8
+ from sqlalchemy import func, or_
9
+ from sqlalchemy.orm import Session
10
+
11
+ from models import (
12
+ BrainDocument,
13
+ Document,
14
+ MaintenanceLogEntry,
15
+ Project,
16
+ ProjectDocumentLink,
17
+ )
18
+ from services.project_naming import (
19
+ JOB_PATTERN,
20
+ SUBJOB_PATTERN,
21
+ compose_subproject_name,
22
+ normalize_digits,
23
+ parse_job_number,
24
+ parse_project_name,
25
+ validate_job_number,
26
+ validate_subproject_number,
27
+ )
28
+
29
+
30
+ def _project_tags_list(project: Project) -> list[str]:
31
+ raw = getattr(project, "project_tags_json", None)
32
+ if not raw:
33
+ return []
34
+ try:
35
+ data = json.loads(raw)
36
+ if isinstance(data, list):
37
+ return [str(x) for x in data if str(x).strip()]
38
+ except json.JSONDecodeError:
39
+ pass
40
+ return []
41
+
42
+
43
+ def _project_level(project: Project) -> Literal["job", "sub_project", "other"]:
44
+ name = project.name or ""
45
+ if project.parent_project_id is not None or SUBJOB_PATTERN.match(name):
46
+ return "sub_project"
47
+ if JOB_PATTERN.match(name):
48
+ return "job"
49
+ return "other"
50
+
51
+
52
+ def project_to_dict(project: Project, *, stats: dict[str, int] | None = None) -> dict[str, Any]:
53
+ level = _project_level(project)
54
+ out: dict[str, Any] = {
55
+ "id": project.id,
56
+ "name": project.name,
57
+ "job_number": project.job_number,
58
+ "parent_project_id": project.parent_project_id,
59
+ "level": level,
60
+ "controller_name": project.controller_name,
61
+ "processor_type": project.processor_type,
62
+ "software_revision": project.software_revision,
63
+ "l5x_file_path": project.l5x_file_path,
64
+ "l5x_file_hash": project.l5x_file_hash,
65
+ "plc_ip": project.plc_ip,
66
+ "plc_read_enabled": bool(project.plc_read_enabled),
67
+ "org_id": project.org_id or "default",
68
+ "project_tags": _project_tags_list(project),
69
+ "l5x_status": project.l5x_status or "analyzed",
70
+ "created_at": project.created_at.isoformat() if project.created_at else None,
71
+ "updated_at": project.updated_at.isoformat() if project.updated_at else None,
72
+ }
73
+ if stats is not None:
74
+ out["stats"] = stats
75
+ return out
76
+
77
+
78
+ def find_project_by_name(session: Session, name: str, *, org_id: str = "default") -> Project | None:
79
+ clean = normalize_digits(name)
80
+ if not clean:
81
+ return None
82
+ return (
83
+ session.query(Project)
84
+ .filter(
85
+ Project.org_id == (org_id or "default"),
86
+ func.lower(Project.name) == clean.lower(),
87
+ )
88
+ .first()
89
+ )
90
+
91
+
92
+ def find_job_project(session: Session, job_number: str, *, org_id: str = "default") -> Project | None:
93
+ return find_project_by_name(session, job_number, org_id=org_id)
94
+
95
+
96
+ def _brain_counts(session: Session) -> dict[int, int]:
97
+ rows = (
98
+ session.query(BrainDocument.project_id, func.count(BrainDocument.id))
99
+ .filter(
100
+ BrainDocument.deleted_at.is_(None),
101
+ BrainDocument.scope == "project",
102
+ BrainDocument.project_id.isnot(None),
103
+ )
104
+ .group_by(BrainDocument.project_id)
105
+ .all()
106
+ )
107
+ return {int(pid): int(count) for pid, count in rows if pid is not None}
108
+
109
+
110
+ def _log_counts(session: Session) -> dict[int, int]:
111
+ rows = (
112
+ session.query(MaintenanceLogEntry.project_id, func.count(MaintenanceLogEntry.id))
113
+ .filter(
114
+ MaintenanceLogEntry.deleted_at.is_(None),
115
+ MaintenanceLogEntry.project_id.isnot(None),
116
+ )
117
+ .group_by(MaintenanceLogEntry.project_id)
118
+ .all()
119
+ )
120
+ return {int(pid): int(count) for pid, count in rows if pid is not None}
121
+
122
+
123
+ def _library_doc_counts(session: Session) -> dict[int, int]:
124
+ linked = (
125
+ session.query(
126
+ ProjectDocumentLink.project_id.label("pid"),
127
+ ProjectDocumentLink.document_id.label("doc_id"),
128
+ )
129
+ .join(Document, Document.id == ProjectDocumentLink.document_id)
130
+ .filter(Document.archived.is_(False))
131
+ )
132
+ owned = session.query(
133
+ Document.owner_project_id.label("pid"),
134
+ Document.id.label("doc_id"),
135
+ ).filter(
136
+ Document.archived.is_(False),
137
+ Document.scope == "project_local",
138
+ Document.owner_project_id.isnot(None),
139
+ )
140
+ combined = linked.union_all(owned).subquery()
141
+ rows = (
142
+ session.query(combined.c.pid, func.count(func.distinct(combined.c.doc_id)))
143
+ .group_by(combined.c.pid)
144
+ .all()
145
+ )
146
+ return {int(pid): int(count) for pid, count in rows if pid is not None}
147
+
148
+
149
+ def _stats_for_projects(session: Session, project_ids: list[int]) -> dict[int, dict[str, int]]:
150
+ if not project_ids:
151
+ return {}
152
+ brains = _brain_counts(session)
153
+ logs = _log_counts(session)
154
+ docs = _library_doc_counts(session)
155
+ out: dict[int, dict[str, int]] = {}
156
+ for pid in project_ids:
157
+ out[pid] = {
158
+ "brain_entries": brains.get(pid, 0),
159
+ "library_docs": docs.get(pid, 0),
160
+ "maintenance_logs": logs.get(pid, 0),
161
+ }
162
+ return out
163
+
164
+
165
+ def _sort_projects(projects: list[Project]) -> list[Project]:
166
+ def key(p: Project) -> tuple[str, int, str]:
167
+ job = p.job_number or "zzzzz"
168
+ level_rank = 0 if _project_level(p) == "job" else 1
169
+ return (job, level_rank, p.name or "")
170
+
171
+ return sorted(projects, key=key)
172
+
173
+
174
+ def list_projects(session: Session, *, include_hidden: bool = False) -> list[dict[str, Any]]:
175
+ query = session.query(Project)
176
+ if not include_hidden:
177
+ query = query.filter(or_(Project.is_system_hidden.is_(False), Project.is_system_hidden.is_(None)))
178
+ return [project_to_dict(p) for p in _sort_projects(query.all())]
179
+
180
+
181
+ def list_projects_with_stats(session: Session, *, include_hidden: bool = False) -> list[dict[str, Any]]:
182
+ query = session.query(Project)
183
+ if not include_hidden:
184
+ query = query.filter(or_(Project.is_system_hidden.is_(False), Project.is_system_hidden.is_(None)))
185
+ projects = _sort_projects(query.all())
186
+ ids = [p.id for p in projects]
187
+ stats_map = _stats_for_projects(session, ids)
188
+ empty = {"brain_entries": 0, "library_docs": 0, "maintenance_logs": 0}
189
+ return [project_to_dict(p, stats=stats_map.get(p.id, empty)) for p in projects]
190
+
191
+
192
+ def get_project(session: Session, project_id: int) -> dict[str, Any] | None:
193
+ project = session.query(Project).filter(Project.id == project_id).first()
194
+ if not project:
195
+ return None
196
+ stats = _stats_for_projects(session, [project.id]).get(project.id)
197
+ return project_to_dict(project, stats=stats)
198
+
199
+
200
+ def _insert_project(
201
+ session: Session,
202
+ *,
203
+ name: str,
204
+ job_number: str,
205
+ parent_project_id: int | None,
206
+ project_tags: list[str] | None,
207
+ org_id: str,
208
+ ) -> Project:
209
+ tags_json = None
210
+ if project_tags:
211
+ cleaned = [str(t).strip() for t in project_tags if str(t).strip()]
212
+ if cleaned:
213
+ tags_json = json.dumps(cleaned)
214
+
215
+ project = Project(
216
+ name=name[:255],
217
+ job_number=job_number,
218
+ parent_project_id=parent_project_id,
219
+ project_tags_json=tags_json,
220
+ org_id=org_id,
221
+ l5x_status="pending",
222
+ )
223
+ session.add(project)
224
+ session.commit()
225
+ session.refresh(project)
226
+ return project
227
+
228
+
229
+ def ensure_job_project(
230
+ session: Session,
231
+ job_number: str,
232
+ *,
233
+ org_id: str = "default",
234
+ project_tags: list[str] | None = None,
235
+ ) -> tuple[Project, bool]:
236
+ org = (org_id or "default").strip() or "default"
237
+ existing = find_job_project(session, job_number, org_id=org)
238
+ if existing:
239
+ return existing, False
240
+ project = _insert_project(
241
+ session,
242
+ name=job_number,
243
+ job_number=job_number,
244
+ parent_project_id=None,
245
+ project_tags=project_tags,
246
+ org_id=org,
247
+ )
248
+ return project, True
249
+
250
+
251
+ def create_project_structured(
252
+ session: Session,
253
+ *,
254
+ job_number: str,
255
+ sub_project_number: str | None = None,
256
+ project_tags: list[str] | None = None,
257
+ org_id: str = "default",
258
+ ) -> dict[str, Any]:
259
+ """Create job-level project (12345) and/or sub-project (12345-001)."""
260
+ job = validate_job_number(job_number)
261
+ org = (org_id or "default").strip() or "default"
262
+ sub_raw = (sub_project_number or "").strip()
263
+
264
+ if not sub_raw:
265
+ existing = find_job_project(session, job, org_id=org)
266
+ if existing:
267
+ raise ValueError(
268
+ f"Project {job!r} already exists (id={existing.id}). "
269
+ "Use that id in chat or add a sub-project number."
270
+ )
271
+ project = _insert_project(
272
+ session,
273
+ name=job,
274
+ job_number=job,
275
+ parent_project_id=None,
276
+ project_tags=project_tags,
277
+ org_id=org,
278
+ )
279
+ else:
280
+ suffix = validate_subproject_number(sub_raw, job_number=job)
281
+ full_name = compose_subproject_name(job, suffix)
282
+ existing = find_project_by_name(session, full_name, org_id=org)
283
+ if existing:
284
+ raise ValueError(
285
+ f"Sub-project {full_name!r} already exists (id={existing.id}). "
286
+ "Use that project or pick a different sub-project number."
287
+ )
288
+ parent, _ = ensure_job_project(session, job, org_id=org, project_tags=None)
289
+ project = _insert_project(
290
+ session,
291
+ name=full_name,
292
+ job_number=job,
293
+ parent_project_id=parent.id,
294
+ project_tags=project_tags,
295
+ org_id=org,
296
+ )
297
+
298
+ result = project_to_dict(
299
+ project,
300
+ stats={"brain_entries": 0, "library_docs": 0, "maintenance_logs": 0},
301
+ )
302
+ if sub_raw:
303
+ result["parent_job_id"] = project.parent_project_id
304
+ else:
305
+ result["created_job_project"] = True
306
+ return result
307
+
308
+
309
+ def create_project(
310
+ session: Session,
311
+ *,
312
+ name: str,
313
+ domain: str | None = None,
314
+ controller_family: str | None = None,
315
+ project_tags: list[str] | None = None,
316
+ org_id: str = "default",
317
+ ) -> dict[str, Any]:
318
+ """Agent/chat create by full name: 12345 or 12345-001."""
319
+ clean_name = normalize_digits(name)
320
+ if not clean_name:
321
+ raise ValueError("Project name is required")
322
+
323
+ job, suffix = parse_project_name(clean_name)
324
+ if job and suffix:
325
+ return create_project_structured(
326
+ session,
327
+ job_number=job,
328
+ sub_project_number=suffix,
329
+ project_tags=project_tags,
330
+ org_id=org_id,
331
+ )
332
+ if job and not suffix:
333
+ return create_project_structured(
334
+ session,
335
+ job_number=job,
336
+ sub_project_number=None,
337
+ project_tags=project_tags,
338
+ org_id=org_id,
339
+ )
340
+
341
+ org = (org_id or "default").strip() or "default"
342
+ existing = find_project_by_name(session, clean_name, org_id=org)
343
+ if existing:
344
+ raise ValueError(
345
+ f"Project name {clean_name!r} already exists (id={existing.id}). "
346
+ "Use five-digit job (12345) or sub-project (12345-001)."
347
+ )
348
+
349
+ project = _insert_project(
350
+ session,
351
+ name=clean_name[:255],
352
+ job_number=parse_job_number(clean_name),
353
+ parent_project_id=None,
354
+ project_tags=project_tags,
355
+ org_id=org,
356
+ )
357
+ if domain or controller_family:
358
+ if domain:
359
+ project.domain = domain.strip() or None
360
+ if controller_family:
361
+ project.controller_family = controller_family.strip() or None
362
+ session.commit()
363
+ session.refresh(project)
364
+
365
+ return project_to_dict(
366
+ project,
367
+ stats={"brain_entries": 0, "library_docs": 0, "maintenance_logs": 0},
368
+ )