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