Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .ai/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

- Consolidate `requirements.txt` into `pyproject.toml` (same pattern as the 3 core repos) — see `.ai/tech-debt.md`.
- Bump `actions/checkout`/`actions/setup-python` to `@v7` in `.github/workflows/tests.yml`.
- Consider a real refactor plan for `app/backend/api/services/project_service.py` (15176 lines) - this is the single largest tech-debt item across all 5 repos by size alone.
- **`project_service.py` split into `app/backend/api/services/project/core/` — done (8 phases, branch `refactor/split-project-service`), see `.ai/tech-debt.md` for the current state.** Two follow-ups deliberately deferred rather than bundled in:
- **Router migration**: `project_router.py`'s ~90 endpoints and the two external "private method" consumers (`protocol_service.py` → `ProjectService()._loadPostgresqlRuntimeProject`, `protocol_steps_sync.py` → `service._shouldRegisterProtocolOutputs`/`registerOutput`) still go through `ProjectService` rather than calling the new `project/core/` modules or `app/backend/runtime/*` services directly. Both of those specific methods are already thin delegators to already-extracted services, so the remaining work is purely updating the two call sites - low risk, but a distinct task from the service-layer split itself.
- `external_viewers.py` (~612 lines) and `protocol_graph_builder.py` (~500 lines) are the two `project/core/` modules over the original ~200-500 line target - each is one cohesive responsibility (external-viewer discovery/resolution/matching/launch; the protocol-graph-node-assembly loop), not obviously further divisible. Worth a second look if either grows again, not a given split.

## For right after the fork merges back upstream

Expand Down
10 changes: 7 additions & 3 deletions .ai/tech-debt.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

Findings from a real audit of this repo (2026-08-04), not a wishlist. Cited so they're checkable, not just asserted.

## The single largest "god file" across all 5 repos
## `project_service.py` — largest file in the ecosystem, now substantially decomposed (2026-08-06)

`app/backend/api/services/project_service.py` **15176 lines**. By a wide margin the most severe size/complexity outlier in the whole ecosystem (the next largest files anywhere are ~2800-3000 lines). Contains at least one unresolved `# TODO: Find viewers...` (line 5738). Any change here should be treated as high-risk regardless of how small it looks — a file this size almost certainly has non-obvious internal coupling. Splitting it is a real, standalone project, not a drive-by refactor.
`app/backend/api/services/project_service.py` was **15176 lines** as a single undifferentiated `ProjectService` class - by far the most severe size/complexity outlier in the whole ecosystem. Split across 8 phases (branch `refactor/split-project-service`) into ~30 focused, independently-testable modules under `app/backend/api/services/project/core/` (~5900 lines total, none over ~610 lines), each targeting a single responsibility (preview-by-output-type, protocol graph, workflow import/export, project CRUD/sharing/paths, etc.) and verified against the full unit suite after every extraction (1077/1077 passing throughout, zero behavior changes).

Other large files for context: `utils/thumbnail_service.py` (7685 lines), `mapper/postgresql_runtime_mapper.py` (5676 lines), `mapper/scipion_set_mapper.py` (4548 lines), `api/routers/project_router.py` (4290 lines).
`project_service.py` itself is now **9974 lines** - still the largest file in the ecosystem, but what remains is genuine composition-root orchestration (methods that wire together the extracted `core/` modules, mutate the few real instance-state fields like `currentProject`/`manager`, or are directly monkeypatched by name in tests and so need to stay as named seams) rather than undifferentiated bulk. Splitting it further is possible but has diminishing returns - the remaining large methods (`_migrateImportedProjectToPostgresql`, `applyWorkflowToProject`, `exportWorkflowProtocolsService`, the big preview-orchestration methods) are inherently imperative glue with real side effects, not pure computation.

Contains at least one unresolved `# TODO: Find viewers...` (line 3177) - not addressed by this refactor, out of scope.

Other large files for context: `utils/thumbnail_service.py` (7685 lines), `mapper/postgresql_runtime_mapper.py` (6027 lines), `mapper/scipion_set_mapper.py` (4676 lines), `api/routers/project_router.py` (4250 lines).

## Dependency management split across two files

Expand Down
Empty file.
Empty file.
62 changes: 62 additions & 0 deletions app/backend/api/services/project/core/coords3d_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Iterating tomograms referenced by a SetOfCoordinates3D, and constructing
the PostgreSQL Coordinates3D reader when available.
"""
from fastapi import HTTPException, status


def iterCoordinates3dTomograms(setOfCoordinates3D):
def asIterator(value):
iterItems = getattr(value, "iterItems", None)

if callable(iterItems):
try:
return iterItems(iterate=False)
except TypeError:
return iterItems()

return iter(value)

for methodName in ("iterTomograms", "iterVolumes"):
method = getattr(setOfCoordinates3D, methodName, None)

if not callable(method):
continue

try:
return asIterator(method())
except Exception:
continue

getTomograms = getattr(setOfCoordinates3D, "getTomograms", None)

if callable(getTomograms):
try:
return asIterator(getTomograms())
except Exception as error:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to iterate Coordinates3D tomograms: {error}",
)

raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="SetOfCoordinates3D does not expose tomograms iterator",
)


def buildPostgresqlCoords3dReader(mapper, projectId: int, protocolId, outputName: str):
"""protocolId here is expected to already be resolved for reader use
(see ProjectService._resolvePostgresqlReaderProtocolId)."""
from app.backend.viewers.postgresql_coords3d_reader import PostgresqlCoords3dReader

reader = PostgresqlCoords3dReader(
db=mapper.db,
projectId=projectId,
protocolId=protocolId,
outputName=outputName,
)

if reader.hasOutput():
return reader

return None
104 changes: 104 additions & 0 deletions app/backend/api/services/project/core/ctftomo_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Building summary/measurement rows for CTFTomoSeries preview, and
constructing the PostgreSQL CTFTomo reader when available.
"""
from typing import Any, Dict


def buildCtftomoSeriesSummary(ctfSeries) -> Dict[str, Any]:
"""Build a JSON-friendly summary for one CTFTomoSeries object."""
tsId = ctfSeries.getTsId()
label = ctfSeries.getObjLabel()
tiltSeries = ctfSeries.getTiltSeries()
dims = list(tiltSeries.getDim())
pixelSize = tiltSeries.getSamplingRate()
nViews = tiltSeries.getSize()

item: Dict[str, Any] = {
"tiltSeriesId": tsId,
"label": str(label) if label is not None else "",
}
if nViews is not None:
item["nViews"] = nViews
if dims is not None:
item["dims"] = dims
if pixelSize is not None:
item["pixelSize"] = pixelSize
return item


def buildCtftomoMeasurementRow(ctfObj, tiltSeries=None) -> Dict[str, Any]:
"""Build a JSON-friendly row with CTF parameters for a single tilt image."""
defocusU = ctfObj.getDefocusU()
defocusV = ctfObj.getDefocusV()
defocusAngle = ctfObj.getDefocusAngle()
resolution = ctfObj.getResolution()
phaseShift = ctfObj.getPhaseShift()
acqOrder = ctfObj.getAcquisitionOrder()
psdFile = ctfObj.getPsdFile()
astigmatism = defocusU - defocusV
tiltAngle = None
enabled = ctfObj.isEnabled()
dose = None

if tiltSeries is not None:
try:
view = tiltSeries.getItem('_acqOrder', acqOrder)
except Exception:
view = None

if view is not None:
try:
tiltAngle = view.getTiltAngle()
except Exception:
tiltAngle = None

try:
acq = view.getAcquisition()
dose = acq.getAccumDose()
except Exception:
dose = None

row: Dict[str, Any] = {}
row["index"] = ctfObj.getObjId()
row["viewIndex"] = ctfObj.getObjId()
if tiltAngle is not None:
row["tiltAngle"] = tiltAngle
if dose is not None:
row["dose"] = dose
if defocusU is not None:
row["defocusU"] = defocusU
if defocusV is not None:
row["defocusV"] = defocusV
row['astigmatism'] = astigmatism
if defocusAngle is not None:
row["defocusAngle"] = defocusAngle
if resolution is not None:
row["resolution"] = resolution
if phaseShift is not None:
row["phaseShift"] = phaseShift
if acqOrder is not None:
row["order"] = acqOrder
if psdFile:
row['psdFile'] = psdFile

row['excluded'] = not enabled

return row


def buildPostgresqlCtftomoReader(mapper, projectId: int, protocolId, outputName: str):
"""protocolId here is expected to already be resolved for reader use
(see ProjectService._resolvePostgresqlReaderProtocolId)."""
from app.backend.viewers.postgresql_ctftomo_reader import PostgresqlCtftomoReader

reader = PostgresqlCtftomoReader(
db=mapper.db,
projectId=projectId,
protocolId=protocolId,
outputName=outputName,
)

if reader.hasOutput():
return reader

return None
Loading
Loading